diff --git a/.claude/plans/2026-04-20-artifact-download-archive-name.md b/.claude/plans/2026-04-20-artifact-download-archive-name.md new file mode 100644 index 000000000..5c0d4295a --- /dev/null +++ b/.claude/plans/2026-04-20-artifact-download-archive-name.md @@ -0,0 +1,460 @@ +# Artifact Download Archive Name Implementation Plan + +> **For agentic execution:** Use `packmind:architect-executor` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When the user directly downloads a rendered artifact from the change proposal page, name the downloaded `.zip` after the artifact's slug (fallback: slugified name) instead of the generic `preview`. + +**Architecture:** Backend (`PreviewArtifactRenderingUseCase` in `packages/coding-agent`) computes the filename from the single artifact in the command and returns it in `PreviewArtifactRenderingResponse.fileName`. The existing NestJS controller already forwards this via `Content-Disposition`. The frontend (`DownloadAsAgentButton` in `apps/frontend`) switches from a hardcoded name to parsing `Content-Disposition`, with a fallback to the existing name if the header is missing. + +**Tech Stack:** TypeScript, NestJS, Jest (`@swc/jest`), React 19, React Testing Library, `@packmind/ui`. + +**Source Spec:** `.claude/specs/2026-04-20-artifact-download-archive-name-design.md` +**EM Spec:** None + +--- + +### Task 1: Backend — filename uses artifact slug in `PreviewArtifactRenderingUseCase` + +**Files:** +- Modify: `packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.ts` +- Modify: `packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.spec.ts` + +- [ ] **Step 1: Update existing single-artifact test + add new cases to spec file** + +Open `packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.spec.ts`. + +At the top of the file (with the other fixtures), add a `SkillVersion` fixture. Import `SkillVersion`, `SkillVersionId`, `SkillId` from `@packmind/types`: + +```typescript +import { + FileUpdates, + PreviewArtifactRenderingCommand, + RecipeVersion, + RecipeVersionId, + RecipeId, + StandardVersion, + StandardVersionId, + StandardId, + SkillVersion, + SkillVersionId, + SkillId, + UserId, +} from '@packmind/types'; +``` + +And add the fixture near the existing `standardVersion` declaration: + +```typescript +const skillVersion: SkillVersion = { + id: 'skv-1' as SkillVersionId, + skillId: 'sk-1' as SkillId, + version: 1, + userId: 'user-1' as UserId, + name: 'Test Skill', + slug: 'test-skill', + description: 'A test skill', + prompt: '# Skill prompt', +}; +``` + +Replace the existing assertion inside `describe('when rendering a command for an agent')`'s first `it('returns a zip with the correct filename', ...)` (currently expects `'packmind-claude-preview.zip'`) with the slug-based filename: + +```typescript +it('returns a zip with the correct filename', async () => { + const command: PreviewArtifactRenderingCommand = { + codingAgent: 'claude', + recipeVersions: [recipeVersion], + standardVersions: [], + skillVersions: [], + }; + + const result = await useCase.execute(command); + + expect(result.fileName).toBe('packmind-claude-test-command.zip'); +}); +``` + +Add this new `describe` block immediately after that test, still inside `describe('execute')`: + +```typescript +describe('filename computation', () => { + beforeEach(() => { + mockDeployer.deployArtifacts.mockResolvedValue({ + createOrUpdate: [], + delete: [], + }); + }); + + it('uses the standard slug when the command carries a single standard', async () => { + const result = await useCase.execute({ + codingAgent: 'claude', + recipeVersions: [], + standardVersions: [standardVersion], + skillVersions: [], + }); + + expect(result.fileName).toBe('packmind-claude-test-standard.zip'); + }); + + it('uses the skill slug when the command carries a single skill', async () => { + const result = await useCase.execute({ + codingAgent: 'cursor', + recipeVersions: [], + standardVersions: [], + skillVersions: [skillVersion], + }); + + expect(result.fileName).toBe('packmind-cursor-test-skill.zip'); + }); + + it('slugifies the artifact name when slug is empty', async () => { + const withEmptySlug: RecipeVersion = { + ...recipeVersion, + slug: '', + name: 'My Cool Thing!', + }; + + const result = await useCase.execute({ + codingAgent: 'claude', + recipeVersions: [withEmptySlug], + standardVersions: [], + skillVersions: [], + }); + + expect(result.fileName).toBe('packmind-claude-my-cool-thing.zip'); + }); + + it('falls back to "preview" when both slug and name are empty', async () => { + const emptyArtifact: RecipeVersion = { + ...recipeVersion, + slug: '', + name: '', + }; + + const result = await useCase.execute({ + codingAgent: 'claude', + recipeVersions: [emptyArtifact], + standardVersions: [], + skillVersions: [], + }); + + expect(result.fileName).toBe('packmind-claude-preview.zip'); + }); + + it('falls back to "preview" when the command carries multiple artifacts', async () => { + const result = await useCase.execute({ + codingAgent: 'claude', + recipeVersions: [recipeVersion], + standardVersions: [standardVersion], + skillVersions: [], + }); + + expect(result.fileName).toBe('packmind-claude-preview.zip'); + }); + + it('falls back to "preview" when the command carries no artifacts', async () => { + const result = await useCase.execute({ + codingAgent: 'copilot', + recipeVersions: [], + standardVersions: [], + skillVersions: [], + }); + + expect(result.fileName).toBe('packmind-copilot-preview.zip'); + }); +}); +``` + +- [ ] **Step 2: Run the backend tests and verify they fail** + +Run: `./node_modules/.bin/nx test coding-agent --testFile=packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.spec.ts` + +Expected: FAIL. The original `returns a zip with the correct filename` now expects `packmind-claude-test-command.zip` but implementation still returns `packmind-claude-preview.zip`. New `filename computation` tests should all fail for the same reason. + +- [ ] **Step 3: Implement slug-based filename logic in the use case** + +Open `packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.ts`. Replace the `const fileName = \`packmind-${codingAgent}-preview.zip\`;` line (currently line 56) with a call to a new private method, and add two helpers at the bottom of the class. + +Updated `execute` body — replace the single `const fileName = ...` line with: + +```typescript + const fileName = this.computeFileName(codingAgent, command); +``` + +Add these private methods at the end of the class (before the closing brace): + +```typescript + private computeFileName( + codingAgent: PreviewArtifactRenderingCommand['codingAgent'], + command: PreviewArtifactRenderingCommand, + ): string { + const artifacts = [ + ...command.recipeVersions, + ...command.standardVersions, + ...command.skillVersions, + ]; + + if (artifacts.length !== 1) { + return `packmind-${codingAgent}-preview.zip`; + } + + const [artifact] = artifacts; + const slug = + artifact.slug && artifact.slug.length > 0 + ? artifact.slug + : this.slugify(artifact.name); + + return `packmind-${codingAgent}-${slug}.zip`; + } + + private slugify(value: string): string { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return slug.length > 0 ? slug : 'preview'; + } +``` + +No other changes to this file — the zip creation, logging, and return shape stay identical. + +- [ ] **Step 4: Run the backend tests and verify they pass** + +Run: `./node_modules/.bin/nx test coding-agent --testFile=packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.spec.ts` + +Expected: PASS — all tests in `PreviewArtifactRenderingUseCase.spec.ts`, including the updated single-artifact test and the new `filename computation` block. + +- [ ] **Step 5: Lint the coding-agent package** + +Run: `./node_modules/.bin/nx lint coding-agent` + +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.ts packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.spec.ts +git commit -m "✨ feat(coding-agent): name preview archive after artifact slug" +``` + +--- + +### Task 2: Frontend — honor `Content-Disposition` in `DownloadAsAgentButton` + +**Files:** +- Modify: `apps/frontend/src/domain/artifacts/components/DownloadAsAgentButton.tsx` +- Create: `apps/frontend/src/domain/artifacts/components/DownloadAsAgentButton.spec.tsx` + +- [ ] **Step 1: Write the failing frontend test** + +Create `apps/frontend/src/domain/artifacts/components/DownloadAsAgentButton.spec.tsx`: + +```tsx +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { UIProvider } from '@packmind/ui'; +import { DownloadAsAgentButton } from './DownloadAsAgentButton'; +import { useAuthContext } from '../../accounts/hooks/useAuthContext'; +import { useCurrentSpace } from '../../spaces/hooks/useCurrentSpace'; + +jest.mock('../../accounts/hooks/useAuthContext', () => ({ + useAuthContext: jest.fn(), +})); + +jest.mock('../../spaces/hooks/useCurrentSpace', () => ({ + useCurrentSpace: jest.fn(), +})); + +const renderWithProviders = (component: React.ReactElement) => + render({component}); + +describe('DownloadAsAgentButton', () => { + const getPreviewCommand = jest.fn().mockReturnValue({ + recipeVersions: [], + standardVersions: [], + skillVersions: [], + }); + let clickSpy: jest.SpyInstance; + let createObjectURLSpy: jest.SpyInstance; + let revokeObjectURLSpy: jest.SpyInstance; + let lastAnchor: HTMLAnchorElement | null; + + beforeEach(() => { + (useAuthContext as jest.Mock).mockReturnValue({ + organization: { id: 'org-1' }, + }); + (useCurrentSpace as jest.Mock).mockReturnValue({ spaceId: 'space-1' }); + + lastAnchor = null; + const originalCreateElement = document.createElement.bind(document); + jest + .spyOn(document, 'createElement') + .mockImplementation((tagName: string) => { + const element = originalCreateElement(tagName); + if (tagName === 'a') { + lastAnchor = element as HTMLAnchorElement; + } + return element; + }); + + clickSpy = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => undefined); + + createObjectURLSpy = jest + .spyOn(URL, 'createObjectURL') + .mockReturnValue('blob:mock'); + revokeObjectURLSpy = jest + .spyOn(URL, 'revokeObjectURL') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const mockFetchResponse = (contentDisposition: string | null) => { + const headers = new Headers(); + if (contentDisposition) { + headers.set('Content-Disposition', contentDisposition); + } + const response = new Response(new Blob(['zip-bytes']), { + status: 200, + headers, + }); + jest.spyOn(globalThis, 'fetch').mockResolvedValue(response); + }; + + it('uses the filename from Content-Disposition when present', async () => { + mockFetchResponse( + 'attachment; filename="packmind-claude-my-slug.zip"', + ); + + renderWithProviders( + , + ); + + await userEvent.click( + screen.getByRole('button', { name: /download for agent/i }), + ); + await userEvent.click(screen.getByRole('button', { name: /claude/i })); + + await waitForAnchor(() => lastAnchor); + expect(lastAnchor?.download).toBe('packmind-claude-my-slug.zip'); + expect(clickSpy).toHaveBeenCalled(); + expect(createObjectURLSpy).toHaveBeenCalled(); + expect(revokeObjectURLSpy).toHaveBeenCalled(); + }); + + it('falls back to the legacy name when Content-Disposition is missing', async () => { + mockFetchResponse(null); + + renderWithProviders( + , + ); + + await userEvent.click( + screen.getByRole('button', { name: /download for agent/i }), + ); + await userEvent.click(screen.getByRole('button', { name: /claude/i })); + + await waitForAnchor(() => lastAnchor); + expect(lastAnchor?.download).toBe('packmind-claude-preview.zip'); + }); + + it('falls back to the legacy name when Content-Disposition is malformed', async () => { + mockFetchResponse('attachment; something-else'); + + renderWithProviders( + , + ); + + await userEvent.click( + screen.getByRole('button', { name: /download for agent/i }), + ); + await userEvent.click(screen.getByRole('button', { name: /claude/i })); + + await waitForAnchor(() => lastAnchor); + expect(lastAnchor?.download).toBe('packmind-claude-preview.zip'); + }); +}); + +async function waitForAnchor(get: () => HTMLAnchorElement | null) { + await waitFor(() => { + expect(get()).not.toBeNull(); + }); +} +``` + +- [ ] **Step 2: Run the frontend test and verify it fails** + +Run: `./node_modules/.bin/nx test frontend --testFile=apps/frontend/src/domain/artifacts/components/DownloadAsAgentButton.spec.tsx` + +Expected: FAIL. The first test expects `packmind-claude-my-slug.zip`; the component still sets `a.download = \`packmind-claude-preview.zip\`` regardless of headers. + +- [ ] **Step 3: Update the component to parse `Content-Disposition`** + +Open `apps/frontend/src/domain/artifacts/components/DownloadAsAgentButton.tsx`. + +Add a small file-local helper above the component: + +```typescript +function parseFilenameFromContentDisposition( + header: string | null, +): string | null { + if (!header) return null; + const match = header.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i); + if (!match) return null; + const value = match[1].trim(); + return value.length > 0 ? value : null; +} +``` + +Replace the download block inside `handleDownload` (currently lines ~66-72) with: + +```typescript + const blob = await response.blob(); + const fileName = + parseFilenameFromContentDisposition( + response.headers.get('Content-Disposition'), + ) ?? `packmind-${agent}-preview.zip`; + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); +``` + +No other changes — the popover UI, `fetch` call, error handling, and loading state stay as is. + +- [ ] **Step 4: Run the frontend test and verify it passes** + +Run: `./node_modules/.bin/nx test frontend --testFile=apps/frontend/src/domain/artifacts/components/DownloadAsAgentButton.spec.tsx` + +Expected: PASS — all three cases. + +- [ ] **Step 5: Lint the frontend app** + +Run: `./node_modules/.bin/nx lint frontend` + +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add apps/frontend/src/domain/artifacts/components/DownloadAsAgentButton.tsx apps/frontend/src/domain/artifacts/components/DownloadAsAgentButton.spec.tsx +git commit -m "✨ feat(frontend): use server-provided filename for artifact download" +``` + +--- + +## Plan self-review notes + +- **Spec coverage:** Backend filename logic covered by Task 1 (all six cases from the spec's testing section). Frontend `Content-Disposition` parsing covered by Task 2 (all three cases from the spec). No spec requirement left without a task. +- **Type consistency:** `computeFileName` and `slugify` method names match between the test expectations (filenames only — no direct method calls in tests) and the implementation. `parseFilenameFromContentDisposition` is used only internally by `DownloadAsAgentButton`. +- **Placeholders:** None — every step has concrete code or commands. diff --git a/.claude/plans/2026-04-27-orga-space-management.md b/.claude/plans/2026-04-27-orga-space-management.md new file mode 100644 index 000000000..a44aa2c1b --- /dev/null +++ b/.claude/plans/2026-04-27-orga-space-management.md @@ -0,0 +1,2262 @@ +# Organization Spaces Management Page Implementation Plan + +> **For agentic execution:** Use `qlb:architect-executor` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Workflow per task (verification-style, not TDD):** +> 1. Implement the code. +> 2. Write tests covering happy path, edge cases, and error paths. +> 3. Run tests; expected PASS. +> 4. Lint. +> 5. Commit. + +**Goal:** Wire `/org/{orgSlug}/settings/spaces` to a new dedicated, paginated, org-admin-only listing endpoint with aggregated admins/member-count/artifact-count; remove bulk-delete UI; add a delete confirmation flow. + +**Architecture:** New backend use case `ListOrganizationSpacesForManagementUseCase` (extends `AbstractAdminUseCase`) in `packages/spaces-management`, exposed via `SpacesManagementController` at `GET /organizations/:orgId/spaces-management/listing?page=N`. Aggregations come via four new port methods (`ISpacesPort.findOrgPagePaginated`, `ISpacesPort` admin/member queries, and `countBySpaceIds` on `IStandardsPort`/`IRecipesPort`/`ISkillsPort`), executed in parallel via `Promise.all`. Frontend swaps in a new TanStack query for the management page only; existing `useGetSpacesQuery` (sidebar/pickers) is untouched. + +**Tech Stack:** NestJS 11, TypeORM 0.3, PostgreSQL, React Router v7, TanStack Query v5, Chakra UI v3, Jest, Playwright. + +**Source Spec:** `.claude/specs/2026-04-27-orga-space-management-design.md` +**EM Spec:** `specs/orga-space-management.md` +**Docs consulted:** +- `apps/CLAUDE.md`, `apps/api/CLAUDE.md`, `apps/frontend/CLAUDE.md`, `packages/CLAUDE.md` +- `apps/frontend/.claude/rules/packmind/standard-frontend-data-flow.md` +- `packages/.claude/rules/packmind/standard-use-case-architecture-patterns.md` +- `packages/.claude/rules/packmind/standard-port-adapter-cross-domain-integration.md` +- `.claude/rules/packmind/standard-back-end-typescript-clean-code-practices.md` +- `.claude/rules/packmind/standard-typescript-good-practices.md` +- `.claude/rules/packmind/standard-compliance-logging-personal-information.md` + +**User Stories / Acceptance Criteria** (from EM spec, authoritative): +- Rule 1: Eligible users can access the spaces management page from the organization settings. +- Rule 2: The page displays organization spaces in a paginated table sourced from a dedicated listing endpoint (page size 8; default org-wide first then `createdAt` ASC; columns Name/Admins/Members/Artifacts/Created with the exact rendering rules in the spec). +- Rule 3 (E2E): Eligible users can create a new space from the management page and remain on the page (dialog closes, query invalidated, new space appears). +- Rule 4 (E2E): A space can be deleted from a row action with a confirmation modal (`Delete space '{name}'? This action is irreversible.`); default org-wide space hides Delete. +- Rule 5: View action navigates to `/org/{orgSlug}/spaces/{spaceSlug}`. + +--- + +## Backend + +### Task 1: Define use case contract types in `@packmind/types` + +**Files:** +- Create: `packages/types/src/spaces-management/contracts/IListOrganizationSpacesForManagementUseCase.ts` +- Modify: `packages/types/src/spaces-management/contracts/index.ts` (add export line) + +- [ ] **Step 1: Implement** + +```typescript +// packages/types/src/spaces-management/contracts/IListOrganizationSpacesForManagementUseCase.ts +import { IUseCase, PackmindCommand } from '../../UseCase'; +import { Space } from '../../spaces/Space'; +import { UserId } from '../../accounts/User'; + +export const ORGA_SPACE_MANAGEMENT_PAGE_SIZE = 8; + +export type SpaceManagementListItemAdmin = { + id: UserId; + displayName: string; +}; + +export type SpaceManagementListItem = Space & { + admins: SpaceManagementListItemAdmin[]; + membersCount: number; + artifactsCount: number; +}; + +export type ListOrganizationSpacesForManagementCommand = PackmindCommand & { + page: number; +}; + +export type ListOrganizationSpacesForManagementResponse = { + items: SpaceManagementListItem[]; + totalCount: number; + page: number; + pageSize: number; +}; + +export type IListOrganizationSpacesForManagementUseCase = IUseCase< + ListOrganizationSpacesForManagementCommand, + ListOrganizationSpacesForManagementResponse +>; +``` + +If `UserId` is not exported from `../../accounts/User`, locate the actual export path (grep `export.*UserId` under `packages/types/src/`) and use it. + +If the project's `User` entity exposes a different "display name" field (e.g. `name`, `username`, `email`-fallback, or a computed concatenation), the contract still uses `displayName: string` as the presentation field — Task 5 (admins query) will resolve which User column maps onto `displayName`. + +Add the export to the barrel: + +```typescript +// packages/types/src/spaces-management/contracts/index.ts (add line) +export * from './IListOrganizationSpacesForManagementUseCase'; +``` + +- [ ] **Step 2: Write verification tests** + +This file is a pure type-export module — no runtime behavior. A smoke test asserts the exports compile and the constant matches the spec. + +Create: `packages/types/src/spaces-management/contracts/IListOrganizationSpacesForManagementUseCase.spec.ts` + +```typescript +import { + ListOrganizationSpacesForManagementCommand, + ListOrganizationSpacesForManagementResponse, + IListOrganizationSpacesForManagementUseCase, + SpaceManagementListItem, + ORGA_SPACE_MANAGEMENT_PAGE_SIZE, +} from './IListOrganizationSpacesForManagementUseCase'; + +describe('IListOrganizationSpacesForManagementUseCase contract', () => { + it('exports a Command type, Response type, and IUseCase interface', () => { + type _Cmd = ListOrganizationSpacesForManagementCommand; + type _Resp = ListOrganizationSpacesForManagementResponse; + type _UC = IListOrganizationSpacesForManagementUseCase; + type _Item = SpaceManagementListItem; + expect(ORGA_SPACE_MANAGEMENT_PAGE_SIZE).toBe(8); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test types --testFile=IListOrganizationSpacesForManagementUseCase.spec.ts` +Expected: PASS. + +- [ ] **Step 4: Lint and typecheck** + +Run: `./node_modules/.bin/nx lint types` and `./node_modules/.bin/nx typecheck types` (if a typecheck target exists; otherwise rely on lint + downstream builds). +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/types/src/spaces-management/contracts/IListOrganizationSpacesForManagementUseCase.ts \ + packages/types/src/spaces-management/contracts/IListOrganizationSpacesForManagementUseCase.spec.ts \ + packages/types/src/spaces-management/contracts/index.ts +git commit -m ":sparkles: feat(types): add ListOrganizationSpacesForManagement use case contract" +``` + +--- + +### Task 2: Add `InvalidPageError` + +**Files:** +- Create: `packages/spaces-management/src/domain/errors/InvalidPageError.ts` +- Create: `packages/spaces-management/src/domain/errors/InvalidPageError.spec.ts` + +- [ ] **Step 1: Implement** + +Mirror the pattern of `CannotDeleteDefaultSpaceError`: + +```typescript +// packages/spaces-management/src/domain/errors/InvalidPageError.ts +export class InvalidPageError extends Error { + constructor(page: unknown) { + super(`Invalid page value: ${String(page)}. Page must be a positive integer.`); + this.name = 'InvalidPageError'; + } +} +``` + +Do not use `Object.setPrototypeOf` (per `standard-typescript-good-practices`). + +- [ ] **Step 2: Write verification tests** + +```typescript +// packages/spaces-management/src/domain/errors/InvalidPageError.spec.ts +import { InvalidPageError } from './InvalidPageError'; + +describe('InvalidPageError', () => { + it('is an Error subclass with the right name and message', () => { + const err = new InvalidPageError(0); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('InvalidPageError'); + expect(err.message).toContain('0'); + }); + + it('coerces non-numeric input to a string in the message', () => { + const err = new InvalidPageError('abc'); + expect(err.message).toContain('abc'); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test spaces-management --testFile=InvalidPageError.spec.ts` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint spaces-management` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/spaces-management/src/domain/errors/InvalidPageError.ts \ + packages/spaces-management/src/domain/errors/InvalidPageError.spec.ts +git commit -m ":sparkles: feat(spaces-management): add InvalidPageError for paginated listing" +``` + +--- + +### Task 3: Verify (and add if missing) `space_id` indexes + +**Files:** +- Inspect (read-only): all migration files under `packages/migrations/src/migrations/` (or wherever migrations live). +- Conditionally create: a new migration file following `standard-typeorm-migrations` if any of the four indexes is missing. + +- [ ] **Step 1: Locate migrations directory and grep for existing `space_id` index creations** + +Run: +```bash +find packages -path '*/migrations/*' -name '*.ts' -not -path '*/node_modules/*' | head -5 +``` + +Then for each of the four target tables (`user_space_memberships`, `standards`, `recipes`, `skills`), grep for an `IDX` or `INDEX` on `space_id`: +```bash +grep -rn "space_id" packages/migrations/src/migrations/ | grep -i "index\|idx" +``` + +- [ ] **Step 2: Inventory which indexes already exist** + +For each table, confirm: +- `user_space_memberships(space_id)` indexed? +- `standards(space_id)` indexed? +- `recipes(space_id)` indexed? +- `skills(space_id)` indexed? + +If a table uses a composite index that already covers `space_id` as the leading column, count it as covered. + +- [ ] **Step 3: If any are missing, write a TypeORM migration** + +Follow `standard-typeorm-migrations` (use `PackmindLogger`, shared column helpers if relevant, and a working `down()` method that drops only the indexes this migration created). Migration naming: `-AddSpaceIdIndexesForManagementListing.ts`. + +Use the existing migration file as a template: `find packages/migrations/src/migrations -type f -name "*.ts" | sort | tail -1` and copy its skeleton. + +The migration's `up()` adds the missing indexes; `down()` drops only those it added. Use `IF NOT EXISTS` / `IF EXISTS` in the SQL or check `queryRunner.hasIndex()` to make the migration safe under partial prior state. + +- [ ] **Step 4: Run the migration locally and verify** + +If a migration was added, run the migration runner the project uses (e.g. `npm run migrate` or `./node_modules/.bin/nx run api:migrate`). Expected: applied without errors. + +If no migration was needed, skip this step. + +- [ ] **Step 5: Lint** + +Run: `./node_modules/.bin/nx lint migrations` (or whichever Nx project owns migrations). +Expected: no errors. + +- [ ] **Step 6: Commit** + +If a migration was added: +```bash +git add packages/migrations/src/migrations/.ts +git commit -m ":wrench: chore(migrations): add space_id indexes for management listing aggregations" +``` + +If no migration was needed, write a one-line note in the task report and skip the commit. + +--- + +### Task 4: Add `findOrgPagePaginated` to `ISpacesPort` + `SpaceRepository` + +**Files:** +- Modify: `packages/types/src/spaces/ports/ISpacesPort.ts` (add method signature) +- Modify: `packages/spaces/src/infra/repositories/SpaceRepository.ts` (add implementation) +- Modify: `packages/spaces/src/application/adapters/SpacesAdapter.ts` (delegate to repository) +- Modify: `packages/spaces/src/infra/repositories/SpaceRepository.spec.ts` (add cases) + +- [ ] **Step 1: Implement** + +Add the method signature to `ISpacesPort`: + +```typescript +// packages/types/src/spaces/ports/ISpacesPort.ts (add inside the interface) +findOrgPagePaginated( + organizationId: OrganizationId, + page: number, + pageSize: number, +): Promise<{ items: Space[]; totalCount: number }>; +``` + +Implement in the repository: + +```typescript +// packages/spaces/src/infra/repositories/SpaceRepository.ts +async findOrgPagePaginated( + organizationId: OrganizationId, + page: number, + pageSize: number, +): Promise<{ items: Space[]; totalCount: number }> { + const skip = (page - 1) * pageSize; + const [items, totalCount] = await this.repository.findAndCount({ + where: { organizationId, deletedAt: IsNull() }, // mirror existing soft-delete handling + order: { isDefaultSpace: 'DESC', createdAt: 'ASC' }, + skip, + take: pageSize, + }); + return { items: items.map((row) => this.toDomain(row)), totalCount }; +} +``` + +If `this.toDomain` is named differently (e.g. `mapToEntity`), use the same conversion helper used by other methods in this file. If soft-delete is handled via TypeORM's `@DeleteDateColumn` + `softDelete`, drop the explicit `deletedAt: IsNull()` because it's automatic. + +Implement in `SpacesAdapter`: + +```typescript +// packages/spaces/src/application/adapters/SpacesAdapter.ts +async findOrgPagePaginated( + organizationId: OrganizationId, + page: number, + pageSize: number, +) { + return this.spaceRepository.findOrgPagePaginated(organizationId, page, pageSize); +} +``` + +If `SpacesAdapter` accesses repos via a service, route through that service (mirror how `listSpacesByOrganization` is wired today). + +- [ ] **Step 2: Write verification tests** + +Append to `SpaceRepository.spec.ts` (follow `repository-implementation-and-testing-pattern` — real DB via factories, no mocks): + +```typescript +describe('findOrgPagePaginated', () => { + it('returns the page of spaces ordered by isDefaultSpace DESC then createdAt ASC, plus totalCount', async () => { + const org = await createOrganizationFactory(); + const defaultSpace = await createSpaceFactory({ organizationId: org.id, isDefaultSpace: true }); + const oldest = await createSpaceFactory({ organizationId: org.id, createdAt: new Date('2025-01-01') }); + const middle = await createSpaceFactory({ organizationId: org.id, createdAt: new Date('2025-02-01') }); + const newest = await createSpaceFactory({ organizationId: org.id, createdAt: new Date('2025-03-01') }); + + const result = await repo.findOrgPagePaginated(org.id, 1, 8); + + expect(result.totalCount).toBe(4); + expect(result.items.map((s) => s.id)).toEqual([defaultSpace.id, oldest.id, middle.id, newest.id]); + }); + + it('respects pagination offsets', async () => { + const org = await createOrganizationFactory(); + for (let i = 0; i < 10; i++) { + await createSpaceFactory({ organizationId: org.id }); + } + const page1 = await repo.findOrgPagePaginated(org.id, 1, 4); + const page2 = await repo.findOrgPagePaginated(org.id, 2, 4); + expect(page1.items.length).toBe(4); + expect(page2.items.length).toBe(4); + expect(new Set([...page1.items, ...page2.items].map((s) => s.id)).size).toBe(8); + expect(page1.totalCount).toBe(10); + expect(page2.totalCount).toBe(10); + }); + + it('returns empty items but accurate totalCount when page is past last', async () => { + const org = await createOrganizationFactory(); + await createSpaceFactory({ organizationId: org.id }); + const result = await repo.findOrgPagePaginated(org.id, 99, 8); + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(1); + }); +}); +``` + +If the existing factory names differ (e.g. `spaceFactory()`), use whatever name is already imported in the file. + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test spaces --testFile=SpaceRepository.spec.ts` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint spaces && ./node_modules/.bin/nx lint types` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/types/src/spaces/ports/ISpacesPort.ts \ + packages/spaces/src/infra/repositories/SpaceRepository.ts \ + packages/spaces/src/infra/repositories/SpaceRepository.spec.ts \ + packages/spaces/src/application/adapters/SpacesAdapter.ts +git commit -m ":sparkles: feat(spaces): add findOrgPagePaginated for management listing" +``` + +--- + +### Task 5: Add `findAdminsForSpaceIds` and `countByRoleForSpaceIds` to `ISpacesPort` + `UserSpaceMembershipRepository` + +**Files:** +- Modify: `packages/types/src/spaces/ports/ISpacesPort.ts` +- Modify: `packages/spaces/src/infra/repositories/UserSpaceMembershipRepository.ts` +- Modify: `packages/spaces/src/application/adapters/SpacesAdapter.ts` +- Modify: `packages/spaces/src/infra/repositories/UserSpaceMembershipRepository.spec.ts` + +- [ ] **Step 1: Implement** + +Add to `UserSpaceMembershipRepository.ts`: + +```typescript +async findAdminsForSpaceIds( + spaceIds: SpaceId[], +): Promise> { + if (spaceIds.length === 0) return []; + const rows = await this.repository + .createQueryBuilder('m') + .innerJoin('users', 'u', 'u.id = m.user_id') + .select(['m.space_id AS "spaceId"', 'u.id AS "userId"', /* displayName column */ ]) + .where('m.space_id IN (:...spaceIds)', { spaceIds }) + .andWhere('m.role = :role', { role: 'admin' }) + .andWhere('m.deleted_at IS NULL') + .getRawMany(); + + return rows.map((r) => ({ + spaceId: r.spaceId as SpaceId, + user: { id: r.userId as UserId, displayName: deriveDisplayName(r) }, + })); +} + +async countByRoleForSpaceIds( + spaceIds: SpaceId[], + role: 'admin' | 'member', +): Promise> { + if (spaceIds.length === 0) return new Map(); + const rows = await this.repository + .createQueryBuilder('m') + .select('m.space_id', 'spaceId') + .addSelect('COUNT(*)', 'count') + .where('m.space_id IN (:...spaceIds)', { spaceIds }) + .andWhere('m.role = :role', { role }) + .andWhere('m.deleted_at IS NULL') + .groupBy('m.space_id') + .getRawMany(); + return new Map(rows.map((r) => [r.spaceId as SpaceId, Number(r.count)])); +} +``` + +`deriveDisplayName(r)` is a small local helper that returns the existing User entity's display field (look at `users` schema columns — likely `name`, `username`, or first+last). Pick the column actually used elsewhere in the codebase for the same concept (search `displayName` and `name` references on the user entity to confirm). If the project uses `email` as the only stable identifier, use the part before `@` to avoid leaking PII per `standard-compliance-logging-personal-information` (note: this method's output is NOT logged, but principle of minimal exposure still applies). + +Add to `ISpacesPort`: + +```typescript +findAdminsForSpaceIds(spaceIds: SpaceId[]): Promise>; +countByRoleForSpaceIds(spaceIds: SpaceId[], role: 'admin' | 'member'): Promise>; +``` + +Wire both through `SpacesAdapter` (delegating to the membership repository). + +- [ ] **Step 2: Write verification tests** + +Append to `UserSpaceMembershipRepository.spec.ts`: + +```typescript +describe('findAdminsForSpaceIds', () => { + it('returns admins (role=admin) joined with User per space, with id and displayName', async () => { + const org = await createOrganizationFactory(); + const spaceA = await createSpaceFactory({ organizationId: org.id }); + const spaceB = await createSpaceFactory({ organizationId: org.id }); + const adminA = await createUserFactory({ organizationId: org.id }); + const adminB1 = await createUserFactory({ organizationId: org.id }); + const adminB2 = await createUserFactory({ organizationId: org.id }); + const memberB = await createUserFactory({ organizationId: org.id }); + + await createUserSpaceMembershipFactory({ userId: adminA.id, spaceId: spaceA.id, role: 'admin' }); + await createUserSpaceMembershipFactory({ userId: adminB1.id, spaceId: spaceB.id, role: 'admin' }); + await createUserSpaceMembershipFactory({ userId: adminB2.id, spaceId: spaceB.id, role: 'admin' }); + await createUserSpaceMembershipFactory({ userId: memberB.id, spaceId: spaceB.id, role: 'member' }); + + const rows = await repo.findAdminsForSpaceIds([spaceA.id, spaceB.id]); + + const bySpace = new Map>(); + rows.forEach((r) => { + const list = bySpace.get(r.spaceId) ?? []; + list.push(r.user); + bySpace.set(r.spaceId, list); + }); + + expect(bySpace.get(spaceA.id)).toHaveLength(1); + expect(bySpace.get(spaceB.id)).toHaveLength(2); + expect(rows.every((r) => typeof r.user.displayName === 'string')).toBe(true); + }); + + it('returns empty array when given empty input', async () => { + expect(await repo.findAdminsForSpaceIds([])).toEqual([]); + }); +}); + +describe('countByRoleForSpaceIds', () => { + it('returns a Map of spaceId -> count for the given role; missing spaces are absent', async () => { + const org = await createOrganizationFactory(); + const spaceA = await createSpaceFactory({ organizationId: org.id }); + const spaceB = await createSpaceFactory({ organizationId: org.id }); + const u1 = await createUserFactory({ organizationId: org.id }); + const u2 = await createUserFactory({ organizationId: org.id }); + + await createUserSpaceMembershipFactory({ userId: u1.id, spaceId: spaceA.id, role: 'member' }); + await createUserSpaceMembershipFactory({ userId: u2.id, spaceId: spaceA.id, role: 'member' }); + await createUserSpaceMembershipFactory({ userId: u1.id, spaceId: spaceB.id, role: 'admin' }); + + const counts = await repo.countByRoleForSpaceIds([spaceA.id, spaceB.id], 'member'); + expect(counts.get(spaceA.id)).toBe(2); + expect(counts.has(spaceB.id)).toBe(false); + }); + + it('returns empty Map for empty input', async () => { + const counts = await repo.countByRoleForSpaceIds([], 'member'); + expect(counts.size).toBe(0); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test spaces --testFile=UserSpaceMembershipRepository.spec.ts` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint spaces && ./node_modules/.bin/nx lint types` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/types/src/spaces/ports/ISpacesPort.ts \ + packages/spaces/src/infra/repositories/UserSpaceMembershipRepository.ts \ + packages/spaces/src/infra/repositories/UserSpaceMembershipRepository.spec.ts \ + packages/spaces/src/application/adapters/SpacesAdapter.ts +git commit -m ":sparkles: feat(spaces): add admin and role-count aggregations for management listing" +``` + +--- + +### Task 6: Add `countBySpaceIds` to `IStandardsPort` + `StandardRepository` + +**Files:** +- Modify: `packages/types/src/standards/ports/IStandardsPort.ts` +- Modify: `packages/standards/src/infra/repositories/StandardRepository.ts` +- Modify: `packages/standards/src/application/adapters/StandardsAdapter.ts` (or equivalent — locate via `find packages/standards/src/application/adapters -type f`) +- Modify: `packages/standards/src/infra/repositories/StandardRepository.spec.ts` + +- [ ] **Step 1: Implement** + +```typescript +// packages/standards/src/infra/repositories/StandardRepository.ts +async countBySpaceIds(spaceIds: SpaceId[]): Promise> { + if (spaceIds.length === 0) return new Map(); + const rows = await this.repository + .createQueryBuilder('s') + .select('s.space_id', 'spaceId') + .addSelect('COUNT(*)', 'count') + .where('s.space_id IN (:...spaceIds)', { spaceIds }) + .andWhere('s.deleted_at IS NULL') // honor soft-delete if applicable + .groupBy('s.space_id') + .getRawMany(); + return new Map(rows.map((r) => [r.spaceId as SpaceId, Number(r.count)])); +} +``` + +If the standards entity uses TypeORM's automatic soft-delete (via `@DeleteDateColumn`), the `deleted_at IS NULL` filter is unnecessary because TypeORM applies it automatically. Match the convention used by other methods in this repo. + +Add the port method: + +```typescript +// packages/types/src/standards/ports/IStandardsPort.ts (inside the interface) +countBySpaceIds(spaceIds: SpaceId[]): Promise>; +``` + +Wire it through `StandardsAdapter` (or the actual adapter file under `packages/standards/src/application/adapters/`). + +- [ ] **Step 2: Write verification tests** + +```typescript +describe('countBySpaceIds', () => { + it('returns a Map of spaceId -> count, omitting spaces with zero standards', async () => { + const org = await createOrganizationFactory(); + const spaceA = await createSpaceFactory({ organizationId: org.id }); + const spaceB = await createSpaceFactory({ organizationId: org.id }); + const spaceC = await createSpaceFactory({ organizationId: org.id }); + await createStandardFactory({ spaceId: spaceA.id }); + await createStandardFactory({ spaceId: spaceA.id }); + await createStandardFactory({ spaceId: spaceB.id }); + + const counts = await repo.countBySpaceIds([spaceA.id, spaceB.id, spaceC.id]); + + expect(counts.get(spaceA.id)).toBe(2); + expect(counts.get(spaceB.id)).toBe(1); + expect(counts.has(spaceC.id)).toBe(false); + }); + + it('returns empty Map for empty input', async () => { + expect((await repo.countBySpaceIds([])).size).toBe(0); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test standards --testFile=StandardRepository.spec.ts` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint standards && ./node_modules/.bin/nx lint types` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/types/src/standards/ports/IStandardsPort.ts \ + packages/standards/src/infra/repositories/StandardRepository.ts \ + packages/standards/src/infra/repositories/StandardRepository.spec.ts \ + packages/standards/src/application/adapters/.ts +git commit -m ":sparkles: feat(standards): add countBySpaceIds for management listing aggregation" +``` + +--- + +### Task 7: Add `countBySpaceIds` to `IRecipesPort` + `RecipeRepository` + +**Files:** +- Modify: `packages/types/src/recipes/ports/IRecipesPort.ts` +- Modify: `packages/recipes/src/infra/repositories/RecipeRepository.ts` +- Modify: `packages/recipes/src/application/adapters/.ts` +- Modify: `packages/recipes/src/infra/repositories/RecipeRepository.spec.ts` + +- [ ] **Step 1: Implement** + +```typescript +async countBySpaceIds(spaceIds: SpaceId[]): Promise> { + if (spaceIds.length === 0) return new Map(); + const rows = await this.repository + .createQueryBuilder('r') + .select('r.space_id', 'spaceId') + .addSelect('COUNT(*)', 'count') + .where('r.space_id IN (:...spaceIds)', { spaceIds }) + .andWhere('r.deleted_at IS NULL') + .groupBy('r.space_id') + .getRawMany(); + return new Map(rows.map((r) => [r.spaceId as SpaceId, Number(r.count)])); +} +``` + +Add the port method to `IRecipesPort` (`countBySpaceIds(spaceIds: SpaceId[]): Promise>`) and wire through the recipes adapter. + +- [ ] **Step 2: Write verification tests** + +Mirror Task 6's test, using `createRecipeFactory` and the recipes repo: + +```typescript +describe('countBySpaceIds', () => { + it('returns a Map of spaceId -> count, omitting spaces with zero recipes', async () => { + const org = await createOrganizationFactory(); + const spaceA = await createSpaceFactory({ organizationId: org.id }); + const spaceB = await createSpaceFactory({ organizationId: org.id }); + const spaceC = await createSpaceFactory({ organizationId: org.id }); + await createRecipeFactory({ spaceId: spaceA.id }); + await createRecipeFactory({ spaceId: spaceA.id }); + await createRecipeFactory({ spaceId: spaceB.id }); + + const counts = await repo.countBySpaceIds([spaceA.id, spaceB.id, spaceC.id]); + expect(counts.get(spaceA.id)).toBe(2); + expect(counts.get(spaceB.id)).toBe(1); + expect(counts.has(spaceC.id)).toBe(false); + }); + + it('returns empty Map for empty input', async () => { + expect((await repo.countBySpaceIds([])).size).toBe(0); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test recipes --testFile=RecipeRepository.spec.ts` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint recipes && ./node_modules/.bin/nx lint types` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/types/src/recipes/ports/IRecipesPort.ts \ + packages/recipes/src/infra/repositories/RecipeRepository.ts \ + packages/recipes/src/infra/repositories/RecipeRepository.spec.ts \ + packages/recipes/src/application/adapters/.ts +git commit -m ":sparkles: feat(recipes): add countBySpaceIds for management listing aggregation" +``` + +--- + +### Task 8: Add `countBySpaceIds` to `ISkillsPort` + `SkillRepository` + +**Files:** +- Modify: `packages/types/src/skills/ports/ISkillsPort.ts` +- Modify: `packages/skills/src/infra/repositories/SkillRepository.ts` +- Modify: `packages/skills/src/application/adapters/.ts` +- Modify: `packages/skills/src/infra/repositories/SkillRepository.spec.ts` + +- [ ] **Step 1: Implement** + +Same shape as Task 7 with the skills entity. Add port method to `ISkillsPort` and wire through the skills adapter. + +```typescript +async countBySpaceIds(spaceIds: SpaceId[]): Promise> { + if (spaceIds.length === 0) return new Map(); + const rows = await this.repository + .createQueryBuilder('sk') + .select('sk.space_id', 'spaceId') + .addSelect('COUNT(*)', 'count') + .where('sk.space_id IN (:...spaceIds)', { spaceIds }) + .andWhere('sk.deleted_at IS NULL') + .groupBy('sk.space_id') + .getRawMany(); + return new Map(rows.map((r) => [r.spaceId as SpaceId, Number(r.count)])); +} +``` + +- [ ] **Step 2: Write verification tests** + +Mirror Task 6 with `createSkillFactory` and the skills repo (same three behaviors: returns counts, omits zero-count spaces, empty input → empty Map). + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test skills --testFile=SkillRepository.spec.ts` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint skills && ./node_modules/.bin/nx lint types` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/types/src/skills/ports/ISkillsPort.ts \ + packages/skills/src/infra/repositories/SkillRepository.ts \ + packages/skills/src/infra/repositories/SkillRepository.spec.ts \ + packages/skills/src/application/adapters/.ts +git commit -m ":sparkles: feat(skills): add countBySpaceIds for management listing aggregation" +``` + +--- + +### Task 9: Implement `ListOrganizationSpacesForManagementUseCase` + +**Files:** +- Create: `packages/spaces-management/src/application/usecases/ListOrganizationSpacesForManagementUseCase.ts` +- Create: `packages/spaces-management/src/application/usecases/ListOrganizationSpacesForManagementUseCase.spec.ts` + +- [ ] **Step 1: Implement** + +```typescript +// packages/spaces-management/src/application/usecases/ListOrganizationSpacesForManagementUseCase.ts +import { AbstractAdminUseCase, AdminContext } from '@packmind/node-utils'; +import { PackmindLogger } from '@packmind/logger'; +import { + IAccountsPort, + IRecipesPort, + ISkillsPort, + ISpacesPort, + IStandardsPort, + ListOrganizationSpacesForManagementCommand, + ListOrganizationSpacesForManagementResponse, + ORGA_SPACE_MANAGEMENT_PAGE_SIZE, + SpaceId, + SpaceManagementListItem, + SpaceManagementListItemAdmin, +} from '@packmind/types'; +import { InvalidPageError } from '../../domain/errors/InvalidPageError'; + +const origin = 'ListOrganizationSpacesForManagementUseCase'; + +export class ListOrganizationSpacesForManagementUseCase extends AbstractAdminUseCase< + ListOrganizationSpacesForManagementCommand, + ListOrganizationSpacesForManagementResponse +> { + constructor( + accountsPort: IAccountsPort, + private readonly spacesPort: ISpacesPort, + private readonly standardsPort: IStandardsPort, + private readonly recipesPort: IRecipesPort, + private readonly skillsPort: ISkillsPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForAdmins( + command: ListOrganizationSpacesForManagementCommand & AdminContext, + ): Promise { + const { organizationId, page } = command; + + if (!Number.isInteger(page) || page < 1) { + throw new InvalidPageError(page); + } + + const { items: spaces, totalCount } = await this.spacesPort.findOrgPagePaginated( + organizationId, + page, + ORGA_SPACE_MANAGEMENT_PAGE_SIZE, + ); + + if (spaces.length === 0) { + return { items: [], totalCount, page, pageSize: ORGA_SPACE_MANAGEMENT_PAGE_SIZE }; + } + + const spaceIds = spaces.map((s) => s.id); + + const [adminRows, memberCounts, standardsCounts, recipesCounts, skillsCounts] = + await Promise.all([ + this.spacesPort.findAdminsForSpaceIds(spaceIds), + this.spacesPort.countByRoleForSpaceIds(spaceIds, 'member'), + this.standardsPort.countBySpaceIds(spaceIds), + this.recipesPort.countBySpaceIds(spaceIds), + this.skillsPort.countBySpaceIds(spaceIds), + ]); + + const adminsBySpace = new Map(); + for (const row of adminRows) { + const list = adminsBySpace.get(row.spaceId) ?? []; + list.push({ id: row.user.id, displayName: row.user.displayName }); + adminsBySpace.set(row.spaceId, list); + } + + const items: SpaceManagementListItem[] = spaces.map((space) => ({ + ...space, + admins: adminsBySpace.get(space.id) ?? [], + membersCount: memberCounts.get(space.id) ?? 0, + artifactsCount: + (standardsCounts.get(space.id) ?? 0) + + (recipesCounts.get(space.id) ?? 0) + + (skillsCounts.get(space.id) ?? 0), + })); + + this.logger.info('Listed organization spaces for management', { + organizationId, + page, + itemsCount: items.length, + totalCount, + }); + + return { items, totalCount, page, pageSize: ORGA_SPACE_MANAGEMENT_PAGE_SIZE }; + } +} +``` + +- [ ] **Step 2: Write verification tests** + +```typescript +// packages/spaces-management/src/application/usecases/ListOrganizationSpacesForManagementUseCase.spec.ts +import { + ORGA_SPACE_MANAGEMENT_PAGE_SIZE, + Space, + createOrganizationId, + createSpaceId, + createUserId, +} from '@packmind/types'; +import { ListOrganizationSpacesForManagementUseCase } from './ListOrganizationSpacesForManagementUseCase'; +import { InvalidPageError } from '../../domain/errors/InvalidPageError'; + +const orgId = createOrganizationId('org-1'); +const userId = createUserId('user-1'); + +function makeSpace(over: Partial = {}): Space { + return { + id: createSpaceId('s' + Math.random()), + name: 'Space', + slug: 'space', + organizationId: orgId, + isDefaultSpace: false, + type: 'open', + createdAt: new Date('2025-01-01'), + ...over, + } as Space; +} + +describe('ListOrganizationSpacesForManagementUseCase', () => { + let accountsPort: any; + let spacesPort: any; + let standardsPort: any; + let recipesPort: any; + let skillsPort: any; + let useCase: ListOrganizationSpacesForManagementUseCase; + + beforeEach(() => { + accountsPort = mockAccountsPortWithAdminMembership({ userId, organizationId: orgId }); + spacesPort = { + findOrgPagePaginated: jest.fn(), + findAdminsForSpaceIds: jest.fn().mockResolvedValue([]), + countByRoleForSpaceIds: jest.fn().mockResolvedValue(new Map()), + }; + standardsPort = { countBySpaceIds: jest.fn().mockResolvedValue(new Map()) }; + recipesPort = { countBySpaceIds: jest.fn().mockResolvedValue(new Map()) }; + skillsPort = { countBySpaceIds: jest.fn().mockResolvedValue(new Map()) }; + + useCase = new ListOrganizationSpacesForManagementUseCase( + accountsPort, spacesPort, standardsPort, recipesPort, skillsPort, + ); + }); + + it('throws InvalidPageError when page < 1', async () => { + await expect( + useCase.execute({ userId, organizationId: orgId, page: 0 }), + ).rejects.toThrow(InvalidPageError); + }); + + it('throws InvalidPageError when page is not an integer', async () => { + await expect( + useCase.execute({ userId, organizationId: orgId, page: 1.5 }), + ).rejects.toThrow(InvalidPageError); + }); + + it('rejects non-admin callers via AbstractAdminUseCase', async () => { + accountsPort = mockAccountsPortWithMemberMembership({ userId, organizationId: orgId }); + useCase = new ListOrganizationSpacesForManagementUseCase( + accountsPort, spacesPort, standardsPort, recipesPort, skillsPort, + ); + spacesPort.findOrgPagePaginated.mockResolvedValue({ items: [], totalCount: 0 }); + + await expect( + useCase.execute({ userId, organizationId: orgId, page: 1 }), + ).rejects.toThrow(/admin/i); + }); + + it('returns empty items but real totalCount when page is past last', async () => { + spacesPort.findOrgPagePaginated.mockResolvedValue({ items: [], totalCount: 32 }); + const result = await useCase.execute({ userId, organizationId: orgId, page: 99 }); + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(32); + expect(result.page).toBe(99); + expect(result.pageSize).toBe(ORGA_SPACE_MANAGEMENT_PAGE_SIZE); + }); + + it('stitches admins, member counts, and artifact counts per space; defaults missing entries to 0', async () => { + const s1 = makeSpace({ id: createSpaceId('s1'), isDefaultSpace: true }); + const s2 = makeSpace({ id: createSpaceId('s2') }); + spacesPort.findOrgPagePaginated.mockResolvedValue({ items: [s1, s2], totalCount: 2 }); + spacesPort.findAdminsForSpaceIds.mockResolvedValue([ + { spaceId: s1.id, user: { id: createUserId('u1'), displayName: 'Alice' } }, + { spaceId: s2.id, user: { id: createUserId('u2'), displayName: 'Bob' } }, + { spaceId: s2.id, user: { id: createUserId('u3'), displayName: 'Carol' } }, + ]); + spacesPort.countByRoleForSpaceIds.mockResolvedValue(new Map([[s2.id, 7]])); + standardsPort.countBySpaceIds.mockResolvedValue(new Map([[s1.id, 4]])); + recipesPort.countBySpaceIds.mockResolvedValue(new Map([[s2.id, 3]])); + skillsPort.countBySpaceIds.mockResolvedValue(new Map()); + + const result = await useCase.execute({ userId, organizationId: orgId, page: 1 }); + + expect(result.items[0].id).toBe(s1.id); + expect(result.items[0].admins.map((a) => a.displayName)).toEqual(['Alice']); + expect(result.items[0].membersCount).toBe(0); + expect(result.items[0].artifactsCount).toBe(4); + expect(result.items[1].id).toBe(s2.id); + expect(result.items[1].admins.map((a) => a.displayName).sort()).toEqual(['Bob', 'Carol']); + expect(result.items[1].membersCount).toBe(7); + expect(result.items[1].artifactsCount).toBe(3); + }); + + it('passes spaceIds as the queried set to all aggregation ports', async () => { + const s1 = makeSpace({ id: createSpaceId('s1') }); + spacesPort.findOrgPagePaginated.mockResolvedValue({ items: [s1], totalCount: 1 }); + + await useCase.execute({ userId, organizationId: orgId, page: 1 }); + + expect(spacesPort.findAdminsForSpaceIds).toHaveBeenCalledWith([s1.id]); + expect(spacesPort.countByRoleForSpaceIds).toHaveBeenCalledWith([s1.id], 'member'); + expect(standardsPort.countBySpaceIds).toHaveBeenCalledWith([s1.id]); + expect(recipesPort.countBySpaceIds).toHaveBeenCalledWith([s1.id]); + expect(skillsPort.countBySpaceIds).toHaveBeenCalledWith([s1.id]); + }); +}); +``` + +The helpers `mockAccountsPortWithAdminMembership` / `mockAccountsPortWithMemberMembership` should mirror however other use case tests in this package mock the accounts port (look at `BrowseSpacesUseCase.spec.ts` or `DeleteSpaceUseCase.spec.ts` for the canonical mock setup). + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test spaces-management --testFile=ListOrganizationSpacesForManagementUseCase.spec.ts` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint spaces-management` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/spaces-management/src/application/usecases/ListOrganizationSpacesForManagementUseCase.ts \ + packages/spaces-management/src/application/usecases/ListOrganizationSpacesForManagementUseCase.spec.ts +git commit -m ":sparkles: feat(spaces-management): add ListOrganizationSpacesForManagement use case" +``` + +--- + +### Task 10: Wire use case into `SpacesManagementAdapter` + extend `ISpacesManagementPort` + +**Files:** +- Modify: `packages/types/src/spaces-management/ports/ISpacesManagementPort.ts` +- Modify: `packages/spaces-management/src/application/adapters/SpacesManagementAdapter.ts` +- Modify (or create): `packages/spaces-management/src/application/adapters/SpacesManagementAdapter.spec.ts` + +- [ ] **Step 1: Implement** + +In `ISpacesManagementPort` (`packages/types/src/spaces-management/ports/ISpacesManagementPort.ts`): + +```typescript +listOrganizationSpacesForManagement( + command: ListOrganizationSpacesForManagementCommand, +): Promise; +``` + +Import the new types and add the method line. + +In `SpacesManagementAdapter.ts`: + +```typescript +import { ListOrganizationSpacesForManagementUseCase } from '../usecases/ListOrganizationSpacesForManagementUseCase'; + +async listOrganizationSpacesForManagement( + command: ListOrganizationSpacesForManagementCommand, +): Promise { + const useCase = new ListOrganizationSpacesForManagementUseCase( + this.accountsPort, + this.spacesPort, + this.standardsPort, + this.recipesPort, + this.skillsPort, + ); + return useCase.execute(command); +} +``` + +(`accountsPort`/`spacesPort`/etc. are already initialized — see existing methods.) + +- [ ] **Step 2: Write verification tests** + +Append to whichever spec already covers the adapter (search for `SpacesManagementAdapter.spec.ts`; if it doesn't exist, create one mirroring an adjacent adapter test): + +```typescript +describe('SpacesManagementAdapter.listOrganizationSpacesForManagement', () => { + it('instantiates ListOrganizationSpacesForManagementUseCase with the configured ports and returns its response', async () => { + const adapter = new SpacesManagementAdapter(); + await adapter.initialize({ + [IAccountsPortName]: accountsPort, + [ISpacesPortName]: spacesPort, + [IStandardsPortName]: standardsPort, + [IRecipesPortName]: recipesPort, + [ISkillsPortName]: skillsPort, + eventEmitterService, + }); + spacesPort.findOrgPagePaginated.mockResolvedValue({ items: [], totalCount: 0 }); + + const result = await adapter.listOrganizationSpacesForManagement({ + userId: createUserId('u'), + organizationId: createOrganizationId('o'), + page: 1, + }); + + expect(result).toEqual({ items: [], totalCount: 0, page: 1, pageSize: 8 }); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test spaces-management` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint spaces-management && ./node_modules/.bin/nx lint types` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/types/src/spaces-management/ports/ISpacesManagementPort.ts \ + packages/spaces-management/src/application/adapters/SpacesManagementAdapter.ts \ + packages/spaces-management/src/application/adapters/SpacesManagementAdapter.spec.ts +git commit -m ":sparkles: feat(spaces-management): wire ListOrganizationSpacesForManagement into adapter" +``` + +--- + +### Task 11: Add API service method on `SpacesManagementService` + +**Files:** +- Modify: `packages/spaces-management/src/nest-api/spaces-management/spaces-management.service.ts` +- Modify (if exists): `packages/spaces-management/src/nest-api/spaces-management/spaces-management.service.spec.ts` + +- [ ] **Step 1: Implement** + +In `spaces-management.service.ts`, add: + +```typescript +async listOrganizationSpacesForManagement( + command: ListOrganizationSpacesForManagementCommand, +): Promise { + this.logger.info('Listing organization spaces for management', { + organizationId: command.organizationId, + page: command.page, + }); + return this.spacesManagementAdapter.listOrganizationSpacesForManagement(command); +} +``` + +Add the necessary imports. + +- [ ] **Step 2: Write verification tests** + +If a service spec exists at `spaces-management.service.spec.ts`, add: + +```typescript +it('listOrganizationSpacesForManagement delegates to the adapter', async () => { + adapter.listOrganizationSpacesForManagement = jest.fn().mockResolvedValue({ + items: [], totalCount: 0, page: 1, pageSize: 8, + }); + const result = await service.listOrganizationSpacesForManagement({ + userId: createUserId('u'), + organizationId: createOrganizationId('o'), + page: 1, + }); + expect(adapter.listOrganizationSpacesForManagement).toHaveBeenCalled(); + expect(result.pageSize).toBe(8); +}); +``` + +If no service spec exists, skip this and rely on the controller test (Task 12) for coverage. + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test spaces-management` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint spaces-management` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/spaces-management/src/nest-api/spaces-management/spaces-management.service.ts \ + packages/spaces-management/src/nest-api/spaces-management/spaces-management.service.spec.ts +git commit -m ":sparkles: feat(spaces-management): expose listOrganizationSpacesForManagement on service" +``` + +--- + +### Task 12: Add `GET listing` endpoint on `SpacesManagementController` + +**Files:** +- Modify: `packages/spaces-management/src/nest-api/spaces-management/spaces-management.controller.ts` +- Modify: `packages/spaces-management/src/nest-api/spaces-management/spaces-management.controller.spec.ts` + +- [ ] **Step 1: Implement** + +Add to `SpacesManagementController` (declare it BEFORE any `:spaceId` param routes so NestJS matches `listing` first): + +```typescript +import { Query } from '@nestjs/common'; +import { + ListOrganizationSpacesForManagementResponse, +} from '@packmind/types'; +import { InvalidPageError } from '../../domain/errors/InvalidPageError'; + +/** + * List the organization's spaces for the admin management page. + * GET /organizations/:orgId/spaces-management/listing?page=N + */ +@Get('listing') +async listOrganizationSpacesForManagement( + @Param('orgId') organizationId: OrganizationId, + @Query('page') pageParam: string | undefined, + @Req() request: AuthenticatedRequest, +): Promise { + const userId = request.user.userId; + const page = pageParam === undefined ? 1 : Number(pageParam); + + this.logger.info( + 'GET /organizations/:orgId/spaces-management/listing - Listing spaces for management', + { organizationId, userId, page }, + ); + + try { + return await this.spacesManagementService.listOrganizationSpacesForManagement({ + userId, + organizationId, + page, + }); + } catch (error) { + if (error instanceof InvalidPageError) { + throw new BadRequestException(error.message); + } + if (error instanceof OrganizationAdminRequiredError) { + throw new ForbiddenException(error.message); + } + throw error; + } +} +``` + +Place this method ABOVE any `@Get(':spaceId/...')`-style methods to keep NestJS route ordering predictable. (The current controller's only `Get` is `browse`, so this is straightforward.) + +- [ ] **Step 2: Write verification tests** + +Append to `spaces-management.controller.spec.ts` (mirror the existing test setup; mock the service): + +```typescript +describe('GET /organizations/:orgId/spaces-management/listing', () => { + it('returns 200 with the response shape on happy path', async () => { + const expected = { items: [], totalCount: 0, page: 1, pageSize: 8 }; + spacesManagementService.listOrganizationSpacesForManagement = jest.fn().mockResolvedValue(expected); + + const response = await request(app.getHttpServer()) + .get('/organizations/org-1/spaces-management/listing?page=1') + .set('Authorization', authHeader) + .expect(200); + + expect(response.body).toEqual(expected); + expect(spacesManagementService.listOrganizationSpacesForManagement).toHaveBeenCalledWith({ + userId: expect.any(String), + organizationId: 'org-1', + page: 1, + }); + }); + + it('returns 400 when page is not a positive integer', async () => { + spacesManagementService.listOrganizationSpacesForManagement = jest + .fn() + .mockRejectedValue(new InvalidPageError('abc')); + await request(app.getHttpServer()) + .get('/organizations/org-1/spaces-management/listing?page=abc') + .set('Authorization', authHeader) + .expect(400); + }); + + it('returns 403 when caller is not org admin', async () => { + spacesManagementService.listOrganizationSpacesForManagement = jest + .fn() + .mockRejectedValue(new OrganizationAdminRequiredError({ userId: 'u', organizationId: 'org-1' })); + await request(app.getHttpServer()) + .get('/organizations/org-1/spaces-management/listing?page=1') + .set('Authorization', authHeader) + .expect(403); + }); + + it('defaults page to 1 when not provided', async () => { + spacesManagementService.listOrganizationSpacesForManagement = jest + .fn() + .mockResolvedValue({ items: [], totalCount: 0, page: 1, pageSize: 8 }); + await request(app.getHttpServer()) + .get('/organizations/org-1/spaces-management/listing') + .set('Authorization', authHeader) + .expect(200); + expect(spacesManagementService.listOrganizationSpacesForManagement).toHaveBeenCalledWith( + expect.objectContaining({ page: 1 }), + ); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test spaces-management --testFile=spaces-management.controller.spec.ts` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint spaces-management` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/spaces-management/src/nest-api/spaces-management/spaces-management.controller.ts \ + packages/spaces-management/src/nest-api/spaces-management/spaces-management.controller.spec.ts +git commit -m ":sparkles: feat(spaces-management): expose listing endpoint for orga space management" +``` + +--- + +## Frontend + +### Task 13: Update frontend types + mapper + +**Files:** +- Modify: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/types.ts` +- Modify: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/toSpaceListItem.ts` +- Modify: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/toSpaceListItem.test.ts` + +- [ ] **Step 1: Implement** + +```typescript +// apps/frontend/src/domain/spaces/components/SpacesManagementPage/types.ts +import type { Space } from '@packmind/types'; +import type { SpaceColorToken } from '../spaceColor'; + +export type { SpaceColorToken }; + +export type SpaceAdminAvatar = { + id: string; + displayName: string; +}; + +export type SpaceListItem = Space & { + colorToken: SpaceColorToken; + isOrgWide: boolean; + admins: SpaceAdminAvatar[]; + membersCount: number; + artifactsCount: number; + createdAt: string; +}; +``` + +```typescript +// apps/frontend/src/domain/spaces/components/SpacesManagementPage/toSpaceListItem.ts +import type { SpaceManagementListItem } from '@packmind/types'; +import { getColorTokenForSpace } from '../spaceColor'; +import type { SpaceListItem } from './types'; + +export function toSpaceListItem(item: SpaceManagementListItem): SpaceListItem { + return { + ...item, + colorToken: getColorTokenForSpace(item), + isOrgWide: item.isDefaultSpace, + admins: item.admins.map((a) => ({ id: a.id as string, displayName: a.displayName })), + }; +} +``` + +- [ ] **Step 2: Write verification tests** + +Replace the existing test to assert behavior on a `SpaceManagementListItem`-shaped input: + +```typescript +import type { SpaceManagementListItem } from '@packmind/types'; +import { toSpaceListItem } from './toSpaceListItem'; + +describe('toSpaceListItem', () => { + const dto: SpaceManagementListItem = { + id: 's1' as any, + name: 'Engineering', + slug: 'engineering', + organizationId: 'org-1' as any, + isDefaultSpace: false, + type: 'open', + createdAt: '2025-01-12T10:00:00.000Z', + admins: [{ id: 'u1' as any, displayName: 'Alice' }], + membersCount: 12, + artifactsCount: 9, + } as any; + + it('decorates with colorToken and isOrgWide and preserves aggregated fields', () => { + const row = toSpaceListItem(dto); + expect(row.colorToken).toBeDefined(); + expect(row.isOrgWide).toBe(false); + expect(row.admins).toEqual([{ id: 'u1', displayName: 'Alice' }]); + expect(row.membersCount).toBe(12); + expect(row.artifactsCount).toBe(9); + expect(row.createdAt).toBe('2025-01-12T10:00:00.000Z'); + }); + + it('marks the default space as org-wide', () => { + const row = toSpaceListItem({ ...dto, isDefaultSpace: true }); + expect(row.isOrgWide).toBe(true); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test frontend --testFile=toSpaceListItem.test.ts` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint frontend` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/domain/spaces/components/SpacesManagementPage/types.ts \ + apps/frontend/src/domain/spaces/components/SpacesManagementPage/toSpaceListItem.ts \ + apps/frontend/src/domain/spaces/components/SpacesManagementPage/toSpaceListItem.test.ts +git commit -m ":sparkles: feat(frontend): align SpaceListItem with SpaceManagementListItem DTO" +``` + +--- + +### Task 14: Add gateway method, query options, and hook + +**Files:** +- Modify: `apps/frontend/src/domain/spaces/api/gateways/SpacesGatewayApi.ts` +- Modify: `apps/frontend/src/domain/spaces/api/queries/SpacesQueries.ts` +- Modify (if exists): a colocated query/gateway spec + +- [ ] **Step 1: Implement** + +In the gateway: + +```typescript +// apps/frontend/src/domain/spaces/api/gateways/SpacesGatewayApi.ts +import type { + ListOrganizationSpacesForManagementResponse, +} from '@packmind/types'; + +// add to the gateway implementation: +listOrganizationSpacesForManagement: (orgId: string, page: number) => + client.get( + `/organizations/${orgId}/spaces-management/listing?page=${encodeURIComponent(String(page))}`, + ), +``` + +The exact gateway construction style (Gateway + axios) follows the codebase convention — copy the pattern from a sibling method like `getUserSpaces`. + +In the queries file: + +```typescript +// apps/frontend/src/domain/spaces/api/queries/SpacesQueries.ts +import { keepPreviousData, queryOptions, useQuery } from '@tanstack/react-query'; + +export function getOrganizationSpacesForManagementQueryOptions( + organizationId: string, + page: number, +) { + return queryOptions({ + queryKey: ['organizations', organizationId, 'spaces', 'management', page] as const, + queryFn: () => + spacesGateway.listOrganizationSpacesForManagement(organizationId, page), + placeholderData: keepPreviousData, + staleTime: 30_000, + }); +} + +export function useGetOrganizationSpacesForManagementQuery( + organizationId: string, + page: number, +) { + return useQuery(getOrganizationSpacesForManagementQueryOptions(organizationId, page)); +} +``` + +- [ ] **Step 2: Write verification tests** + +If a `SpacesQueries.test.ts` already exists, add: + +```typescript +describe('getOrganizationSpacesForManagementQueryOptions', () => { + it('builds a query key that includes orgId and page', () => { + const opts = getOrganizationSpacesForManagementQueryOptions('org-1', 3); + expect(opts.queryKey).toEqual(['organizations', 'org-1', 'spaces', 'management', 3]); + }); +}); +``` + +If no such spec exists, skip and let downstream component tests (Tasks 16, 18) cover the integration. + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test frontend` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint frontend` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/domain/spaces/api/gateways/SpacesGatewayApi.ts \ + apps/frontend/src/domain/spaces/api/queries/SpacesQueries.ts +git commit -m ":sparkles: feat(frontend): add management listing gateway and query options" +``` + +--- + +### Task 15: Refactor `SpacesPagination` to a controlled component + +**Files:** +- Modify: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesPagination.tsx` +- Create: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesPagination.test.tsx` + +- [ ] **Step 1: Implement** + +Refactor `SpacesPagination.tsx` to a controlled component with the props shape `{ page, pageSize, totalCount, onPageChange }`. Internal logic computes `totalPages = Math.ceil(totalCount / pageSize)` and returns `null` when `totalCount <= pageSize`. Use the existing PMPagination/PMButton primitives the codebase already uses (look at the current implementation for the underlying primitives). + +The component must expose buttons with accessible names matching `/prev/i` and `/next/i` (or pick names that match the design kit's primitives — keep the test in sync). + +- [ ] **Step 2: Write verification tests** + +```typescript +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SpacesPagination } from './SpacesPagination'; + +describe('SpacesPagination', () => { + it('renders nothing when totalCount <= pageSize', () => { + render( {}} />); + expect(screen.queryByRole('navigation')).toBeNull(); + }); + + it('disables prev on page 1 and next on last page', () => { + const { rerender } = render( + {}} />, + ); + expect(screen.getByRole('button', { name: /prev/i })).toBeDisabled(); + + rerender( {}} />); + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); + }); + + it('calls onPageChange with the requested page', async () => { + const onPageChange = jest.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + expect(onPageChange).toHaveBeenCalledWith(2); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test frontend --testFile=SpacesPagination.test.tsx` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint frontend` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesPagination.tsx \ + apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesPagination.test.tsx +git commit -m ":recycle: refactor(frontend): make SpacesPagination a controlled component" +``` + +--- + +### Task 16: Refactor `SpacesManagementPage` to use the new query and remove bulk selection + +**Files:** +- Modify: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesManagementPage.tsx` +- Modify: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesManagementPage.test.tsx` + +- [ ] **Step 1: Implement** + +Rewrite `SpacesManagementPage.tsx`: + +```tsx +import { useState } from 'react'; +import { useGetOrganizationSpacesForManagementQuery } from '../../api/queries/SpacesQueries'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; +import { toSpaceListItem } from './toSpaceListItem'; +import { SpacesTable } from './SpacesTable'; +import { SpacesPagination } from './SpacesPagination'; + +export function SpacesManagementPage() { + const { organization } = useAuthContext(); + const [page, setPage] = useState(1); + const orgId = organization?.id; + const { data, isLoading, isError } = useGetOrganizationSpacesForManagementQuery(orgId ?? '', page); + + if (!orgId) return null; + if (isLoading) return /* preserve current loading UI */; + if (isError) return /* preserve current error UI */; + if (!data) return null; + + const rows = data.items.map(toSpaceListItem); + + return ( + <> + + + + ); +} +``` + +Remove all `selectedRows` / `onSelectionChange` state. Remove the `` import and JSX. Keep loading and error UI exactly as today (just dropping selection state, not visual treatment). + +- [ ] **Step 2: Write verification tests** + +```typescript +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { SpacesManagementPage } from './SpacesManagementPage'; +import * as queries from '../../api/queries/SpacesQueries'; + +function renderWithQuery(ui: React.ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +} + +describe('SpacesManagementPage', () => { + it('renders rows from the new management query', async () => { + jest.spyOn(queries, 'useGetOrganizationSpacesForManagementQuery').mockReturnValue({ + data: { + items: [{ id: 's1', name: 'Engineering', slug: 'engineering', isDefaultSpace: false, + admins: [], membersCount: 0, artifactsCount: 0, createdAt: '2025-01-12T00:00:00.000Z', + organizationId: 'org-1', type: 'open' }], + totalCount: 1, page: 1, pageSize: 8, + }, + isLoading: false, isError: false, + } as any); + renderWithQuery(); + expect(await screen.findByText('Engineering')).toBeInTheDocument(); + }); + + it('does not render bulk action UI', () => { + jest.spyOn(queries, 'useGetOrganizationSpacesForManagementQuery').mockReturnValue({ + data: { items: [], totalCount: 0, page: 1, pageSize: 8 }, isLoading: false, isError: false, + } as any); + renderWithQuery(); + expect(screen.queryByTestId('spaces-bulk-action-bar')).toBeNull(); + expect(screen.queryByRole('checkbox', { name: /select/i })).toBeNull(); + }); + + it('triggers a refetch with the new page when SpacesPagination calls onPageChange', async () => { + const useQueryMock = jest.spyOn(queries, 'useGetOrganizationSpacesForManagementQuery'); + useQueryMock.mockReturnValue({ + data: { items: [], totalCount: 32, page: 1, pageSize: 8 }, isLoading: false, isError: false, + } as any); + renderWithQuery(); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + expect(useQueryMock).toHaveBeenLastCalledWith(expect.any(String), 2); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test frontend --testFile=SpacesManagementPage.test.tsx` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint frontend` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesManagementPage.tsx \ + apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesManagementPage.test.tsx +git commit -m ":sparkles: feat(frontend): wire SpacesManagementPage to management listing query" +``` + +--- + +### Task 17: Refactor `SpacesTable` and delete `SpacesBulkActionBar` + +**Files:** +- Modify: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesTable.tsx` +- Delete: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesBulkActionBar.tsx` +- Delete (if exists): `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesBulkActionBar.test.tsx` +- Modify: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/index.ts` (drop barrel export if any) +- Modify (or create): `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesTable.test.tsx` + +- [ ] **Step 1: Implement** + +Edit `SpacesTable.tsx`: +- Remove `selectedRows`, `onSelectionChange`, and any prop types related to selection. +- Remove the checkbox column (header and per-row cell). +- Keep all other columns: Name, Admins, Members, Artifacts, Created, Actions. +- Remove the `` import/render. + +Then delete the bulk action bar file: + +```bash +git rm apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesBulkActionBar.tsx +# if a colocated test exists: +git rm -f apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesBulkActionBar.test.tsx 2>/dev/null || true +``` + +If the page's `index.ts` re-exports the bulk-action bar, drop that export. Fix any unused-import lint issues that surface from the cleanup. + +- [ ] **Step 2: Write verification tests** + +If there's an existing `SpacesTable.test.tsx`, update its assertions to ensure no checkbox column is rendered. Otherwise create: + +```typescript +import { render, screen } from '@testing-library/react'; +import { SpacesTable } from './SpacesTable'; +import type { SpaceListItem } from './types'; + +const row: SpaceListItem = { + id: 's1' as any, name: 'Engineering', slug: 'engineering', organizationId: 'o' as any, + isDefaultSpace: false, type: 'open', createdAt: '2025-01-12T00:00:00.000Z', + colorToken: 'blue.500' as any, isOrgWide: false, + admins: [], membersCount: 0, artifactsCount: 0, +}; + +describe('SpacesTable', () => { + it('renders the expected columns and no row checkbox', () => { + render(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Admins')).toBeInTheDocument(); + expect(screen.getByText('Members')).toBeInTheDocument(); + expect(screen.getByText('Artifacts')).toBeInTheDocument(); + expect(screen.getByText('Created')).toBeInTheDocument(); + expect(screen.queryByRole('checkbox', { name: /select/i })).toBeNull(); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test frontend --testFile=SpacesTable.test.tsx` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint frontend` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesTable.tsx \ + apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesTable.test.tsx \ + apps/frontend/src/domain/spaces/components/SpacesManagementPage/index.ts +# git rm was already staged in Step 1 +git commit -m ":fire: refactor(frontend): remove bulk-action UI from spaces management table" +``` + +--- + +### Task 18: Refactor route `clientLoader` to prefetch the new query and source the subtitle from `totalCount` + +**Files:** +- Modify: `apps/frontend/app/routes/org.$orgSlug._protected.settings.spaces._index.tsx` + +- [ ] **Step 1: Implement** + +```tsx +import type { LoaderFunctionArgs } from 'react-router'; +import { useLoaderData } from 'react-router'; +import { + DEFAULT_FEATURE_DOMAIN_MAP, + ORGA_SPACE_MANAGEMENT_FEATURE_KEY, + PMFeatureFlag, + PMPage, +} from '@packmind/ui'; +import { queryClient } from '../../src/shared/data/queryClient'; +import { ensureOrgContext } from '../../src/shared/data/ensureOrgContext'; +import { getOrganizationSpacesForManagementQueryOptions } from '../../src/domain/spaces/api/queries/SpacesQueries'; +import { useAuthContext } from '../../src/domain/accounts/hooks/useAuthContext'; +import { SpacesManagementPage } from '../../src/domain/spaces/components/SpacesManagementPage'; +import { SpacesToolbar } from '../../src/domain/spaces/components/SpacesManagementPage/SpacesToolbar'; + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const me = await ensureOrgContext(params.orgSlug!); + try { + const data = await queryClient.ensureQueryData( + getOrganizationSpacesForManagementQueryOptions(me.organization.id, 1), + ); + return { totalCount: data.totalCount }; + } catch { + return { totalCount: null }; + } +} + +export default function SettingsSpacesRouteModule() { + const { user, organization } = useAuthContext(); + const { totalCount } = useLoaderData(); + + if (!organization) return null; + + const subtitle = + totalCount === null + ? 'Manage every space in your organization' + : `Manage every space in your organization · ${totalCount} ${totalCount === 1 ? 'space' : 'spaces'}`; + + return ( + + }> + + + + ); +} +``` + +- [ ] **Step 2: Write verification tests** + +If sibling routes have route-level tests, add one that asserts the subtitle string for `totalCount = 0`, `totalCount = 1` (singular), `totalCount = 12` (plural), and `totalCount = null` (fallback). + +If no route-level test pattern exists, the existing component tests (Task 16) plus the manual smoke at the end of the plan cover this. + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test frontend` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint frontend` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/app/routes/org.\$orgSlug._protected.settings.spaces._index.tsx +git commit -m ":sparkles: feat(frontend): source spaces settings subtitle from management listing total" +``` + +--- + +### Task 19: Add `DeleteSpaceConfirmDialog` and wire `SpaceRowActions` + +**Files:** +- Create: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/DeleteSpaceConfirmDialog.tsx` +- Create: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/DeleteSpaceConfirmDialog.test.tsx` +- Modify: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceRowActions.tsx` +- Modify (or create): `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceRowActions.test.tsx` +- Modify (if needed): `apps/frontend/src/domain/spaces/api/queries/SpacesQueries.ts` (add `useDeleteSpaceMutation` if missing) + +- [ ] **Step 1: Implement** + +`DeleteSpaceConfirmDialog.tsx`: + +```tsx +import { useQueryClient } from '@tanstack/react-query'; +import { PMDialog, PMButton, PMText, useToast } from '@packmind/ui'; +import { useDeleteSpaceMutation } from '../../api/queries/SpacesQueries'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; +import type { Space } from '@packmind/types'; + +type Props = { + isOpen: boolean; + onClose: () => void; + space: Pick; +}; + +export function DeleteSpaceConfirmDialog({ isOpen, onClose, space }: Props) { + const { organization } = useAuthContext(); + const queryClient = useQueryClient(); + const toast = useToast(); + const { mutateAsync, isPending } = useDeleteSpaceMutation(); + + const handleConfirm = async () => { + try { + await mutateAsync({ spaceId: space.id, organizationId: organization!.id }); + toast({ title: `Space '${space.name}' deleted`, status: 'success' }); + await queryClient.invalidateQueries({ + queryKey: ['organizations', organization!.id, 'spaces', 'management'], + }); + onClose(); + } catch (err) { + toast({ title: 'Failed to delete space', status: 'error' }); + // dialog stays open + } + }; + + return ( + + {`Delete space '${space.name}'? This action is irreversible.`} + Cancel + Delete + + ); +} +``` + +Match the actual PM-prefixed primitives in this codebase per `working-with-pm-design-kit`. Mirror an existing confirmation dialog (search for `*ConfirmDialog.tsx`) for the exact dialog/button structure. + +If `useDeleteSpaceMutation` doesn't exist yet, add it next to the existing space mutations in `SpacesQueries.ts`, calling the existing backend `DELETE /organizations/:orgId/spaces-management/:spaceId` endpoint. + +`SpaceRowActions.tsx`: + +```tsx +import { useState } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { DeleteSpaceConfirmDialog } from './DeleteSpaceConfirmDialog'; + +export function SpaceRowActions({ space }: Props) { + const [confirmOpen, setConfirmOpen] = useState(false); + const { orgSlug } = useParams(); + const navigate = useNavigate(); + const showView = Boolean(space.slug); + const showDelete = !space.isDefaultSpace; + + return ( + <> + + {showView && ( + navigate(`/org/${orgSlug}/spaces/${space.slug}`)}> + View + + )} + {showDelete && ( + setConfirmOpen(true)}>Delete + )} + + {showDelete && ( + setConfirmOpen(false)} + space={space} + /> + )} + + ); +} +``` + +Edit row action: not rendered. + +- [ ] **Step 2: Write verification tests** + +```tsx +// DeleteSpaceConfirmDialog.test.tsx +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { DeleteSpaceConfirmDialog } from './DeleteSpaceConfirmDialog'; + +describe('DeleteSpaceConfirmDialog', () => { + it('renders the confirmation copy with the space name', () => { + renderWithQuery( + {}} space={{ id: 's1', name: 'Engineering' } as any} />, + ); + expect(screen.getByText(/Delete space 'Engineering'\?/)).toBeInTheDocument(); + expect(screen.getByText(/This action is irreversible\./)).toBeInTheDocument(); + }); + + it('Cancel calls onClose without mutating', async () => { + const onClose = jest.fn(); + renderWithQuery(); + await userEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(onClose).toHaveBeenCalled(); + }); + + it('Delete invokes the mutation, fires success toast, invalidates queries, and closes', async () => { + // Mock useDeleteSpaceMutation to return mutateAsync that resolves + const onClose = jest.fn(); + renderWithQuery(); + await userEvent.click(screen.getByRole('button', { name: /^delete$/i })); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + // assert toast and invalidation by spying on the toast helper / queryClient + }); + + it('on mutation error, fires error toast and stays open', async () => { + const onClose = jest.fn(); + // Mock useDeleteSpaceMutation to reject + renderWithQuery(); + await userEvent.click(screen.getByRole('button', { name: /^delete$/i })); + await waitFor(() => expect(onClose).not.toHaveBeenCalled()); + }); +}); +``` + +```tsx +// SpaceRowActions.test.tsx +describe('SpaceRowActions', () => { + it('hides Delete on the default org-wide space', () => { + render(); + expect(screen.queryByRole('menuitem', { name: /delete/i })).toBeNull(); + }); + + it('shows Delete on a non-default space and opens the confirmation dialog', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /actions/i })); + await userEvent.click(screen.getByRole('menuitem', { name: /delete/i })); + expect(screen.getByText(/Delete space/i)).toBeInTheDocument(); + }); + + it('hides View when slug is missing', () => { + render(); + expect(screen.queryByRole('menuitem', { name: /view/i })).toBeNull(); + }); + + it('does not render Edit', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /actions/i })); + expect(screen.queryByRole('menuitem', { name: /edit/i })).toBeNull(); + }); +}); +``` + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test frontend` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint frontend` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/domain/spaces/components/SpacesManagementPage/DeleteSpaceConfirmDialog.tsx \ + apps/frontend/src/domain/spaces/components/SpacesManagementPage/DeleteSpaceConfirmDialog.test.tsx \ + apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceRowActions.tsx \ + apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceRowActions.test.tsx \ + apps/frontend/src/domain/spaces/api/queries/SpacesQueries.ts +git commit -m ":sparkles: feat(frontend): add delete confirmation flow to spaces management row actions" +``` + +--- + +### Task 20: Wire `CreateSpaceDialog.onCreated` from `SpacesToolbar` to invalidate the new query + +**Files:** +- Modify: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesToolbar.tsx` +- Modify (if needed): `apps/frontend/src/domain/spaces-management/components/CreateSpaceDialog.tsx` to ensure an `onCreated` callback prop exists +- Modify (or create): `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesToolbar.test.tsx` + +- [ ] **Step 1: Implement** + +`SpacesToolbar.tsx`: + +```tsx +import { useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { CreateSpaceDialog } from '../../../spaces-management/components/CreateSpaceDialog'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; + +export function SpacesToolbar() { + const [open, setOpen] = useState(false); + const queryClient = useQueryClient(); + const { organization } = useAuthContext(); + + const handleCreated = async () => { + if (organization) { + await queryClient.invalidateQueries({ + queryKey: ['organizations', organization.id, 'spaces', 'management'], + }); + } + setOpen(false); + }; + + return ( + <> + {/* visual-only Search input + Admin/Member dropdowns kept as-is */} + setOpen(true)}>+ New space + setOpen(false)} + redirectAfterCreate={false} + onCreated={handleCreated} + /> + + ); +} +``` + +If `CreateSpaceDialog` does not yet expose an `onCreated` prop, add it: a callback invoked after a successful create, before closing. + +- [ ] **Step 2: Write verification tests** + +```tsx +// SpacesToolbar.test.tsx +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SpacesToolbar } from './SpacesToolbar'; +import { QueryClient } from '@tanstack/react-query'; + +describe('SpacesToolbar', () => { + it('opens CreateSpaceDialog and invalidates the management query on success', async () => { + const invalidateSpy = jest.spyOn(QueryClient.prototype, 'invalidateQueries'); + renderWithAuthAndQuery(); + await userEvent.click(screen.getByRole('button', { name: /new space/i })); + // simulate the dialog calling onCreated + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: expect.arrayContaining(['spaces', 'management']) }), + ); + }); +}); +``` + +If a precise `onCreated` simulation is hard in the existing test harness, replace with a unit test that asserts the `onCreated` callback wired by the toolbar invokes `queryClient.invalidateQueries` with the expected key. + +- [ ] **Step 3: Run tests** + +Run: `./node_modules/.bin/nx test frontend --testFile=SpacesToolbar.test.tsx` +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint frontend` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesToolbar.tsx \ + apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesToolbar.test.tsx \ + apps/frontend/src/domain/spaces-management/components/CreateSpaceDialog.tsx +git commit -m ":sparkles: feat(frontend): invalidate management spaces query after CreateSpaceDialog success" +``` + +--- + +## End-to-end + +### Task 21: E2E — `+ New space` keeps the user on the page (Rule 3) + +**Files:** +- Create or update: `apps/e2e-tests//SpacesManagementPage.ts` (POM helper) +- Create: `apps/e2e-tests/specs/spaces-management-create.spec.ts` + +- [ ] **Step 1: Implement (POM helper)** + +Add (or update) a Playwright Page Object Model helper for the spaces management page. Mirror the existing POM pattern from `apps/e2e-tests/`. + +Helper methods needed: +- `goto(orgSlug)` — navigate to `/org/{orgSlug}/settings/spaces` +- `clickNewSpace()` — clicks `+ New space` +- `fillCreateForm({ name, type })` — fills the dialog inputs +- `submitCreateForm()` — clicks `Create space` +- `getRowByName(name)` — returns a Locator for a row matching a space name + +- [ ] **Step 2: Write verification spec** + +```typescript +// apps/e2e-tests/specs/spaces-management-create.spec.ts +test('creating a space stays on the management page', async ({ page }) => { + await loginAsOrgAdmin(page); // existing helper + const pom = new SpacesManagementPagePom(page); + await pom.goto('acme'); + + await pom.clickNewSpace(); + const name = `E2E Space ${Date.now()}`; + await pom.fillCreateForm({ name, type: 'open' }); + await pom.submitCreateForm(); + + // dialog closed + await expect(page.getByRole('dialog')).toHaveCount(0); + // still on the page + await expect(page).toHaveURL(/\/settings\/spaces$/); + // new row visible + await expect(pom.getRowByName(name)).toBeVisible(); +}); +``` + +- [ ] **Step 3: Run the spec** + +Run the e2e suite for this single spec (use the project's e2e command — `./node_modules/.bin/nx e2e e2e-tests --testFile=spaces-management-create.spec.ts` or whatever the project uses). +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint e2e-tests` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/e2e-tests/specs/spaces-management-create.spec.ts \ + apps/e2e-tests//SpacesManagementPage.ts +git commit -m ":white_check_mark: test(e2e): create space stays on management page" +``` + +--- + +### Task 22: E2E — Delete with confirmation (Rule 4) + +**Files:** +- Update: `apps/e2e-tests//SpacesManagementPage.ts` +- Create: `apps/e2e-tests/specs/spaces-management-delete.spec.ts` + +- [ ] **Step 1: Implement (POM helper additions)** + +Add helpers: +- `openRowActions(spaceName)` — opens the row actions menu for a given space +- `clickDeleteAction()` — clicks `Delete` in the open menu +- `confirmDelete()` — clicks the `Delete` button in the confirm modal +- `getDefaultSpaceRow()` — returns the row for the default org-wide space + +- [ ] **Step 2: Write verification spec** + +```typescript +// apps/e2e-tests/specs/spaces-management-delete.spec.ts +test('a space can be deleted from the row actions menu', async ({ page }) => { + await loginAsOrgAdmin(page); + const seeded = await seedSpace({ orgSlug: 'acme', name: 'To Delete' }); // existing factory + const pom = new SpacesManagementPagePom(page); + await pom.goto('acme'); + + await pom.openRowActions('To Delete'); + await pom.clickDeleteAction(); + + await expect(page.getByText("Delete space 'To Delete'? This action is irreversible.")).toBeVisible(); + await pom.confirmDelete(); + + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(page.getByText(/space.*deleted/i)).toBeVisible(); // success toast + await expect(pom.getRowByName('To Delete')).toHaveCount(0); +}); + +test('the default org-wide space hides the Delete action', async ({ page }) => { + await loginAsOrgAdmin(page); + const pom = new SpacesManagementPagePom(page); + await pom.goto('acme'); + await pom.openRowActions('Global'); // default space name + await expect(page.getByRole('menuitem', { name: /delete/i })).toHaveCount(0); +}); +``` + +- [ ] **Step 3: Run the spec** + +Run the e2e suite for this spec. +Expected: PASS. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint e2e-tests` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/e2e-tests/specs/spaces-management-delete.spec.ts \ + apps/e2e-tests//SpacesManagementPage.ts +git commit -m ":white_check_mark: test(e2e): delete space with confirmation modal" +``` + +--- + +## Final validation + +After all 22 tasks complete: + +- [ ] **Run all affected tests:** `npm run test:staged` +- [ ] **Run all affected lints:** `npm run lint:staged` +- [ ] **Manual smoke (frontend):** + - Start the local stack (Docker Compose). + - Sign in with a feature-flag-eligible org admin. + - Navigate to `/org/{slug}/settings/spaces`. + - Verify rows render with admins/members/artifacts/created. + - Paginate forward and backward. + - Create a new space → stays on page, row appears. + - Delete a non-default space → modal copy correct, row disappears. + - Open row actions on the default space → `Delete` not shown. + - Click `View` on a non-default space → navigates to its dashboard. + +- [ ] **No `--no-verify` was used at any commit.** Confirm via `git log --since='1 day ago' --pretty=full`. diff --git a/.claude/plans/2026-04-29-space-identity-and-rights-management.md b/.claude/plans/2026-04-29-space-identity-and-rights-management.md new file mode 100644 index 000000000..36dabd89a --- /dev/null +++ b/.claude/plans/2026-04-29-space-identity-and-rights-management.md @@ -0,0 +1,1279 @@ +# Space Identity and Rights Management (Org Admin Drawer) Implementation Plan + +> **For agentic execution:** Use `packmind:architect-executor` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a side drawer to `/org/:orgSlug/settings/spaces` that lets an org admin manage a space's identity (name + color), members (add / remove / change role), and deletion via three tabs (General / Members / Danger), without leaving the listing. + +**Architecture:** Backend gains a small authz extension — three space-membership use cases get the same org-admin short-circuit `UpdateSpaceUseCase` already has. Frontend lifts data dependencies out of two existing section components so the drawer can compose them, and adds one new `SpaceManagementDrawer` container plus row-click wiring on the listing table. + +**Tech Stack:** NestJS use cases (`AbstractSpaceAdminUseCase`), TypeORM unchanged, React + Chakra (`PMDrawer`, `PMTabs`), TanStack Query mutations and invalidations. + +**Source Spec:** `.claude/specs/2026-04-29-space-identity-and-rights-management-design.md` +**EM Spec:** None (fresh idea) +**Docs consulted:** +- `.claude/docs/domain-map.md` +- `.claude/docs/discoveries/2026-04-28-session-orga-space-management.md` +- `.claude/docs/patterns/frontend/pmtable-jest-mock.md` +- `.claude/specs/2026-04-27-orga-space-management-design.md` + +--- + +## Task 1: Extend `AddMembersToSpaceUseCase` to allow org admins + +**Files:** +- Modify: `packages/spaces/src/application/usecases/AddMembersToSpaceUseCase.ts` +- Modify: `packages/spaces/src/application/usecases/AddMembersToSpaceUseCase.spec.ts` + +- [ ] **Step 1: Add the org-admin override** + +In `AddMembersToSpaceUseCase.ts`, add `executeForMembers` override above `executeForSpaceAdmins`. Import `MemberContext` from `@packmind/node-utils`. Final shape: + +```ts +import { + AbstractSpaceAdminUseCase, + MemberContext, + PackmindEventEmitterService, + SpaceAdminContext, +} from '@packmind/node-utils'; + +// ...inside class AddMembersToSpaceUseCase: + +protected override async executeForMembers( + command: AddMembersToSpaceCommand & MemberContext, +): Promise { + if (command.membership.role === 'admin') { + return this.executeForSpaceAdmins(command); + } + return super.executeForMembers(command); +} +``` + +The existing `executeForSpaceAdmins` body stays untouched. + +- [ ] **Step 2: Add a test for org-admin success path** + +In `AddMembersToSpaceUseCase.spec.ts`, add a new `describe('when caller is an org admin without space admin role', () => { ... })` block. Pattern is copied from `UpdateSpaceUseCase.spec.ts:396` (search "when caller is an org admin without space admin role"). The block sets `accountsPort.getUserById.mockResolvedValue(orgAdmin)` where `orgAdmin` has `memberships: [{ userId, organizationId, role: 'admin' }]`. The use case must succeed (return created memberships) and emit `SpaceMembersAddedEvent` with the org admin's `userId`. Also add an assertion that `spacesPort.findMembership` is NOT called for the caller (the override skips it). + +```ts +describe('when caller is an org admin without space admin role', () => { + beforeEach(() => { + const orgAdmin = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + accountsPort.getUserById.mockResolvedValue(orgAdmin); + membershipService.addSpaceMembership.mockResolvedValue( + userSpaceMembershipFactory({ + userId: targetUserId, + spaceId, + role: UserSpaceRole.MEMBER, + }), + ); + }); + + it('adds members without checking space-admin membership', async () => { + const result = await useCase.execute( + buildCommand({ + members: [{ userId: targetUserId, role: UserSpaceRole.MEMBER }], + }), + ); + + expect(result).toHaveLength(1); + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + userId, + organizationId, + spaceId, + }), + ); + expect(spacesPort.findMembership).not.toHaveBeenCalled(); + }); +}); +``` + +Adapt the imports/factory calls to match the existing spec file structure (the spec already imports `userFactory`, `userSpaceMembershipFactory`, etc.). + +- [ ] **Step 3: Run tests; expected: pass** + +Run: `./node_modules/.bin/nx test spaces --testFile=AddMembersToSpaceUseCase.spec.ts` +Expected: all tests (existing + new) pass. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint spaces` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/spaces/src/application/usecases/AddMembersToSpaceUseCase.ts \ + packages/spaces/src/application/usecases/AddMembersToSpaceUseCase.spec.ts +git commit -m ":sparkles: feat(spaces): allow org admins to add members to any space" +``` + +--- + +## Task 2: Extend `RemoveMemberFromSpaceUseCase` to allow org admins + +**Files:** +- Modify: `packages/spaces/src/application/usecases/RemoveMemberFromSpaceUseCase.ts` +- Modify: `packages/spaces/src/application/usecases/RemoveMemberFromSpaceUseCase.spec.ts` + +- [ ] **Step 1: Add the org-admin override** + +In `RemoveMemberFromSpaceUseCase.ts`, import `MemberContext` and add the override: + +```ts +protected override async executeForMembers( + command: RemoveMemberFromSpaceCommand & MemberContext, +): Promise { + if (command.membership.role === 'admin') { + return this.executeForSpaceAdmins(command); + } + return super.executeForMembers(command); +} +``` + +The existing `executeForSpaceAdmins` body stays untouched. Note: `CannotRemoveFromDefaultSpaceError` and `CannotRemoveSelfError` are still raised inside `executeForSpaceAdmins` so they continue to apply to org admins. + +- [ ] **Step 2: Add tests for org-admin paths** + +In `RemoveMemberFromSpaceUseCase.spec.ts`, add a new describe block: + +```ts +describe('when caller is an org admin without space admin role', () => { + beforeEach(() => { + const orgAdmin = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + accountsPort.getUserById.mockResolvedValue(orgAdmin); + membershipService.getSpaceById.mockResolvedValue( + spaceFactory({ id: spaceId, isDefaultSpace: false }), + ); + membershipService.removeSpaceMembership.mockResolvedValue(true); + }); + + it('removes a member without checking space-admin membership', async () => { + const result = await useCase.execute(buildCommand()); + + expect(result).toEqual({ removed: true }); + expect(eventEmitterService.emit).toHaveBeenCalled(); + expect(spacesPort.findMembership).not.toHaveBeenCalled(); + }); + + it('still rejects removal from the default space', async () => { + membershipService.getSpaceById.mockResolvedValue( + spaceFactory({ id: spaceId, isDefaultSpace: true }), + ); + + await expect(useCase.execute(buildCommand())).rejects.toBeInstanceOf( + CannotRemoveFromDefaultSpaceError, + ); + }); + + it('still rejects self-removal', async () => { + await expect( + useCase.execute(buildCommand({ targetUserId: userId })), + ).rejects.toBeInstanceOf(CannotRemoveSelfError); + }); +}); +``` + +Imports needed: `spaceFactory` from `@packmind/spaces/test/spaceFactory`; `CannotRemoveFromDefaultSpaceError` and `CannotRemoveSelfError` from the local `domain/errors` (existing imports in the spec). + +- [ ] **Step 3: Run tests; expected: pass** + +Run: `./node_modules/.bin/nx test spaces --testFile=RemoveMemberFromSpaceUseCase.spec.ts` +Expected: all tests pass. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint spaces` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/spaces/src/application/usecases/RemoveMemberFromSpaceUseCase.ts \ + packages/spaces/src/application/usecases/RemoveMemberFromSpaceUseCase.spec.ts +git commit -m ":sparkles: feat(spaces): allow org admins to remove members from any space" +``` + +--- + +## Task 3: Extend `UpdateMemberRoleUseCase` to allow org admins + +**Files:** +- Modify: `packages/spaces/src/application/usecases/UpdateMemberRoleUseCase.ts` +- Modify: `packages/spaces/src/application/usecases/UpdateMemberRoleUseCase.spec.ts` + +- [ ] **Step 1: Add the org-admin override** + +In `UpdateMemberRoleUseCase.ts`, import `MemberContext` and add the override: + +```ts +protected override async executeForMembers( + command: UpdateMemberRoleCommand & MemberContext, +): Promise { + if (command.membership.role === 'admin') { + return this.executeForSpaceAdmins(command); + } + return super.executeForMembers(command); +} +``` + +The existing `executeForSpaceAdmins` body is untouched. `CannotUpdateOwnRoleError` and `MemberNotFoundError` continue to be raised inside, so they still apply to org admins. + +- [ ] **Step 2: Add tests for org-admin paths** + +In `UpdateMemberRoleUseCase.spec.ts`, add a new describe block: + +```ts +describe('when caller is an org admin without space admin role', () => { + beforeEach(() => { + const orgAdmin = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + accountsPort.getUserById.mockResolvedValue(orgAdmin); + membershipService.findMembership.mockResolvedValue( + userSpaceMembershipFactory({ + userId: targetUserId, + spaceId, + role: UserSpaceRole.MEMBER, + }), + ); + membershipService.updateMembershipRole.mockResolvedValue(true); + }); + + it('updates a member role without checking space-admin membership', async () => { + const result = await useCase.execute( + buildCommand({ role: UserSpaceRole.ADMIN }), + ); + + expect(result).toEqual({ updated: true }); + expect(eventEmitterService.emit).toHaveBeenCalled(); + expect(spacesPort.findMembership).not.toHaveBeenCalled(); + }); + + it('still rejects self-role-update', async () => { + await expect( + useCase.execute(buildCommand({ targetUserId: userId })), + ).rejects.toBeInstanceOf(CannotUpdateOwnRoleError); + }); + + it('still rejects when target is not a member', async () => { + membershipService.findMembership.mockResolvedValue(null); + + await expect(useCase.execute(buildCommand())).rejects.toBeInstanceOf( + MemberNotFoundError, + ); + }); +}); +``` + +- [ ] **Step 3: Run tests; expected: pass** + +Run: `./node_modules/.bin/nx test spaces --testFile=UpdateMemberRoleUseCase.spec.ts` +Expected: all tests pass. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint spaces` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add packages/spaces/src/application/usecases/UpdateMemberRoleUseCase.ts \ + packages/spaces/src/application/usecases/UpdateMemberRoleUseCase.spec.ts +git commit -m ":sparkles: feat(spaces): allow org admins to change any member's role" +``` + +--- + +## Task 4: Extend `members.controller.spec.ts` with org-admin coverage + +**Files:** +- Modify: `apps/api/src/app/organizations/spaces/members/members.controller.spec.ts` + +- [ ] **Step 1: Read the existing controller spec** + +Use `Read` on `apps/api/src/app/organizations/spaces/members/members.controller.spec.ts` to understand the existing test structure. The test should be unit-style (direct controller method calls + assert on rejections), following the convention documented in `.claude/docs/discoveries/2026-04-28-session-orga-space-management.md` item 5. + +- [ ] **Step 2: Add three smoke tests confirming the controller doesn't reject org-admin callers** + +Add tests that mock `membersService.addMembersToSpace`, `removeMemberFromSpace`, and `updateMemberRole` to resolve successfully and confirm no exception is thrown. These are smoke checks — the underlying authz logic is fully covered by the use-case specs in tasks 1-3. + +```ts +describe('when caller is an org admin (not a space member)', () => { + it('addMembers succeeds', async () => { + membersService.addMembersToSpace.mockResolvedValue([]); + + await expect( + controller.addMembers(orgId, spaceId, { members: [] }, request), + ).resolves.toEqual([]); + }); + + it('removeMember succeeds', async () => { + membersService.removeMemberFromSpace.mockResolvedValue({ removed: true }); + + await expect( + controller.removeMember(orgId, spaceId, targetUserId, request), + ).resolves.toEqual({ removed: true }); + }); + + it('updateMemberRole succeeds', async () => { + membersService.updateMemberRole.mockResolvedValue({ updated: true }); + + await expect( + controller.updateMemberRole( + orgId, + spaceId, + targetUserId, + { role: 'admin' }, + request, + ), + ).resolves.toEqual({ updated: true }); + }); +}); +``` + +Adapt mock variable names (`membersService`, `controller`, `orgId`, `spaceId`, `targetUserId`, `request`) to match the existing test file's local names. + +- [ ] **Step 3: Run tests; expected: pass** + +Run: `./node_modules/.bin/nx test api --testFile=members.controller.spec.ts` +Expected: all tests pass. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint api` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/app/organizations/spaces/members/members.controller.spec.ts +git commit -m ":white_check_mark: test(api): cover org-admin paths in members controller" +``` + +--- + +## Task 5: Refactor `SpaceMembersList` to be prop-driven + +**Files:** +- Modify: `apps/frontend/src/domain/spaces/components/SpaceMembersList.tsx` +- Modify: `apps/frontend/src/domain/spaces/components/SpaceMembersList.test.tsx` + +- [ ] **Step 1: Refactor the component** + +Change the props from implicit (via `useCurrentSpace`) to explicit. New signature: + +```tsx +import { Space } from '@packmind/types'; + +interface SpaceMembersListProps { + space: Space; + isSpaceAdmin: boolean; +} + +export function SpaceMembersList({ + space, + isSpaceAdmin, +}: Readonly) { + const { user } = useAuthContext(); + const currentUserId = user?.id; + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [memberToRemove, setMemberToRemove] = useState( + null, + ); + + const { data, isLoading } = useGetSpaceMembersQuery(space.id); + const removeMutation = useRemoveMemberFromSpaceMutation(space.id); + const updateRoleMutation = useUpdateMemberRoleMutation(space.id); + + // ...rest of the existing body, replacing `spaceId ?? ''` with `space.id` + // and replacing `space?.isDefaultSpace` with `space.isDefaultSpace` +} +``` + +Remove the `useCurrentSpace` import. Replace internal usages of `spaceId` and `space` with the new prop-derived values. Drop the local `isSpaceAdmin` derivation — use the prop instead. + +- [ ] **Step 2: Update the test file** + +Existing test cases assume the component pulls from `useCurrentSpace`. Update each render call to pass `space` and `isSpaceAdmin` as props: + +```tsx +const space = spaceFactory({ id: createSpaceId('s-1'), isDefaultSpace: false }); + +render(, { wrapper }); +``` + +Use `spaceFactory` from `@packmind/spaces/test/spaceFactory`. Add a new test case: + +```tsx +it('when isSpaceAdmin is false, hides the Add button and disables remove/role controls', () => { + // setup query mock to return at least one non-current member + render(, { wrapper }); + + expect(screen.queryByRole('button', { name: /add members/i })).toBeNull(); + // role select should be disabled — depends on PMNativeSelect's disabled rendering +}); +``` + +If the existing test mocks `useCurrentSpace`, remove that mock entirely. If it mocks `useGetSpaceMembersQuery`, keep that. PMTable mock is required if `SpaceMembersTable` is rendered — see `.claude/docs/patterns/frontend/pmtable-jest-mock.md`. + +- [ ] **Step 3: Run tests; expected: pass** + +Run: `./node_modules/.bin/nx test frontend --testFile=SpaceMembersList.test.tsx` +Expected: all tests pass. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint frontend` +Expected: no errors. (Note: typecheck may break in callers — they are fixed in Task 8.) + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/domain/spaces/components/SpaceMembersList.tsx \ + apps/frontend/src/domain/spaces/components/SpaceMembersList.test.tsx +git commit -m ":art: refactor(spaces): make SpaceMembersList prop-driven" +``` + +--- + +## Task 6: Refactor `SpaceDangerZoneSection` to be prop-driven + +**Files:** +- Modify: `apps/frontend/src/domain/spaces/components/SpaceDangerZoneSection.tsx` +- Modify: `apps/frontend/src/domain/spaces/components/SpaceDangerZoneSection.test.tsx` + +- [ ] **Step 1: Read the existing component to confirm the current API** + +Use `Read` on `apps/frontend/src/domain/spaces/components/SpaceDangerZoneSection.tsx`. Today it likely calls `useCurrentSpace()` and `useDeleteSpaceMutation()` and redirects on success. Confirm this before refactoring. + +- [ ] **Step 2: Refactor the component** + +New signature: + +```tsx +import { Space } from '@packmind/types'; + +interface SpaceDangerZoneSectionProps { + space: Space; + canDelete: boolean; + onDeleted?: () => void; +} + +export function SpaceDangerZoneSection({ + space, + canDelete, + onDeleted, +}: Readonly) { + // existing local state for the confirmation dialog open/close + const deleteMutation = useDeleteSpaceMutation(); + const navigate = useNavigate(); + + const handleConfirm = async () => { + try { + await deleteMutation.mutateAsync({ spaceId: space.id }); + pmToaster.create({ type: 'success', title: 'Space deleted' }); + if (onDeleted) { + onDeleted(); + } else { + // existing default redirect (preserve whatever path was there) + } + } catch (err) { + // existing error toaster + } + }; + + // ...rest of the existing JSX, with `canDelete` gating the button +} +``` + +Replace the original `useCurrentSpace` usage with `space` prop. The `canDelete` prop replaces whatever the previous internal derivation was. The `onDeleted` callback replaces the default redirect when provided. + +- [ ] **Step 3: Update the test file** + +Render with the new props: + +```tsx +const space = spaceFactory({ id: createSpaceId('s-1'), isDefaultSpace: false }); + +it('renders delete button when canDelete is true', () => { + render(, { wrapper }); + expect(screen.getByRole('button', { name: /delete/i })).toBeEnabled(); +}); + +it('disables delete button when canDelete is false', () => { + render(, { wrapper }); + expect(screen.queryByRole('button', { name: /delete/i })).toBeDisabled(); +}); + +it('calls onDeleted instead of redirecting when provided', async () => { + const onDeleted = jest.fn(); + // mock the delete mutation to resolve + render( + , + { wrapper }, + ); + // simulate confirm flow → assert onDeleted called and navigate NOT called + expect(onDeleted).toHaveBeenCalled(); +}); +``` + +Remove any `useCurrentSpace` mock. Mock `useDeleteSpaceMutation` and `useNavigate` per existing patterns in the test file. + +- [ ] **Step 4: Run tests; expected: pass** + +Run: `./node_modules/.bin/nx test frontend --testFile=SpaceDangerZoneSection.test.tsx` +Expected: all tests pass. + +- [ ] **Step 5: Lint** + +Run: `./node_modules/.bin/nx lint frontend` +Expected: no errors. (Caller `SpaceGeneralSettings` will typecheck-fail until Task 7.) + +- [ ] **Step 6: Commit** + +```bash +git add apps/frontend/src/domain/spaces/components/SpaceDangerZoneSection.tsx \ + apps/frontend/src/domain/spaces/components/SpaceDangerZoneSection.test.tsx +git commit -m ":art: refactor(spaces): make SpaceDangerZoneSection prop-driven" +``` + +--- + +## Task 7: Adapt `SpaceGeneralSettings` to pass `space` to `SpaceDangerZoneSection` + +**Files:** +- Modify: `apps/frontend/src/domain/spaces/components/SpaceGeneralSettings.tsx` + +- [ ] **Step 1: Update the call site** + +In `SpaceGeneralSettings.tsx`, the existing line `` becomes: + +```tsx +{!space?.isDefaultSpace && space && ( + +)} +``` + +(Adapt to whatever the existing exact JSX is; the only changes are: pass `space` prop and rename `canDeleteSpace` prop to `canDelete`.) Keep the rest of the file unchanged. The component already has `space` available from `useCurrentSpace`. + +- [ ] **Step 2: Run frontend typecheck** + +Run: `./node_modules/.bin/nx typecheck frontend` +Expected: no errors. + +- [ ] **Step 3: Lint** + +Run: `./node_modules/.bin/nx lint frontend` +Expected: no errors. + +- [ ] **Step 4: Run frontend tests for the per-space settings tree** + +Run: `./node_modules/.bin/nx test frontend --testPathPattern="(SpaceGeneralSettings|SpaceSettingsPage)"` +Expected: any existing tests pass (none may exist for these — that's OK). + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/domain/spaces/components/SpaceGeneralSettings.tsx +git commit -m ":art: refactor(spaces): pass space prop to SpaceDangerZoneSection" +``` + +--- + +## Task 8: Adapt `SpaceSettingsPage` to pass `space` and `isSpaceAdmin` to `SpaceMembersList` + +**Files:** +- Modify: `apps/frontend/src/domain/spaces/components/SpaceSettingsPage.tsx` + +- [ ] **Step 1: Update the page to pass props down** + +`SpaceSettingsPage.tsx` currently renders `` with no props. Update it to fetch `space` via `useCurrentSpace` and `isSpaceAdmin` via the existing pattern: + +```tsx +import { useAuthContext } from '../../accounts/hooks/useAuthContext'; +import { useCurrentSpace } from '../hooks/useCurrentSpace'; +import { useGetSpaceMembersQuery } from '../api/queries/SpacesQueries'; + +export function SpaceSettingsPage() { + const { space, spaceId } = useCurrentSpace(); + const { user } = useAuthContext(); + const { data: membersData } = useGetSpaceMembersQuery(spaceId ?? ''); + + const isSpaceAdmin = + membersData?.members?.find((m) => m.userId === user?.id)?.role === 'admin'; + + const tabs = [ + { + value: 'general', + triggerLabel: 'General', + content: , + }, + { + value: 'members', + triggerLabel: 'Members', + content: ( + + {space && ( + + )} + + ), + }, + ]; + + return ( + + + + ); +} +``` + +- [ ] **Step 2: Run frontend typecheck** + +Run: `./node_modules/.bin/nx typecheck frontend` +Expected: no errors. After this task the existing per-space settings tree compiles end-to-end. + +- [ ] **Step 3: Lint** + +Run: `./node_modules/.bin/nx lint frontend` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add apps/frontend/src/domain/spaces/components/SpaceSettingsPage.tsx +git commit -m ":art: refactor(spaces): pass space and isSpaceAdmin to SpaceMembersList" +``` + +--- + +## Task 9: Add `SpaceManagementDrawer` component + +**Files:** +- Create: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceManagementDrawer.tsx` +- Create: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceManagementDrawer.test.tsx` + +- [ ] **Step 1: Implement the drawer container** + +```tsx +import { useMemo, useState } from 'react'; +import { + PMBox, + PMCloseButton, + PMDrawer, + PMHeading, + PMHStack, + PMPortal, + PMStatus, + PMTabs, + PMText, + PMVStack, +} from '@packmind/ui'; +import { useQueryClient } from '@tanstack/react-query'; +import { Space } from '@packmind/types'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; +import { useGetSpaceMembersQuery } from '../../api/queries/SpacesQueries'; +import { SpaceIdentitySection } from '../SpaceIdentitySection'; +import { SpaceMembersList } from '../SpaceMembersList'; +import { SpaceDangerZoneSection } from '../SpaceDangerZoneSection'; +import { SpaceManagementListItem } from './types'; + +type DrawerTab = 'general' | 'members' | 'danger'; + +interface SpaceManagementDrawerProps { + space: SpaceManagementListItem | null; + onClose: () => void; +} + +export function SpaceManagementDrawer({ + space, + onClose, +}: Readonly) { + const queryClient = useQueryClient(); + const { user, organization } = useAuthContext(); + const [activeTab, setActiveTab] = useState('general'); + + const { data: membersData } = useGetSpaceMembersQuery(space?.id ?? ''); + + const currentUserMember = membersData?.members?.find( + (m) => m.userId === user?.id, + ); + const isSpaceAdmin = currentUserMember?.role === 'admin'; + const isOrgAdmin = organization?.role === 'admin'; + const canEdit = isSpaceAdmin || isOrgAdmin; + const canDelete = isSpaceAdmin || isOrgAdmin; + + const handleDeleted = () => { + if (organization?.id) { + queryClient.invalidateQueries({ + queryKey: ['organizations', organization.id, 'spaces', 'management'], + }); + } + onClose(); + }; + + const tabs = useMemo(() => { + if (!space) return []; + const base = [ + { + value: 'general' as const, + triggerLabel: 'General', + content: , + }, + { + value: 'members' as const, + triggerLabel: 'Members', + content: ( + + + + ), + }, + ]; + if (!space.isDefaultSpace) { + base.push({ + value: 'danger' as const, + triggerLabel: 'Danger', + content: ( + + ), + }); + } + return base; + }, [space, canEdit, canDelete, handleDeleted]); + + return ( + { + if (!e.open) { + setActiveTab('general'); + onClose(); + } + }} + placement="end" + size="md" + > + + + + + {space && ( + <> + + + + + + + + {space.name} + + + {space.membersCount} member + {space.membersCount === 1 ? '' : 's'} ·{' '} + {space.artifactsCount} artifact + {space.artifactsCount === 1 ? '' : 's'} + + + + + + + setActiveTab(value as DrawerTab) + } + tabs={tabs} + /> + + + + + + )} + + + + + ); +} +``` + +If `PMTabs` exposes a different controlled-value prop than `value`/`onValueChange`, adapt — examine an existing controlled `PMTabs` usage (search `apps/frontend/src` for `PMTabs`). The status indicator color is the SpaceColor token (`red | orange | … | pink`). Adjust the `colorPalette` prop name if PMStatus.Indicator uses something different — check an existing usage in `apps/frontend/src/domain/spaces/components/SpaceIdentitySection.tsx` or the playground prototype. + +- [ ] **Step 2: Write tests** + +```tsx +import { render, screen } from '@testing-library/react'; +import { SpaceManagementDrawer } from './SpaceManagementDrawer'; +// ...wrapper with QueryClientProvider, AuthContext mock, etc. + +// PMTable mock per .claude/docs/patterns/frontend/pmtable-jest-mock.md + +describe('SpaceManagementDrawer', () => { + const space = makeSpaceManagementListItem({ isDefaultSpace: false }); + const defaultSpace = makeSpaceManagementListItem({ isDefaultSpace: true }); + + it('does not render when space is null', () => { + render(, { wrapper }); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('renders three tabs for a non-default space', () => { + render(, { wrapper }); + expect(screen.getByRole('tab', { name: /general/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /members/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /danger/i })).toBeInTheDocument(); + }); + + it('omits the Danger tab for the default space', () => { + render(, { wrapper }); + expect(screen.queryByRole('tab', { name: /danger/i })).toBeNull(); + }); + + it('renders the header subtitle with member and artifact counts', () => { + const s = makeSpaceManagementListItem({ + membersCount: 5, + artifactsCount: 12, + isDefaultSpace: false, + }); + render(, { wrapper }); + expect(screen.getByText(/5 members/)).toBeInTheDocument(); + expect(screen.getByText(/12 artifacts/)).toBeInTheDocument(); + }); + + it('calls onClose when the drawer requests close', () => { + const onClose = jest.fn(); + render(, { wrapper }); + // Trigger onOpenChange with open=false — adapt to PMDrawer's actual API + // e.g. fireEvent.click(screen.getByRole('button', { name: /close/i })) + expect(onClose).toHaveBeenCalled(); + }); +}); + +function makeSpaceManagementListItem( + overrides: Partial = {}, +): SpaceManagementListItem { + return { + id: createSpaceId('s-1'), + name: 'Frontend', + slug: 'frontend', + color: 'orange', + type: SpaceType.open, + organizationId: createOrganizationId('org-1'), + isDefaultSpace: false, + admins: [], + membersCount: 3, + artifactsCount: 7, + createdAt: '2026-04-01T00:00:00.000Z', + ...overrides, + }; +} +``` + +Mock `useGetSpaceMembersQuery` to return a stable response. Mock `useAuthContext` to set `organization.role`. The PMTable mock from the pattern doc is required because `SpaceMembersTable` is rendered inside `SpaceMembersList`. + +- [ ] **Step 3: Run tests; expected: pass** + +Run: `./node_modules/.bin/nx test frontend --testFile=SpaceManagementDrawer.test.tsx` +Expected: all tests pass. + +- [ ] **Step 4: Lint** + +Run: `./node_modules/.bin/nx lint frontend` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceManagementDrawer.tsx \ + apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceManagementDrawer.test.tsx +git commit -m ":sparkles: feat(frontend): add SpaceManagementDrawer for org-admin space management" +``` + +--- + +## Task 10: Wire row click in `SpacesTable` + +**Files:** +- Modify: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesTable.tsx` +- Modify: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesTable.test.tsx` + +- [ ] **Step 1: Add `onSelectSpace` callback and wire it** + +Add a new prop: + +```tsx +interface SpacesTableProps { + spaces: SpaceListItem[]; + // ...existing props + onSelectSpace?: (space: SpaceListItem) => void; +} +``` + +Inside the row rendering, attach a click handler that calls `onSelectSpace(space)` only when the click target is the row body (not a button or interactive element): + +```tsx + { + const target = e.target as HTMLElement; + // Skip clicks on interactive elements + if (target.closest('button, a, input, [role="menu"], [role="menuitem"]')) { + return; + } + onSelectSpace?.(space); + }} + cursor={onSelectSpace ? 'pointer' : 'default'} +/> +``` + +If `PMTableRow` doesn't accept an `onClick` prop directly, wrap each cell content in a clickable container, or use a different pattern. Read `PMTable` source or an existing row-clickable example before deciding. The existing kebab menu (`SpaceRowActions`) must continue to work — its buttons are wrapped in ` + + + + + + + + + + + + +
QueryShould TriggerActions
+ +

+ + + + diff --git a/.claude/skills/audit-skill-context/README.md b/.claude/skills/audit-skill-context/README.md new file mode 100644 index 000000000..14bec854f --- /dev/null +++ b/.claude/skills/audit-skill-context/README.md @@ -0,0 +1,35 @@ +# audit-skill-context + +A meta-skill that audits another agent skill for context-inflation hotspots and writes a priority-ranked report with a 1–10 efficiency score. + +## Purpose + +Skills pay a context tax every time they trigger. SKILL.md, the description, and any eagerly-cited reference file get loaded into the conversation — whether the agent ends up needing them or not. This skill scans a target skill folder and surfaces where that footprint can be cut: bloated descriptions, oversized SKILL.md, eager loads that should be lazy, decision trees the agent has to evaluate every run, formulaic boilerplate, broken or unused references. + +Output: `./audit-skill-context-.md` at project root. + +## Philosophy + +- **Detect, don't rewrite.** The audit produces findings; the skill author decides what to apply. Never edit the target. +- **Eager vs lazy is the central distinction.** What loads on every trigger is the budget that matters; lazy references are nearly free. +- **Frequency-weighted.** A skill that fires on every commit needs to be leaner than one that fires monthly. Penalties scale with how often the skill triggers. +- **Mechanical where possible, judgment where necessary.** A preflight script handles inventory, citation classification, and pattern detection that can be regex-matched. The agent applies semantic judgment for patterns scripts can't reliably catch (decision trees, meta prose, sequential commands). +- **Single-skill scope.** No cross-skill comparison, no audit history, no diffs against past audits. One skill, one report. + +## Convictions + +- **Most bloat lives in always-loaded surfaces.** SKILL.md and the description are where leverage is highest; reference files matter only if they're cited eagerly. +- **The score is qualitative, not measured.** It's an opinionated heuristic over file size, eager-load footprint, and finding counts — not a runtime token count. Don't mistake the number for a benchmark. +- **When in doubt, downgrade priority.** False positives cost more than missed low-value findings; they teach the user to ignore the report. +- **Specifics over generalities.** Every finding must point to a verbatim line range and excerpt. Speculative findings are dropped. +- **Reports inform, they don't enforce.** The skill author is the authority on their skill; this audit is one input. + +## How it runs + +1. Preflight (`scripts/preflight.js`) walks the folder, computes inventory and footprint, and pre-fires mechanical detectors. +2. The agent validates script-emitted candidates (drops false positives, confirms priority). +3. The agent applies judgment-only detectors against the corpus. +4. Findings are scored and ranked using the rubric in `references/patterns.md`. +5. The agent writes the report from `references/report-template.md`. + +See `SKILL.md` for the full runtime contract and `references/scope.md` for explicit non-goals. diff --git a/.claude/skills/audit-skill-context/SKILL.md b/.claude/skills/audit-skill-context/SKILL.md new file mode 100644 index 000000000..d726227db --- /dev/null +++ b/.claude/skills/audit-skill-context/SKILL.md @@ -0,0 +1,74 @@ +--- +name: 'audit-skill-context' +description: 'Audit a single agent skill folder for context-inflation hotspots (eager vs lazy loads, decision-tree bloat, redundant prose) and produce a priority-ranked report with a 1–10 efficiency score. Triggers on phrases like "audit skill context", "optimize this skill", "reduce skill context", "skill efficiency review", or "check if this skill is bloated".' +--- + +# Audit Skill Context + +Detect context-inflation patterns in one target skill folder and produce a priority-ranked markdown report at project root. The skill detects only — it does not rewrite the target. + +## Input + +One argument: path to the target skill folder, e.g. `.claude/skills//`. If absent, ask the user. + +## Phase 1 — Run the preflight + +Run the bundled preflight to collect inventory, classify citations (eager vs lazy), pre-fire mechanical detectors, and compute a preliminary efficiency score: + +``` +node .claude/skills/audit-skill-context/scripts/preflight.js +``` + +Parse the JSON. Key fields you will use: + +- `skillName`, `skillPath`, `inferredScriptLanguage`, `fileCount` — header data. +- `inventory` — `[{path, lines, chars, tokenEstimate, role, binary}]`. +- `description` — `{text, wordCount, tokenEstimate, redundantPhraseCount}`. +- `skillMdMetrics`, `loadedFootprint`, `referenceUsage` — header metrics for the report. +- `citations`, `unreferenced`, `broken` — for the report's tail sections (broken citations are already filtered for double-quoted false positives). +- `candidates` — script-emitted findings for P4, P6, P7, P9, P11, P13, P14, P15. Each has `pattern`, `location`, `span`, `excerpt`, `confidence`, plus pattern-specific fields. +- `efficiencyScore` — `{preliminary, raw, breakdown, frequency}`. The frequency tier is heuristic; you may override it. + +If the script exits non-zero, surface the error and stop. + +## Phase 2 — Validate script-emitted candidates + +Walk `candidates[]`. For each, decide keep / drop / re-prioritize: + +- **Drop** when the match is a false positive (e.g. P14 fires on a JSON-shape description; P7 fires on incidental separator lines). The validation guidance in `references/patterns.md` lists drop conditions per pattern. +- **Keep** when the match is real. Confirm the priority using the rubric in Phase 4. +- **Read `references/patterns.md` only when uncertain** about a specific pattern — it is not loaded by default. + +## Phase 3 — Apply judgment-only detectors + +The script cannot semantic-match these; you must scan the corpus yourself. Apply on SKILL.md and any reference file you have already read: + +- **P1** — sequential parameter-free commands the agent runs in order. +- **P2** — decision trees with 3+ branches in prose. +- **P3** — formulaic multi-file edits with cross-file consistency. +- **P5** — read → grep → read chains the agent cannot parallelize. +- **P8** — `if version / if file exists / if count` conditionals a script could resolve. +- **P10** — phase-gated content blocks ≥30 lines. +- **P12** — self-referential / meta prose ("This skill detects…", scope enumerations). + +For each finding emit: `pattern_id`, `location` (`path:L-L`), `excerpt` (≤20 verbatim words), `issue` (one sentence), `proposed_fix` (one sentence; mention `inferredScriptLanguage` if a script is recommended), `priority`, `est_tokens_saved`. + +Read `references/patterns.md` for the exact signal/fix wording when needed. + +## Phase 4 — Score and rank + +Priority rubric (per finding): + +- **HIGH** — block ≥100 lines and lives in SKILL.md (loaded every trigger), OR a >3-way decision tree the agent must evaluate, OR a 4+-step deterministic operation, OR description >150 words. +- **MEDIUM** — block 40–100 lines in SKILL.md, OR ≥3 repetitions of the same boilerplate, OR a 2–3 step read-then-grep chain, OR description 100–150 words. +- **LOW** — prose trims under 40 lines, isolated rationale, or edge-path branches the agent rarely evaluates. + +When in doubt, downgrade. + +**Compute the final efficiency score** using the formula in `references/patterns.md` → "Score formula appendix". If the script's frequency tier looks wrong (e.g. a "create" skill that actually fires every commit), override it and recompute the eager-footprint penalty per the same appendix. + +## Phase 5 — Write report + +Read `references/report-template.md` and follow it verbatim. Write to `./audit-skill-context-.md` at project root. Overwrite if present. The template is the single source of truth for the section list, ordering, and omit rules. + +If you need to know what this skill explicitly does NOT do, see `references/scope.md` (authoring-time reference, not loaded at runtime). diff --git a/.claude/skills/audit-skill-context/references/patterns.md b/.claude/skills/audit-skill-context/references/patterns.md new file mode 100644 index 000000000..1fb3e251f --- /dev/null +++ b/.claude/skills/audit-skill-context/references/patterns.md @@ -0,0 +1,267 @@ +# Token-Inflation Patterns + +Detectors for `audit-skill-context`. Patterns split into two families: + +- **Workflow patterns (P1–P3, P5, P8, P10, P12)** — agent-judgment. The script can't reliably semantic-match these; the agent must read the corpus and apply them. +- **Loading patterns (P4, P6, P7, P9, P11, P13, P14, P15)** — preflight emits candidates; the agent only validates (drop false positives, confirm priority). + +Do not fire a detector on speculative matches. If you cannot point to a specific line range and quote a verbatim excerpt, skip it. + +--- + +## P1 — Deterministic ops → script + +**Signal**: Sequential, parameter-free shell/MCP commands the agent must run in order, then parse and branch on their output. Typically a numbered list of `bash`/`MCP call` blocks with trailing "remember this as $VAR" prose. + +**Why expensive**: Every invocation re-ingests the command list, parsing rules, and variable names. A single script returning JSON collapses N commands into one Read. + +**Fix**: Extract to `scripts/.` and have it return structured output (JSON). Reference it from SKILL.md with one line. + +**Priority**: +- HIGH if ≥4 sequential commands with output-parsing prose. +- MEDIUM if 2–3 commands. +- LOW if commands are genuinely different on each run (not truly deterministic). + +## P2 — Complex workflow → simplified + +**Signal**: Decision trees with 3+ branches expressed in prose ("if no packages exist, do A; if one exists, do B; if multiple, do C"). Nested conditionals two or more levels deep. Phases that could be one conceptual step. + +**Why expensive**: The agent holds the full tree in working memory even though only one branch fires per run. + +**Fix**: Let a preflight script or earlier phase resolve the branch and pass a single state value; keep only the relevant branch in SKILL.md, or dispatch to a branch-specific reference file. + +**Priority**: +- HIGH for 3+ branches or nested 2-level trees. +- MEDIUM for two-way branches with heavy prose. +- LOW for simple guard clauses ("if X is missing, tell the user"). + +## P3 — File edits → script + +**Signal**: Formulaic, multi-file edits with explicit ordering ("update the version in two manifest files, then add a section to the changelog, then regenerate the lockfile"). Template-string interpolation described in prose. + +**Why expensive**: Each edit instruction reloads into context every run; the agent must re-derive the template each time. + +**Fix**: Single script accepting parameters (`version`, `date`); SKILL.md calls it with one line. + +**Priority**: +- HIGH for ≥3 files with cross-file consistency requirements. +- MEDIUM for 2-file edits. +- LOW for a single-file formulaic edit. + +## P4 — Large inline blocks → `assets/` + +**Signal**: Code/markdown blocks ≥40 lines inlined in SKILL.md, used only at *execution* time (final output shaping), not at *decision* time. Usually appears under headings like "Output Format", "Examples", "Template", "Scaffold". + +**Why expensive**: Loaded on every trigger; only needed once per run at the final step. + +**Fix**: Move to `assets/.md`. Reference it with "read this at Step N before writing output". + +**Priority**: +- HIGH for ≥100-line blocks. +- MEDIUM for 40–100-line blocks. +- LOW if the block is already cited but duplicated inline. + +**Validation** (script-emitted): Drop the candidate if the block is mostly comments or formatting markers (e.g. JSON schema with property docs) — those have to stay inline. Keep if it's example output, scaffold, or template. + +## P5 — Multi-step read-then-grep → one script + +**Signal**: Instructions chained as "read file A, extract X; grep for X in files matching Y; read each match; extract Z." Three or more file operations the agent cannot parallelize because each depends on the previous. + +**Why expensive**: Each read/grep is a separate tool call and context hit; a single script loads once and returns the final structure. + +**Fix**: Consolidate into one script returning the terminal data structure. SKILL.md calls the script and consumes the result directly. + +**Priority**: +- HIGH for ≥3 chained dependent operations. +- MEDIUM for 2 chained operations. +- LOW for 2-step reads without grep interleaving. + +## P6 — Verbose prose / rationale → `references/` + +**Signal**: Multi-paragraph background, architecture explanation, or rationale that does not change the agent's next action. Typical headings: "Context", "Definitions", "Background", "Guarantees", "Philosophy", "Why this matters". + +**Why expensive**: Loaded every trigger; only relevant when the agent is disambiguating unusual situations. + +**Fix**: Move to `references/context.md` or similar; keep a one-line summary in SKILL.md plus "read this when ". + +**Priority**: +- HIGH for ≥80 lines of pure prose with no direct instructions. +- MEDIUM for 40–80 lines. +- LOW for short rationale blocks (<40 lines). + +**Validation** (script-emitted): Drop the candidate if the run is *structured* prose (numbered phases, sub-steps, decision points) rather than narrative rationale. Keep if it reads as background/explanation that could move to a reference file. + +## P7 — Repeated boilerplate → parameterized template + +**Signal**: The same structural scaffold repeated ≥3 times with minor variance — same agent-prompt shape with a different section name, same phase template with different file targets, same output-format block with different headers. + +**Why expensive**: Duplicates the common structure in context N times when one template plus variant data would suffice. + +**Fix**: Extract shared template to `references/template.md` with placeholders; enumerate only the variant data inline in SKILL.md. + +**Priority**: +- HIGH for ≥4 repetitions or repetitions >20 lines each. +- MEDIUM for exactly 3 repetitions. +- LOW for duplication <10 lines each. + +**Validation** (script-emitted): The 5-line shingle hash can fire on incidental matches (boilerplate license headers, repeated separators, identical empty bullet lists). Drop if the "repetition" is structural framing rather than meaningful duplicated content. + +## P8 — Conditionals a preflight script could resolve + +**Signal**: `if / if / if ` decisions the agent evaluates *after* reading instructions, when a small script could settle the question *before* any skill text is read. + +**Why expensive**: The agent holds all branches in context to evaluate one. The resolved value is often a single string or boolean. + +**Fix**: Preflight script emits the resolved value; SKILL.md branches on the resolved value, not the raw inputs. + +**Priority**: +- HIGH when the conditional gates a large block (>50 lines) of downstream instructions. +- MEDIUM when it gates 20–50 lines. +- LOW for small conditionals (<20 lines). + +## P9 — Bloated frontmatter description + +**Signal**: SKILL.md `description:` field >100 words, especially with repetitive trigger phrasing ("Use when …", "Triggers on …", "Also triggers on …" stacked). + +**Why expensive**: The description is loaded into *every* Claude Code session globally for trigger matching, regardless of whether the skill ever fires. A 200-word description is paid by every conversation across the whole user base — far more leverage than any in-body fix. + +**Fix**: Tighten to one sentence stating what the skill does plus a short trigger clause naming the user phrases that should fire it. Drop redundant restatements ("Use when X. Triggers on X. Should be used when X."). + +**Priority**: +- HIGH if >150 words. +- MEDIUM if 100–150 words. +- LOW if 80–100 words and contains ≥3 redundant phrase patterns. + +**Validation** (script-emitted): Always keep — description bloat is unambiguous. Adjust priority based on `redundantPhraseCount`: high redundancy escalates priority; low redundancy can downgrade. + +## P10 — Phase-gated content kept inline + +**Signal**: Phase / Step / "If validation fails" sections ≥30 lines that the agent only reaches conditionally (gated by a guard at a prior phase, error path, or rare branch). + +**Why expensive**: The phase loads on every trigger but executes <20% of the time. P2 covers the decision tree itself; P10 catches the *content* under a rarely-fired branch. + +**Fix**: Move the gated content to `references/phase-N.md` or `references/.md`. Replace inline with one line: "if condition X, follow `references/phase-N.md`". + +**Priority**: +- HIGH for ≥80 lines of gated content. +- MEDIUM for 40–80 lines. +- LOW for 30–40 lines. + +## P11 — Verbose tool-invocation prose + +**Signal**: Lines of the form "Use the Read tool to open X", "Invoke the Bash tool with `command=...`", "Call the WebFetch tool". The harness already describes tools to the agent; restating their use inflates without adding signal. + +**Why expensive**: Pure ceremony. The agent already knows how to call tools; replacing imperative prose ("Read X.") with tool-named prose ("Use the Read tool to open X.") adds 5–10 tokens per occurrence with zero behavioral effect. + +**Fix**: Replace with the imperative ("Read X", "Run `cmd`", "Fetch URL"). + +**Priority**: +- LOW unless ≥10 occurrences (then MEDIUM). + +**Validation** (script-emitted): Most candidates are real. Drop only when the prose names a *non-obvious* tool the agent would otherwise miss (e.g. an MCP-specific tool with a particular parameter shape). + +## P12 — Self-referential / meta prose + +**Signal**: Paragraphs that describe the skill rather than instruct. "This skill detects X. The purpose of this skill is Y. As described above…". "What this skill does NOT do" enumerations. + +**Why expensive**: Authoring-time scaffolding that the agent doesn't need at runtime. The next-action remains unchanged whether or not the agent reads "this skill is intended for…". + +**Fix**: Remove, or move to `references/scope.md` for archival reference. The positive instructions imply the boundary. + +**Priority**: +- MEDIUM for ≥20 lines of meta prose (intro + scope + "does NOT" combined). +- LOW for shorter meta blocks. + +## P13 — Cross-file content duplication + +**Signal**: ≥5 consecutive lines repeated across two or more files in the corpus (e.g. a paragraph in SKILL.md duplicated verbatim in `references/foo.md`). + +**Why expensive**: Both files load and pay tokens for the duplicate content. The agent processes it twice. + +**Fix**: Keep the content in one place (typically the reference file). Cite from the other. + +**Priority**: +- HIGH for duplicated blocks ≥15 lines. +- MEDIUM for 10–15 lines. +- LOW for 5–10 lines (often incidental — common phrasing rather than true duplication). + +**Validation** (script-emitted): Drop if the "duplication" is structural framing (table headers, common section names like "## Output Format", license boilerplate). Keep if it's substantive content. + +## P14 — Eager-load anti-pattern + +**Signal**: Instructions that fan-read all references unconditionally — "Phase 2: Read every file in `filesToRead`", "Read all references before Phase 3", "For each artifact, read the pair and apply the same checks". + +**Why expensive**: Defeats the entire point of a `references/` directory. References are designed to load on demand — when the agent needs the content, not as a default before any work begins. + +**Fix**: Gate each file behind the phase that actually needs it. Replace "Read all references" with "Read `references/X.md` when validating P4 candidates" / "Read `references/Y.md` only when computing the score." + +**Priority**: +- HIGH if the eager-load is unconditional and references >2 files. +- MEDIUM for 2-file eager loads. +- LOW for single-file eager loads (often unavoidable). + +**Validation** (script-emitted): Drop if the matched line is describing data shape or tool output (e.g. "every file in `filesToRead` has these fields") rather than instructing the agent to read them. + +## P15 — Top-of-file context lag (preamble) + +**Signal**: Many lines from the start of SKILL.md (after frontmatter) before the agent encounters its first concrete imperative ("Run", "Read", "Check"…). Long Context / Definitions / Guarantees / Philosophy intros. + +**Why expensive**: All preamble is paid every trigger even when the agent already knows the routine. The agent reads through "What this skill does" before reaching anything actionable. + +**Fix**: Lead with action. Push background to `references/context.md`, kept available for first-time readers. + +**Priority**: +- HIGH if preamble >50 lines. +- MEDIUM if 30–50 lines. +- LOW if 25–30 lines. + +**Validation** (script-emitted): Almost always keep. The preamble line count is mechanical. + +--- + +## Score formula + +The preflight script computes a preliminary `Context efficiency` score 1–10: + +``` +score = 10 + − (eagerFootprint penalty × frequency multiplier) + − descriptionBloat penalty + − preambleLag penalty +floor 1, round to nearest int +``` + +**Eager footprint brackets** (eagerTotal = SKILL.md + description + eager refs, in tokens): + +| Range | Penalty | +|---|---| +| > 25,000 | −4 | +| > 15,000 | −3 | +| > 8,000 | −2 | +| > 4,000 | −1 | +| > 2,000 | −0.5 | +| ≤ 2,000 | 0 | + +**Frequency multiplier** (heuristic from description verbs): + +| Tier | Multiplier | Description keywords | +|---|---|---| +| `rare` | ×0.5 | release, publish, deploy, scaffold, onboard, bootstrap, set up, migrate | +| `periodic` | ×0.75 | audit, review, report, digest, inventory, weekly, monthly | +| `regular` | ×1.0 | (default — per-task, per-commit usage) | + +The agent should override the inferred tier in Phase 4 if context makes the actual cadence clearer (e.g. a "create" skill that actually fires every commit). + +**Description bloat**: −1 if >150 words; −0.5 if 100–150 words. + +**Preamble lag**: −0.5 if >30 lines before first imperative. + +**Final adjustment** (agent applies after validating findings): + +Start from `efficiencyScore.raw` (the unrounded preliminary score returned by the preflight). Then: + +- −1 per validated HIGH (cap −5) +- −0.5 per validated MEDIUM (cap −2) +- LOW findings do not change the score + +Floor the result at 1 and round to the nearest integer for the report. This appendix is the canonical source for the score formula — SKILL.md and `report-template.md` cite it rather than duplicating the rule. diff --git a/.claude/skills/audit-skill-context/references/report-template.md b/.claude/skills/audit-skill-context/references/report-template.md new file mode 100644 index 000000000..eb5fc0ed3 --- /dev/null +++ b/.claude/skills/audit-skill-context/references/report-template.md @@ -0,0 +1,111 @@ +# Report Template + +The skill writes to `./audit-skill-context-.md` at project root. Use the template below verbatim. Omit any section whose data is empty, except the Header, Corpus inventory, and Summary — those are always present. + +--- + +````markdown +# Skill Context Audit — + +**Target**: `` +**Date**: +**Files analyzed**: +**Context efficiency**: /10 — +**Always-loaded footprint**: SKILL.md ~ tokens ( lines) · description ~ tokens · eager refs ~ tokens · **total ~ tokens / trigger** +**Eager / lazy references**: / +**Frequency tier**: ) — +**Preamble lag**:

lines before first imperative +**Inferred script language**: + +## Corpus inventory + +| File | Lines | ~Tokens | Role | +|---|---|---|---| +| SKILL.md | 89 | 1329 | entrypoint | +| references/patterns.md | 200 | 2200 | reference | +| scripts/preflight.js | 678 | 5450 | script (invoked, not loaded) | +| ... | ... | ... | ... | + +## Summary + +**Workflow patterns** (agent-judgment): + +| Priority | P1 | P2 | P3 | P5 | P8 | P10 | P12 | Total | +|---|---|---|---|---|---|---|---|---| +| HIGH | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| MEDIUM | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| LOW | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | + +**Loading patterns** (script-emitted, agent-validated): + +| Priority | P4 | P6 | P7 | P9 | P11 | P13 | P14 | P15 | Total | +|---|---|---|---|---|---|---|---|---|---| +| HIGH | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| MEDIUM | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 2 | +| LOW | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | + +Pattern legend: P1 deterministic ops · P2 complex workflow · P3 file edits · P4 large inline content · P5 read-then-grep chain · P6 verbose prose · P7 repeated boilerplate · P8 conditionals · P9 description bloat · P10 phase-gated content · P11 verbose tool prose · P12 meta prose · P13 cross-file duplication · P14 eager-load · P15 preamble lag. + +## Findings — HIGH + +### [HIGH] P1 — Deterministic ops → script + +**Location**: `SKILL.md:L52-L104` +**Excerpt**: "Run `git rev-parse`, then `my-cli whoami`, then parse output and branch..." +**Issue**: Five sequential commands with output-parsing prose loaded on every trigger. +**Fix**: Extract to `scripts/preflight.js` returning `{version, user, context}`; SKILL.md calls it once. +**Est. tokens saved**: ~520 + + + +## Findings — MEDIUM + + + +## Findings — LOW + + + +## Top 3 wins + +Tackle in this order for maximum context reduction: + +1. **P4 at `SKILL.md:L197-L303`** — saves ~1,060 tokens / trigger by moving Examples 1–8 to `assets/examples.md`. +2. **P1 at `SKILL.md:L52-L104`** — preflight script collapses 5 sequential commands into one Read. +3. **P6 at `SKILL.md:L10-L22`** — move Context block to `references/context.md`. + +## Unreferenced files + + + +These files exist in the folder but are never mentioned in SKILL.md. Review for dead weight: + +- `references/old-notes.md` (84 lines) + +## Broken references + + + +These references appear in SKILL.md but point to files that do not exist. Fix or remove: + +- `references/missing.md` — cited at `SKILL.md:L145` +```` + +--- + +## Field rules + +- **``** — pull from `skillName` in preflight output. +- **``** — `skillPath` from preflight, ending with a `/`. +- **``** — current date in ISO format. +- **`/10`** — final efficiency score per `references/patterns.md` → "Score formula appendix" (start from `efficiencyScore.raw`, then apply HIGH/MEDIUM adjustments, floor 1, round). Justification is one short clause naming the dominant penalty (e.g. "eager footprint dominates", "5 HIGH findings", "near-optimal"). +- **`////`** — from `loadedFootprint`. ` = eagerTotal`. +- **`/`** — from `referenceUsage.eager` / `referenceUsage.lazy`. +- **`` and ``** — from `efficiencyScore.frequency.tier` and `.multiplier`. If you override the tier in Phase 4, state the override in the reason. +- **`

`** — from `skillMdMetrics.preambleLines`. +- **Priority section order** — always HIGH first, then MEDIUM, then LOW. Omit any section with zero findings. +- **Finding heading format** — `### [PRIORITY] P` — exactly that shape. +- **Excerpt rule** — ≤20 words, verbatim from the quoted line range. No paraphrasing. +- **Fix rule** — one sentence. If the fix involves a script, include the inferred language (e.g. "a Node script", "a Bash one-liner"). +- **Est. tokens saved rule** — for extraction patterns (P4/P6/P10), use `span.tokenEstimate` from the candidate. For collapse-into-script patterns (P1/P3/P5), estimate 0.6 × `span.tokenEstimate`. For description bloat (P9), the saving is the full `tokenEstimate` × number-of-sessions-per-day (use 1× as a per-session estimate). For P11, estimate 5 tokens × number of occurrences. +- **Do not include** — a preamble, an executive summary paragraph, or recommendations beyond the Top 3 wins section. diff --git a/.claude/skills/audit-skill-context/references/scope.md b/.claude/skills/audit-skill-context/references/scope.md new file mode 100644 index 000000000..a6124975d --- /dev/null +++ b/.claude/skills/audit-skill-context/references/scope.md @@ -0,0 +1,12 @@ +# Scope + +Boundary notes for `audit-skill-context`. Not loaded at runtime — kept for skill authors deciding whether this skill matches their need. + +## What this skill does NOT do + +- Rewrite or edit the target skill. The audit produces a report; the user decides what to apply. +- Compare multiple skills against each other. Run the audit per skill. +- Generate the scripts it recommends (e.g. extracting a P1 finding into `scripts/release.js`). The report identifies where a script would help; authoring it is a separate task. +- Run benchmarks or measure runtime token cost. The `Context efficiency` score is qualitative — based on file size, eager-load footprint, and finding counts — not a measured live token count. +- Track audit history or diff against prior audits. +- Detect cross-skill duplication (audit is single-skill scoped). diff --git a/.claude/skills/audit-skill-context/scripts/preflight.js b/.claude/skills/audit-skill-context/scripts/preflight.js new file mode 100644 index 000000000..472e14c0a --- /dev/null +++ b/.claude/skills/audit-skill-context/scripts/preflight.js @@ -0,0 +1,799 @@ +#!/usr/bin/env node +/* + * Preflight for `audit-skill-context`. Walks the target skill folder, collects + * inventory, classifies citations as eager vs lazy, pre-fires the mechanical + * detectors (P4, P6, P7, P9, P11, P13, P14, P15), and computes a preliminary + * efficiency score. The agent then validates candidates and writes the report. + * + * Output JSON: see SKILL.md "Phase 1 — Run the preflight" for the fields the + * agent consumes. Pattern definitions and the score formula are canonical in + * references/patterns.md. + * + * Citation forms recognized in SKILL.md (the only canonical list): + * references/foo.md — relative path in prose + * @references/foo.md — at-path reference + * `assets/template.md` — backticked inline path + * path=references/foo.md — code-fence annotation + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const BINARY_EXTS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', + '.woff', '.woff2', '.ttf', '.otf', '.eot', + '.pdf', '.zip', '.tar', '.gz', + '.mp4', '.mp3', '.wav', +]); + +const REFERENCE_REGEX = /(?:references|agents|steps|scripts|assets|inputs)\/[A-Za-z0-9_\-./]+\.(?:md|txt|json|ts|js|py|sh|html)/g; + +const ROLE_BY_DIR = { + references: 'reference', + agents: 'agent', + steps: 'step', + assets: 'asset', + scripts: 'script', + inputs: 'input', +}; + +const IGNORED_FILES = new Set(['.DS_Store']); + +const REDUNDANT_PHRASES = [ + /\buse when\b/gi, + /\btriggers? on\b/gi, + /\balso triggers?\b/gi, + /\bshould be used\b/gi, + /\buse this skill\b/gi, +]; + +const CONDITIONAL_KEYWORDS = [ + /\bif\s+\w+/i, + /\bonly when\b/i, + /\bbefore composing\b/i, + /\bon demand\b/i, + /\bif uncertain\b/i, + /\bwhen uncertain\b/i, + /\bto validate\b/i, + /\bfor calibration\b/i, + /\bsanity[- ]check\b/i, + /\blazy[- ]?load/i, +]; + +const IMPERATIVE_VERB = /^(?:[*\-]\s+|\d+\.\s+)?(run|read|write|call|update|create|check|verify|walk|fetch|parse|extract|grep|edit|apply|emit|move|delete|remove|list|build|configure|install|launch|invoke|trigger|generate|compute|scan|inspect|review|analyze|audit|load|open|append|prepend|set|configure|push|pull|tag|commit|deploy)\b/i; + +const TOKEN_RATIO = 4; // chars per token (approximation) + +function tokenEstimate(charCount) { + return Math.round(charCount / TOKEN_RATIO); +} + +function truncateExcerpt(text, maxWords = 20) { + const cleaned = String(text || '').replace(/\s+/g, ' ').trim(); + if (!cleaned) return ''; + const words = cleaned.split(' '); + if (words.length <= maxWords) return cleaned; + return words.slice(0, maxWords).join(' ') + '…'; +} + +function walk(dir, base = dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (IGNORED_FILES.has(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...walk(full, base)); + } else if (entry.isFile()) { + out.push(path.relative(base, full)); + } + } + return out; +} + +function getRole(rel) { + if (rel === 'SKILL.md') return 'SKILL.md'; + const first = rel.split('/')[0]; + return ROLE_BY_DIR[first] || 'other'; +} + +function isBinary(rel) { + return BINARY_EXTS.has(path.extname(rel).toLowerCase()); +} + +function safeRead(abs) { + try { + return fs.readFileSync(abs, 'utf8'); + } catch { + return ''; + } +} + +function countLines(content) { + return content.length === 0 ? 0 : content.split('\n').length; +} + +function readSkillName(frontmatter) { + const name = frontmatter.match(/^name:\s*['"]?([^'"\n]+?)['"]?\s*$/m); + return name ? name[1].trim() : null; +} + +function parseFrontmatter(skillMdContent) { + const fm = skillMdContent.match(/^---\n([\s\S]*?)\n---/); + if (!fm) return { block: '', endLine: 0 }; + const endLine = fm[0].split('\n').length; + return { block: fm[1], endLine }; +} + +// Extract the SKILL.md `description:` field and compute the metrics P9 needs: +// word count, character count, token estimate, and a count of redundant +// trigger-clause phrases ("Use when …", "Triggers on …", etc.). Returns null +// if no description is present. +function parseDescription(frontmatterBlock) { + if (!frontmatterBlock) return null; + // Capture from "description:" up to the next ":" line or end. + const m = frontmatterBlock.match(/^description:\s*([\s\S]*?)(?=\n[a-zA-Z_][\w-]*:\s|$)/m); + if (!m) return null; + let value = m[1].trim(); + if (value.startsWith("'") && value.endsWith("'")) { + value = value.slice(1, -1).replace(/''/g, "'"); + } else if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1).replace(/\\"/g, '"'); + } + value = value.replace(/\s+/g, ' ').trim(); + if (!value) return null; + const wordCount = value.split(/\s+/).length; + const charCount = value.length; + let redundantPhraseCount = 0; + for (const re of REDUNDANT_PHRASES) { + const matches = value.match(re); + if (matches) redundantPhraseCount += matches.length; + } + return { + text: value, + wordCount, + charCount, + tokenEstimate: tokenEstimate(charCount), + redundantPhraseCount, + }; +} + +// Pair triple-backtick fences into [startLine, endLine] blocks (1-indexed). +// Used by P4 (large inline blocks) and to mask code fences out of prose-only +// detectors (P6, P11, P14). +function findFencedBlocks(lines) { + const blocks = []; + let openLine = -1; + for (let i = 0; i < lines.length; i++) { + if (/^\s*```/.test(lines[i])) { + if (openLine === -1) openLine = i + 1; + else { + blocks.push({ startLine: openLine, endLine: i + 1 }); + openLine = -1; + } + } + } + return blocks; +} + +// Flatten a list of fenced blocks into the set of all line numbers they cover, +// for O(1) "is this line inside a fence?" checks. +function buildFenceLineSet(blocks) { + const set = new Set(); + for (const b of blocks) { + for (let i = b.startLine; i <= b.endLine; i++) set.add(i); + } + return set; +} + +// Returns true if `matchStart` falls inside a double-quoted span on `line`. +// Used to drop citations that appear in illustrative quoted prose (e.g. +// "references/foo.md is the path") rather than real instructions. +function isInDoubleQuotes(line, matchStart) { + const before = line.slice(0, matchStart); + let inQuote = false; + for (let i = 0; i < before.length; i++) { + if (before[i] === '"' && before[i - 1] !== '\\') inQuote = !inQuote; + } + return inQuote; +} + +function findRepoRoot(start) { + let cur = path.resolve(start); + while (cur !== path.dirname(cur)) { + if (fs.existsSync(path.join(cur, '.git'))) return cur; + cur = path.dirname(cur); + } + return process.cwd(); +} + +// Heuristic: pick the script flavour the host repo already uses, so a P1/P3/P5 +// "extract to a script" recommendation lands in something that matches local +// tooling. Falls back to bash when no manifest is recognized. +function inferScriptLanguage(repoRoot) { + if (fs.existsSync(path.join(repoRoot, 'package.json'))) return 'node'; + if ( + fs.existsSync(path.join(repoRoot, 'pyproject.toml')) || + fs.existsSync(path.join(repoRoot, 'requirements.txt')) || + fs.existsSync(path.join(repoRoot, 'setup.py')) + ) return 'python'; + return 'bash'; +} + +// First non-decoration line below the frontmatter that begins with an action +// verb. Drives the P15 "preamble lag" metric — how many lines the agent reads +// before reaching anything actionable. +function findFirstImperativeLine(lines, frontmatterEndLine) { + for (let i = frontmatterEndLine; i < lines.length; i++) { + const raw = lines[i]; + const trimmed = raw.trim(); + if (!trimmed) continue; + if (trimmed.startsWith('#')) continue; + if (trimmed.startsWith('```')) continue; + if (trimmed.startsWith('|')) continue; + if (trimmed.startsWith('>')) continue; + if (trimmed.startsWith(' +

+ + +
+
+ +
+
Prompt
+
+
+
+
+ + +
+
Output
+
+
No output files found
+
+
+ + + + + + + + +
+
Your Feedback
+
+ + + +
+
+
+ + +
+ + +
+
+
No benchmark data available. Run a benchmark to see quantitative results here.
+
+
+ + + +
+
+

Review Complete

+

Your feedback has been saved. Go back to your Claude Code session and tell Claude you're done reviewing.

+
+ +
+
+
+ + +
+ + + + diff --git a/.claude/skills/feature-spec/SKILL.md b/.claude/skills/feature-spec/SKILL.md new file mode 100644 index 000000000..7ee6ce9eb --- /dev/null +++ b/.claude/skills/feature-spec/SKILL.md @@ -0,0 +1,135 @@ +--- +name: 'feature-spec' +description: 'Generate a Packmind feature specification from a GitHub issue, file, URL, or direct description. Breaks Phase 3 into 4 fine-grained approval gates (domain data structures, ports/use cases/routes, frontend components, implementation plan) to catch problems early. Routes work between the OSS sibling (../packmind) and the proprietary repo (cwd). Outputs spec artifacts under tmp/feature-specs/{slug}/ for use with /feature-sprint.' +--- + +# Feature Spec Skill + +Generate a comprehensive feature specification grounded in Packmind's hexagonal architecture, frontend gateway/PM-UI conventions, and the OSS/proprietary fork boundary. + +## When to Use + +Use this skill when the user wants to: +- Plan a new feature, fix, or refactor that spans Packmind's stack +- Convert a GitHub issue, design doc, or rough idea into a structured spec +- Get fine-grained validation of data structures, routes, and components before committing to a plan + +**Do NOT use** for trivial one-line fixes or pure dependency bumps. + +## Packmind Repo Layout (essential context) + +- Closed-source fork (`packmind-proprietary`): the current working directory when this skill runs. +- Open-source repo (`packmind`): the sibling directory at `../packmind` (cloned next to the proprietary repo). Resolve to an absolute path at runtime with `realpath ../packmind`. +- **Most feature work happens on the OSS repo and auto-merges into the proprietary fork.** The proprietary fork only contains paid/closed features (e.g. `packages/editions`, certain `packages/deployments` extensions). After an OSS merge, you typically pull on the proprietary side. +- Architecture: hexagonal (`packages/{domain}/{domain,application,infra}`), NestJS API at `apps/api`, React+`@packmind/ui` frontend at `apps/frontend`, TypeORM, Jest+@swc/jest. + +## Input Sources + +| Source | Examples | Detection | +|--------|----------|-----------| +| GitHub | `#123`, `owner/repo#123`, full issue URL | Pattern `#\d+` or `github.com/.../issues/` | +| File | `file:spec.md`, `path/to/spec.md` | Prefix `file:` or readable file | +| URL | `https://...` | HTTP(S) URL (non-GitHub) | +| Prompt | Any other text | Default | + +## Workflow Phases + +Execute phases **sequentially**. Phases 1 and 4 run seamlessly (no approval gate). Phases 2 and 3 require user approval before proceeding. + +| Phase | File | Purpose | Gate | +|-------|------|---------|------| +| 1 | `phase-1-resolve-source.md` | Detect source type, fetch content, decide OSS-vs-proprietary routing, create state | seamless | +| 2 | `phase-2-discovery-and-spec.md` | Research Packmind patterns (reference domain, hex layers, frontend conventions), draft acceptance criteria, generate functional spec | approval | +| 3 | `phase-3-implementation.md` | Technical approach + implementation plan (4 subtasks below) | 4 approvals | +| 3A | ↳ Subtask 3A | Validate domain data structures (entities, value objects, events, migrations) | approval | +| 3B | ↳ Subtask 3B | Validate ports, contracts, use cases, services, adapters, NestJS routes | approval | +| 3C | ↳ Subtask 3C | Validate frontend components, gateways, query hooks, PM-UI usage | approval | +| 3D | ↳ Subtask 3D | Generate full implementation plan (task breakdown, grouping, coverage) | approval | +| 4 | `phase-4-finalize.md` | Generate `context.md`, mark spec complete, report (no commit — artifacts live in `tmp/`) | seamless | + +## State Management + +State is persisted to markdown files under a git-ignored `tmp/` directory for cross-session continuity. Phase progress is inferred from which output files exist — no separate state file needed. + +``` +tmp/feature-specs/{slug}/ +├── {slug}.md # Main spec (status: DRAFT → COMPLETE in frontmatter) +├── discovery.md # Phase 2 output (YAML frontmatter + markdown) +├── functional-spec.md # Phase 2 output (informed by discovery) +├── implementation-plan.md # Phase 3 output (includes parallel groups section) +└── context.md # Phase 4 output (YAML frontmatter + markdown) +``` + +### Phase Detection (Resume Logic) + +| Files Present | Completed Phase | Resume At | +|---------------|----------------|-----------| +| `{slug}.md` only | Phase 1 | Phase 2 | +| + `discovery.md`, `functional-spec.md` | Phase 2 | Phase 3 | +| + `implementation-plan.md` | Phase 3 | Phase 4 | +| + `context.md` (status: COMPLETE) | Phase 4 | Done | + +## Invocation + +### Auto-Detection (Resuming) + +Before starting, check for an existing task directory at `tmp/feature-specs/{slug}/`: + +1. Check which output files exist to determine current phase (see Phase Detection table above) +2. Read the main spec file's YAML frontmatter for `status: DRAFT|COMPLETE` +3. If resuming, display: + ``` + **Resuming Feature Spec**: {slug} + **Current Phase**: {detected_phase} + **Status**: {status from frontmatter} + + Continue from Phase {detected_phase}? (Y/n) + ``` +4. If user confirms, proceed to the appropriate phase file +5. If no task directory found, start a new feature spec + +### Starting New + +To start a new feature spec: Read `phase-1-resolve-source.md` and follow its instructions. + + + Execute phases SEQUENTIALLY — never skip phases + Phases 1 and 4 proceed automatically — no approval gate + Phases 2 and 3 require USER APPROVAL via AskUserQuestion before proceeding + Save all artifacts under tmp/feature-specs/{slug}/ — never under tracked folders + Use Task tool with Explore subagent for codebase research; never read 50 files yourself + Agent NEVER implements code in this skill — output ONLY specification documents + Main spec file has status DRAFT until Phase 4 completes + Always record target_repo (oss | proprietary | both) decided in Phase 1; consumed by /feature-sprint + Never invent OSS/proprietary boundaries — if unsure, ask the user + + +## Output + +Final deliverable: `tmp/feature-specs/{slug}/{slug}.md` + sibling artifacts. + +Contains: +- Functional specification with acceptance criteria +- Technical approach grounded in Packmind reference patterns +- Implementation plan with phased tasks (each task has reference `file:line` patterns) +- Parallel Groups section (for parallel execution via `/feature-sprint`) +- `target_repo`: oss | proprietary | both + +## Parallel Execution Support + +Phase 3 includes a grouping step that analyzes task dependencies and creates parallel execution groups: + +**How grouping works:** +1. After generating the implementation plan, a Plan subagent analyzes tasks +2. Tasks are grouped by file dependencies (tasks sharing files go together) +3. Groups are non-overlapping (no file belongs to multiple groups) +4. Results are stored in `implementation-plan.md` (Parallel Groups section) and `context.md` (YAML frontmatter) + +**When grouping is skipped:** +- Tasks have no clear file dependencies +- Single-file or trivial implementations +- User opts out during Phase 3 review + +## Handoff + +When the spec is complete (Phase 4), the next step is `/feature-sprint {slug}` — the companion skill that executes the implementation plan. \ No newline at end of file diff --git a/.claude/skills/feature-spec/phase-1-resolve-source.md b/.claude/skills/feature-spec/phase-1-resolve-source.md new file mode 100644 index 000000000..6f21a1240 --- /dev/null +++ b/.claude/skills/feature-spec/phase-1-resolve-source.md @@ -0,0 +1,171 @@ +# Phase 1: Resolve Source & Decide Fork Routing + +Detect the source type, fetch content, decide where the work will land (OSS fork or proprietary), and create the initial state directory. + +## Input + +User provides `task_input` — one of: +- GitHub issue: `#123`, `owner/repo#123`, or `https://github.com/.../issues/123` +- File path: `file:spec.md`, `path/to/spec.md` +- URL: `https://example.com/...` +- Direct prompt: any other text + +## Steps + +### 1.1 Detect Source Type + +| Pattern | Source Type | +|---------|-------------| +| `#\d+` or `owner/repo#\d+` or `github.com/.../issues/` | github | +| `file:` prefix or readable file path with `.md`/`.txt`/`.json` extension | file | +| `https?://` (non-GitHub) | url | +| Anything else | prompt | + +### 1.2 Generate Task Slug + +Create `task_slug` from the user-provided title or first meaningful phrase: +- Convert to lowercase +- Replace spaces and special characters with hyphens +- Strip leading/trailing hyphens, collapse consecutive hyphens +- Keep it under 60 characters + +### 1.3 Create State Directory + +```bash +mkdir -p tmp/feature-specs/{task_slug}/ +``` + +`tmp/` is git-ignored — these files never get committed. + +### 1.4 Create Initial Spec File (DRAFT) + +Write `tmp/feature-specs/{task_slug}/{task_slug}.md`: + +```markdown +--- +status: DRAFT +task_slug: {task_slug} +created: {ISO-8601 timestamp} +finished: null +--- + +# {task_name} + +_Specification in progress. See state directory for current phase._ +``` + +### 1.5 Fetch Content + +Use the appropriate tool based on source type: + +**GitHub:** +- If `gh` is available, use `gh issue view {issue_ref} --json title,body,labels,url,author` +- Otherwise use WebFetch on the issue URL +- Extract: `task_id` (e.g. `GH-{number}`), `task_name`, `task_description`, `task_url`, `labels` + +**File:** +- Read the file with the Read tool +- `task_name` = first heading or filename; `task_description` = full body + +**URL:** +- Use WebFetch +- Extract: `task_name`, `task_description`, `task_url` + +**Prompt:** +- `task_description` = the raw input +- `task_name` = first line (truncated to ~80 chars) +- `task_id` = `PROMPT-{timestamp}` + +### 1.6 Decide Fork Routing (Required) + +Packmind ships from two repos: +- **OSS** (`../packmind`): public packages and features. **Most code changes go here.** After merge, the proprietary fork pulls from upstream automatically. +- **Proprietary** (this repo): closed-source extensions. Examples: `packages/editions` (forbidden import elsewhere), some `packages/deployments` proprietary flows, billing, enterprise auth. + +Before continuing, classify the work using these heuristics: + +| Signal | Likely target | +|--------|---------------| +| Touches `packages/editions/`, billing, license, enterprise auth, paid deploy flows | proprietary | +| Touches `apps/api`, `apps/frontend`, `apps/cli`, `apps/mcp-server`, or any package present in BOTH repos | oss | +| Mentions enterprise-only features or paid customers in the issue | likely proprietary | +| Public bug, generic feature, OSS-visible UI | oss | +| Unsure | ask the user | + +Use `AskUserQuestion` to confirm: + +```json +{ + "questions": [{ + "question": "Where should this feature be implemented?", + "header": "Fork routing", + "multiSelect": false, + "options": [ + {"label": "OSS (../packmind) (Recommended)", "description": "Default for most work. Auto-merges into proprietary; you pull afterward."}, + {"label": "Proprietary (this repo)", "description": "Closed-source only: editions, paid deployments, enterprise features."}, + {"label": "Both (split)", "description": "Some tasks land on OSS, others on proprietary. Spec will split them in Phase 3."} + ] + }] +} +``` + +Record the answer as `target_repo` in `oss | proprietary | both`. + +**If `target_repo == "oss"`**: verify the OSS repo exists at `../packmind`. If missing, warn the user and ask whether to proceed targeting proprietary instead. + +### 1.7 Check for Existing Documentation + +Look for prior work on the same topic: +- Glob: `tmp/feature-specs/*{task_slug_keyword}*/` +- Glob: `.claude/specs/*{task_slug_keyword}*.md` (existing design specs) +- Glob: `.claude/plans/*{task_slug_keyword}*.md` (existing plans) + +If matches are found, read them and surface them in the summary so the user can decide whether to extend or supersede. + +### 1.8 Update Spec Frontmatter + +Update `tmp/feature-specs/{task_slug}/{task_slug}.md` with full metadata: + +```markdown +--- +status: DRAFT +task_slug: {task_slug} +task_id: {task_id} +source_type: {github | file | url | prompt} +source_url: {url or null} +task_name: {task_name} +target_repo: {oss | proprietary | both} +created: {ISO-8601 timestamp} +finished: null +--- + +# {task_name} + +{task_description} + +## Related Prior Work + +{Optional: list of existing specs/plans found in step 1.7, or "None found."} +``` + +This frontmatter is the source of truth for task metadata. Phase progress is inferred from which output files exist in the directory — no separate state file needed. + +## Task Summary + +Display before proceeding: + +``` +**Phase 1 complete.** Source resolved. + +**Task:** {task_name} +**ID:** {task_id} +**Source:** {source_type} +**Target repo:** {target_repo} +**Prior work:** {none | list of matched files} + +{task_description (first 500 chars)} +``` + +## Next Phase + +Proceed automatically to `phase-2-discovery-and-spec.md`. diff --git a/.claude/skills/feature-spec/phase-2-discovery-and-spec.md b/.claude/skills/feature-spec/phase-2-discovery-and-spec.md new file mode 100644 index 000000000..ec9a0dd34 --- /dev/null +++ b/.claude/skills/feature-spec/phase-2-discovery-and-spec.md @@ -0,0 +1,443 @@ +# Phase 2: Discovery & Functional Spec + +Discover how Packmind actually does this kind of thing, then draft requirements grounded in that reality. + +## Purpose + +Two goals in one phase: +1. Find **the closest existing implementation** in Packmind — which domain package, which use case, which frontend component — and trace its full hex stack +2. Draft **functional requirements** informed by that discovery, so acceptance criteria match what Packmind's architecture can actually support + +## Prerequisites + +- Phase 1 completed (source resolved, `target_repo` decided) +- `tmp/feature-specs/{task_slug}/{task_slug}.md` exists with `status: DRAFT` + +## Steps + +### 2.1 Load Source Context + +Read `tmp/feature-specs/{task_slug}/{task_slug}.md`: +- YAML frontmatter: `task_name`, `source_type`, `target_repo` +- Body: `task_description` + +Extract key terms from `task_description`: +- **Domain entities** mentioned (e.g., "standard", "recipe", "space", "deployment", "user") +- **Actions/verbs** (e.g., "create", "import", "deploy", "rename", "preview") +- **Cross-cutting concerns** (e.g., "event", "migration", "permission", "rate limit") + +### 2.2 Pick the Discovery Root + +Based on `target_repo`: +- `oss` → run discovery in `../packmind` (most patterns live there) +- `proprietary` → run discovery in `.` (this repo) +- `both` → discovery in `../packmind` first; if a needed reference doesn't exist there, also search `.` + +Subagents should be told explicitly which root(s) to search. + +### 2.3 Launch Research Subagents (PARALLEL) + +Launch **TWO** subagents in parallel using the Task tool. Both should run concurrently in the same message. + +#### Subagent A: Packmind Pattern Finder + +Use `Agent` tool with `subagent_type="Explore"`, `description="Find Packmind reference patterns"`. Run "very thorough" search: + +``` +Find Packmind reference patterns for: {task_name} + +Search root: {discovery_root absolute path} +Key terms: {extracted_terms} +Domain entities mentioned: {entities} +Task description: {task_description} + +## What to find + +Trace ACTUAL code paths for the closest similar feature in Packmind. Don't just list files — explain HOW the feature flows through the hex layers. + +### 1. Reference domain package +Find the closest existing `packages/{domain}/` whose problem matches. Example domains: accounts, deployments, spaces, standards, recipes, skills, coding-agent, playbook-change-management. + +For the chosen reference domain, document: +- **Domain layer** (`packages/{domain}/src/domain/`): + - Entities used: file path + key fields + - Repository interfaces: `IFooRepository` file path + - Events: any domain events emitted + - Errors: domain error classes +- **Application layer** (`packages/{domain}/src/application/`): + - Closest use case: `useCases/{name}/{name}.usecase.ts` with the abstract base it extends (AbstractMemberUseCase, AbstractSpaceMemberUseCase, AbstractAdminUseCase) + - Services involved + - Adapter: `adapter/{Domain}Adapter.ts` and which ports it exposes +- **Infrastructure layer** (`packages/{domain}/src/infra/`): + - Repository impl + TypeORM Schema + - Migration pattern reference (under `packages/migrations/`) +- **Types package** (`packages/types/src/{domain}/`): + - Port interface + - Contract for the use case (request/response shapes) +- **Hexa facade** (`{Domain}Hexa.ts`) and `index.ts` exports + +Capture every `file:line` reference. + +### 2. Reference API route +Search `apps/api/src/` for the closest NestJS controller method. Document: +- Controller file + method name + decorators +- DTO file (if any) under `apps/api/src/` or types package +- How it resolves the use case (`HexaRegistry.getAdapter(...)`) + +### 3. Reference frontend feature +Search `apps/frontend/` for the closest existing component that does this kind of thing. Document: +- **Gateway** (`apps/frontend/src/.../gateways/{Name}Gateway.ts`) — how it wraps the API call +- **Query/mutation hook** (TanStack Query or local equivalent) +- **Page / container component** that orchestrates queries + UI +- **UI components** built from `@packmind/ui` (PM-prefixed wrappers around Chakra) +- Form/validation library, if any (Zod, react-hook-form, etc.) + +### 4. Test patterns +For each layer touched, find the matching test file pattern (e.g. `*.usecase.spec.ts`, `*.repository.spec.ts`, `*.tsx` with React Testing Library). Note where integration tests live (`packages/integration-tests/`). + +### 5. Conventions +Cross-reference 2-3 similar features and note: +- Naming conventions for use cases, ports, contracts +- Authorization base class typically used +- Error-mapping approach (domain errors → HTTP) +- How events propagate cross-domain +- Frontend data-flow shape (gateway → query hook → component) + +### 6. Constraints +Note any: +- Files under `packages/editions/` (forbidden to import from elsewhere when on proprietary) +- Feature flags involved +- Existing migrations that overlap + +## Output Format + +Return JSON: +{ + "discovery_root": "absolute path searched", + "reference_domain": { + "package": "packages/standards", + "domain": [{"file": "", "line": 0, "role": ""}], + "application": [{"file": "", "line": 0, "role": ""}], + "infra": [{"file": "", "line": 0, "role": ""}], + "types": [{"file": "", "line": 0, "role": ""}], + "hexa_facade": {"file": "", "line": 0} + }, + "reference_route": {"controller": "", "method": "", "file": "", "line": 0, "uses_port": ""}, + "reference_frontend": { + "gateway": {"file": "", "line": 0}, + "queries": [{"file": "", "line": 0}], + "page": {"file": "", "line": 0}, + "ui_components": [{"name": "", "file": "", "line": 0}] + }, + "test_patterns": {"usecase_spec": "", "repository_spec": "", "frontend_component_spec": "", "integration_tests_dir": ""}, + "conventions": { + "naming": "", + "authorization": "", + "error_mapping": "", + "events": "", + "frontend_data_flow": "" + }, + "constraints": ["..."] +} +``` + +#### Subagent B: Documentation Researcher + +Use `Agent` tool with `subagent_type="Explore"`, `description="Search Packmind docs for context"`, "medium" thoroughness: + +``` +Search documentation for context on: {task_name} + +Search roots: {discovery_root}, and also the proprietary repo at {proprietary_root} if different + +## Where to look + +1. `apps/doc/` — public Mintlify end-user docs +2. `AGENTS.md`, `CLAUDE.md`, root `README.md` +3. `.claude/specs/` — prior design specs in this repo +4. `.claude/plans/` — prior implementation plans in this repo +5. `.claude/rules/packmind/*.md` — coding standards (frontmatter `paths` shows which paths each governs) +6. `tmp/feature-specs/` — earlier feature specs (any directory matching the task keywords) +7. Package-level `*.md` files (e.g., `packages/standards/README.md`) + +## Find + +- Related specifications, RFCs, or design docs +- Coding standards whose `paths` glob will match the files this feature touches +- Planned work that might overlap or conflict +- Historical context or decisions + +## Output Format + +Return JSON: +{ + "related_docs": [{"path": "", "relevance": "high|medium|low", "summary": ""}], + "applicable_standards": [{"path": ".claude/rules/packmind/foo.md", "paths_glob": "", "summary": ""}], + "planned_work": [{"path": "", "description": "", "potential_overlap": ""}], + "historical_context": [""] +} +``` + +Wait for BOTH subagents to complete before proceeding. + +### 2.4 Synthesize & Present Discovery + +Combine subagent results and present to the user (informational — no approval gate here): + +``` +## Discovery Summary + +### Target repo +{target_repo} (discovery root: {discovery_root}) + +### Reference Domain Package: `packages/{name}/` +Full hex stack trace: + +**Domain layer** +- `{file}:{line}` — {role} + +**Application layer** +- `{file}:{line}` — {role} + +**Infrastructure layer** +- `{file}:{line}` — {role} + +**Types package** (`packages/types/src/{domain}/`) +- `{file}:{line}` — {role} + +**Hexa facade** +- `{file}:{line}` + +### Reference API Route +- `{controller_file}:{line}` — `{METHOD} {path}` → uses port `{port_name}` + +### Reference Frontend Feature +- Gateway: `{file}:{line}` +- Queries: `{file}:{line}` +- Page: `{file}:{line}` +- UI components: {list of PM-* components used} + +### Test Patterns +- Use case spec: `{file_pattern}` +- Repository spec: `{file_pattern}` +- Frontend component spec: `{file_pattern}` +- Integration tests: `{dir}` + +### Conventions Found +- Naming: {conventions.naming} +- Authorization: {conventions.authorization} +- Error mapping: {conventions.error_mapping} +- Events: {conventions.events} +- Frontend data flow: {conventions.frontend_data_flow} + +### Applicable Coding Standards +{For each from documentation researcher:} +- `{path}` (governs: `{paths_glob}`) — {summary} + +### Related Documentation +{For each:} +- `{path}` ({relevance}) — {summary} + +### Constraints +{constraints list, including OSS/proprietary boundary callouts if relevant} +``` + +### 2.5 Draft Acceptance Criteria + +Using the discovery context, analyze `task_description` for: +- Existing acceptance criteria (checkboxes, numbered lists) +- User stories or personas mentioned +- Technical constraints or requirements +- Scope boundaries (what's included/excluded) + +Validate patterns against the task: + +1. **Relevance check** — Does the reference domain cover the layers this task needs? +2. **Consistency check** — Do similar features follow the same conventions? +3. **Gap check** — Does the task require something no existing feature does (e.g., a new port type, a new event)? + +Then draft acceptance criteria and present everything for approval: + +``` +### Pattern Validation + +**Reference template:** `packages/{name}` — {applicable | partially applicable | not applicable} +**Convention consistency:** {consistent across N features | divergences noted: ...} +**Gaps:** {none | list of things no existing feature covers} + +**Derived approach:** Follow `packages/{name}` pattern across {layers}. {Gap handling, if any.} + +## Draft Acceptance Criteria + +Based on the source description and discovery analysis: + +- [ ] {Criterion 1 — extracted from source} +- [ ] {Criterion 2 — extracted from source} +- [ ] {Criterion N — inferred from discovery patterns, e.g., "Soft delete supported following standard repository pattern"} + +### Potential Gaps +{List anything the source doesn't cover but the reference feature handles, e.g., authorization, events, error mapping} + +### Potential Over-scope +{List anything in the source that may be too broad or vague} +``` + +### 2.6 Approval Gate + +Use the `AskUserQuestion` tool: + +```json +{ + "questions": [{ + "question": "Do the discovered patterns and draft acceptance criteria look correct?", + "header": "Phase 2", + "multiSelect": false, + "options": [ + {"label": "Yes, proceed", "description": "Patterns and criteria are accurate, continue to generate the full functional spec"}, + {"label": "Needs adjustment", "description": "I'll clarify what's wrong or missing"} + ] + }] +} +``` + +If the user needs adjustment, incorporate their feedback and re-present step 2.5. + +### 2.7 Generate Functional Specification + +Create `tmp/feature-specs/{task_slug}/functional-spec.md`: + +```markdown +## Functional Specification + +### Overview +{1-2 paragraphs: what the feature does and why} +{Reference the derived approach from pattern validation} + +### Target Repo +{target_repo} — most changes land in {discovery_root}. Note any tasks that must live in the proprietary fork (e.g., editions, paid deployments). + +### Business Requirements +1. {Requirement 1} +2. {Requirement 2} +3. {Requirement 3} + +### User Stories +- As a {role}, I want to {action}, so that {benefit} +- As a {role}, I want to {action}, so that {benefit} + +### Acceptance Criteria +- [ ] {Criterion 1 — specific, measurable} +- [ ] {Criterion 2 — specific, measurable} +- [ ] {Criterion 3 — specific, measurable} + +### Constraints +- {Technical constraint, e.g., "Must follow AbstractSpaceMemberUseCase authorization pattern"} +- {Performance requirement} +- {Compatibility requirement} +- {OSS/proprietary boundary — e.g., "No imports from @packmind/editions in OSS tasks"} +{Include constraints from discovery if relevant} + +### Out of Scope +- {Explicitly excluded item} + +### Dependencies +{List known dependencies — other packages, ports, planned work that must precede this} +``` + +### 2.8 Save Discovery + +Write `tmp/feature-specs/{task_slug}/discovery.md` with YAML frontmatter capturing everything the implementation plan subagent will need: + +```markdown +--- +target_repo: {oss | proprietary | both} +discovery_root: "{absolute path}" + +reference_domain: + package: "packages/{name}" + domain_files: + - file: "packages/{name}/src/domain/entities/{Entity}.ts" + line: 1 + role: "Main entity" + application_files: + - file: "packages/{name}/src/application/useCases/{name}/{name}.usecase.ts" + line: 1 + role: "Closest existing use case" + infra_files: + - file: "packages/{name}/src/infra/repositories/{Entity}Repository.ts" + line: 1 + role: "Repository implementation" + types_files: + - file: "packages/types/src/{name}/ports/I{Name}Port.ts" + line: 1 + role: "Port interface" + hexa_facade: + file: "packages/{name}/src/{Name}Hexa.ts" + line: 1 + +reference_route: + controller: "apps/api/src/controllers/{Name}Controller.ts" + method: "create" + line: 42 + http: "POST /api/{path}" + uses_port: "I{Name}Port" + +reference_frontend: + gateway: + file: "apps/frontend/src/.../{Name}Gateway.ts" + line: 1 + queries: + - file: "apps/frontend/src/.../use{Name}.ts" + line: 1 + page: + file: "apps/frontend/src/.../{Name}Page.tsx" + line: 1 + ui_components: + - name: "PMButton" + file: "packages/ui/src/PMButton.tsx" + +test_patterns: + usecase_spec: "packages/{name}/src/application/useCases/{name}/{name}.usecase.spec.ts" + repository_spec: "packages/{name}/src/infra/repositories/{Entity}Repository.spec.ts" + frontend_component_spec: "apps/frontend/src/.../{Name}Page.spec.tsx" + integration_tests_dir: "packages/integration-tests" + +conventions: + naming: "{from subagent}" + authorization: "AbstractSpaceMemberUseCase | AbstractMemberUseCase | AbstractAdminUseCase" + error_mapping: "{from subagent}" + events: "{from subagent}" + frontend_data_flow: "gateway → query hook → component, PM-prefixed UI from @packmind/ui" + +applicable_standards: + - path: ".claude/rules/packmind/packmind-proprietary.md" + paths_glob: "**/*" + summary: "Forbid imports from @packmind/editions in proprietary code" + +constraints: + - "{e.g., must use soft-delete-aware AbstractRepository}" + +pattern_validation: + reference_applicable: true + convention_consistency: "consistent across N features" + gaps: [] + derived_approach: "Follow packages/{name} pattern across {layers}" + +related_documentation: + - path: "{path}" + relevance: "high" + summary: "..." +--- + +## Discovery Summary + +{Mirror the human-readable view from step 2.4} + +## Pattern Validation + +{Mirror the pattern-validation block from step 2.5} +``` + +## Next Phase + +Proceed automatically to `phase-3-implementation.md`. diff --git a/.claude/skills/feature-spec/phase-3-implementation.md b/.claude/skills/feature-spec/phase-3-implementation.md new file mode 100644 index 000000000..290349702 --- /dev/null +++ b/.claude/skills/feature-spec/phase-3-implementation.md @@ -0,0 +1,403 @@ +# Phase 3: Implementation Plan + +Generate the technical approach and detailed task breakdown, with Packmind-specific reference patterns and file targets. + +Phase 3 is split into **four subtasks**, each with its own approval gate. Foundational decisions are validated before generating the full plan, so problems get caught early. + +| Subtask | Purpose | Gate | +|---------|---------|------| +| 3A | Validate **domain data structures** (entities, value objects, events, repositories, schemas, migrations) | approval | +| 3B | Validate **ports, contracts, use cases, services, adapters, NestJS routes** | approval | +| 3C | Validate **frontend components** (page/container, gateway, query hooks, PM-UI usage, forms) | approval | +| 3D | Generate full **implementation plan** (task breakdown, grouping, coverage) | approval | + +## Prerequisites + +- Phase 2 completed +- If resuming a fresh session, read `tmp/feature-specs/{task_slug}/functional-spec.md` and `discovery.md` + +## Steps + +### 3.1 Present Technical Approach + +Using functional spec + discovery, present a brief technical approach to the user: + +``` +## Technical Approach + +### Target Repo +{target_repo} — implementation root: `{discovery_root}`. Tasks landing in this fork (proprietary) will be tagged; otherwise default to OSS. + +### Stack +- API: NestJS (`apps/api`) +- Domain packages: hexagonal layout under `packages/{domain}/src/{domain,application,infra}` +- Types/ports/contracts: `packages/types/src/{domain}` +- Frontend: React + `@packmind/ui` (PM-prefixed Chakra wrappers) (`apps/frontend`) +- Tests: Jest + `@swc/jest`; integration tests in `packages/integration-tests` +- DB: TypeORM + PostgreSQL; migrations under `packages/migrations` + +### Reference Patterns (from discovery) +- Reference domain: `{reference_domain.package}` +- Reference use case: `{reference_domain.application_files[0].file}:{line}` +- Reference route: `{reference_route.controller}:{line}` (`{reference_route.http}`) +- Reference frontend page: `{reference_frontend.page.file}:{line}` + +### Architecture Decisions +- {derived_approach from pattern_validation} +- Authorization: {convention chosen, e.g. AbstractSpaceMemberUseCase} +- Error mapping: {convention} +- Cross-domain: {via injected port or via domain event} + +### New Patterns Required +{If any:} +- {e.g., "New port `IFooPort` — no existing port covers this responsibility"} +- {e.g., "New domain event `FooDeletedEvent` — to notify deployments domain"} + +{Else: "None — fully covered by existing patterns."} +``` + +If new patterns are identified, use `AskUserQuestion` for each decision (one question per decision): + +```json +{ + "questions": [{ + "question": "{Describe the specific new pattern decision}", + "header": "New pattern", + "multiSelect": false, + "options": [ + {"label": "{Option A}", "description": "{What this means concretely}"}, + {"label": "{Option B}", "description": "{What this means concretely}"} + ] + }] +} +``` + +If no new patterns are needed, proceed directly. + +### 3.2 Append Technical Approach to discovery.md + +Append the finalized technical approach to `tmp/feature-specs/{task_slug}/discovery.md` after the YAML frontmatter: + +```markdown +## Technical Approach + +### Target Repo +... + +### Stack +... + +### Reference Patterns +... + +### Architecture Decisions +... +``` + +--- + +## Subtask 3A: Validate Domain Data Structures + +### 3A.1 Extract Data Structure Changes + +From functional spec + discovery, identify ALL data-structure impacts in the **domain** and **infra** layers: + +**New entities** (`packages/{domain}/src/domain/entities/`) — domain objects that don't exist yet: +- Name, purpose, key fields with TS types +- Relationships to existing entities (1:1, 1:N, N:M) +- Whether it carries an ID type (`{Entity}Id` branded type in `packages/types`) + +**Entity modifications** — changes to existing domain objects: +- New fields (name, type, nullable, default) +- Modified fields (what changes and why) +- Removed fields (what + migration impact) + +**Value objects / branded ID types** (`packages/types/src/{domain}/`): +- Name, shape + +**Domain events** (`packages/types/src/events/{EventName}.ts`): +- Event name, payload shape +- Which domain emits it, which listeners consume it +- Reference the `event.md` component guide + +**Repository interfaces** (`packages/{domain}/src/domain/repositories/I{Entity}Repository.ts`): +- Methods needed (findById, findByX, etc.) +- Whether `AbstractRepository` (soft-delete) is appropriate + +**TypeORM schemas** (`packages/{domain}/src/infra/schemas/{Entity}Schema.ts`): +- Columns + types + indexes + foreign keys + +**Migrations** (`packages/migrations/src/migrations/`): +- New tables, altered columns, new indexes +- Reference the `how-to-write-typeorm-migrations-in-packmind` skill +- Down-migration plan + +**Domain errors** (`packages/{domain}/src/domain/errors/`): +- New error classes for invariant violations + +Use `discovery.md` reference paths to ground every naming and structure decision. + +### 3A.2 Present Data Structures for Validation + +Render the **Subtask 3A** presentation template from `references/spec-templates.md`, substituting the items extracted in 3A.1. Do not paraphrase the section headings — they keep 3A → 3D consistent. + +### 3A.3 Approval Gate — Data Structures + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Are the proposed domain data structures correct? (entities, events, repositories, schemas, migrations)", + "header": "Subtask 3A", + "multiSelect": false, + "options": [ + {"label": "Approved", "description": "Data structures are correct, proceed to ports/use cases/routes"}, + {"label": "Needs changes", "description": "I'll specify what to adjust"} + ] + }] +} +``` + +If "Needs changes", incorporate feedback and re-present 3A.2. + +If there are no data-structure changes for this task, state so briefly and skip the approval gate — proceed directly to Subtask 3B. + +--- + +## Subtask 3B: Validate Ports, Contracts, Use Cases, Services, Adapters, Routes + +### 3B.1 Extract Application + API Changes + +From functional spec + discovery, identify ALL impacts in the **application** layer and **API** layer: + +**New ports** (`packages/types/src/{domain}/ports/I{Domain}Port.ts`): +- Port name, methods exposed +- Which adapter implements it +- Justify: why a new port (vs. extending an existing one) + +**New use case contracts** (`packages/types/src/{domain}/contracts/{UseCaseName}.ts`): +- Request shape, Response shape +- Which port method returns these + +**New use cases** (`packages/{domain}/src/application/useCases/{name}/{name}.usecase.ts`): +- Name, purpose +- Which `Abstract*UseCase` base class (`AbstractMemberUseCase`, `AbstractSpaceMemberUseCase`, `AbstractAdminUseCase`) +- Authorization rules (who can call) +- Which services / repos it uses +- Which events it emits + +**Modified use cases** — changes to existing use cases: +- What changes, backward-compatibility notes + +**New services** (`packages/{domain}/src/application/services/{Name}Service.ts`): +- Name, methods, callers (use cases) + +**New adapter methods** (`packages/{domain}/src/application/adapter/{Domain}Adapter.ts`): +- Which port methods are now implemented + +**New listeners** (`packages/{domain}/src/application/listeners/{Domain}Listener.ts`): +- Which events listened to, what it does + +**New / modified NestJS routes** (`apps/api/src/`): +- Method + path (e.g., `POST /api/standards/{id}/preview`) +- Request/response DTOs (referencing contracts from above) +- Controller file + method +- How it resolves the port: `HexaRegistry.getAdapter('foo')` + +**Removed routes**: +- Path + reason + +Use `discovery.md` references for path conventions, base classes, and adapter patterns. + +### 3B.2 Present for Validation + +Render the **Subtask 3B** presentation template from `references/spec-templates.md`, substituting the items extracted in 3B.1. + +### 3B.3 Approval Gate — Application + Routes + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Are the proposed ports, contracts, use cases, services, adapters, and routes correct?", + "header": "Subtask 3B", + "multiSelect": false, + "options": [ + {"label": "Approved", "description": "Application + API layer is correct, proceed to frontend"}, + {"label": "Needs changes", "description": "I'll specify what to adjust"} + ] + }] +} +``` + +If "Needs changes", incorporate feedback and re-present 3B.2. + +If nothing changes in this layer, state so and skip the gate — proceed to Subtask 3C. + +--- + +## Subtask 3C: Validate Frontend Components + +### 3C.1 Extract Frontend Changes + +From functional spec + discovery, identify ALL frontend impacts in `apps/frontend/`: + +**New page / route components** — top-level orchestrators: +- File path under `apps/frontend/src/` +- Route it mounts on (React Router or app router) +- Which gateway queries/mutations it owns +- Which container/domain components it renders + +**New domain / container components** — feature-scoped UI: +- File path, props interface (TypeScript) +- Pure-display vs. stateful (owns queries?) +- Form components: validation lib + schema, submit callback shape +- List components: item shape, empty state, loading skeleton +- Detail components: data shown, actions + +**Modified domain components**: +- File + line, what changes, props added/removed + +**New gateways** (`apps/frontend/src/.../gateways/{Name}Gateway.ts`): +- Method signatures wrapping API calls +- Reference the existing gateway pattern (see `gateway-pattern-implementation-in-packmind-frontend` command) +- Type mapping (contract → frontend type) + +**New query / mutation hooks**: +- Hook name, query key, return shape +- Cache invalidation rules + +**`@packmind/ui` usage** (PM-prefixed Chakra wrappers): +- Which existing PM-* components are used (PMButton, PMDialog, PMField, etc.) +- Whether any new wrapper is needed (see `wrapping-chakra-ui-with-slot-components` command and `working-with-pm-design-kit` skill) + +**Layout / navigation changes**: +- New nav links, sidebar items, page tabs + +**Microcopy**: +- New user-facing strings — flag for `ux-microcopy` skill if there are non-trivial messages (errors, empty states, dialogs) + +Use `discovery.md`'s reference frontend feature to ground naming and structure decisions. + +### 3C.2 Present for Validation + +Render the **Subtask 3C** presentation template from `references/spec-templates.md`, substituting the items extracted in 3C.1. + +### 3C.3 Approval Gate — Frontend Components + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Are the proposed frontend components correct? (pages, containers, gateways, queries, PM-UI usage)", + "header": "Subtask 3C", + "multiSelect": false, + "options": [ + {"label": "Approved", "description": "Frontend is correct, proceed to full implementation plan"}, + {"label": "Needs changes", "description": "I'll specify what to adjust"} + ] + }] +} +``` + +If "Needs changes", incorporate feedback and re-present 3C.2. + +If no frontend changes, state so and skip — proceed to Subtask 3D. + +--- + +## Subtask 3D: Generate Implementation Plan + +### 3D.1 Skill Loading (conditional) + +Load Packmind skills based on which layers the task touches: + +**Backend (domain / application / infra) is touched:** +1. Read `.claude/skills/hexagonal-architecture/SKILL.md` +2. Read `.claude/skills/hexagonal-architecture/components/usecase.md` +3. Read `.claude/skills/hexagonal-architecture/components/repository.md` +4. Read `.claude/skills/repository-implementation-and-testing-pattern/SKILL.md` (if it exists) + +**Migrations are touched:** +5. Read `.claude/skills/how-to-write-typeorm-migrations-in-packmind/SKILL.md` +6. Read `.claude/skills/create-or-update-model-and-typeorm-schemas/SKILL.md` + +**Frontend is touched:** +7. Read `.claude/skills/working-with-pm-design-kit/SKILL.md` +8. Read `.claude/commands/gateway-pattern-implementation-in-packmind-frontend.md` +9. Read `.claude/commands/wrapping-chakra-ui-with-slot-components.md` (only if new PM wrappers needed) + +**CLI tests are touched:** +10. Read `.claude/skills/cli-e2e-test-authoring/SKILL.md` + +Skip a category entirely if no task in that layer. + +### 3D.2 Launch Implementation Plan Subagent + +Read the bundled prompt template at `.claude/skills/feature-spec/references/implementation-plan-prompt.md`. Substitute its placeholders (`{task_name}`, `{task_slug}`, `{target_repo}`, and the three `{APPROVED_3*_BLOCK}` blocks pasted verbatim from the prior approval gates). Include or omit the Backend / Frontend / Migration constraint sections based on which layers the prior subtasks produced work for — guidance is at the top of the reference file. + +Invoke `Agent` with `subagent_type="general-purpose"` and the substituted prompt. Wait for the subagent to complete. + +Wait for the subagent to complete. + +### 3D.3 Validate Plan Output + +Read `tmp/feature-specs/{task_slug}/implementation-plan.md` and verify: +- Every acceptance criterion is covered in the Coverage Matrix +- Every task has a reference pattern (`file:line`) +- Every task has target files and a `repo` tag +- Plan is consistent with 3A, 3B, 3C decisions (no extra entities, no missing routes) +- Parallel Groups section exists with at least 1 group +- All tasks belong to exactly one group; no file appears in multiple groups + +If validation fails: tell the user what's missing and ask whether to refine manually or relaunch the subagent. + +### 3D.4 Approval Gate — Implementation Plan + +Display the summary: + +``` +**Subtask 3D complete.** Implementation plan generated. + +**Phases:** {count} +**Total tasks:** {count} +**Parallel groups:** {group_count} +**Repos used:** {oss only | proprietary only | both, with count} + +Review the plan in `tmp/feature-specs/{task_slug}/implementation-plan.md` + +Questions to consider: +- Does each task follow the identified patterns? +- Are file references specific enough? +- Are inter-group dependencies clear? +- Are OSS-vs-proprietary tags correct? +``` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Is the implementation plan approved? Proceed to finalize?", + "header": "Subtask 3D", + "multiSelect": false, + "options": [ + {"label": "Yes, proceed", "description": "Approve plan and continue to Phase 4: Finalize"}, + {"label": "Need changes", "description": "I need to modify the implementation plan first"} + ] + }] +} +``` + +Do not continue until user confirms via AskUserQuestion response. + +### 3.7 Phase Complete + +The existence of `implementation-plan.md` marks Phase 3 as complete. No separate state update needed. + +## Next Phase + +After approval, proceed automatically to `phase-4-finalize.md`. diff --git a/.claude/skills/feature-spec/phase-4-finalize.md b/.claude/skills/feature-spec/phase-4-finalize.md new file mode 100644 index 000000000..842d1d6da --- /dev/null +++ b/.claude/skills/feature-spec/phase-4-finalize.md @@ -0,0 +1,89 @@ +# Phase 4: Finalize + +Generate the implementation context, mark the spec as complete, and report. This phase runs seamlessly — no approval gate. + +Artifacts live in `tmp/feature-specs/{task_slug}/` which is git-ignored, so **no commit step** here. The user commits implementation code via `/feature-sprint`. + +## Prerequisites + +- Phases 1-3 completed +- If resuming a fresh session, read `discovery.md`, `functional-spec.md`, and `implementation-plan.md` from `tmp/feature-specs/{task_slug}/` + +## Steps + +### 4.1 Generate Implementation Context + +**Purpose**: produce a structured `context.md` that `/feature-sprint` will read to set up parallel execution. + +Read `.claude/skills/feature-spec/references/context-schema.md` — it contains the full YAML template, the body template, and the ordered extraction process (sources: `discovery.md` frontmatter, main spec frontmatter, `functional-spec.md` body, `implementation-plan.md` Parallel Groups table). + +Write the substituted result to `tmp/feature-specs/{task_slug}/context.md`. + +### 4.2 Mark Spec Complete + +Update the main spec file's YAML frontmatter: + +```markdown +--- +status: COMPLETE +... +finished: {ISO-8601 timestamp} +--- +``` + +The body of the main spec should now include a brief summary (one paragraph) and links to the sibling artifacts: + +```markdown +# {task_name} + +{1-paragraph summary of the feature} + +## Artifacts + +- [Discovery](./discovery.md) — reference patterns and conventions +- [Functional Spec](./functional-spec.md) — acceptance criteria and scope +- [Implementation Plan](./implementation-plan.md) — task breakdown and parallel groups +- [Context](./context.md) — machine-readable handoff for `/feature-sprint` + +## Next Step + +``` +/feature-sprint {task_slug} +``` +``` + +### 4.3 Completion Report + +Output to the user: + +``` +## ✅ FEATURE SPEC COMPLETE + +**Task**: {task_name} +**Slug**: {task_slug} +**Target repo**: {target_repo} + +### Summary +- Acceptance criteria: {count} items +- Implementation phases: {phase_count} +- Total tasks: {task_count} +- Parallel groups: {group_count} + +### Artifacts +- `tmp/feature-specs/{task_slug}/{task_slug}.md` +- `tmp/feature-specs/{task_slug}/discovery.md` +- `tmp/feature-specs/{task_slug}/functional-spec.md` +- `tmp/feature-specs/{task_slug}/implementation-plan.md` +- `tmp/feature-specs/{task_slug}/context.md` + +### Reminder +Artifacts live in `tmp/` (git-ignored). They will NOT be committed automatically — they're scratch state for `/feature-sprint`. + +### Next Steps +1. Review the artifacts above +2. Execute: `/feature-sprint {task_slug}` +``` + +## End of Workflow + +The feature spec is ready. The companion skill `/feature-sprint` will pick it up and execute the implementation plan. diff --git a/.claude/skills/feature-spec/references/context-schema.md b/.claude/skills/feature-spec/references/context-schema.md new file mode 100644 index 000000000..a3175418c --- /dev/null +++ b/.claude/skills/feature-spec/references/context-schema.md @@ -0,0 +1,141 @@ +# context.md Schema + +Loaded by `phase-4-finalize.md` step 4.1. Defines the structured artifact +that `/feature-sprint` consumes to set up parallel execution. + +## Extraction process (in order) + +1. Read `discovery.md` YAML frontmatter → `target_repo`, `discovery_root`, + `reference_domain.*`, `reference_route.*`, `reference_frontend.*`, + `applicable_standards`. +2. Read `{task_slug}.md` YAML frontmatter → `task_id`, `task_name`, + `source_type`, `source_url`. +3. Parse `functional-spec.md` body → scope (`in_scope`, `out_of_scope`, + `constraints`). These need light prose-to-list conversion; keep items + short and verbatim where possible. +4. Parse `implementation-plan.md` "Parallel Groups" table → populate + `parallel_groups[]`. Each row maps to one entry; `task_ids` is the + comma-split task IDs column. +5. Set `version: "1.1"` if `parallel_groups[]` is non-empty, else `"1.0"`. + +## Template + +Write to `tmp/feature-specs/{task_slug}/context.md`: + +```markdown +--- +version: "1.0" # bump to "1.1" if parallel_groups non-empty +task_slug: "{task_slug}" +task_id: "{from main spec frontmatter}" +task_name: "{task_name}" + +source: + type: "{source_type from main spec}" + url: "{source_url from main spec}" + +target_repo: "{oss | proprietary | both}" +discovery_root: "{absolute path from discovery.md}" +proprietary_root: "{absolute path of the proprietary repo — i.e. `realpath .` when running this skill from the proprietary repo root}" +oss_root: "{absolute path of the OSS sibling — i.e. `realpath ../packmind`}" + +stack: + api: "NestJS (apps/api)" + domain_packages: "packages/{domain}/src/{domain,application,infra}" + types: "packages/types" + frontend: "React + @packmind/ui (apps/frontend)" + tests: "Jest + @swc/jest" + integration_tests: "packages/integration-tests" + db: "TypeORM + PostgreSQL" + migrations: "packages/migrations" + +reference: + domain_package: "{from discovery.reference_domain.package}" + usecase_file: "{from discovery}" + api_controller: "{from discovery}" + frontend_page: "{from discovery}" + +discovery_summary: + patterns: "{1-2 sentence summary of the derived approach}" + applicable_standards: + - path: ".claude/rules/packmind/packmind-proprietary.md" + summary: "Forbid imports from @packmind/editions" + +scope_definition: + in_scope: + - "{extracted from functional-spec.md}" + out_of_scope: + - "{extracted from functional-spec.md}" + constraints: + - "{extracted from functional-spec.md}" + +quality_gates: + # Nx targets to run for the projects this feature touches. + # /feature-sprint will resolve which projects need each target. + lint: true + test: true + build: true + extra: [] + +parallel_groups: # Populate from implementation-plan.md "Parallel Groups" section + - group_id: "A" + group_name: "backend-{domain}" + task_ids: ["1.1", "1.2", "1.3"] + target_files: ["packages/{domain}/..."] + repo: "oss" + blocked_by: [] + - group_id: "B" + group_name: "frontend-{domain}" + task_ids: ["2.1", "2.2"] + target_files: ["apps/frontend/..."] + repo: "oss" + blocked_by: [] + - group_id: "C" + group_name: "tests" + task_ids: ["3.1", "3.2"] + target_files: ["packages/integration-tests/..."] + repo: "oss" + blocked_by: ["A", "B"] +--- + +## Implementation Context + +**Task:** {task_name} +**Slug:** {task_slug} +**Target repo:** {target_repo} + +### Discovery summary +- Reference domain: `{reference.domain_package}` +- Reference use case: `{reference.usecase_file}` +- Reference API: `{reference.api_controller}` +- Reference frontend: `{reference.frontend_page}` + +### Scope +**In scope:** +- {items} + +**Out of scope:** +- {items} + +**Constraints:** +- {items} + +### Quality gates +- Lint: nx lint on affected projects +- Test: nx test on affected projects +- Build: nx build on affected projects + +### Parallel groups +| Group | Name | Tasks | Repo | Blocked by | +|-------|------|-------|------|------------| +| A | {name} | 1.1, 1.2, 1.3 | oss | — | +| B | {name} | 2.1, 2.2 | oss | — | +| C | {name} | 3.1, 3.2 | oss | A, B | +``` + +## Notes + +- `parallel_groups[*].repo` is independent of the top-level `target_repo`: + one feature can have OSS and proprietary groups in `both` mode. +- `blocked_by` may be empty `[]`. Don't omit the key. +- If a group has no obvious file area (e.g., cross-cutting tests), name it + by purpose (`tests`, `wiring`) rather than by file path. diff --git a/.claude/skills/feature-spec/references/implementation-plan-prompt.md b/.claude/skills/feature-spec/references/implementation-plan-prompt.md new file mode 100644 index 000000000..96254d2fd --- /dev/null +++ b/.claude/skills/feature-spec/references/implementation-plan-prompt.md @@ -0,0 +1,138 @@ +# Implementation Plan Subagent Prompt Template + +Loaded by `phase-3-implementation.md` step 3D.2 to generate +`tmp/feature-specs/{task_slug}/implementation-plan.md`. The orchestrator +substitutes every `{placeholder}` and then passes the result as the `prompt` +to `Agent` with `subagent_type="general-purpose"`. + +## Placeholders + +| Placeholder | Source | +|-------------|--------| +| `{task_name}` | main spec frontmatter | +| `{task_slug}` | spec directory name | +| `{target_repo}` | discovery.md frontmatter (`oss \| proprietary \| both`) | +| `{APPROVED_3A_BLOCK}` | the approved 3A.2 summary, pasted verbatim | +| `{APPROVED_3B_BLOCK}` | the approved 3B.2 summary, pasted verbatim | +| `{APPROVED_3C_BLOCK}` | the approved 3C.2 summary, pasted verbatim | + +## Conditional sections + +The template includes layer-specific constraint sections. Include or skip each +based on whether any task in that layer exists: + +- Backend section → include if 3A or 3B produced any work +- Frontend section → include if 3C produced any work +- Migration section → include if 3A produced any migration entries + +**Do not** paste the contents of referenced skills inline. The subagent has +access to the project's `.claude/skills/` directory and should `Read` them +directly. Inline pastes would force the orchestrator to grow with every +upstream skill change. + +## Template + +``` +Design implementation tasks for: {task_name} + +IMPORTANT: Do NOT use EnterPlanMode. Write the plan directly to the file path in the Output section below. + +## Context files (Read these first) +- tmp/feature-specs/{task_slug}/functional-spec.md (requirements, acceptance criteria) +- tmp/feature-specs/{task_slug}/discovery.md (patterns, reference paths in YAML frontmatter + Technical Approach) + +## Validated decisions (from earlier subtasks — treat as LOCKED) + +The following have been reviewed and approved by the user. The implementation plan MUST be consistent — do not add, remove, or rename anything listed here. + +### Domain Data Structures (Subtask 3A) +{APPROVED_3A_BLOCK} + +### Ports, Contracts, Use Cases, Routes (Subtask 3B) +{APPROVED_3B_BLOCK} + +### Frontend Components (Subtask 3C) +{APPROVED_3C_BLOCK} + +## Architecture Constraints — Backend (include only if backend touched) + +Read these skills before producing backend tasks. Do NOT paste their contents into this prompt — they are loaded conditionally and may evolve: + +- `.claude/skills/hexagonal-architecture/SKILL.md` +- `.claude/skills/hexagonal-architecture/components/usecase.md` +- `.claude/skills/hexagonal-architecture/components/repository.md` +- `.claude/skills/repository-implementation-and-testing-pattern/SKILL.md` (if it exists) + +For each backend task ensure: +- Layer is explicit (domain, application, infra) +- Cross-domain communication goes through a port in `packages/types/` or a domain event — NEVER direct cross-package imports +- The right `Abstract*UseCase` base class is used for authorization +- Domain errors are mapped at the API layer, not raised as HTTP exceptions inside the domain +- New repositories use `AbstractRepository` with soft-delete support unless explicitly justified otherwise + +## Testing Constraints + +For each backend task involving logic, plan a sibling `*.spec.ts` task using Jest + `@swc/jest`. For repositories, follow the test pattern from `repository-implementation-and-testing-pattern` skill (factory-driven tests). For end-to-end behavior, add an `integration-tests` task in `packages/integration-tests`. + +## Frontend Constraints (include only if frontend touched) + +Read these skills before producing frontend tasks: + +- `.claude/skills/working-with-pm-design-kit/SKILL.md` +- `.claude/commands/gateway-pattern-implementation-in-packmind-frontend.md` + +For each frontend task ensure: +- UI is built from `@packmind/ui` PM-prefixed components, NOT raw Chakra primitives (unless wrapping a new slot component — then plan a slot-wrapping task referencing `.claude/commands/wrapping-chakra-ui-with-slot-components.md`) +- API calls go through a Gateway, not directly from components or hooks +- TanStack Query (or the project's equivalent) is used via the existing hook pattern from discovery +- Tests use React Testing Library at the component layer; integration scenarios go to e2e + +## Migration Constraints (include only if migrations touched) + +Read: `.claude/skills/how-to-write-typeorm-migrations-in-packmind/SKILL.md` + +Each migration task must: +- Create both `up` and `down` methods +- Use the standard logger +- Land under `packages/migrations/src/migrations/{timestamp}-{name}.ts` + +## OSS / Proprietary Constraints + +`target_repo` for this spec: `{target_repo}`. + +- If `target_repo == "oss"`, all tasks must be implementable in `../packmind` (the OSS sibling next to the proprietary repo). Verify no task references `packages/editions/` or paid-only paths. +- If `target_repo == "proprietary"`, mark each task with its repo. Never import from `@packmind/editions` outside files that already live in the editions package (see `.claude/rules/packmind/packmind-proprietary.md`). +- If `target_repo == "both"`, tag every task with `repo: oss | proprietary`. Place OSS tasks first (they unblock proprietary ones after auto-merge). + +## Requirements + +Generate a phased implementation plan that: +1. Maps each acceptance criterion to specific tasks (Coverage Matrix at end) +2. Groups tasks logically: domain entities/events → repositories/migrations → use cases → API routes → frontend gateway/queries → frontend components → tests +3. Uses reference patterns from discovery.md for every task (`file:line`) +4. Specifies target files (existing or new) for every task +5. Includes test tasks for every behavioral change +6. Groups tasks into parallel execution groups by file dependencies + +## Task format + +See `.claude/skills/feature-spec/references/spec-templates.md` for the canonical task block shape and section structure. The format must match exactly so `/feature-sprint`'s parse_implementation_plan.py can read it. + +## Parallel grouping rules + +After defining all tasks, append a "Parallel Groups" section. + +- Tasks sharing the SAME target files MUST be in the same group +- Tasks where A writes a file B reads MUST be in the same group +- Backend domain/application tasks usually share `packages/{domain}/`, so they often go in one group +- Frontend tasks under `apps/frontend/` often share the gateway file and form a second group +- Migration tasks usually stand alone +- Name groups by dominant file area (e.g. `backend-{domain}`, `frontend-{domain}`, `migrations`) +- Maximize parallelism while respecting file conflicts + +## Output + +Write to: `tmp/feature-specs/{task_slug}/implementation-plan.md` + +Use the section structure documented in `.claude/skills/feature-spec/references/spec-templates.md` (Implementation Plan section). Then return a summary: `{phase_count}` phases, `{task_count}` tasks, `{group_count}` parallel groups, coverage complete/incomplete. +``` diff --git a/.claude/skills/feature-spec/references/spec-templates.md b/.claude/skills/feature-spec/references/spec-templates.md new file mode 100644 index 000000000..77b7530a9 --- /dev/null +++ b/.claude/skills/feature-spec/references/spec-templates.md @@ -0,0 +1,253 @@ +# Spec Presentation & Plan Templates + +Reusable markdown skeletons for Phase 3 (Subtasks 3A/3B/3C) and Phase 3D +(implementation plan + task format). Keeps `phase-3-implementation.md` lean +and procedural; this file holds the bulky presentation structure. + +The templates are fill-in-the-blank — substitute the `{placeholder}` tokens +from `discovery.md` + the prior subtask's approved decisions. Always preserve +the exact section ordering: `/feature-sprint` parses the resulting +`implementation-plan.md` and the order matters for it. + +## Subtask 3A: Domain Data Structures (presentation template) + +``` +## Subtask 3A: Domain Data Structures + +### Target Repo +{target_repo} — these changes live in {oss | proprietary}. + +### New Entities +{For each:} +- **`{EntityName}`** — {purpose} + - File: `packages/{domain}/src/domain/entities/{EntityName}.ts` + - Fields: + - `id: {EntityName}Id` (branded type in `packages/types/src/{domain}/`) + - `{field}: {type}` {constraints} + - Relationships: {description} + - **Reference**: `{discovery.reference_domain.domain_files[*].file}:{line}` — follows pattern + +### Entity Modifications +{For each:} +- **`{EntityName}`** (`{existing_file}:{line}`) + - Add: `{field}: {type}` — {reason} + - Modify: `{field}: {old}` → `{new}` — {reason + migration impact} + - Remove: `{field}` — {reason + migration note} + +### New Value Objects / Branded IDs +{For each:} +- **`{Name}`** (`packages/types/src/{domain}/{Name}.ts`) + +### New Domain Events +{For each:} +- **`{EventName}`** (`packages/types/src/events/{EventName}.ts`) + - Payload: `{shape}` + - Emitted by: `{Domain}` (use case `{name}`) + - Listened by: `{ListenerDomain}` — `application/listeners/{Name}Listener.ts` + +### New Repository Interfaces +{For each:} +- **`I{Entity}Repository`** (`packages/{domain}/src/domain/repositories/`) + - Methods: `{methods}` + - Extends `AbstractRepository<{Entity}>`: yes/no + +### TypeORM Schemas +{For each:} +- **`{Entity}Schema`** (`packages/{domain}/src/infra/schemas/`) + - Table: `{table_name}` + - Columns: {list} + - Indexes: {list} + - FKs: {list} + +### Migrations +{For each:} +- **`{TimestampMigrationName}`** under `packages/migrations/src/migrations/` + - Up: {description} + - Down: {description} + - Reference pattern: `{existing_migration_file}` + +### New Domain Errors +{For each:} +- **`{ErrorClass}`** — thrown when {invariant} + +{If no data-structure changes: "No domain data-structure changes — this task only touches application/UI behavior."} +``` + +## Subtask 3B: Ports, Contracts, Use Cases, Routes (presentation template) + +``` +## Subtask 3B: Ports, Contracts, Use Cases, Routes + +### Target Repo +{target_repo} + +### New Ports +{For each:} +- **`I{Name}Port`** (`packages/types/src/{domain}/ports/`) + - Methods: `{signatures}` + - Implemented by: `{Domain}Adapter` + - Why new: {justification} + +### New Contracts +{For each:} +- **`{UseCaseName}`** (`packages/types/src/{domain}/contracts/`) + - Request: `{shape}` + - Response: `{shape}` + +### New Use Cases +{For each:} +- **`{name}.usecase.ts`** (`packages/{domain}/src/application/useCases/{name}/`) — {purpose} + - Base class: `AbstractSpaceMemberUseCase` (etc.) + - Authorization: {who can call} + - Uses repos: {list} + - Uses services: {list} + - Emits events: {list, referencing 3A} + - **Reference**: `{discovery.reference_domain.application_files[*].file}:{line}` + +### Modified Use Cases +{For each:} +- **`{file}:{line}`** — {change description} + +### New Services +{For each:} +- **`{Name}Service`** (`packages/{domain}/src/application/services/`) + - Methods: {signatures} + - Used by: {use cases} + +### New Adapter Methods +{For each:} +- **`{Domain}Adapter.{method}()`** (`packages/{domain}/src/application/adapter/`) + - Implements port: `I{Name}Port.{method}` + +### New Listeners +{For each:} +- **`{Domain}Listener.handle{Event}()`** — reacts to `{EventName}` + +### New API Routes +{For each:} +- **`{METHOD} {path}`** — {purpose} + - Controller: `apps/api/src/controllers/{Name}Controller.ts` (new or extends existing) + - Request DTO: `{shape or file}` + - Response DTO: `{shape or file}` + - Resolves port: `IFooPort.{method}` via `HexaRegistry` + - **Reference**: `{discovery.reference_route.controller}:{line}` + +### Modified API Routes +{For each:} +- **`{METHOD} {path}`** (`{file}:{line}`) + - Change: {description} + - Backward compatible: yes/no + detail + +### Removed Routes +{For each:} +- **`{METHOD} {path}`** — {reason + deprecation plan} + +{If no application/API changes: "No application or API changes — this task only modifies data or UI."} +``` + +## Subtask 3C: Frontend Components (presentation template) + +``` +## Subtask 3C: Frontend Components + +### Target Repo +{target_repo} + +### New Page / Route Components +{For each:} +- **`{ComponentName}`** (`apps/frontend/src/.../{ComponentName}.tsx`) — {purpose} + - Route: `{path}` + - Queries/mutations owned: {list} + - Renders: {list of child components} + - **Reference**: `{discovery.reference_frontend.page.file}:{line}` + +### New Domain / Container Components +{For each:} +- **`{ComponentName}`** (`apps/frontend/src/.../{ComponentName}.tsx`) — {purpose} + - Props: `{prop}: {type}` + - Behavior: pure display | form (validation: {schema}) | stateful with queries + - **Reference**: `{similar_component_file}:{line}` + +### Modified Domain Components +{For each:} +- **`{ComponentName}`** (`{file}:{line}`) — {change} + +### New Gateways +{For each:} +- **`{Name}Gateway`** (`apps/frontend/src/.../gateways/{Name}Gateway.ts`) + - Methods: `{signatures}` wrapping `{contract from 3B}` + - **Reference**: `{discovery.reference_frontend.gateway.file}:{line}` + +### New Query / Mutation Hooks +{For each:} +- **`use{Name}`** (`apps/frontend/src/.../{name}.ts`) + - Query key: `{key}` + - Returns: `{shape}` + - Invalidates: `{keys}` + +### `@packmind/ui` Usage +- Reused PM components: {list} +- New PM wrapper components needed: {list or "none"} +- **If new wrappers**: see `wrapping-chakra-ui-with-slot-components` command before implementing + +### Layout / Navigation +{For each change:} +- **`{file}`** — {description} + +### Microcopy +- {Flag any non-trivial user-facing strings that should be reviewed with the `ux-microcopy` skill} + +{If no frontend changes: "No frontend changes — this task only touches backend/data."} +``` + +## 3D: Task & Implementation Plan Format + +The implementation-plan.md generated by 3D must use this exact shape so that +`/feature-sprint`'s `parse_implementation_plan.py` can read it. + +### Task block + +``` +- [ ] **{PHASE}.{TASK}: {short description}** + - Repo: `oss | proprietary` + - Layer: `domain | application | infra | api | frontend | tests | migrations` + - Files: `{paths}` + - Reference: `{file}:{line}` — {pattern_role} + - Notes: {short implementation notes} +``` + +Phases are numbered (1, 2, 3...). Tasks within a phase use decimals (1.1, +1.2, 2.1). Indentation is exactly two spaces for the metadata lines — the +parser depends on it. + +### Plan-level structure + +``` +# Implementation Plan: {task_name} + +## Phase 1: {short label} +- [ ] **1.1: ...** + - Repo, Layer, Files, Reference, Notes +- [ ] **1.2: ...** + ... + +## Phase 2: ... + +## Coverage Matrix + +| Acceptance Criterion | Task IDs | +|----------------------|----------| +| {criterion} | 1.1, 2.3 | + +## Parallel Groups + +| group_id | group_name | task_ids | target_files | rationale | +|----------|-----------|----------|--------------|-----------| +| A | backend-standards | 1.1, 1.2, 1.3 | packages/standards/... | shared domain package | +| B | frontend-standards | 2.1, 2.2 | apps/frontend/src/standards/... | shared gateway + page | +| C | tests | 3.1, 3.2 | varies | after A and B | + +## Dependency Notes +- Group C blocked by Groups A and B +- ... +``` diff --git a/.claude/skills/feature-sprint/SKILL.md b/.claude/skills/feature-sprint/SKILL.md new file mode 100644 index 000000000..06e1aa8a6 --- /dev/null +++ b/.claude/skills/feature-sprint/SKILL.md @@ -0,0 +1,165 @@ +--- +name: 'feature-sprint' +description: 'Execute the implementation plan produced by /feature-spec. Loads tasks from tmp/feature-specs/{slug}/implementation-plan.md, hydrates them as Claude Tasks, runs parallel groups via subagents in the correct repo (OSS sibling at ../packmind or the proprietary repo in cwd), syncs progress back to checkboxes, commits per group, and runs Nx quality gates. Resumable across sessions via checkbox state.' +--- + +# Feature Sprint Skill + +Execute a Packmind feature implementation plan via subagents with automatic parallel execution and sync-back. Companion to `/feature-spec`. + +## When to Use + +| Scenario | Use feature-sprint | Use plan + architect-executor | +|----------|--------------------|-----------------------------------| +| Fast execution of an already-specified feature | ✅ | ❌ | +| Parallel group execution | ✅ | partial | +| Multi-repo (OSS + proprietary) execution | ✅ | ❌ | +| Heavy TDD enforcement, per-task escalation | ❌ | ✅ | +| Specs in `tmp/feature-specs/` (this flow) | ✅ | ❌ | +| Specs in `.claude/specs/` and `.claude/plans/` | ❌ | ✅ | + +**Rule of thumb**: `/feature-sprint` is the executor for `/feature-spec`. For more careful, single-task-at-a-time execution with the global `plan` skill, use `architect-executor` instead. + +## Prerequisites + +A completed feature spec at `tmp/feature-specs/{slug}/`: +- `{slug}.md` with `status: COMPLETE` in frontmatter +- `context.md` with YAML frontmatter (version 1.0+) +- `implementation-plan.md` with task checkboxes + +If missing or DRAFT: tell the user to run `/feature-spec {source}` first. + +## Architecture: Orchestration + Subagents + Sync-Back + +**Core Principle**: The main session **orchestrates only**. All implementation happens in Task subagents — that keeps the main context clean and lets agents do heavy reading without poisoning your conversation. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Persistent Layer (tmp/, git-ignored) │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ implementation-plan.md │ │ +│ │ Source of truth: task checkboxes [ ] / [x] │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ ▲ + │ Hydrate (session start) │ Sync-back (per group) + ▼ │ +┌─────────────────────────────────────────────────────────────────┐ +│ Main Session (ORCHESTRATION ONLY) │ +│ • Load spec & state • Spawn subagents (one per group) │ +│ • Parse agent output • Sync checkboxes │ +│ • Run Nx quality gates • Commit per group │ +│ • NEVER implement tasks directly │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ Spawn (parallel or sequential) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Subagent Layer (IMPLEMENTATION) │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────┐ │ +│ │ Group A agent │ │ Group B agent │ │ Group C agent │ │ +│ │ Backend tasks │ │ Frontend tasks │ │ Tests / integration│ │ +│ │ cwd: OSS repo │ │ cwd: OSS repo │ │ cwd: depends │ │ +│ └────────────────┘ └────────────────┘ └────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Hydration (session start) +1. Read `implementation-plan.md` → extract tasks with checkbox state (`[ ]` pending, `[x]` done) +2. Read `context.md` → extract `parallel_groups`, `target_repo`, `quality_gates` +3. Use `TaskCreate` for each pending task (visual tracking) +4. Wire `addBlockedBy` from `parallel_groups[*].blocked_by` + +### Subagent delegation +Every group runs in a Task subagent — parallel or sequential. The main session never touches code, never reads source files, never runs Nx commands itself. + +### Sync-back (automatic) +After each group's subagent completes: +1. Parse output for `TASK_COMPLETE: {id}` markers +2. Update `implementation-plan.md` checkboxes: `[ ]` → `[x]` +3. Update Claude Tasks status + +Progress is durable: kill the session, restart `/feature-sprint {slug}`, and it resumes from where checkboxes left off. + +## Workflow Phases + +| Phase | File | Purpose | +|-------|------|---------| +| 1 | `phase-1-init.md` | Load spec, hydrate tasks, get approval | +| 2 | `phase-2-execute.md` | Parallel execution via subagents with auto sync-back + per-group commits | +| 3 | `phase-3-finalize.md` | Run Nx quality gates, final report, optional archive | + +## State Management + +``` +tmp/feature-specs/{slug}/ +├── {slug}.md # status: COMPLETE +├── context.md # parallel_groups, target_repo, quality_gates +├── implementation-plan.md # tasks with checkboxes — SOURCE OF TRUTH for progress +├── discovery.md # reference patterns +└── functional-spec.md # acceptance criteria +``` + +Progress is tracked entirely through `implementation-plan.md` checkboxes — no separate state file. + +## OSS / Proprietary Routing + +`context.md` declares `target_repo` and each parallel group declares its `repo` field. The main session sets `cwd` for each subagent accordingly: + +- `repo: oss` → `cwd: ../packmind` (the OSS sibling cloned next to the proprietary repo) +- `repo: proprietary` → `cwd: .` (this repo) + +After all OSS work is committed and merged upstream, remind the user to `git pull` here so the proprietary fork picks up the auto-merge before any proprietary tasks run. + +## Commit Strategy + +**Each parallel group gets its own commit** when its subagent reports success (one commit per group, not per task). This matches the Packmind CLAUDE.md rule "Each sub-task should have its own commit" — we treat a parallel group as a coherent sub-task unit. + +Commits follow the `git-commit-guidelines` skill format (gitmoji + Conventional Commits, never auto-closing issues). The main session always shows the proposed message and asks for approval before running `git commit`. + +## Invocation + +### New sprint + +``` +/feature-sprint {task_slug} +``` + +If `{task_slug}` is omitted, the skill lists available specs in `tmp/feature-specs/` and asks the user to pick one. + +### Resume + +Same command. The skill detects existing checkbox state and offers to continue. + +## Status + +Show progress across active sprints: + +``` +/feature-sprint status +``` + +(Implemented by listing `tmp/feature-specs/*/implementation-plan.md` and counting checkbox state in each.) + +## Error Handling + +| Situation | Action | +|-----------|--------| +| Spec not found | Error: "Run /feature-spec first" | +| Spec DRAFT | Error: "Complete the spec first — `status` is DRAFT" | +| No parallel groups | Fall back to single-group sequential execution | +| `repo: oss` but `../packmind` missing | Ask user — switch to proprietary or abort | +| Agent fails | Sync completed tasks, pause sprint, report error | +| Quality gate fails | Sync progress, report failures, prompt for fix-and-retry | + +## Integration with /feature-spec + +``` +/feature-spec #123 # produces tmp/feature-specs/{slug}/ +/feature-sprint {slug} # executes the plan +``` + +Sprint reads: +- `context.md` → `parallel_groups`, `target_repo`, `quality_gates` +- `implementation-plan.md` → task list with checkboxes (source of truth) +- `{slug}.md` → verify `status: COMPLETE` \ No newline at end of file diff --git a/.claude/skills/feature-sprint/phase-1-init.md b/.claude/skills/feature-sprint/phase-1-init.md new file mode 100644 index 000000000..4cdd6225c --- /dev/null +++ b/.claude/skills/feature-sprint/phase-1-init.md @@ -0,0 +1,267 @@ +# Phase 1: Initialize Sprint + +Load the feature spec, hydrate Claude Tasks, detect parallel groups, decide OSS-vs-proprietary execution roots, get user approval. + +## Prerequisites + +- Feature spec exists at `tmp/feature-specs/{task_slug}/` +- Spec `status: COMPLETE` + +## Steps + +### 1.1 Resolve Task Slug + +Extract `{task_slug}` from user input. If not provided: + +```bash +ls tmp/feature-specs/ +``` + +For each candidate directory, read `{slug}.md` YAML frontmatter: +- Skip directories without `status: COMPLETE` +- Capture `task_name`, `source_type`, `target_repo` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Which feature spec do you want to sprint?", + "header": "Task", + "multiSelect": false, + "options": [ + {"label": "{slug-1}", "description": "[{target_repo}] {task_name}"}, + {"label": "{slug-2}", "description": "[{target_repo}] {task_name}"} + ] + }] +} +``` + +If `tmp/feature-specs/` has no COMPLETE specs, tell the user to run `/feature-spec` first. + +### 1.2 Load Specification Files + +Read from `tmp/feature-specs/{task_slug}/`: + +1. **`{task_slug}.md`** — Read YAML frontmatter, verify `status: COMPLETE`. If DRAFT or missing, error and stop. + +2. **`context.md`** — Extract YAML frontmatter fields: + - `version` (≥ 1.0) + - `target_repo` (`oss | proprietary | both`) + - `oss_root`, `proprietary_root`, `discovery_root` + - `parallel_groups[]` (may be empty for trivial features) + - `quality_gates` (`lint`, `test`, `build`, `extra`) + +3. **`implementation-plan.md`** — Parse via the bundled script (do NOT hand-roll the regex; it must handle the `_(BLOCKED: …)_` annotation and indented metadata blocks): + + ```bash + python3 .claude/skills/feature-sprint/scripts/parse_implementation_plan.py \ + tmp/feature-specs/{task_slug}/implementation-plan.md + ``` + + Output is JSON: `{tasks: [{id, completed, description, metadata, blocked_reason, raw_block}], summary}`. Keep each task's `raw_block` — Phase 2 pastes it verbatim into the subagent prompt. + +### 1.3 Verify Repos Exist + +For each parallel group, check the repo it targets exists: + +- `repo: oss` → confirm `oss_root` directory exists (the OSS sibling, typically `realpath ../packmind` from the proprietary repo) +- `repo: proprietary` → confirm `proprietary_root` exists (the current working directory when /feature-spec was run) + +If a required repo is missing, ask the user: + +```json +{ + "questions": [{ + "question": "OSS repo at `{oss_root}` is missing. How to proceed?", + "header": "Missing repo", + "multiSelect": false, + "options": [ + {"label": "Abort", "description": "Cancel sprint; user will set up the repo"}, + {"label": "Switch to proprietary", "description": "Run all groups in this repo regardless of repo tag"}, + {"label": "Skip OSS groups", "description": "Only execute groups tagged proprietary"} + ] + }] +} +``` + +### 1.4 Map Tasks to Groups + +If `context.md` has non-empty `parallel_groups`: + +```javascript +const groupedTasks = {}; +for (const group of parallel_groups) { + groupedTasks[group.group_id] = { + name: group.group_name, + tasks: group.task_ids, + target_files: group.target_files, + repo: group.repo, + blocked_by: group.blocked_by ?? [], + status: 'pending', + }; +} +``` + +If `parallel_groups` is empty, create a single sequential group covering all tasks, using `target_repo` from context for `repo`. + +### 1.5 Detect Resume State + +Use the plan-wide totals (`completed`, `pending`, `total`, `percent`) from the `summary` field already returned by `parse_implementation_plan.py`. + +For the **per-group** breakdown table below, also run the readiness resolver and use each group's `completed_ids`/`task_ids` lengths: + +```bash +python3 .claude/skills/feature-sprint/scripts/resolve_ready_groups.py \ + tmp/feature-specs/{task_slug}/context.md \ + tmp/feature-specs/{task_slug}/implementation-plan.md +``` + +Map each `groups[*]` entry to a row — `Tasks = len(task_ids)`, `Completed = len(completed_ids)`, `Status` from `status` (`ready` → ✅ Ready / 🔄 Partial / ⏳ Blocked / ✅ Done). + +If any tasks are already completed: + +``` +**Resuming Sprint: {task_slug}** + +Previous progress: +- Completed: {n}/{total} ({percent}%) +- Pending: {pending} + +| Group | Repo | Tasks | Completed | Status | +|-------|------|-------|-----------|--------| +| A: {name} | oss | 3 | 3 | ✅ Done | +| B: {name} | oss | 2 | 1 | 🔄 Partial | +| C: {name} | oss | 2 | 0 | ⏳ Blocked (after A, B) | +``` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Resume sprint from previous progress?", + "header": "Resume", + "multiSelect": false, + "options": [ + {"label": "Continue (Recommended)", "description": "Resume from {completed}/{total} done"}, + {"label": "Restart", "description": "Reset all checkboxes to [ ] and start over"} + ] + }] +} +``` + +If "Restart": rewrite `implementation-plan.md` replacing every `- [x]` with `- [ ]` and stripping any `_(BLOCKED: …)_` annotations (a single `sed` pass is fine since this is destructive on purpose). + +### 1.6 Pull-Before-Sprint (proprietary only) + +If `target_repo` is `proprietary` or `both`: + +Remind the user (informational, no gate): +``` +Heads up: most OSS work auto-merges into this proprietary fork. +Before starting, you may want to run `git pull` here so the fork is up to date. +``` + +If `target_repo` is `oss` only, skip this reminder. + +### 1.7 Hydrate Claude Tasks + +For visual feedback, use `TaskCreate` for each pending task: + +``` +TaskCreate({ + subject: `${task.id}: ${task.description}`, + description: `Repo: ${task.repo} | Layer: ${task.layer} | Files: ${task.files}`, + activeForm: `Implementing ${task.id}...`, +}) +``` + +Capture each returned task ID into a `sessionTasks` map keyed by `task.id`. + +### 1.8 Set Up Task Dependencies + +For each group with `blocked_by`: + +``` +for blockerGroupId in group.blocked_by: + for taskId in group.tasks: + TaskUpdate( + taskId: sessionTasks[taskId], + addBlockedBy: blockerGroup.tasks.map(t => sessionTasks[t]) + ) +``` + +This is purely visual — the actual execution gating is enforced in Phase 2 by ready-group selection. + +### 1.9 Display Execution Plan + +``` +**Sprint Ready: {task_slug}** + +**Target repo:** {target_repo} +**Tasks:** {pending}/{total} pending ({completed} already done) + +**Parallel Execution Plan:** +| Group | Repo | Tasks | Status | Dependencies | +|-------|------|-------|--------|--------------| +| A: {name} | oss | {ids} | ✅ Ready | None | +| B: {name} | oss | {ids} | ✅ Ready | None | +| C: {name} | oss | {ids} | ⏳ Blocked | After A, B | + +**Quality gates (final phase):** Nx lint / test / build on affected projects +**Commit strategy:** one commit per group via git-commit-guidelines +``` + +### 1.10 Authorize Subagent File Edits + +Phase 2 spawns Agent subagents that call Edit / Write / Bash on files in the target repo. **Subagents inherit the parent session's permission mode** — in default mode every tool call pauses for approval, which serializes parallel execution and stalls the sprint. + +**Before continuing, the user must enable auto-accept mode in the Claude Code TUI** (`Shift+Tab` cycles through modes; pick "accept edits" or "bypass permissions"). The orchestrator cannot toggle this on the user's behalf. + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Confirm auto-accept (Shift+Tab) is enabled so subagents can edit files without prompts?", + "header": "Authorize", + "multiSelect": false, + "options": [ + {"label": "Yes, ready to sprint", "description": "Auto-accept is on; subagents will edit files without per-call prompts"}, + {"label": "Not yet — cancel", "description": "Stop the sprint; I'll toggle auto-accept and re-run /feature-sprint"} + ] + }] +} +``` + +If "Not yet — cancel": stop and leave state untouched. Otherwise continue to §1.11. + +### 1.11 Get Approval + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Start sprint execution?", + "header": "Sprint", + "multiSelect": false, + "options": [ + {"label": "Start parallel (Recommended)", "description": "Execute all ready groups in parallel via subagents"}, + {"label": "Sequential only", "description": "One group at a time — slower but easier to debug"}, + {"label": "Cancel", "description": "Don't start"} + ] + }] +} +``` + +Do not continue until the user confirms. + +- "Start parallel" → set `execution_mode = "parallel"` +- "Sequential only" → set `execution_mode = "sequential"` +- "Cancel" → stop, leave state untouched + +### 1.12 Proceed + +Read `phase-2-execute.md` and continue. diff --git a/.claude/skills/feature-sprint/phase-2-execute.md b/.claude/skills/feature-sprint/phase-2-execute.md new file mode 100644 index 000000000..5507d67c5 --- /dev/null +++ b/.claude/skills/feature-sprint/phase-2-execute.md @@ -0,0 +1,222 @@ +# Phase 2: Execute Sprint + +Execute tasks via subagents with automatic sync-back and per-group commits. Runs autonomously after Phase 1 approval. + +## Critical Rule: Subagent-Only Execution + +**The main session MUST NOT implement tasks directly.** All implementation lives in Task subagents. + +- Main session = orchestration (spawn agents, parse output, sync state, run commits) +- Subagents = implementation (read specs, write code, run small validation commands) + +This keeps the main context clean and lets agents read heavily without poisoning your conversation. + +## Prerequisites + +- Phase 1 completed +- `execution_mode` chosen (parallel or sequential) +- `sessionTasks` map populated +- `groupedTasks` populated from `parallel_groups` + +## Execution Modes + +### Parallel Mode (Default) + +Spawn Task subagents for ALL ready groups **simultaneously in a single message**. Agents run concurrently. + +### Sequential Mode + +Spawn Task subagents **one at a time**. Wait for each to complete before spawning the next. Used when: +- No `parallel_groups` defined (single sequential group) +- User chose "Sequential only" in Phase 1 +- Only one ready group exists + +## Steps + +### 2.1 Identify Ready Groups + +Call the bundled script — it reads `context.md` + `implementation-plan.md` and classifies every group as `ready | completed | partial | blocked`: + +```bash +python3 .claude/skills/feature-sprint/scripts/resolve_ready_groups.py \ + tmp/feature-specs/{task_slug}/context.md \ + tmp/feature-specs/{task_slug}/implementation-plan.md +``` + +Output JSON contains `ready_group_ids[]` and per-group `status`/`completed_ids`/`blocked_ids`. Use this every iteration of the loop in step 2.10 — never hand-compute the readiness check. + +### 2.2 Pick Working Directory per Group + +For each ready group: + +| group.repo | cwd | +|------------|-----| +| `oss` | `oss_root` from context (the OSS sibling at `../packmind`, resolved to absolute path at spec time) | +| `proprietary` | `proprietary_root` from context (the proprietary repo, the cwd when /feature-spec ran) | + +This `cwd` is passed to the subagent's prompt as the *implementation root*. The subagent must Bash with absolute paths so it works regardless of the agent's own cwd. + +### 2.3 Build Subagent Prompt + +Read the bundled template once: `.claude/skills/feature-sprint/references/group-prompt-template.md`. Substitute the placeholders documented at the top of that file. For `{TASKS_BLOCK}`, concatenate the `raw_block` of each task in `group.task_ids` (from the Phase 1 `parse_implementation_plan.py` output) separated by blank lines. + +Do not paraphrase the template — it is the authoritative contract subagents respond to (`TASK_COMPLETE`, `GROUP_COMPLETE`, `BLOCKED`, `FILES_MODIFIED` markers are parsed in step 2.5). + +### 2.4 Spawn Subagents + +**Parallel mode**: send a single message containing one `Agent` tool call per ready group. They run concurrently. + +**Sequential mode**: send one `Agent` call, wait for it to return, then move to the next. + +In both cases, set `subagent_type="general-purpose"`. + +### 2.5 Parse Subagent Output + +For each agent that returns, parse: + +- `TASK_COMPLETE: {id}` markers — collect into `completed[]` +- `GROUP_COMPLETE: {id}` marker — group finished cleanly +- `BLOCKED: {id} - {reason}` markers — collect into `blocked[]` +- `FILES_MODIFIED: ...` — capture for the commit step + +### 2.6 Sync Checkboxes to implementation-plan.md + +Apply both completed and blocked updates in one pass via the bundled script: + +```bash +python3 .claude/skills/feature-sprint/scripts/sync_checkboxes.py \ + tmp/feature-specs/{task_slug}/implementation-plan.md \ + --complete {comma-separated ids from completed[]} \ + --blocked "{id1}:{reason1}" "{id2}:{reason2}" +``` + +`--blocked` takes one `id:reason` per argument (so reasons may contain commas freely). `--complete` is a single comma-joined ID list. + +**Omit a flag entirely when its list is empty** — do not pass an unquoted empty value (`--complete `) since argparse will treat the next flag as the value. If `completed[]` and `blocked[]` are both empty, skip this step (nothing to sync). + +The script returns JSON with `applied_complete`, `applied_blocked`, and `missing` (any task ID that didn't match — surface these as a warning; don't retry the agent). Exit code is `1` if anything was missing, `0` otherwise. + +### 2.7 Update Claude Tasks + +For every task in `completed[]`: + +``` +TaskUpdate({ taskId: sessionTasks[task_id], status: "completed" }) +``` + +For tasks currently being executed but not yet complete (shouldn't normally happen here since groups are atomic), mark them `in_progress`. + +### 2.8 Commit the Group + +After the agent finishes (whether all tasks completed or some blocked), commit the changes if anything was modified: + +1. Switch to the group's working repo (use absolute path): + ``` + git -C {repo_root} status --short + ``` + If nothing changed, skip the commit step for this group. + +2. Stage only the files listed in `FILES_MODIFIED` (don't `git add -A`): + ``` + git -C {repo_root} add {files...} + ``` + +3. Build the commit message using the `git-commit-guidelines` skill conventions: + - Gitmoji prefix (✨ for new features, 🐛 for fixes, ♻️ for refactor, ✅ for tests, etc.) + - Conventional Commits format: `(): ` + - NEVER use `Close`/`Fix #` keywords (no auto-closing of issues) + - Body lists completed task IDs and references the spec slug + - Include `Co-Authored-By: Claude ` + + Template: + ``` + {emoji} {type}({scope}): {one-line subject derived from group_name} + + Sprint group {group_id} ({group_name}) for feature `{task_slug}`. + + Completed tasks: + - {1.1}: {description} + - {1.2}: {description} + + Refs: tmp/feature-specs/{task_slug}/ + + Co-Authored-By: Claude + ``` + +4. Show the message to the user and ask for approval: + + ```json + { + "questions": [{ + "question": "Commit group {group_id} ({group_name})?", + "header": "Commit", + "multiSelect": false, + "options": [ + {"label": "Commit (Recommended)", "description": "Apply the proposed message"}, + {"label": "Edit message", "description": "Provide a new commit message"}, + {"label": "Skip commit", "description": "Leave changes staged for manual commit later"} + ] + }] + } + ``` + +5. On approval, run: + ``` + git -C {repo_root} commit -m "$(cat <<'EOF' + {message} + EOF + )" + ``` + + If a pre-commit hook fails, do NOT use `--no-verify`. Fix the underlying issue or pass control back to the user. + +### 2.9 Refresh Group Status + +Group status is derived from checkbox state on the next call to `resolve_ready_groups.py` — no in-memory bookkeeping required. The sync step in 2.6 is the only state mutation. + +### 2.10 Loop Until No Ready Groups Remain + +After each round, re-run `resolve_ready_groups.py` (it reads from disk, so it picks up the sync from 2.6): + +1. If `ready_group_ids[]` is non-empty → repeat steps 2.3–2.8 for the new ready groups +2. If `ready_group_ids[]` is empty AND `all_completed` is false → every remaining group is blocked (status `blocked` or `partial`). Report blockers and exit to Phase 3. +3. If `all_completed` is true → proceed to Phase 3. + +### 2.11 Final Sync Summary + +Before handing off to Phase 3, print a summary: + +``` +**Execution Phase Complete** + +| Group | Repo | Tasks | Status | Commit | +|-------|------|-------|--------|--------| +| A: backend-{domain} | oss | 3/3 | ✅ Complete | abc123 | +| B: frontend-{domain} | oss | 2/2 | ✅ Complete | def456 | +| C: tests | oss | 1/2 | 🔄 Partial | ghi789 | + +Total: {completed}/{total} tasks done +Blocked: {list of blocked task IDs with reasons} +``` + +## Error Recovery + +| Scenario | Action | +|----------|--------| +| Agent times out | Sync completed tasks, mark group `partial`, continue | +| Agent crashes | Sync completed tasks, log the error, pause sprint and ask user | +| Lint/test fails inside subagent | Subagent should self-correct; if it can't, it emits `BLOCKED` | +| File conflict (group A writes while group B is reading) | Shouldn't happen if Phase 3D grouping was correct. If it does: pause, report, ask user to resolve | +| All ready groups are blocked | Pause sprint, report blockers, suggest fixes | +| `git commit` fails | Surface the error verbatim; do NOT retry with `--no-verify` | + +## Handling Interruption + +If the session is killed mid-sprint: +- Completed tasks already have `[x]` in `implementation-plan.md` +- Blocked tasks have the `_(BLOCKED: ...)_` annotation +- Re-running `/feature-sprint {task_slug}` resumes from those checkboxes + +## Next Phase + +After all reachable groups are completed or blocked, read `phase-3-finalize.md`. diff --git a/.claude/skills/feature-sprint/phase-3-finalize.md b/.claude/skills/feature-sprint/phase-3-finalize.md new file mode 100644 index 000000000..ac7f3f426 --- /dev/null +++ b/.claude/skills/feature-sprint/phase-3-finalize.md @@ -0,0 +1,227 @@ +# Phase 3: Finalize Sprint + +Run Nx quality gates on affected projects, produce the final report, optionally archive the spec. + +## Prerequisites + +- Phase 2 completed (all reachable groups completed or blocked) +- Per-group commits already created (or skipped) in each working repo + +## Steps + +### 3.1 Load Final State + +Read `tmp/feature-specs/{task_slug}/implementation-plan.md`: +- Count `[x]` vs `[ ]` +- Find any `_(BLOCKED: ...)_` annotations + +Read `tmp/feature-specs/{task_slug}/context.md`: +- `quality_gates` (lint/test/build flags + extras) +- `parallel_groups[]` to derive which Nx projects were touched + +### 3.2 Resolve Affected Nx Projects + +For each completed group, look at `target_files`: +- A path under `packages/{name}/` → Nx project `{name}` +- A path under `apps/{name}/` → Nx project `{name}` + +Build a deduplicated set of `affected_projects[]`. Also note which **repo** each project belongs to (`oss` or `proprietary`) — gates run in the right repo. + +If unsure or you want a broader gate, the Nx convention is: +``` +./node_modules/.bin/nx affected -t lint +./node_modules/.bin/nx affected -t test +./node_modules/.bin/nx affected -t build +``` +But narrowly targeting the specific projects is faster and clearer when you know them. + +### 3.3 Run Quality Gates + +For each `affected_project` × each gate flag in `quality_gates`: + +```bash +# Always cd to the repo via -C; use absolute paths. +{ensure node version per .nvmrc, e.g. via nvm if available} + +# Lint +./node_modules/.bin/nx lint {project} + +# Test +./node_modules/.bin/nx test {project} + +# Build (only if quality_gates.build is true) +./node_modules/.bin/nx build {project} +``` + +Set `PACKMIND_EDITION=proprietary` in the environment when running gates against the proprietary repo (per `CLAUDE.md`). + +Capture for each: `passed: bool`, `duration`, truncated output. + +If any extras are listed in `quality_gates.extra`, run them too (e.g., a project-specific e2e command). + +### 3.4 Handle Gate Failures + +If any gate fails, display the failure: + +``` +**Quality Gate Failed: {project} / {gate}** + +Repo: {repo} +Command: {command} +Exit: {code} + +{Last 30 lines of output} +``` + +Then use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Quality gate '{project}/{gate}' failed. How to proceed?", + "header": "Gate Failed", + "multiSelect": false, + "options": [ + {"label": "Fix and retry (Recommended)", "description": "I'll fix the issue, then retry the gate"}, + {"label": "Spawn fixer subagent", "description": "Delegate the fix to a fresh general-purpose subagent in the right repo"}, + {"label": "Skip gate", "description": "Continue without this gate passing (risky)"}, + {"label": "Pause sprint", "description": "Stop here, investigate manually"} + ] + }] +} +``` + +- "Fix and retry" → wait for the user to make changes, then re-run the gate +- "Spawn fixer subagent" → launch an `Agent` with `subagent_type="general-purpose"`. Prompt: working repo, the failed command and output, the relevant files. Instruct it to make the smallest fix and re-run the gate. On success, commit the fix as a follow-up commit (with user approval, per the per-group commit policy). +- "Skip gate" → record skipped, continue +- "Pause sprint" → exit; progress is preserved in checkboxes + commits + +### 3.5 Verify Final Checkbox State + +Re-read `implementation-plan.md`: +- Count completed (`[x]`) +- Count remaining (`[ ]`) — these should all be annotated `_(BLOCKED: ...)_` or the sprint is incomplete + +If any unblocked `[ ]` tasks remain, the sprint is not finished. Tell the user and offer to resume Phase 2. + +### 3.6 Offer to Archive + +``` +**Sprint Complete: {task_slug}** + +All reachable tasks executed, gates run, commits created. + +Move spec artifacts to an archive folder? (Still in tmp/ — git-ignored.) +- From: tmp/feature-specs/{task_slug}/ +- To: tmp/feature-specs/done/{task_slug}/ +``` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Move completed spec to tmp/feature-specs/done/?", + "header": "Archive", + "multiSelect": false, + "options": [ + {"label": "Yes, archive", "description": "Move under tmp/feature-specs/done/ so /feature-spec sees a clean pending list"}, + {"label": "Keep in place (Recommended)", "description": "Leave it; you may want to revisit it"} + ] + }] +} +``` + +If "Yes, archive": +```bash +mkdir -p tmp/feature-specs/done/ +mv tmp/feature-specs/{task_slug} tmp/feature-specs/done/{task_slug} +``` + +(No commit needed — `tmp/` is git-ignored.) + +### 3.7 Pull Reminder (OSS → proprietary) + +If any commits landed in the OSS repo (`oss_root`), remind the user: + +``` +✅ OSS commits created in {oss_root}. + +When upstream auto-merges into the proprietary fork, run: + git -C {proprietary_root} pull +to pick up the changes here. + +If this feature has proprietary-only tasks pending (e.g., editions wiring), do that pull before resuming /feature-sprint {task_slug}. +``` + +Skip this section if `target_repo` was `proprietary`. + +### 3.8 Final Report + +``` +## ✅ SPRINT COMPLETE + +**Task**: {task_name} +**Slug**: {task_slug} +**Target repo**: {target_repo} + +### Execution Summary + +| Metric | Value | +|--------|-------| +| Tasks completed | {n}/{total} | +| Parallel groups | {completed_groups}/{total_groups} | +| Commits created | {commit_count} | +| Files modified | {file_count} | +| Quality gates | {passed}/{total} passed{, X skipped if any} | + +### Commits + +| Repo | Group | SHA | Message | +|------|-------|-----|---------| +| oss | A: backend-... | abc123 | ✨ feat(...): ... | +| oss | B: frontend-... | def456 | ✨ feat(...): ... | + +### Quality Gates + +| Project | Gate | Status | Duration | +|---------|------|--------|----------| +| standards | lint | ✅ | 4s | +| standards | test | ✅ | 22s | +| frontend | lint | ✅ | 8s | +| frontend | test | ✅ | 31s | + +### Blocked Tasks + +{if any:} +| ID | Reason | +|----|--------| +| 3.2 | {reason} | + +{else: "None."} + +### Files Modified + +{abbreviated git status -s output per repo} + +--- + +**Spec**: `tmp/feature-specs/{pending|done}/{task_slug}/` +**Plan**: `tmp/feature-specs/{pending|done}/{task_slug}/implementation-plan.md` + +Sprint complete. Changes committed locally (not pushed). +``` + +### 3.9 Cleanup Session Tasks + +``` +for (taskId, sessionTaskId) in sessionTasks: + TaskUpdate({ taskId: sessionTaskId, status: "completed" }) +``` + +`sessionTasks` is ephemeral — nothing else to clean up. + +## End of Sprint + +To run another: `/feature-sprint {another_task_slug}` +To check overall progress: `/feature-sprint status` diff --git a/.claude/skills/feature-sprint/references/group-prompt-template.md b/.claude/skills/feature-sprint/references/group-prompt-template.md new file mode 100644 index 000000000..d928f20ba --- /dev/null +++ b/.claude/skills/feature-sprint/references/group-prompt-template.md @@ -0,0 +1,84 @@ +# Per-Group Subagent Prompt Template + +Loaded by `phase-2-execute.md` step 2.3. The orchestrator substitutes every +`{placeholder}` (and the `{TASKS_BLOCK}` section) with concrete values, then +passes the result as the `prompt` argument to `Agent` with +`subagent_type="general-purpose"`. + +## Placeholders + +| Placeholder | Source | +|-------------|--------| +| `{group_id}` | `context.md` → `parallel_groups[*].group_id` | +| `{group_name}` | `context.md` → `parallel_groups[*].group_name` | +| `{task_slug}` | spec directory name (`tmp/feature-specs/{slug}/`) | +| `{repo}` | `parallel_groups[*].repo` (`oss` or `proprietary`) | +| `{repo_root}` | absolute path: `oss_root` or `proprietary_root` from `context.md` | +| `{proprietary_root}` | always `context.md` → `proprietary_root` (spec artifacts live here) | +| `{TASKS_BLOCK}` | concatenation of each task's `raw_block` from `parse_implementation_plan.py` output | + +## Template + +``` +Execute sprint group {group_id}: {group_name} for feature {task_slug}. + +## Working repo +- Repo: {repo} +- Root (absolute path): {repo_root} +- All file paths in tasks are relative to this root unless otherwise noted. + +## Context files (read in this exact order) +1. {proprietary_root}/tmp/feature-specs/{task_slug}/context.md +2. {proprietary_root}/tmp/feature-specs/{task_slug}/functional-spec.md +3. {proprietary_root}/tmp/feature-specs/{task_slug}/discovery.md +4. {proprietary_root}/tmp/feature-specs/{task_slug}/implementation-plan.md + +Note: spec artifacts live in the PROPRIETARY repo's tmp/ even when implementation +targets OSS. Always read from `{proprietary_root}/tmp/feature-specs/{task_slug}/`. + +## Your tasks (in this exact order) + +{TASKS_BLOCK} + +## Rules + +1. For each task in order: + a. Re-read the task block in implementation-plan.md for full detail + b. Open the Reference file:line — that is your pattern template + c. Implement the task following the Packmind conventions established in discovery.md + d. If the task is a backend domain/application/infra task, respect the hexagonal-architecture skill at `{proprietary_root}/.claude/skills/hexagonal-architecture/` + e. If the task is a frontend task, respect `working-with-pm-design-kit` and `gateway-pattern-implementation-in-packmind-frontend` + f. If the task is a migration, respect `how-to-write-typeorm-migrations-in-packmind` + g. After finishing the task, output exactly: `TASK_COMPLETE: {task_id}` + +2. NEVER import from `@packmind/editions` unless the file you're editing already lives inside `packages/editions/` (this is enforced by `.claude/rules/packmind/packmind-proprietary.md`). + +3. Run `./node_modules/.bin/nx lint ` and `./node_modules/.bin/nx test ` after finishing each Nx project's tasks within the group. If anything fails, fix it before moving on. + +4. After ALL tasks in this group are complete, output exactly: + ``` + GROUP_COMPLETE: {group_id} + COMPLETED_TASKS: + FILES_MODIFIED: + ``` + +5. If you encounter a blocker: + - Output: `BLOCKED: - ` + - Continue with later tasks in this group only if they don't depend on the blocked task + - At the end, list blocked tasks in your group summary + +## Hard constraints + +- Do NOT commit changes — the main session handles commits per group +- Do NOT modify `implementation-plan.md` checkboxes — the main session handles sync-back +- Do NOT modify any file in `{proprietary_root}/tmp/feature-specs/{task_slug}/` +- Focus only on implementing the listed task IDs; do not "improve" unrelated files +- Use absolute paths in your Bash calls so they work regardless of your cwd + +## Tools you should rely on + +- Read, Edit, Write for code changes +- Bash with absolute paths for `nx lint`, `nx test`, `nx build` in the working repo +- Glob/Grep within the working repo for navigation +- Avoid spawning further subagents (`Agent`) — flatten everything in this one +``` diff --git a/.claude/skills/feature-sprint/scripts/parse_implementation_plan.py b/.claude/skills/feature-sprint/scripts/parse_implementation_plan.py new file mode 100644 index 000000000..b8e6068c2 --- /dev/null +++ b/.claude/skills/feature-sprint/scripts/parse_implementation_plan.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Parse a feature-sprint implementation-plan.md into structured JSON. + +Reads checkbox tasks of the form: + + - [ ] **1.2: short description** + - Repo: oss + - Layer: application + - Files: packages/foo/... + - Reference: packages/bar/baz.ts:42 + - Notes: free text + +and emits JSON to stdout: + + { + "tasks": [ + { + "id": "1.2", + "completed": false, + "description": "short description", + "metadata": { + "repo": "oss", + "layer": "application", + "files": "packages/foo/...", + "reference": "packages/bar/baz.ts:42", + "notes": "free text" + }, + "blocked_reason": null, + "raw_block": "..." + } + ], + "summary": {"total": 1, "completed": 0, "pending": 1, "blocked": 0} + } + +Tasks annotated with `_(BLOCKED: reason)_` after the description carry that +reason on `blocked_reason`. The full markdown block (including indented +metadata lines) is preserved on `raw_block` so the orchestrator can paste it +verbatim into subagent prompts without re-reading the file. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +TASK_LINE = re.compile( + r"""^-\s\[(?P[ x])\]\s\*\* + (?P\d+(?:\.\d+)+):\s + (?P.+?)\*\* + (?:\s+_\(BLOCKED:\s(?P.+?)\)_)? + \s*$""", + re.VERBOSE, +) +META_LINE = re.compile(r"^\s+-\s(?P[A-Za-z]+):\s*(?P.*)$") + + +def parse(content: str) -> dict: + tasks: list[dict] = [] + current: dict | None = None + raw_lines: list[str] = [] + + def flush() -> None: + if current is None: + return + current["raw_block"] = "\n".join(raw_lines).rstrip() + tasks.append(current) + + for line in content.splitlines(): + task_match = TASK_LINE.match(line) + if task_match: + flush() + raw_lines = [line] + current = { + "id": task_match.group("id"), + "completed": task_match.group("state") == "x", + "description": task_match.group("desc").strip(), + "metadata": {}, + "blocked_reason": task_match.group("blocked"), + "raw_block": "", + } + continue + + if current is None: + continue + + meta_match = META_LINE.match(line) + if meta_match: + raw_lines.append(line) + key = meta_match.group("key").lower() + value = meta_match.group("value").strip() + current["metadata"][key] = value + continue + + if line.startswith("- [") or (line.strip() == "" and len(raw_lines) > 1): + flush() + current = None + raw_lines = [] + continue + + if line.strip(): + raw_lines.append(line) + + flush() + + completed = sum(1 for t in tasks if t["completed"]) + blocked = sum(1 for t in tasks if t["blocked_reason"]) + return { + "tasks": tasks, + "summary": { + "total": len(tasks), + "completed": completed, + "pending": len(tasks) - completed, + "blocked": blocked, + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("plan", help="Path to implementation-plan.md") + parser.add_argument( + "--ids-only", + action="store_true", + help="Print only the task IDs (one per line) instead of JSON", + ) + parser.add_argument( + "--pending-only", + action="store_true", + help="Filter to non-completed tasks before printing", + ) + args = parser.parse_args() + + path = Path(args.plan) + if not path.exists(): + print(f"Error: plan file not found: {path}", file=sys.stderr) + return 1 + + result = parse(path.read_text(encoding="utf-8")) + + if args.pending_only: + result["tasks"] = [t for t in result["tasks"] if not t["completed"]] + + if args.ids_only: + for task in result["tasks"]: + print(task["id"]) + return 0 + + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/feature-sprint/scripts/resolve_ready_groups.py b/.claude/skills/feature-sprint/scripts/resolve_ready_groups.py new file mode 100644 index 000000000..b6da2306a --- /dev/null +++ b/.claude/skills/feature-sprint/scripts/resolve_ready_groups.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Compute which parallel groups are ready to execute next. + +Reads: +- context.md (YAML frontmatter with `parallel_groups[]`) +- implementation-plan.md (checkbox state per task) + +A group is **completed** when every task in `task_ids` is checked `[x]` AND +not annotated `_(BLOCKED: ...)_`. A group is **partial** when some tasks are +done and some are blocked. A group is **ready** when its status is not yet +`completed` and every group it depends on (`blocked_by`) has status +`completed`. + +Output (stdout, JSON): + + { + "groups": [ + { + "group_id": "A", + "group_name": "backend-foo", + "repo": "oss", + "status": "ready" | "completed" | "partial" | "blocked", + "task_ids": ["1.1", "1.2"], + "completed_ids": ["1.1"], + "blocked_ids": [], + "blocked_by": [] + } + ], + "ready_group_ids": ["A"], + "all_completed": false + } + +Requires PyYAML. Exit 1 on missing files or malformed YAML. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +try: + import yaml +except ImportError: + print( + "Error: PyYAML is required (pip install pyyaml)", + file=sys.stderr, + ) + sys.exit(1) + + +FRONTMATTER = re.compile(r"^---\n(.*?)\n---", re.DOTALL) +TASK_STATE = re.compile( + r"^-\s\[(?P[ x])\]\s\*\*(?P\d+(?:\.\d+)+):\s.+?\*\*" + r"(?P\s+_\(BLOCKED:.+?\)_)?\s*$", + re.MULTILINE, +) + + +def load_frontmatter(path: Path) -> dict: + text = path.read_text(encoding="utf-8") + match = FRONTMATTER.match(text) + if not match: + print(f"Error: no YAML frontmatter in {path}", file=sys.stderr) + sys.exit(1) + try: + data = yaml.safe_load(match.group(1)) + except yaml.YAMLError as exc: + print(f"Error: invalid YAML in {path}: {exc}", file=sys.stderr) + sys.exit(1) + if not isinstance(data, dict): + print(f"Error: frontmatter must be a mapping in {path}", file=sys.stderr) + sys.exit(1) + return data + + +def extract_task_state(plan_path: Path) -> dict[str, dict]: + states: dict[str, dict] = {} + for match in TASK_STATE.finditer(plan_path.read_text(encoding="utf-8")): + states[match.group("id")] = { + "completed": match.group("state") == "x", + "blocked": bool(match.group("blocked")), + } + return states + + +def classify_group(group: dict, task_state: dict[str, dict]) -> dict: + task_ids = group.get("task_ids", []) or [] + completed: list[str] = [] + blocked: list[str] = [] + for task_id in task_ids: + info = task_state.get(task_id) + if info is None: + continue + if info["completed"]: + completed.append(task_id) + elif info["blocked"]: + blocked.append(task_id) + + if task_ids and len(completed) == len(task_ids): + status = "completed" + elif blocked and (len(completed) + len(blocked)) == len(task_ids): + status = "partial" + else: + status = "pending" # may become "ready" after dep check + + return { + "group_id": group.get("group_id"), + "group_name": group.get("group_name"), + "repo": group.get("repo"), + "task_ids": task_ids, + "blocked_by": group.get("blocked_by", []) or [], + "target_files": group.get("target_files", []) or [], + "status": status, + "completed_ids": completed, + "blocked_ids": blocked, + } + + +def resolve_readiness(groups: list[dict]) -> None: + by_id = {g["group_id"]: g for g in groups} + for group in groups: + if group["status"] in ("completed", "partial"): + continue + deps_done = all( + by_id.get(dep, {}).get("status") == "completed" + for dep in group["blocked_by"] + ) + group["status"] = "ready" if deps_done else "blocked" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("context", help="Path to context.md") + parser.add_argument("plan", help="Path to implementation-plan.md") + args = parser.parse_args() + + context_path = Path(args.context) + plan_path = Path(args.plan) + for path, label in [(context_path, "context"), (plan_path, "plan")]: + if not path.exists(): + print(f"Error: {label} file not found: {path}", file=sys.stderr) + return 1 + + frontmatter = load_frontmatter(context_path) + parallel_groups = frontmatter.get("parallel_groups") or [] + if not isinstance(parallel_groups, list): + print("Error: parallel_groups must be a list", file=sys.stderr) + return 1 + + task_state = extract_task_state(plan_path) + groups = [classify_group(g, task_state) for g in parallel_groups] + resolve_readiness(groups) + + ready_ids = [g["group_id"] for g in groups if g["status"] == "ready"] + all_completed = bool(groups) and all(g["status"] == "completed" for g in groups) + + result = { + "groups": groups, + "ready_group_ids": ready_ids, + "all_completed": all_completed, + } + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/feature-sprint/scripts/sync_checkboxes.py b/.claude/skills/feature-sprint/scripts/sync_checkboxes.py new file mode 100644 index 000000000..0253477fa --- /dev/null +++ b/.claude/skills/feature-sprint/scripts/sync_checkboxes.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Batch-update task checkboxes in a feature-sprint implementation-plan.md. + +Supports two operations in a single pass: + + --complete 1.1,1.2,2.3 Flip `- [ ] **{id}:` to `- [x] **{id}:` + --blocked '3.1:reason' '3.2:r2' Annotate `- [ ] **{id}: desc**` with + `_(BLOCKED: reason)_` (keeps the checkbox + unchecked so resume picks it up). + +`--blocked` accepts one pair per argument so reasons can contain commas +freely (e.g. `'3.1:waiting on API, see #123'`). Pass either flag without +values, or omit it entirely, when the corresponding list is empty. + +Already-completed tasks (`[x]`) are left alone. Tasks already annotated +BLOCKED have their annotation replaced if a new reason is supplied. Any task +ID passed but not found in the file is reported on stderr; exit code is 1 if +any IDs were not applied, 0 otherwise. + +Output (stdout, JSON): + + { + "applied_complete": ["1.1", "1.2"], + "applied_blocked": [{"id": "3.1", "reason": "..."}], + "missing": ["9.9"] + } +""" + +import argparse +import json +import re +import sys +from pathlib import Path + + +def parse_id_list(raw: str | None) -> list[str]: + if not raw: + return [] + return [item.strip() for item in raw.split(",") if item.strip()] + + +def parse_blocked_list(items: list[str] | None) -> list[tuple[str, str]]: + if not items: + return [] + pairs = [] + for item in items: + item = item.strip() + if not item: + continue + if ":" not in item: + print( + f"Error: --blocked entry '{item}' missing ':reason'", + file=sys.stderr, + ) + sys.exit(2) + task_id, reason = item.split(":", 1) + pairs.append((task_id.strip(), reason.strip())) + return pairs + + +def task_line_pattern(task_id: str) -> re.Pattern[str]: + # Match the exact task line, preserving the description and any trailing + # markup. Captures: 1=state ([ ] or [x]), 2=description, 3=optional + # BLOCKED annotation already present. + return re.compile( + rf"^-\s\[(?P[ x])\]\s\*\*{re.escape(task_id)}:\s(?P.+?)\*\*" + r"(?P\s+_\(BLOCKED:.+?\)_)?\s*$", + re.MULTILINE, + ) + + +def apply_complete(content: str, ids: list[str]) -> tuple[str, list[str], list[str]]: + applied: list[str] = [] + missing: list[str] = [] + for task_id in ids: + pattern = task_line_pattern(task_id) + match = pattern.search(content) + if not match: + missing.append(task_id) + continue + if match.group("state") == "x": + applied.append(task_id) # idempotent — already complete + continue + # Replace the matched line: flip state, strip any BLOCKED annotation + new_line = f"- [x] **{task_id}: {match.group('desc')}**" + content = content[: match.start()] + new_line + content[match.end():] + applied.append(task_id) + return content, applied, missing + + +def apply_blocked( + content: str, pairs: list[tuple[str, str]] +) -> tuple[str, list[dict], list[str]]: + applied: list[dict] = [] + missing: list[str] = [] + for task_id, reason in pairs: + pattern = task_line_pattern(task_id) + match = pattern.search(content) + if not match: + missing.append(task_id) + continue + if match.group("state") == "x": + # Already complete — don't re-annotate + continue + new_line = ( + f"- [ ] **{task_id}: {match.group('desc')}** " + f"_(BLOCKED: {reason})_" + ) + content = content[: match.start()] + new_line + content[match.end():] + applied.append({"id": task_id, "reason": reason}) + return content, applied, missing + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("plan", help="Path to implementation-plan.md") + parser.add_argument( + "--complete", + help="Comma-separated task IDs to mark complete (e.g. 1.1,1.2)", + ) + parser.add_argument( + "--blocked", + nargs="*", + default=[], + help=( + "One or more 'id:reason' pairs, each as a single argument so " + "reasons may contain commas " + "(e.g. --blocked '3.1:waiting on API, see #123' '3.2:other')" + ), + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would change without writing the file", + ) + args = parser.parse_args() + + path = Path(args.plan) + if not path.exists(): + print(f"Error: plan file not found: {path}", file=sys.stderr) + return 1 + + completes = parse_id_list(args.complete) + blocks = parse_blocked_list(args.blocked) + + if not completes and not blocks: + print( + "Error: at least one of --complete or --blocked is required", + file=sys.stderr, + ) + return 2 + + content = path.read_text(encoding="utf-8") + content, applied_complete, missing_complete = apply_complete(content, completes) + content, applied_blocked, missing_blocked = apply_blocked(content, blocks) + + if not args.dry_run: + path.write_text(content, encoding="utf-8") + + result = { + "applied_complete": applied_complete, + "applied_blocked": applied_blocked, + "missing": sorted(set(missing_complete + missing_blocked)), + "dry_run": args.dry_run, + } + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + + return 1 if result["missing"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/packmind-onboard/steps/edge-cases.md b/.claude/skills/packmind-onboard/steps/edge-cases.md deleted file mode 100644 index d50f21737..000000000 --- a/.claude/skills/packmind-onboard/steps/edge-cases.md +++ /dev/null @@ -1,37 +0,0 @@ -## Edge Cases - -### Package creation fails - -If `packmind-cli packages create` fails: - -``` -❌ Failed to create package: [error message] - -Please check: - - You are logged in: `packmind-cli login` - - Your network connection is working - - The package name is valid - -Cannot proceed with onboarding until package is created. -``` - -Exit the skill. Do not proceed to analysis. - -### Not logged in - -If CLI commands fail with authentication errors: - -``` -❌ Not logged in to Packmind - -Please run: - packmind-cli login - -Then re-run this skill. -``` - -### No packages available - -If the package listing command returns no packages: - -Auto-create a package using the repository name. diff --git a/.claude/skills/packmind-onboard/steps/step-0-introduction.md b/.claude/skills/packmind-onboard/steps/step-0-introduction.md deleted file mode 100644 index 093426137..000000000 --- a/.claude/skills/packmind-onboard/steps/step-0-introduction.md +++ /dev/null @@ -1,7 +0,0 @@ -## Step 0 — Introduction - -Print exactly: - -``` -I'll start the Packmind onboarding process. I'll create your first standards and commands and send them to your Packmind organization. This usually takes ~3 minutes. -``` diff --git a/.claude/skills/packmind-onboard/steps/step-1-get-repository-name.md b/.claude/skills/packmind-onboard/steps/step-1-get-repository-name.md deleted file mode 100644 index 3377064ab..000000000 --- a/.claude/skills/packmind-onboard/steps/step-1-get-repository-name.md +++ /dev/null @@ -1,11 +0,0 @@ -## Step 1 — Get Repository Name - -Get the repository name for package naming: - -```bash -basename "$(git rev-parse --show-toplevel)" -``` - -Remember this as the repository name for package creation in Step 2. - -Also run `packmind-cli whoami` and extract the `Host:` value from the output. Remember this URL for the completion summary. diff --git a/.claude/skills/packmind-onboard/steps/step-10-completion-summary.md b/.claude/skills/packmind-onboard/steps/step-10-completion-summary.md deleted file mode 100644 index 966eb70a8..000000000 --- a/.claude/skills/packmind-onboard/steps/step-10-completion-summary.md +++ /dev/null @@ -1,3 +0,0 @@ -## Step 10 — Completion Summary - -Follow [`packmind-versions/$PACKMIND_CLI_VERSION/completion-summary.md`](packmind-versions/$PACKMIND_CLI_VERSION/completion-summary.md). diff --git a/.claude/skills/packmind-onboard/steps/step-2-package-handling.md b/.claude/skills/packmind-onboard/steps/step-2-package-handling.md deleted file mode 100644 index ba7b1e4a6..000000000 --- a/.claude/skills/packmind-onboard/steps/step-2-package-handling.md +++ /dev/null @@ -1,46 +0,0 @@ -## Step 2 — Package Handling - -Handle package creation or selection. - -### Check existing packages - -List available packages by following [`packmind-versions/$PACKMIND_CLI_VERSION/list-packages.md`](packmind-versions/$PACKMIND_CLI_VERSION/list-packages.md). - -Parse the output to get package names and slugs. - -### No packages exist - -Auto-create a package using the repository name. Follow [`packmind-versions/$PACKMIND_CLI_VERSION/create-package.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-package.md) using `${REPO_NAME}-standards` as the package name. - -The create-package step will determine the space. Capture the chosen space slug as `$SPACE_SLUG` and the new package slug as `$PACKAGE_SLUG`. - -Print: -``` -No existing packages found — created a new one: ${REPO_NAME}-standards -``` - -### One package exists - -Ask via AskUserQuestion: -- "Add to `{package-name}`?" -- "Create new package instead" - -### Multiple packages exist - -Ask via AskUserQuestion: -- List each existing package as an option -- Include "Create new package" option - -### If "Create new package" is selected - -- Ask for package name (suggest `${REPO_NAME}-standards` as default) -- Follow [`packmind-versions/$PACKMIND_CLI_VERSION/create-package.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-package.md) using the chosen name. -- The create-package step will determine the space. Capture the chosen space slug as `$SPACE_SLUG` and the new package slug as `$PACKAGE_SLUG`. - -### If an existing package is selected - -Follow [`packmind-versions/$PACKMIND_CLI_VERSION/select-package.md`](packmind-versions/$PACKMIND_CLI_VERSION/select-package.md). - -### After this step - -Remember `$PACKAGE_SLUG` (the slug of the selected/created package) and `$SPACE_SLUG` for later reference — they will be used together in Step 9 to ensure items are added to the correct package in the correct space (as `@$SPACE_SLUG/$PACKAGE_SLUG`). diff --git a/.claude/skills/packmind-onboard/steps/step-3-announce.md b/.claude/skills/packmind-onboard/steps/step-3-announce.md deleted file mode 100644 index 9cd862107..000000000 --- a/.claude/skills/packmind-onboard/steps/step-3-announce.md +++ /dev/null @@ -1,8 +0,0 @@ -## Step 3 — Announce - -Print exactly: - -``` -packmind-onboard: analyzing codebase (read-only) -Target package: [package-name] -``` diff --git a/.claude/skills/packmind-onboard/steps/step-4-detect-existing-config.md b/.claude/skills/packmind-onboard/steps/step-4-detect-existing-config.md deleted file mode 100644 index 0bef746db..000000000 --- a/.claude/skills/packmind-onboard/steps/step-4-detect-existing-config.md +++ /dev/null @@ -1,31 +0,0 @@ -## Step 4 — Detect Existing Packmind and Agent Configuration - -Before analyzing, detect and preserve any existing Packmind/agent configuration. - -### Glob (broad, future-proof) -Glob for markdown in these roots (recursive): -- `.packmind/**/*.md` -- `.claude/**/*.md` -- `.agents/**/*.md` -- `**/skills/**/*.md` -- `**/rules/**/*.md` - -### Classify -Classify found files into counts: -- **standards**: `.packmind/standards/**/*.md` -- **commands**: `.packmind/commands/**/*.md` -- **other_docs**: any markdown under `.claude/`, `.agents/`, or any `skills/` or `rules/` directory outside `.packmind` - -If any exist, print exactly: - -``` -Existing Packmind/agent docs detected: - - Standards: [N] - - Commands: [M] - - Other docs: [P] -``` - -No overwrites. New files (if you Export) will be added next to the existing ones. diff --git a/.claude/skills/packmind-onboard/steps/step-5-detect-project-stack.md b/.claude/skills/packmind-onboard/steps/step-5-detect-project-stack.md deleted file mode 100644 index 79f301519..000000000 --- a/.claude/skills/packmind-onboard/steps/step-5-detect-project-stack.md +++ /dev/null @@ -1,28 +0,0 @@ -## Step 5 — Detect Project Stack (Minimal, Evidence-Based) - -### Language markers (check presence) -- JS/TS: `package.json`, `pnpm-lock.yaml`, `yarn.lock`, `tsconfig.json` -- Python: `pyproject.toml`, `requirements.txt`, `setup.py` -- Go: `go.mod` -- Rust: `Cargo.toml` -- Ruby: `Gemfile` -- JVM: `pom.xml`, `build.gradle`, `build.gradle.kts` -- .NET: `*.csproj`, `*.sln` -- PHP: `composer.json` - -### Architecture markers (check directories) -- Hexagonal/DDD: `src/application/`, `src/domain/`, `src/infra/` -- Layered/MVC: `src/controllers/`, `src/services/` -- Monorepo: `packages/`, `apps/` - -Print exactly: - -``` -Stack detected (heuristic): - - Languages: [..] - - Repo shape: [monorepo|single] - - Architecture markers: [..|none] -``` diff --git a/.claude/skills/packmind-onboard/steps/step-6-run-analyses.md b/.claude/skills/packmind-onboard/steps/step-6-run-analyses.md deleted file mode 100644 index 0b1ec0d6a..000000000 --- a/.claude/skills/packmind-onboard/steps/step-6-run-analyses.md +++ /dev/null @@ -1,24 +0,0 @@ -## Step 6 — Run Analyses - -Read each reference file for detailed search patterns, thresholds, and insight templates. - -| Analysis | Reference File | Output focus | -|----------|----------------|--------------| -| File Template Consistency | `references/file-template-consistency.md` | Commands | -| CI/Local Workflow Parity | `references/ci-local-workflow-parity.md` | Commands | -| Role Taxonomy Drift | `references/role-taxonomy-drift.md` | Standards | -| Test Data Construction | `references/test-data-construction.md` | Standards | - -### Output schema (internal; do not print as-is to user) -For every finding, keep an internal record: - -``` -INSIGHT: -title: ... -why_it_matters: ... -confidence: [high|medium|low] -evidence: -- path[:line-line] -where_it_doesnt_apply: -- path[:line-line] -``` diff --git a/.claude/skills/packmind-onboard/steps/step-7-generate-drafts.md b/.claude/skills/packmind-onboard/steps/step-7-generate-drafts.md deleted file mode 100644 index e109f0e44..000000000 --- a/.claude/skills/packmind-onboard/steps/step-7-generate-drafts.md +++ /dev/null @@ -1,12 +0,0 @@ -## Step 7 — Generate All Drafts - -Generate all draft files in one batch, using the format defined for your CLI version. - -Read the **Draft Format** section in [`packmind-versions/$PACKMIND_CLI_VERSION/create-items.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-items.md) and create draft files accordingly. - -### Generation Rules (all versions) - -- Generate drafts **only from discovered insights** (no invention) -- Use evidence from analysis to populate rules/steps -- Cap output: max **5 Standards** + **5 Commands** -- Never overwrite existing files; append `-2`, `-3`, etc. if slug exists diff --git a/.claude/skills/packmind-onboard/steps/step-8-present-summary.md b/.claude/skills/packmind-onboard/steps/step-8-present-summary.md deleted file mode 100644 index bb9c27cd2..000000000 --- a/.claude/skills/packmind-onboard/steps/step-8-present-summary.md +++ /dev/null @@ -1,32 +0,0 @@ -## Step 8 — Present Summary & Confirm - -Present the generated draft files and ask for confirmation: - -``` -============================================================ - ANALYSIS COMPLETE -============================================================ - -Target package: [package-name] -Stack detected: [languages], [monorepo?], [architecture markers] -Analyses run: [N] checks - -DRAFTS CREATED: - -Standards ([N]): - 1. [Name] → .packmind/standards/_drafts/[slug].draft.md - 2. ... - -Commands ([M]): - 1. [Name] → .packmind/commands/_drafts/[slug].draft.md - 2. ... - -Drafts are saved in .packmind/*/_drafts/ — you can review or edit them before creating. -============================================================ -``` - -Then ask via AskUserQuestion with three options: - -- **Create all now** — Proceed with creating all standards and commands -- **Let me review drafts first** — Pause to allow editing, re-run skill when ready -- **Cancel** — Exit without creating anything diff --git a/.claude/skills/packmind-onboard/steps/step-9-create-items.md b/.claude/skills/packmind-onboard/steps/step-9-create-items.md deleted file mode 100644 index b70d15612..000000000 --- a/.claude/skills/packmind-onboard/steps/step-9-create-items.md +++ /dev/null @@ -1,3 +0,0 @@ -## Step 9 — Create Items - -Follow [`packmind-versions/$PACKMIND_CLI_VERSION/create-items.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-items.md). diff --git a/.claude/skills/playbook-cli-audit/SKILL.md b/.claude/skills/playbook-cli-audit/SKILL.md new file mode 100644 index 000000000..aa57be232 --- /dev/null +++ b/.claude/skills/playbook-cli-audit/SKILL.md @@ -0,0 +1,203 @@ +--- +name: 'playbook-cli-audit' +description: 'Audit all packmind-* skills for CLI compatibility issues: deprecated commands, invalid options, wrong file formats, missing flags, and version mismatches against the locally installed Packmind CLI. Use when you want to check that skills referencing packmind-cli are up to date, after a CLI upgrade, before a release, or whenever you suspect skills may reference outdated CLI commands. Also triggers on phrases like "audit CLI usage", "check skills for CLI issues", "are my skills up to date with the CLI", or "CLI compatibility check".' +--- + +# Playbook CLI Audit + +Detect incompatibilities between the packmind-* skills deployed in `.claude/skills/` and the current Packmind CLI. Skills evolve independently from the CLI — when the CLI deprecates or removes commands, renames flags, or changes accepted file formats, skills can silently break. This audit catches those issues before users hit them at runtime. + +**This skill only detects issues — it does not fix them.** + +## Phase 1: Establish CLI Surface + +Build the authoritative map of what the CLI currently supports. + +### 1.1 Get CLI version + +```bash +node ./dist/apps/cli/main.cjs --version +``` + +Remember this as `$CLI_VERSION` (e.g., `0.26.0`). If the command fails, try `packmind-cli --version` as a fallback. If both fail, stop and tell the user the CLI is not available. + +### 1.2 Build the command map from CLI source code + +The CLI command definitions live in `apps/cli/src/infra/commands/`. Read the command registration files to build a complete map of: + +- **Available top-level commands** and their subcommands +- **Accepted options/flags** for each command (names, types, whether required) +- **Accepted argument types** (file paths, strings, etc.) and any format constraints +- **Deprecated commands** — look for `[Deprecated]` in descriptions and `has been removed` in handler code +- **Migration paths** — what the deprecated command's error message suggests instead + +Structure this internally as a reference table. Here is the expected shape — adapt if the source reveals more or fewer commands: + +| Command | Status | Accepted Inputs | Key Flags | Migration | +|---|---|---|---|---| +| `standards create` | REMOVED | JSON file/stdin | `--space`, `--from-skill` | `playbook add ` | +| `commands create` | REMOVED | JSON file/stdin | `--space`, `--from-skill` | `playbook add ` | +| `skills add` | REMOVED | directory path | `--space`, `--from-skill` | `playbook add ` | +| `install --list` | REMOVED | — | — | `packages list` | +| `install --show` | REMOVED | — | — | `packages show ` | +| `diff` | DEPRECATED | — | `--submit`, `-m` | `playbook diff`, `playbook submit` | +| `diff add` | DEPRECATED | file path | `-m` | `playbook add` | +| `lint --diff` | DEPRECATED | — | — | `--changed-files` / `--changed-lines` | +| `playbook add` | ACTIVE | `.md`, `.mdc` files | `--space` | — | +| `playbook submit` | ACTIVE | — | `-m` (needed in non-TTY) | — | +| `packages add` | ACTIVE | — | `--to`, `--standard`/`--command`/`--skill` (one type per call) | — | +| ... | ... | ... | ... | ... | + +Do not hardcode this table — **read the actual source** in `apps/cli/src/infra/commands/` to build it. The table above is a starting point to orient your search, not the source of truth. + +### 1.3 Print summary + +``` +CLI version: $CLI_VERSION +Commands mapped: N active, M deprecated/removed +``` + +## Phase 2: Discover and Scan Skills + +### 2.1 Find all packmind-* skills + +Glob `.claude/skills/packmind-*/` to find all skill directories. For each, collect: +- `SKILL.md` (the main instruction file) +- All files in subdirectories (`references/`, `steps/`, `scripts/`, `packmind-versions/`, etc.) + +### 2.2 Handle version-specific subdirectories + +Some skills contain `packmind-versions//` directories with version-specific instructions. The version resolution logic works like this: + +1. List all version directories (e.g., `0.21.0/`, `0.23.0/`) +2. Compare each against `$CLI_VERSION` +3. A version directory is **in scope** if it is the highest version that is `<=` `$CLI_VERSION` +4. Version directories that are **not in scope** (lower versions superseded by a higher one that is still `<= $CLI_VERSION`) should be flagged as INFO-level findings ("version `0.21.0` is superseded by `0.23.0` for CLI `$CLI_VERSION`") but their CLI invocations still need to be checked — they may still be reachable by users with older CLI versions + +**Example**: If `$CLI_VERSION` is `0.25.0` and the skill has `0.21.0/` and `0.23.0/`: +- `0.23.0/` is the **active version** (highest `<= 0.25.0`) +- `0.21.0/` is **superseded** but still scanned + +### 2.3 Extract CLI invocations + +For every file in scope, extract all lines that invoke the CLI. Look for these patterns: +- `packmind-cli ` — the primary pattern +- `node ./dist/apps/cli/main.cjs ` — local dev invocation +- Code blocks containing CLI commands (inside ` ``` ` fences) +- Inline code references (inside `` ` `` backticks) +- Plain-text references in prose (e.g., "Run `packmind-cli standards create`") + +For each invocation, parse: +- **Command**: the top-level command (e.g., `standards`) +- **Subcommand**: if any (e.g., `create`) +- **Arguments**: positional args (e.g., file paths) +- **Flags/options**: named options (e.g., `--space`, `-m`) +- **Source location**: file path and line number + +## Phase 3: Cross-Reference and Detect Issues + +For each extracted CLI invocation, check it against the command map from Phase 1. Detect these categories of issues: + +### CRITICAL — Will fail at runtime + +| Check | Description | Example | +|---|---|---| +| **Removed command** | Invocation uses a command the CLI has removed | `standards create` → removed, use `playbook add` | +| **Non-existent command** | Command or subcommand does not exist in the CLI | `packmind-cli list standards` (correct: `standards list`) | +| **Invalid file format** | File type passed to a command that doesn't accept it | `.json` file to `playbook add` (accepts only `.md`/`.mdc`) | +| **Mixed artifact types** | `packages add` called with multiple artifact type flags | `--standard X --command Y` in one call | + +### WARNING — May fail or produce unexpected behavior + +| Check | Description | Example | +|---|---|---| +| **Deprecated command** | Command works but shows deprecation warning | `diff add` → use `playbook add` | +| **Missing required flag in agent context** | Command lacks a flag needed in non-interactive/CI contexts | `playbook submit` without `-m` opens an editor | +| **Deprecated flag** | A specific flag is deprecated | `lint --diff` → use `--changed-files` | +| **Incorrect install syntax** | `install --list` or `install --show` used instead of `packages list`/`packages show` | `packmind-cli install --list` | + +### INFO — Worth noting + +| Check | Description | Example | +|---|---|---| +| **Superseded version directory** | A version-specific directory is no longer the active one | `0.21.0/` when `0.23.0/` exists and CLI is `>= 0.23.0` | +| **CLI version metadata mismatch** | Skill frontmatter declares `packmind-cli-version` that conflicts with installed version | `packmind-cli-version: "< 0.25.0"` when CLI is `0.26.0` | +| **Undocumented flag usage** | A flag is used that doesn't appear in the command definition | May indicate a removed or renamed flag | + +## Phase 4: Generate Report + +Write the report to `playbook-cli-audit-report.md` at the project root. + +### Report Structure + +```markdown +# Playbook CLI Audit Report + +**Generated**: {date} +**CLI version**: {$CLI_VERSION} +**Skills scanned**: {count} +**Files analyzed**: {count} + +## Summary + +| Severity | Count | +|---|---| +| CRITICAL | {n} | +| WARNING | {n} | +| INFO | {n} | + +## Findings + +### CRITICAL + +#### {n}. [{skill-name}] {short description} + +- **File**: `{path}:{line}` +- **Invocation**: `{the CLI command as written in the skill}` +- **Issue**: {what's wrong} +- **Fix**: {what the command should be replaced with} + +### WARNING + +...same structure... + +### INFO + +...same structure... + +## Skills Scanned + +| Skill | Files | Invocations | Issues | +|---|---|---|---| +| packmind-onboard | 19 | 12 | 5 CRITICAL, 1 WARNING | +| packmind-update-playbook | 8 | 15 | 0 | +| ... | ... | ... | ... | + +## CLI Command Surface Reference + +List of all active commands with their accepted inputs, for quick cross-reference. +``` + +### If no issues are found + +```markdown +# Playbook CLI Audit Report + +**Generated**: {date} +**CLI version**: {$CLI_VERSION} +**Skills scanned**: {count} + +All packmind-* skills are compatible with the installed CLI version. No issues found. +``` + +## Phase 5: Present to User + +After writing the report: + +1. Print a summary to the conversation: + - Total CRITICAL / WARNING / INFO counts + - Top 3 most affected skills + - The report file path +2. If there are CRITICAL findings, highlight them explicitly — these are commands that **will fail** when users invoke the skill + +Do not suggest fixes automatically. The user decides how to address each finding. \ No newline at end of file diff --git a/.claude/skills/product-map/SKILL.md b/.claude/skills/product-map/SKILL.md new file mode 100644 index 000000000..0a47422f8 --- /dev/null +++ b/.claude/skills/product-map/SKILL.md @@ -0,0 +1,190 @@ +--- +name: 'product-map' +description: 'Produces a functional cartography of the Packmind application (domains × features × usage signals) as `product-map.md` at the project root. Manual-only skill — invoke ONLY when the user explicitly runs `/product-map` or asks to "map the application", "cartographier l''application", "list every feature", or "find decommission candidates". Do NOT auto-load on generic mentions of features, domains, spaces, or standards.' +--- + +# Product Map + +Build a point-in-time functional map of the Packmind application by scanning the backend (use cases + endpoints), the frontend (routes + pages), and the CLI (commands). Aggregate features by functional domain, compute a usage signal per feature, and write a single Markdown report at `product-map.md` (project root). The report supports decommission decisions: features with weak signals (no frontend entry, no recent activity, no tests) become candidates for removal. + +**Manual-only.** Run only when the user explicitly invokes `/product-map` or asks for a functional cartography. The description above intentionally avoids generic trigger keywords. + +**This skill only maps — it does not delete anything.** Decommission decisions stay with the user. + +## Phase 0: Confirm Scope + +Before scanning, confirm with the user: + +- **Target output path** — default `product-map.md` at project root; offer to override. +- **Scope override** — default scans `apps/api`, `apps/frontend`, `apps/cli`, and `packages/*`. Ask if any package should be excluded (e.g., infra-only packages like `migrations`, `logger`, `node-utils`). +- **Decommission tolerance** — confirm signal heuristics (see Phase 4); user may want stricter or looser flagging. + +If the user invoked the skill with explicit instructions, skip confirmation and proceed with stated parameters. + +## Phase 1: Seed Domain Taxonomy + +Before launching subagents, read `packages/` and `apps/` to discover the domain taxonomy. Use folder names as the seed list — do NOT invent domains. + +The seed domains in this codebase (verify by `ls packages/` at run time, this list may drift): + +- **Spaces** — `packages/spaces`, `packages/spaces-management` +- **Standards** — `packages/standards`, `packages/linter`, `packages/linter-ast`, `packages/linter-execution` +- **Skills** — `packages/skills` +- **Recipes / Playbooks** — `packages/recipes` +- **Change Proposals** — `packages/playbook-change-applier`, `packages/playbook-change-management` +- **Users / Auth / Organizations** — `packages/accounts` +- **AI Agents / Coding Agent** — `packages/coding-agent` +- **Git Integration** — `packages/git` +- **Analytics** — `packages/amplitude` +- **Support / Crisp** — `packages/crisp` +- **Deployments** — `packages/deployments` +- **LLM Infrastructure** — `packages/llm` +- **Legacy Import** — `packages/import-practices-legacy` + +Cross-cutting infra packages (`logger`, `node-utils`, `migrations`, `types`, `ui`, `frontend`, `editions`, `test-utils`, `integration-tests`, `plugins`) are NOT user-facing domains — exclude from the map unless they expose user-facing features (e.g., `editions` may expose subscription/edition selection). + +Pass this seed taxonomy to all subagents so they classify consistently. + +## Phase 2: Launch 3 Parallel Source Subagents + +Launch three `Agent(general-purpose)` subagents simultaneously. Each scans one source and returns a structured feature list classified by domain. + +| Subagent | Source | Scans | +|----------|--------|-------| +| 1. Backend | Use cases + API endpoints | `packages/*/src/application/useCases/**/*.ts`, NestJS controllers in `apps/api/src/**/*.controller.ts` | +| 2. Frontend | Routes + pages | `apps/frontend/src` router config + page components | +| 3. CLI | Commander commands | `apps/cli/src` command definitions | + +### Subagent Prompt Template + +Each subagent receives: +1. The seed domain taxonomy from Phase 1 +2. The source-specific instructions below +3. A request for structured output + +``` +You are mapping the Packmind application. Your scope: . + +# Domain taxonomy (use these labels — do not invent new domains unless you find a clear standalone area): + + +# Your task +Scan and produce a list of every user-facing feature exposed through this source. +Group features under their domain. A "feature" is a user-meaningful capability (e.g. +"create a space", "invite a member", "archive a standard"), not a technical implementation +detail (e.g. "TypeORM repository method"). + +For each feature, return: +- domain: +- feature: short imperative phrase (≤ 8 words) +- evidence: file path(s) + relevant export/route/command name +- visibility: "user-facing" or "admin-only" or "internal" + +Skip: +- Pure infra plumbing (logging, config loading, health checks) +- Cross-cutting middleware (auth guards, rate limiters) — list them once under "Auth" +- Test utilities + +# Output format +Markdown list grouped by domain. One bullet per feature. End with a 2-line summary +(total features, anything unclassifiable). +``` + +### Source-Specific Instructions + +**Backend subagent**: scan use cases first (they map 1:1 to features), then controllers to confirm the endpoint exists. A use case with no controller binding is an "orphan" — flag it as `visibility: internal`. + +**Frontend subagent**: scan the router config first to enumerate routes, then read each route's page component to determine what the user actually does there. A route that only renders a placeholder or 404 is a candidate for decommission — flag it. + +**CLI subagent**: enumerate every Commander `.command(...)` registration. Subcommands count as separate features (e.g. `playbook add` and `playbook submit` are two features under "Change Proposals"). + +### Sequential Fallback + +If the Agent tool is unavailable, perform the three scans sequentially in the current session using `Grep` + `Read`. Maintain the same output structure. + +## Phase 3: Merge Sources Per Feature + +After subagents return, merge their lists into a single feature table. Two source entries belong to the same feature when: +- Same domain, AND +- Feature phrases describe the same capability (e.g. "create a space" backend ↔ "Create Space" frontend route ↔ `space create` CLI command) + +For each unified feature row, record which sources cover it: + +| Domain | Feature | Backend | Frontend | CLI | Evidence | +|--------|---------|:-:|:-:|:-:|----------| +| Spaces | Create a space | ✓ | ✓ | ✓ | `packages/spaces/src/.../createSpace.ts`, `/spaces/new`, `space create` | + +If a feature exists in only one source, that is a signal — keep the row, leave the missing sources blank. + +## Phase 4: Compute Usage Signal + +For each feature row, compute additional signal columns. These power the decommission section. + +| Signal | How to compute | +|--------|----------------| +| **Tests** | Grep for the use case class or controller path in `**/*.spec.ts` and `**/*.test.ts`. Mark ✓ if at least one test references it. | +| **Amplitude** | Grep for the feature's domain prefix in `packages/amplitude/src/application/AmplitudeEventListener.ts`. Mark ✓ if any event for this domain is subscribed AND the event name plausibly matches the feature. | +| **Recent activity** | `git log --since="6 months ago" --pretty=format:%H -- `. Mark ✓ if at least one commit. | + +A feature is a **decommission candidate** when ANY of the following holds: +- No frontend coverage AND no CLI coverage (= backend-only, possibly orphaned) +- No tests AND no recent activity (= cold code) +- Frontend route exists but backend use case missing (= dead UI) +- Backend use case exists but no entry point in any client (= dead endpoint) + +Do NOT auto-delete — only flag. + +## Phase 5: Write Report + +Write `product-map.md` at the project root with this structure: + +```markdown +# Packmind Product Map — + +> Snapshot of every user-facing feature, grouped by functional domain. +> Generated by the `product-map` skill. Manual invocation only. + +## Scope + +- Sources scanned: backend use cases & endpoints, frontend routes & pages, CLI commands +- Packages excluded: +- Git revision: + +## Functional Map + +| Domain | Feature | Backend | Frontend | CLI | Tests | Amplitude | Recent | Notes | +|--------|---------|:-:|:-:|:-:|:-:|:-:|:-:|-------| +| Spaces | Create a space | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| Spaces | Archive a space | ✓ | – | – | – | – | – | backend-only orphan | +| ... + +## Decommission Candidates + +Features matching at least one decommission heuristic (see methodology). Sorted by +weakest signal first. + +| Feature | Reason | Evidence | +|---------|--------|----------| +| Archive a space | backend-only, no tests, no commits in 6 months | `packages/spaces/src/.../archiveSpace.ts` | +| ... + +## Methodology + +- A feature = a user-meaningful capability, not a technical implementation detail. +- Sources merged when same domain + same capability across backend/frontend/CLI. +- Signal heuristics: see SKILL.md Phase 4. +- This is a point-in-time snapshot — re-run to refresh. +``` + +End by printing to the user: +- Path of the generated report +- Total features mapped +- Total decommission candidates +- Top 3 domains by feature count + +## Operating Rules + +- **Never delete code based on the report.** Decommission decisions belong to the user. +- **Do not invent domains.** If a feature does not fit the seed taxonomy, place it under "Other" with a one-line justification — the user will refine the taxonomy on re-run. +- **Stay factual.** Every row must cite an evidence path. No inferred features. +- **Re-runnable.** The skill produces a fresh snapshot each run; overwrite the previous report after confirming with the user. \ No newline at end of file diff --git a/.claude/skills/qa-review/SKILL.md b/.claude/skills/qa-review/SKILL.md new file mode 100644 index 000000000..d990de2b4 --- /dev/null +++ b/.claude/skills/qa-review/SKILL.md @@ -0,0 +1,194 @@ +--- +name: 'qa-review' +description: 'Review a user story implementation against its Example Mapping (EM) specification.' +argument-hint: '["em-file-path"]' +disable-model-invocation: true +--- + +# QA Review + +Audit a user story implementation against its Example Mapping specification. Reads the EM +markdown, finds all implementing code in the codebase, then runs two parallel agents — one for +functional coverage, one for code review. Produces a single compact report. + +**This skill only detects issues — it does not fix them.** + +## Reference + +A ready-to-fill EM template is available at `em_template.md` (in this skill's directory). Share it with the user when they need to write a new spec from scratch. + +## 1. Parse the EM Spec + +Read the markdown file at the provided path. Extract a structured summary with these sections: + +### What to extract + +1. **User Story title** — the first line or heading describing the US +2. **Rules** — each `# Rule N: ` block. For each rule, extract: + - Rule number and title + - Each `## Example N` with its setup/action/outcome narrative (preserve the full text) +3. **Technical Rules** — bullet points under a `# Technical rules` heading (implementation-focused constraints) +4. **User Events** — content under `# User Events` heading: event names, properties, schemas +5. **Check Also items** — bullet points after "Check also" markers (additional rules/constraints, often separated by dashes) +6. **Code References** — all backtick-quoted terms across the entire spec (class names, field names, event names like `ConflictDetector`, `space_created`, `decision`) +7. **Domain Keywords** — key nouns and verbs from rule titles and examples (e.g., "space", "create", "slug", "rename", "conflict") + +### Handling ambiguity + +If a spec item is ambiguous or explicitly deferred (e.g., "TBD", "on verra plus tard", "later"), flag it in the Parsed Spec Summary but exclude it from coverage assessment. Note it in the report as "Deferred — not assessed." + +Compile everything into a **Parsed Spec Summary** formatted as below. The "Full Examples" section preserves the complete raw text of every example — sub-agents need this full context to accurately assess coverage. + +``` +## Parsed Spec Summary + +### User Story +{title} + +### Rules and Examples +Rule 1: {title} + Example 1: {one-line summary of scenario} + Example 2: {one-line summary of scenario} +Rule 2: {title} + Example 1: {one-line summary of scenario} +[...] + +### Full Examples (raw text) +{Copy the complete text of every example verbatim from the spec, preserving setup/action/outcome narratives. Do not summarize here — this section is passed to sub-agents so they can assess nuanced behaviors.} + +### Technical Rules +- {rule text} +[...] + +### Deferred Items +- {item text} — Deferred, not assessed +[...] + +### User Events +- {event_name}: {properties} +[...] + +### Check Also +- {constraint text} +[...] + +### Code References (from backticks) +{list of all backtick-quoted terms} + +### Domain Keywords +{list of distinctive nouns/verbs extracted from rules} +``` + +## 2. Select Target Domains + +Ask the user which domains this user story touches. Use **AskUserQuestion** with `multiSelect: true`: + +| Option | Directories | +|--------|-------------| +| CLI (apps/cli) | `apps/cli/src/**` | +| API (apps/api) | `apps/api/src/**` | +| Packages (packages/*) | `packages/*/src/**`, `packages/types/src/**` | +| Frontend (apps/frontend) | `apps/frontend/app/**`, `apps/frontend/src/**` | +| MCP (apps/mcp-server) | `apps/mcp-server/src/**` | + +**Packages is always included**, even if the user does not select it — it contains domain logic, contracts, events, and infra that all other layers depend on. + +Store the user's selection as `{target_domains}` — a list of domain labels and their directory patterns. All subsequent steps use this to scope their searches. + +## 3. Validate Implementation Exists + +Grep 2–3 of the most distinctive backtick-quoted terms from the spec, **scoped to the `{target_domains}` directories only**. If fewer than 3 implementing files are found, **stop and ask the user** to confirm that the US has been fully implemented. Do not proceed with a full review on a partially-implemented or not-yet-started US — the report would be misleading. + +## 4. Build Code Map + +Launch a **Code Map Agent** (`subagent_type: general-purpose`) using the prompt from `agents/code-map-agent.md`. Replace: +- `{parsed_spec_summary}` with the Parsed Spec Summary from step 1 +- `{target_domains}` with the selected domains and their directory patterns from step 2 + +The agent will search only within the target domain directories, then return a structured Code Map organized by architectural layer. Only layers matching the selected domains will appear in the output. + +Wait for the agent to complete before proceeding to step 5. + +## 5. Pre-filter Packmind Standards + +Before launching the review agents, collect the applicable Packmind coding standards: + +1. Glob for `**/.claude/rules/packmind/*.md` across the repository +2. Read the YAML frontmatter of each file to extract its `paths` glob patterns +3. Match the Code Map file paths against each standard's `paths` patterns +4. For each standard that matches at least one Code Map file, read its full content + +Compile the applicable standards into `{applicable_standards}` — the full text of each matching standard, prefixed with its name. If no standards match, set `{applicable_standards}` to "None". + +## 6. Launch Parallel Sub-Agents + +Launch **two sub-agents in parallel** (same turn), each receiving the Parsed Spec Summary (including the Full Examples section with raw text), Code Map, and target domains as context: + +1. **Functional Coverage Agent** (`subagent_type: general-purpose`) — prompt built from `agents/functional-coverage-agent.md`. Replace `{parsed_spec_summary}`, `{code_map}`, and `{target_domains}`. +2. **Code Review Agent** (`subagent_type: general-purpose`) — prompt built from `agents/code-review-agent.md`. Replace `{parsed_spec_summary}`, `{code_map}`, `{target_domains}`, and `{applicable_standards}`. + +Launch both agents simultaneously. The full raw example text is critical — sub-agents need the complete setup/action/outcome narratives to assess nuanced behaviors, not just one-line summaries. + +### Sequential Fallback + +If the Agent tool is unavailable, perform both reviews sequentially yourself, following the instructions from each agent prompt file. + +## 7. Combine & Write Report + +Once both agents complete, merge their outputs into a single report. + +### Output path + +Derive from the input path: if input is `path/to/my-spec.md`, output is `path/to/my-spec-report.md`. + +### Report template + +```markdown +# QA Review Report + +**Spec**: {filename} | **Date**: {date} | **Branch**: {branch} | **Commit**: {short-sha} +**Rules**: {N} | **Examples**: {N} | **Tech Rules**: {N} | **Events**: {N} + +## Summary + +| Metric | Count | +|--------|-------| +| Covered | N | +| Partially Covered | N | +| Not Covered | N | +| Code Findings | N (Critical: X, High: Y, Medium: Z) | +| Standards Violations | N | + +## Functional Coverage + +### Coverage Matrix + +{coverage matrix table from functional coverage agent} + +### Gaps + +{reproduction steps from functional coverage agent — omit this subsection if all items are Covered} + +## Code Review + +### Findings + +{findings from code review agent — omit this section if no issues found} + +## Deferred Items + +{list of items marked as deferred/TBD in the spec — not assessed in this review} + +--- +*Static analysis only. No code was executed during this review.* +``` + +**Omit any section that has zero content.** Only include sections with actual results. + +### Print Summary + +After writing the report, print a brief summary to the console: +- Total rules/examples in the spec +- Coverage stats (Covered / Partially / Not Covered counts) +- Code review findings count by severity +- The report file path \ No newline at end of file diff --git a/.claude/skills/qa-review/agents/code-map-agent.md b/.claude/skills/qa-review/agents/code-map-agent.md new file mode 100644 index 000000000..fb083cef1 --- /dev/null +++ b/.claude/skills/qa-review/agents/code-map-agent.md @@ -0,0 +1,133 @@ +You are a codebase navigator building a Code Map for a user story implementation. Your job is to find all files involved in implementing the feature described in the Parsed Spec Summary below, **scoped to the target domains only**. + +## Target Domains + +{target_domains} + +**Only search within the directories listed above.** Do not search domains that are not listed. + +## Parsed Spec Summary + +{parsed_spec_summary} + +## Available Tools + +You have access to **Glob**, **Grep**, and **Read** (read-only). Use them to systematically search the codebase. + +## Search Strategy + +Using the code references (backtick-quoted terms) and domain keywords from the spec, follow this funnel (precise → broad). **All searches must be scoped to the target domain directories above.** + +### Step 1: Backtick terms first (highest signal) + +Grep each backtick-quoted term, but only within the target domain directories. These are author-curated pointers into the codebase and almost always resolve to exact matches. + +### Step 2: Domain keyword search + +Grep the 3–5 most distinctive domain keywords against the target domain directories. Here is the mapping of domains to their search patterns — **only use patterns for selected domains**: + +**Backend (packages/*):** +- `packages/*/src/application/useCases/**/*.ts` and `packages/*/src/application/usecases/**/*.ts` (use cases — both casing variants exist) +- `packages/*/src/infra/**/*.ts` (repository implementations, TypeORM schemas, BullMQ job factories) +- `packages/types/src/*/events/**/*.ts` (domain events — centralized in @packmind/types) +- `packages/types/src/*/contracts/**/*.ts` (use case contracts — Command, Response, IUseCase interfaces) + +**API (apps/api):** +- `apps/api/src/app/**/*.ts` (NestJS controllers, guards, modules) + +**Frontend (apps/frontend):** +- `apps/frontend/app/routes/**/*.tsx` (file-based route components with clientLoaders) +- `apps/frontend/src/domain/**/*.ts` (gateways extending PackmindGateway, TanStack Query hooks) + +**CLI (apps/cli):** +- `apps/cli/src/**/*.ts` (CLI commands and handlers) + +**MCP (apps/mcp-server):** +- `apps/mcp-server/src/**/*.ts` (MCP server tools and prompts) + +### Step 3: Package-level scan (Backend only) + +Skip if Backend is not in the target domains. Once a relevant package is identified (e.g., `packages/spaces/`), list its `application/useCases/` directory (or `application/usecases/` — both casing variants exist) to find all related use cases. + +### Step 4: Hexagonal registry check (Backend only) + +Skip if Backend is not in the target domains. Once a relevant package is identified, read its `{PackageName}Hexa.ts` file (e.g., `packages/standards/src/StandardsHexa.ts`) to understand which ports, adapters, and use cases are registered. This reveals the full wiring of the feature. + +### Step 5: Contract discovery (Backend only) + +Skip if Backend is not in the target domains. For each use case found, check `packages/types/src/{domain}/contracts/` for the corresponding contract file, which defines `{Name}Command`, `{Name}Response`, and `I{Name}UseCase`. + +### Step 6: Test file discovery + +For each source file found within target domain directories, check for a sibling `.spec.ts` equivalent. + +### Step 7: Event tracking (Backend only) + +Skip if Backend is not in the target domains. Grep event names from the "User Events" section. Look for event class definitions in `packages/types/src/{domain}/events/`, emission via `eventEmitterService.emit(new {EventName}Event(`, and consumption via `PackmindListener` classes with `this.subscribe({EventName}Event, ...)`. + +### Step 8: Cross-US dependencies + +When a rule depends on behavior from another feature (e.g., conflict detection logic that belongs to a different US), include those files in the Code Map but mark them as `[DEPENDENCY]`. Only follow dependencies within the target domain directories. The functional agent should verify the integration point works, not re-audit the dependency itself. + +## Output Format + +Compile results into a **Code Map** organized by layer: + +``` +## Code Map + +### Hexagonal Registry +- packages/{pkg}/src/{Pkg}Hexa.ts (port/adapter wiring) + +### Contracts (@packmind/types) +- packages/types/src/{domain}/contracts/I{UseCaseName}UseCase.ts + ({Name}Command, {Name}Response, I{Name}UseCase) + +### Backend Domain +- packages/{pkg}/src/application/useCases/{useCaseName}/{UseCaseName}UseCase.ts + Test: {path}.spec.ts [EXISTS | NOT FOUND] +- packages/{pkg}/src/domain/{Entity}.ts + Test: [EXISTS | NOT FOUND] + +### Infra (repositories, schemas, jobs) +- packages/{pkg}/src/infra/repositories/{Repository}.ts + Test: [EXISTS | NOT FOUND] +- packages/{pkg}/src/infra/schemas/{Schema}.ts +- packages/{pkg}/src/infra/jobs/{JobFactory}.ts (BullMQ background jobs) + +### API Layer (NestJS) +- apps/api/src/app/{...}/{controller}.ts + Guards: [OrganizationAccessGuard | SpaceAccessGuard | None] + Adapter injection: [@Inject{Pkg}Adapter()] + Test: [EXISTS | NOT FOUND] + +### Frontend +- apps/frontend/app/routes/{...}.tsx (route component + clientLoader) +- apps/frontend/src/domain/{entity}/api/gateways/{Entity}GatewayApi.ts (extends PackmindGateway) +- apps/frontend/src/domain/{entity}/api/queries/{Entity}Queries.ts (TanStack Query v5 hooks) + Test: [EXISTS | NOT FOUND] + +### CLI +- apps/cli/src/{...}/{command|handler}.ts + Test: [EXISTS | NOT FOUND] + +### MCP Server +- apps/mcp-server/src/app/tools/{toolName}/{toolName}.tool.ts + Test: [EXISTS | NOT FOUND] + +### Domain Events (@packmind/types) +- packages/types/src/{domain}/events/{EventName}Event.ts (extends UserEvent/SystemEvent) +- {files containing eventEmitterService.emit()} +- {files containing PackmindListener / this.subscribe()} + +### Background Jobs (BullMQ) +- packages/{pkg}/src/infra/jobs/{JobFactory}.ts (WorkerQueue registration) + +### Dependencies (from other USs — verify integration only) +- {files that implement behavior from another feature but are used by this US} [DEPENDENCY] + +### Other Relevant Files +- {any other files found via keyword search} +``` + +Only include sections that have actual files **and** match the target domains. Omit empty sections and sections for domains not in the target list. diff --git a/.claude/skills/qa-review/agents/code-review-agent.md b/.claude/skills/qa-review/agents/code-review-agent.md new file mode 100644 index 000000000..4d4563ddd --- /dev/null +++ b/.claude/skills/qa-review/agents/code-review-agent.md @@ -0,0 +1,183 @@ +You are a senior engineer performing a technical code review on a user story implementation. Your focus is exclusively on actual bugs, edge cases, and inconsistencies — not style, naming, formatting, or minor improvements. Report findings at **Medium** severity or above. If no Critical, High, or Medium issues are found, include **Low** severity findings instead. + +## Target Domains + +{target_domains} + +**Only review code within the target domains listed above.** Skip layers not in scope. + +## Spec Context + +{parsed_spec_summary} + +Understanding the spec helps you know what the code is *supposed* to do, which makes it easier to spot where it does something wrong or incomplete. + +## Code Map + +{code_map} + +## Available Tools + +You have access to **Glob**, **Grep**, and **Read** (read-only). Use them to read every file in the Code Map and trace the logic across target layers only. + +## Your Process + +### Step 1: Read All Implementing Files (within target domains) + +Read every file listed in the Code Map that belongs to the target domains. Build a mental model of the relevant flows — **only for selected domains**: + +**Backend (packages/*):** +- The hexagonal data flow: use case → domain entity → infra repository → TypeORM schema → database +- How contracts in `@packmind/types` define the shared interface (`{Name}Command`/`{Name}Response`/`I{Name}UseCase`) +- How domain events are emitted (`eventEmitterService.emit()`) and consumed (`PackmindListener` with `this.subscribe()`) +- How background jobs are registered and processed (BullMQ `WorkerQueue`, `JobsService`) +- How the Hexa registry (`{Pkg}Hexa.ts`) wires ports to adapters + +**API (apps/api):** +- NestJS controller → adapter (via `@Inject{Pkg}Adapter()`) → use case +- Where validations and guards are applied (NestJS guards like `OrganizationAccessGuard`) + +**Frontend (apps/frontend):** +- Route `clientLoader` → `queryClient.ensureQueryData()` → TanStack Query hook → gateway (extends `PackmindGateway`) → API call + +**CLI (apps/cli):** +- CLI command structure, output formatting, error handling + +**MCP (apps/mcp-server):** +- MCP tool implementation, use case invocation, result formatting + +### Step 2: Check for These Issue Categories + +#### A. Actual Bugs (Critical / High) + +- **Logic errors**: wrong conditions, inverted checks, off-by-one, missing null checks on required paths +- **Data integrity**: unique constraints that can be bypassed (e.g., race conditions on concurrent create), missing database constraints that the spec requires +- **Missing error handling**: operations that can throw but have no try/catch or return no meaningful error to the caller +- **Incorrect queries**: TypeORM queries that don't match the intended behavior (wrong where clause, missing relations, incorrect join) +- **Security gaps**: missing authorization checks the spec explicitly requires (e.g., "only admins can...") + +#### B. Edge Cases (Medium / High) + +- **Boundary values**: empty strings, max length violations, special characters (accents, unicode), whitespace handling +- **Concurrent operations**: two users performing the same action simultaneously (e.g., creating a resource with the same name at the same time) +- **State transitions**: operations that can leave data inconsistent if they fail partway through (e.g., entity created but event not emitted) + +#### C. Cross-File Inconsistencies (Medium / High) + +Look specifically for mismatches **between** files within the target domains. Only check mismatches between layers that are both in scope: +- **Gateway-to-contract mismatch** (Frontend + Backend): Frontend gateway method sends a field name or shape that differs from the `@packmind/types` contract (`{Name}Command`/`{Name}Response`) +- **Contract-to-API mismatch** (API + Backend): NestJS controller params or response types don't match the `@packmind/types` contract +- **Validation layer inconsistency** (any two selected layers): API validates a constraint that the domain use case does not enforce (or vice versa — validation only in one layer) +- **Event payload drift** (Backend): Event class in `packages/types/src/{domain}/events/` defines one payload shape, but `eventEmitterService.emit()` passes different properties, or `PackmindListener` handler expects different fields +- **Entity-to-schema drift** (Backend): Domain entity field names don't match TypeORM schema column names in `infra/schemas/` +- **Duplicate logic** (any two selected layers): Same logic implemented differently across layers (e.g., slug generation in both frontend gateway and backend use case with different rules) +- **Hexa wiring gap** (Backend): Adapter registered in `{Pkg}Hexa.ts` but the corresponding use case not wired, or a new use case not added to the Hexa registry + +#### D. Missing Validations (Medium) + +Constraints explicitly mentioned in the spec but not enforced in code: +- Max length for a field (spec says 64, no validation in DTO or entity) +- Authorization checks (spec says "only admins", no guard or role check) +- Uniqueness constraints (spec says "cannot have the same name", no unique check before insert) +- Required fields that accept null/undefined + +#### E. Technical Rules Violations (Medium / High) + +Systematically verify **every** technical rule from the "Technical Rules" section of the Parsed Spec Summary. For each technical rule: + +1. Identify which files in the Code Map are relevant — technical rules can apply to **any layer** (backend domain, infra, API, frontend, CLI, MCP server, background jobs, etc.) +2. Read those files and check whether the constraint is enforced +3. Report a finding if a technical rule is not implemented or is implemented incorrectly + +Common technical rule patterns to look for — **only check layers within the target domains**: +- **Backend**: missing domain validations, incorrect repository queries, missing event emissions, wrong error types, missing guards or authorization checks +- **Frontend**: missing form validations, incorrect API call parameters, missing loading/error states, wrong route guards, missing optimistic updates or cache invalidation +- **Cross-cutting**: naming conventions not followed, missing logging, incorrect feature flag checks, missing analytics events, wrong data transformations + +If a technical rule is ambiguous about which layer should enforce it, check only within the target domains. + +#### F. Hexagonal Architecture & NestJS Issues (Medium / High) + +- **Missing Hexa registration**: A new use case or port exists but is not registered in the package's `{Pkg}Hexa.ts` registry +- **Missing adapter injection**: NestJS controller uses a port but doesn't inject it via `@Inject{Pkg}Adapter()` decorator +- **Missing guard**: Controller endpoint modifies data but doesn't use `@UseGuards(OrganizationAccessGuard)` or appropriate guard +- **Event listener not registered**: A `PackmindListener` class exists but isn't wired to receive the expected events via `this.subscribe()` +- **Background job not registered**: A `WorkerQueue` is defined but `JobsService` doesn't register or submit it +- **Contract not exported**: A new use case contract exists in `@packmind/types` but isn't exported from the package's barrel file + +### Step 3: Check Test Quality + +If test files exist for the implementing code: +- Are the tests testing the **right behavior** or just mocking everything away? A test that mocks the repository and only checks the mock was called does not catch real bugs. +- Are there assertions that would catch regressions on the spec's key behaviors? +- Are error paths and edge cases tested, or only the happy path? + +Only flag test quality issues if they represent a **Medium+ risk** — e.g., a critical validation has no test at all, or tests mock away the exact layer where a bug exists. + +### Step 4: Check Pre-loaded Packmind Standards + +The following Packmind standards have been pre-filtered by the orchestrator and apply to files in the Code Map: + +{applicable_standards} + +If "None" is shown above, skip this step entirely. + +**Verification:** +- Read each applicable standard's rules carefully +- Check every file in the Code Map that matches the standard's scope +- Report violations at the same severity levels as other findings (Critical/High/Medium depending on impact) +- In the **Spec Reference** field, reference the standard name (e.g., "Standard: Testing Good Practices") + +## Severity Definitions + +- **Critical**: Data loss, security vulnerability, or crash in a production code path. Would block a release. +- **High**: Incorrect behavior that users will encounter in normal usage. A bug that produces wrong results or breaks a flow. +- **Medium**: Edge case that could cause issues under specific conditions. Missing validation that the spec explicitly requires. Cross-file inconsistency that could cause subtle bugs. +- **Low**: Minor issues — small inconsistencies, non-critical missing validations, minor edge cases unlikely to affect normal usage, slight contract mismatches that don't break functionality. + +**Do NOT report**: Informational, cosmetic, style preferences, missing comments, naming suggestions, or "nice to have" improvements. If you find fewer than 2 issues, that is perfectly fine — do not manufacture findings to fill the report. + +## Output Format + +Return findings in this exact format: + +``` +### Findings + +#### [CRITICAL] {Short descriptive title} +- **Category**: Bug | Edge Case | Inconsistency | Missing Validation | Technical Rule Violation +- **File**: `{file-path}:{line}` +- **Description**: {What is wrong, why it matters, and what could happen} +- **Spec Reference**: {Which rule/example/technical rule this relates to, e.g., "Rule 2 / Example 1", "Check Also: max length 64", or "Technical Rule: ..."} +- **Suggested Fix**: {Brief direction — not full code, just the approach} + +--- + +#### [HIGH] {Short descriptive title} +- **Category**: ... +[same format] + +--- + +#### [MEDIUM] {Short descriptive title} +- **Category**: ... +[same format] +``` + +**Order findings by severity**: Critical first, then High, then Medium. + +**Low-severity fallback**: If you found **no** Critical, High, or Medium issues, include any Low findings using this format: + +``` +#### [LOW] {Short descriptive title} +- **Category**: ... +[same format] +``` + +If no issues are found at any severity, return exactly: + +``` +### Findings + +No significant issues found. The implementation appears sound for the behaviors described in the spec. +``` diff --git a/.claude/skills/qa-review/agents/functional-coverage-agent.md b/.claude/skills/qa-review/agents/functional-coverage-agent.md new file mode 100644 index 000000000..d82534b83 --- /dev/null +++ b/.claude/skills/qa-review/agents/functional-coverage-agent.md @@ -0,0 +1,116 @@ +You are a QA analyst reviewing whether a user story implementation fully covers its Example Mapping specification. Your job is evidence-based: every assessment must cite specific files and code patterns you found (or did not find) in the codebase. + +## Target Domains + +{target_domains} + +**Only trace rules through the layers listed above.** Skip layers not in the target domains — they are out of scope for this review. + +## Spec to Review + +{parsed_spec_summary} + +## Code Map + +{code_map} + +## Available Tools + +You have access to **Glob**, **Grep**, and **Read** (read-only). Use them to: +- Read source files identified in the Code Map +- Search for specific behavior implementations (Grep for method names, conditions, error messages) +- Check test file existence and content (Glob for `*.spec.ts` patterns) + +The Code Map is a starting index — always verify by reading the actual source files. + +## Your Process + +### Step 1: Trace Each Rule and Example Across Target Layers + +For every Rule + Example pair in the spec, trace the behavior through **only the target domain layers listed above**. A rule is only fully covered when all target layers that should implement it actually do. + +1. **Identify the expected behavior** from the example's setup/action/outcome +2. **Search for the implementation across target layers only**. Below are the checks for each domain — **only perform checks for domains in the target list**: + + **Backend (packages/*):** + - **Contract layer** (`@packmind/types`): Read the use case contract (`I{UseCaseName}UseCase.ts` in `packages/types/src/{domain}/contracts/`). Does it define the correct `{Name}Command` input shape and `{Name}Response` output shape for this behavior? + - **Backend domain**: Trace the hexagonal flow: NestJS controller → adapter (via `@Inject{Pkg}Adapter()`) → use case → domain entity. Does the use case handle the precondition, perform the action, and produce the expected outcome? Is the Hexa registry (`{Pkg}Hexa.ts`) wiring the correct adapter? + - **Infra layer**: Do the repository implementations in `infra/` correctly persist or query the data? Are TypeORM schemas aligned with domain entities? Are BullMQ jobs (`WorkerQueue`) registered for async operations? + + **API (apps/api):** + - **API layer** (NestJS): Does the controller expose this behavior at the correct route under `/organizations/:orgId`? Is `@UseGuards(OrganizationAccessGuard)` (or appropriate guard) present? Are the request/response types aligned with the `@packmind/types` contract? + + **Frontend (apps/frontend):** + - **Frontend**: Trace the data flow: route `clientLoader` → `queryClient.ensureQueryData()` → TanStack Query hook → gateway (extends `PackmindGateway`) → API call. Does each layer handle this scenario? Does the gateway method signature match the API endpoint and `@packmind/types` contract? + + **CLI (apps/cli):** + - **CLI**: Does the CLI command implement the expected behavior for this rule? Correct output, error handling, flags? + + **MCP (apps/mcp-server):** + - **MCP Server**: Does the tool in `apps/mcp-server/src/app/tools/` correctly invoke the use case and return the expected result? + + **Cross-layer consistency** (check only between selected domains): Do field names match between `@packmind/types` contracts, NestJS controller params, frontend gateway methods, and CLI handlers? Are validations applied consistently across selected layers (not just in one)? +3. **Check for test coverage** — look for test cases that exercise this specific scenario. Not just that test files exist, but that a test covers this particular case (look for describe/it blocks, test data, assertions matching the example). +4. **Assess coverage level**: + - **Covered**: Implementation code exists AND handles this specific case across all target layers. Cite the file:line where the behavior is implemented. + - **Partially Covered**: Implementation exists but is incomplete — e.g., backend handles it but frontend doesn't (when both are in scope), happy path only, missing error case, missing edge case from the example. Cite what exists and what is missing. + - **Not Covered**: No implementation found for this behavior in any target layer. Describe what you searched for and where. + +### Step 2: Check Technical Rules + +For each bullet under "Technical rules": +- Search for the implementation of the described technical constraint +- Verify the code matches what the spec says (e.g., if the spec says "ConflictDetector uses the `decision` field if available, payload otherwise", read the ConflictDetector and verify this logic) + +### Step 3: Check User Events + +For each event described: +- Check the event class definition in `packages/types/src/{domain}/events/{EventName}Event.ts`. Verify it extends `UserEvent<TPayload>` or `SystemEvent<TPayload>` with the correct payload type. +- Grep for `eventEmitterService.emit(new {EventName}Event(` to find where it is emitted. Verify it is emitted in the correct use case after the action succeeds. +- Grep for `PackmindListener` classes that `this.subscribe({EventName}Event, ...)` to verify the event is consumed where expected. +- Check the event payload properties match the spec (property names, types, optional/required flags). + +### Step 4: Check "Check Also" Items + +For each bullet under "Check also": +- Search for the constraint or validation in the code +- Assess coverage the same way as rules (Covered / Partially Covered / Not Covered) + +## Output Format + +Return **two sections**: + +### Coverage Matrix + +Use this exact table format, one row per rule+example, technical rule, user event, and check-also item. The **Layer** column indicates where the behavior is implemented (Contract, Backend Domain, Infra, API, Frontend (Route/Gateway/Query), CLI, MCP Server, Event, Background Job, or Cross-layer) — this helps route fixes to the right team/area: + +``` +| ID | Rule / Item | Layer | Status | Evidence | Test Coverage | +|----|-------------|-------|--------|----------|---------------| +| R1-E1 | Rule 1: {title} / Example 1: {summary} | Backend Domain | Covered | `file.ts:45` - {what the code does} | `file.spec.ts:23` - {test description} | +| R1-E2 | Rule 1: {title} / Example 2: {summary} | Frontend | Not Covered | No uniqueness check found in {file} | None | +| R2-E1 | Rule 2: {title} / Example 1: {summary} | Backend Domain | Partially Covered | `file.ts:80` - slug generated but not immutable | None | +| T1 | Tech: {description} | Cross-layer | Covered | `ConflictDetector.ts:12` - uses decision field | `ConflictDetector.spec.ts:5` | +| EV1 | Event: {event_name} | Event | Not Covered | No emit found for this event | None | +| C1 | Check: {description} | API | Covered | `guard.ts:15` - admin role check | None | +``` + +**ID format**: `R{rule}-E{example}` for rules, `T{n}` for technical rules, `EV{n}` for events, `C{n}` for check-also items. + +### Reproduction Steps for Gaps + +For each item marked **Not Covered** or **Partially Covered**, provide: + +``` +#### [{ID}] {Rule title} / {Example summary} +**Status**: Not Covered | Partially Covered +**What is missing**: {specific behavior not implemented} +**Where to look**: {file paths where this should be implemented} +**How to reproduce**: +1. {step-by-step reproduction of the scenario from the example} +2. {what happens vs what should happen} +``` + +Keep reproduction steps concise — focus on the minimum steps to demonstrate the gap. + +If everything is fully covered, state: "All rules and examples are fully covered." diff --git a/.claude/skills/qa-review/em_template.md b/.claude/skills/qa-review/em_template.md new file mode 100644 index 000000000..a2a483b55 --- /dev/null +++ b/.claude/skills/qa-review/em_template.md @@ -0,0 +1,50 @@ +User Story Review: {Title of the user story} + +# Rule 1: {Short rule title describing the expected behavior} + +## Example 1 + +{Setup: describe the initial state} + +{Action: describe what the user does} + +{Outcome: describe the expected result} + +## Example 2 + +{Setup} + +{Action} + +{Outcome} + +# Rule 2: {Short rule title} + +## Example 1 + +{Setup} + +{Action} + +{Outcome} + +# Technical rules + +- {Implementation constraint that applies across frontend and backend, e.g., "Slug generation must be deterministic and locale-independent"} +- {Performance or infrastructure constraint, e.g., "The operation must be idempotent"} +- {Security or authorization constraint, e.g., "Only organization admins can perform this action"} +- {Data constraint, e.g., "Max length for the name field: 64 characters"} +- {Integration constraint, e.g., "Must work on both Linux and Windows (normalize paths)"} + +# User Events + +- `{event_name}`: emitted when {trigger description}. Properties: `{property1}`, `{property2}` +- `{event_name}`: emitted when {trigger description}. Properties: `{property1}` + +--- + +Check also the following rules are applied: + +- {Additional business rule or constraint not covered by the rules above} +- {Edge case to verify, e.g., "Two items in the same scope cannot have the same name"} +- {Default behavior, e.g., "By default, the user lands on the default space"} diff --git a/.claude/skills/references/schemas.md b/.claude/skills/references/schemas.md new file mode 100644 index 000000000..b6eeaa2d4 --- /dev/null +++ b/.claude/skills/references/schemas.md @@ -0,0 +1,430 @@ +# JSON Schemas + +This document defines the JSON schemas used by skill-creator. + +--- + +## evals.json + +Defines the evals for a skill. Located at `evals/evals.json` within the skill directory. + +```json +{ + "skill_name": "example-skill", + "evals": [ + { + "id": 1, + "prompt": "User's example prompt", + "expected_output": "Description of expected result", + "files": ["evals/files/sample1.pdf"], + "expectations": [ + "The output includes X", + "The skill used script Y" + ] + } + ] +} +``` + +**Fields:** +- `skill_name`: Name matching the skill's frontmatter +- `evals[].id`: Unique integer identifier +- `evals[].prompt`: The task to execute +- `evals[].expected_output`: Human-readable description of success +- `evals[].files`: Optional list of input file paths (relative to skill root) +- `evals[].expectations`: List of verifiable statements + +--- + +## history.json + +Tracks version progression in Improve mode. Located at workspace root. + +```json +{ + "started_at": "2026-01-15T10:30:00Z", + "skill_name": "pdf", + "current_best": "v2", + "iterations": [ + { + "version": "v0", + "parent": null, + "expectation_pass_rate": 0.65, + "grading_result": "baseline", + "is_current_best": false + }, + { + "version": "v1", + "parent": "v0", + "expectation_pass_rate": 0.75, + "grading_result": "won", + "is_current_best": false + }, + { + "version": "v2", + "parent": "v1", + "expectation_pass_rate": 0.85, + "grading_result": "won", + "is_current_best": true + } + ] +} +``` + +**Fields:** +- `started_at`: ISO timestamp of when improvement started +- `skill_name`: Name of the skill being improved +- `current_best`: Version identifier of the best performer +- `iterations[].version`: Version identifier (v0, v1, ...) +- `iterations[].parent`: Parent version this was derived from +- `iterations[].expectation_pass_rate`: Pass rate from grading +- `iterations[].grading_result`: "baseline", "won", "lost", or "tie" +- `iterations[].is_current_best`: Whether this is the current best version + +--- + +## grading.json + +Output from the grader agent. Located at `<run-dir>/grading.json`. + +```json +{ + "expectations": [ + { + "text": "The output includes the name 'John Smith'", + "passed": true, + "evidence": "Found in transcript Step 3: 'Extracted names: John Smith, Sarah Johnson'" + }, + { + "text": "The spreadsheet has a SUM formula in cell B10", + "passed": false, + "evidence": "No spreadsheet was created. The output was a text file." + } + ], + "summary": { + "passed": 2, + "failed": 1, + "total": 3, + "pass_rate": 0.67 + }, + "execution_metrics": { + "tool_calls": { + "Read": 5, + "Write": 2, + "Bash": 8 + }, + "total_tool_calls": 15, + "total_steps": 6, + "errors_encountered": 0, + "output_chars": 12450, + "transcript_chars": 3200 + }, + "timing": { + "executor_duration_seconds": 165.0, + "grader_duration_seconds": 26.0, + "total_duration_seconds": 191.0 + }, + "claims": [ + { + "claim": "The form has 12 fillable fields", + "type": "factual", + "verified": true, + "evidence": "Counted 12 fields in field_info.json" + } + ], + "user_notes_summary": { + "uncertainties": ["Used 2023 data, may be stale"], + "needs_review": [], + "workarounds": ["Fell back to text overlay for non-fillable fields"] + }, + "eval_feedback": { + "suggestions": [ + { + "assertion": "The output includes the name 'John Smith'", + "reason": "A hallucinated document that mentions the name would also pass" + } + ], + "overall": "Assertions check presence but not correctness." + } +} +``` + +**Fields:** +- `expectations[]`: Graded expectations with evidence +- `summary`: Aggregate pass/fail counts +- `execution_metrics`: Tool usage and output size (from executor's metrics.json) +- `timing`: Wall clock timing (from timing.json) +- `claims`: Extracted and verified claims from the output +- `user_notes_summary`: Issues flagged by the executor +- `eval_feedback`: (optional) Improvement suggestions for the evals, only present when the grader identifies issues worth raising + +--- + +## metrics.json + +Output from the executor agent. Located at `<run-dir>/outputs/metrics.json`. + +```json +{ + "tool_calls": { + "Read": 5, + "Write": 2, + "Bash": 8, + "Edit": 1, + "Glob": 2, + "Grep": 0 + }, + "total_tool_calls": 18, + "total_steps": 6, + "files_created": ["filled_form.pdf", "field_values.json"], + "errors_encountered": 0, + "output_chars": 12450, + "transcript_chars": 3200 +} +``` + +**Fields:** +- `tool_calls`: Count per tool type +- `total_tool_calls`: Sum of all tool calls +- `total_steps`: Number of major execution steps +- `files_created`: List of output files created +- `errors_encountered`: Number of errors during execution +- `output_chars`: Total character count of output files +- `transcript_chars`: Character count of transcript + +--- + +## timing.json + +Wall clock timing for a run. Located at `<run-dir>/timing.json`. + +**How to capture:** When a subagent task completes, the task notification includes `total_tokens` and `duration_ms`. Save these immediately — they are not persisted anywhere else and cannot be recovered after the fact. + +```json +{ + "total_tokens": 84852, + "duration_ms": 23332, + "total_duration_seconds": 23.3, + "executor_start": "2026-01-15T10:30:00Z", + "executor_end": "2026-01-15T10:32:45Z", + "executor_duration_seconds": 165.0, + "grader_start": "2026-01-15T10:32:46Z", + "grader_end": "2026-01-15T10:33:12Z", + "grader_duration_seconds": 26.0 +} +``` + +--- + +## benchmark.json + +Output from Benchmark mode. Located at `benchmarks/<timestamp>/benchmark.json`. + +```json +{ + "metadata": { + "skill_name": "pdf", + "skill_path": "/path/to/pdf", + "executor_model": "claude-sonnet-4-20250514", + "analyzer_model": "most-capable-model", + "timestamp": "2026-01-15T10:30:00Z", + "evals_run": [1, 2, 3], + "runs_per_configuration": 3 + }, + + "runs": [ + { + "eval_id": 1, + "eval_name": "Ocean", + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 0.85, + "passed": 6, + "failed": 1, + "total": 7, + "time_seconds": 42.5, + "tokens": 3800, + "tool_calls": 18, + "errors": 0 + }, + "expectations": [ + {"text": "...", "passed": true, "evidence": "..."} + ], + "notes": [ + "Used 2023 data, may be stale", + "Fell back to text overlay for non-fillable fields" + ] + } + ], + + "run_summary": { + "with_skill": { + "pass_rate": {"mean": 0.85, "stddev": 0.05, "min": 0.80, "max": 0.90}, + "time_seconds": {"mean": 45.0, "stddev": 12.0, "min": 32.0, "max": 58.0}, + "tokens": {"mean": 3800, "stddev": 400, "min": 3200, "max": 4100} + }, + "without_skill": { + "pass_rate": {"mean": 0.35, "stddev": 0.08, "min": 0.28, "max": 0.45}, + "time_seconds": {"mean": 32.0, "stddev": 8.0, "min": 24.0, "max": 42.0}, + "tokens": {"mean": 2100, "stddev": 300, "min": 1800, "max": 2500} + }, + "delta": { + "pass_rate": "+0.50", + "time_seconds": "+13.0", + "tokens": "+1700" + } + }, + + "notes": [ + "Assertion 'Output is a PDF file' passes 100% in both configurations - may not differentiate skill value", + "Eval 3 shows high variance (50% ± 40%) - may be flaky or model-dependent", + "Without-skill runs consistently fail on table extraction expectations", + "Skill adds 13s average execution time but improves pass rate by 50%" + ] +} +``` + +**Fields:** +- `metadata`: Information about the benchmark run + - `skill_name`: Name of the skill + - `timestamp`: When the benchmark was run + - `evals_run`: List of eval names or IDs + - `runs_per_configuration`: Number of runs per config (e.g. 3) +- `runs[]`: Individual run results + - `eval_id`: Numeric eval identifier + - `eval_name`: Human-readable eval name (used as section header in the viewer) + - `configuration`: Must be `"with_skill"` or `"without_skill"` (the viewer uses this exact string for grouping and color coding) + - `run_number`: Integer run number (1, 2, 3...) + - `result`: Nested object with `pass_rate`, `passed`, `total`, `time_seconds`, `tokens`, `errors` +- `run_summary`: Statistical aggregates per configuration + - `with_skill` / `without_skill`: Each contains `pass_rate`, `time_seconds`, `tokens` objects with `mean` and `stddev` fields + - `delta`: Difference strings like `"+0.50"`, `"+13.0"`, `"+1700"` +- `notes`: Freeform observations from the analyzer + +**Important:** The viewer reads these field names exactly. Using `config` instead of `configuration`, or putting `pass_rate` at the top level of a run instead of nested under `result`, will cause the viewer to show empty/zero values. Always reference this schema when generating benchmark.json manually. + +--- + +## comparison.json + +Output from blind comparator. Located at `<grading-dir>/comparison-N.json`. + +```json +{ + "winner": "A", + "reasoning": "Output A provides a complete solution with proper formatting and all required fields. Output B is missing the date field and has formatting inconsistencies.", + "rubric": { + "A": { + "content": { + "correctness": 5, + "completeness": 5, + "accuracy": 4 + }, + "structure": { + "organization": 4, + "formatting": 5, + "usability": 4 + }, + "content_score": 4.7, + "structure_score": 4.3, + "overall_score": 9.0 + }, + "B": { + "content": { + "correctness": 3, + "completeness": 2, + "accuracy": 3 + }, + "structure": { + "organization": 3, + "formatting": 2, + "usability": 3 + }, + "content_score": 2.7, + "structure_score": 2.7, + "overall_score": 5.4 + } + }, + "output_quality": { + "A": { + "score": 9, + "strengths": ["Complete solution", "Well-formatted", "All fields present"], + "weaknesses": ["Minor style inconsistency in header"] + }, + "B": { + "score": 5, + "strengths": ["Readable output", "Correct basic structure"], + "weaknesses": ["Missing date field", "Formatting inconsistencies", "Partial data extraction"] + } + }, + "expectation_results": { + "A": { + "passed": 4, + "total": 5, + "pass_rate": 0.80, + "details": [ + {"text": "Output includes name", "passed": true} + ] + }, + "B": { + "passed": 3, + "total": 5, + "pass_rate": 0.60, + "details": [ + {"text": "Output includes name", "passed": true} + ] + } + } +} +``` + +--- + +## analysis.json + +Output from post-hoc analyzer. Located at `<grading-dir>/analysis.json`. + +```json +{ + "comparison_summary": { + "winner": "A", + "winner_skill": "path/to/winner/skill", + "loser_skill": "path/to/loser/skill", + "comparator_reasoning": "Brief summary of why comparator chose winner" + }, + "winner_strengths": [ + "Clear step-by-step instructions for handling multi-page documents", + "Included validation script that caught formatting errors" + ], + "loser_weaknesses": [ + "Vague instruction 'process the document appropriately' led to inconsistent behavior", + "No script for validation, agent had to improvise" + ], + "instruction_following": { + "winner": { + "score": 9, + "issues": ["Minor: skipped optional logging step"] + }, + "loser": { + "score": 6, + "issues": [ + "Did not use the skill's formatting template", + "Invented own approach instead of following step 3" + ] + } + }, + "improvement_suggestions": [ + { + "priority": "high", + "category": "instructions", + "suggestion": "Replace 'process the document appropriately' with explicit steps", + "expected_impact": "Would eliminate ambiguity that caused inconsistent behavior" + } + ], + "transcript_insights": { + "winner_execution_pattern": "Read skill -> Followed 5-step process -> Used validation script", + "loser_execution_pattern": "Read skill -> Unclear on approach -> Tried 3 different methods" + } +} +``` diff --git a/.claude/skills/release-proprietary/SKILL.md b/.claude/skills/release-proprietary/SKILL.md new file mode 100644 index 000000000..e20ce1386 --- /dev/null +++ b/.claude/skills/release-proprietary/SKILL.md @@ -0,0 +1,207 @@ +--- +name: 'release-proprietary' +description: 'Orchestrate a full Packmind proprietary release: verify Main CI/CD Pipeline is green on both OSS and proprietary main, drive the release on the OSS sibling repo (version bumps, CHANGELOG, tag, push), wait for the oss-sync workflow to land the release commit on the proprietary fork, then tag and push `release/{{version}}` on the proprietary repo. Invoke from the proprietary repo.' +--- + +Create a full Packmind proprietary release with version `{{version}}`. + +This skill orchestrates a cross-repository release that spans the OSS sibling (`../packmind`, cloned next to the proprietary repo) and the proprietary repo itself (the current working directory). All real release content (version bumps, CHANGELOG) lives on OSS; the proprietary side receives the OSS commit through the `oss-sync` workflow and gets its own `release/{{version}}` tag pointing at a **different SHA** than the OSS tag — see below. + +**Invocation requirement:** run this skill from the root of the proprietary repo, with the OSS sibling checked out at `../packmind`. All commands below assume that layout. + +**Dual-SHA model — important:** + +* On **OSS**, the `release/{{version}}` tag points at the release commit itself (subject `chore: release {{version}}`). That commit's tree contains OSS code, which is what OSS deployments need. +* On **proprietary**, the same tag points at the **sync-merge commit** that brought the OSS release into proprietary `main`. The OSS release commit has no proprietary-only files in its tree, so tagging it on proprietary would break deployments (the build can't find `@packmind/proprietary/*` modules). The sync-merge commit's tree combines OSS-at-release with the proprietary-only files, which is what proprietary deployments need. + +The wait/tag scripts handle this for you — Phase 2 emits the proprietary merge SHA, and Phase 3 tags that SHA. + +Repository layout assumed by every command in this file: + +* OSS repo: `../packmind` (remote `PackmindHub/packmind`) +* Proprietary repo: `.` — the current working directory (remote `PackmindHub/packmind-proprietary`) + +Scripts live next to this file under `./scripts/`. From the proprietary repo root, paths are `.claude/skills/release-proprietary/scripts/<name>`. + +## Phase 0 — Pre-flight (proprietary repo) + +### 0.1 Feature flags audit gate (MANDATORY) + +Before doing anything, stop and ask the user: + +> Has the `feature-flags-audit` skill been run on the **proprietary** repo for this release? (yes / no) + +If the answer is anything other than an explicit `yes`, abort and tell the user to run `feature-flags-audit` on the proprietary repo first. + +If the audit surfaced any loose / stale flags that should have been removed, instruct the user to: + +1. Check whether each loose flag also exists in the OSS sibling at `../packmind`. Most code flows from OSS → proprietary via `oss-sync`, so flags should generally be cleaned up on the OSS side. +2. Remove them in OSS first, let the oss-sync workflow land the cleanup on proprietary, then re-invoke this skill. + +### 0.2 No open `oss-sync` PR + +```bash +./.claude/skills/release-proprietary/scripts/check-oss-sync-pr.sh PackmindHub/packmind-proprietary +``` + +When the auto-sync hits a merge conflict it opens a PR on branch `oss-sync` instead of fast-forwarding. If such a PR is open, Phase 2 polling cannot succeed. Abort and ask the user to resolve and merge the PR first. + +### 0.3 CI green on both repos + +Run the check script for both repositories: + +```bash +./.claude/skills/release-proprietary/scripts/check-ci.sh PackmindHub/packmind +./.claude/skills/release-proprietary/scripts/check-ci.sh PackmindHub/packmind-proprietary +``` + +Exit codes: + +* `0` — green, proceed. +* `1` — the run failed (or no run found, or auth missing). Abort, surface the URL. +* `2` — the run is still in progress / queued. Not a failure: ask the user whether to wait and retry, or abort. + +### 0.4 Clean working tree on proprietary and no local divergence + +```bash +git status --porcelain # must be empty +git fetch origin main +git merge-base --is-ancestor HEAD origin/main \ + || (echo "local main has diverged from origin/main — reconcile before releasing" && exit 1) +``` + +If the working tree is dirty, or local `HEAD` is not an ancestor of `origin/main`, abort and ask the user to reconcile. + +## Phase 1 — Drive the OSS release (`../packmind`) + +All commands in this phase target the OSS repo via `git -C` or `cd`. + +### 1.1 Verify clean OSS state and update + +```bash +git -C ../packmind status --porcelain # must be empty +git -C ../packmind checkout main +git -C ../packmind pull --ff-only origin main +``` + +If the working tree is dirty or the pull is not fast-forward, abort and ask the user to reconcile. + +### 1.2 Bump versions + +```bash +node ./.claude/skills/release-proprietary/scripts/bump-versions.mjs {{version}} ../packmind +( cd ../packmind && npm install ) +``` + +`npm install` regenerates `package-lock.json` with the new version. + +### 1.3 Rewrite CHANGELOG for release + +Resolve today's date in ISO 8601 (`YYYY-MM-DD`) — call it `{{today}}`. + +```bash +node ./.claude/skills/release-proprietary/scripts/changelog-release.mjs {{version}} {{today}} ../packmind +``` + +### 1.4 Commit the release and push main + +```bash +./.claude/skills/release-proprietary/scripts/commit-release.sh ../packmind {{version}} +``` + +This stages `package.json`, `apps/api/docker-package.json`, `package-lock.json`, and `CHANGELOG.MD`, commits with the exact subject `chore: release {{version}}` (Phase 2 polls for this fixed string), and pushes main. The script never uses `--no-verify`. + +### 1.5 Tag the release and push the tag + +```bash +./.claude/skills/release-proprietary/scripts/tag-release.sh ../packmind {{version}} +``` + +Creates `release/{{version}}` at HEAD (the release commit just pushed) and pushes the tag. If the tag already exists, the script verifies it points at HEAD before re-pushing. + +### 1.6 Prepare next dev cycle on OSS + +```bash +node ./.claude/skills/release-proprietary/scripts/changelog-next.mjs {{version}} ../packmind +./.claude/skills/release-proprietary/scripts/commit-next-cycle.sh ../packmind +``` + +## Phase 2 — Wait for oss-sync to land on proprietary + +The `sync-from-oss-repository.yml` workflow on the proprietary repo merges OSS commits into proprietary `main` automatically (usually under a minute). It will land BOTH the release commit AND the subsequent "prepare next development cycle" commit; each goes through its own sync-merge commit on proprietary `main`. + +```bash +PROP_TAG_SHA=$(./.claude/skills/release-proprietary/scripts/wait-for-oss-sync.sh \ + . {{version}}) +``` + +The script writes the **proprietary sync-merge SHA** to stdout (captured into `PROP_TAG_SHA`) and progress to stderr. Internally it: + +1. Polls `origin/main` every 5 seconds for the OSS release commit (subject exactly `chore: release {{version}}`), with a 10-minute timeout. +2. Once found, locates the proprietary merge commit whose **2nd parent** is that OSS commit — that's the upstream side of the sync merge. Requiring the OSS release to be the direct 2nd parent (not a grandparent) ensures the merge's tree is exactly "OSS at release + proprietary state at that moment" — i.e. version `{{version}}`, not `{{next}}-next`. + +This is the SHA you tag on proprietary. It is NOT the OSS release SHA — that one's tree contains only OSS code and would break proprietary deployments. See the "Dual-SHA model" note at the top. + +If it times out, abort and instruct the user to: + +* Check the `sync-from-oss-repository` workflow on `PackmindHub/packmind-proprietary`. +* Check whether an open `oss-sync` PR appeared after Phase 0 ran. + +If the script reports `OSS release commit … is on proprietary origin/main, but no merge commit has it as its direct upstream (2nd) parent`, the auto-sync either fast-forwarded or batched release + next-cycle into one merge. The operator must resolve manually before continuing — there is no merge commit with the right tree to tag. + +Once `PROP_TAG_SHA` is set, fast-forward the local proprietary checkout (purely to keep the working tree in sync — we do NOT rely on HEAD for the tag): + +```bash +git checkout main +git pull --ff-only origin main +``` + +## Phase 3 — Tag proprietary + +### 3.1 Re-check proprietary CI green + +The sync just pushed new commits to proprietary main; the Phase 0 CI gate is now stale. Re-run it (allowing exit 2 = still running) and either wait or abort: + +```bash +./.claude/skills/release-proprietary/scripts/check-ci.sh PackmindHub/packmind-proprietary +``` + +### 3.2 Tag the proprietary release commit + +```bash +./.claude/skills/release-proprietary/scripts/tag-release.sh \ + . {{version}} "${PROP_TAG_SHA}" +``` + +Passing the SHA explicitly is essential — tagging `HEAD` would tag a later sync-merge or the next-cycle commit. The script re-verifies that the target is either a direct release commit OR a merge commit whose 2nd parent is the release commit, and refuses to tag otherwise. It will not allow retagging an existing tag at a different commit. + +### 3.3 Done + +Report to the user: + +* OSS release commit URL: `https://github.com/PackmindHub/packmind/releases/tag/release/{{version}}` (or the compare link) +* Proprietary tag URL: `https://github.com/PackmindHub/packmind-proprietary/releases/tag/release/{{version}}` +* Reminder: any deployment workflow that watches `release/*` tags will trigger. + +## Important notes + +* Do NOT use `--no-verify` on any commit. If a hook fails, fix the root cause and create a new commit. +* The release commit subject MUST be exactly `chore: release {{version}}` — Phase 2 matches subjects by exact equality and `tag-release.sh` verifies subject equality before tagging. Any prefix/suffix (e.g. gitmoji) will break the flow. +* Date format MUST be ISO 8601 (`YYYY-MM-DD`). +* Verify each commit and push succeeded before proceeding to the next step. +* If anything fails mid-way after the OSS tag is pushed, do NOT delete the OSS tag — coordinate a follow-up patch release instead. + +## Recovery cheatsheet + +If the flow fails at a known phase, recover as follows: + +* **After 1.4, before 1.5** (release commit pushed, no OSS tag yet): re-run `tag-release.sh <oss-dir> {{version}}` from Phase 1.5. The script tags HEAD only if HEAD's subject is `chore: release {{version}}`. +* **After 1.5, before 1.6** (OSS is tagged, no next-cycle commit): re-run `changelog-next.mjs` + `commit-next-cycle.sh`. Then proceed to Phase 2. +* **After 1.6, Phase 2 timed out**: investigate the sync workflow / oss-sync PR. Once unstuck, re-run only Phase 2 + Phase 3. +* **After Phase 3 failure** (OSS tagged, proprietary not tagged): once any blocker is cleared, re-run only Phase 3, passing the same `PROP_TAG_SHA`. Recover it by re-running `wait-for-oss-sync.sh` (idempotent — it finds and emits the same merge SHA as before) or manually with: + ```bash + OSS_SHA=$(git log origin/main \ + --format='%H%x09%s' -500 | awk -F '\t' -v subj="chore: release {{version}}" '$2 == subj { print $1; exit }') + git log origin/main --merges \ + --format='%H %P' -500 | awk -v oss="${OSS_SHA}" '$3 == oss { print $1; exit }' + ``` \ No newline at end of file diff --git a/.claude/skills/release-proprietary/scripts/bump-versions.mjs b/.claude/skills/release-proprietary/scripts/bump-versions.mjs new file mode 100755 index 000000000..86f17a5ac --- /dev/null +++ b/.claude/skills/release-proprietary/scripts/bump-versions.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// Bump version field in package.json and apps/api/docker-package.json to the +// provided version. Preserves 2-space indentation and trailing newline. +// +// Usage: node bump-versions.mjs <version> <repo-dir> + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const [, , version, repoDirArg] = process.argv; + +if (!version || !repoDirArg) { + console.error('usage: bump-versions.mjs <version> <repo-dir>'); + process.exit(1); +} + +const repoDir = resolve(repoDirArg); +const targets = ['package.json', 'apps/api/docker-package.json']; + +for (const rel of targets) { + const path = join(repoDir, rel); + const raw = readFileSync(path, 'utf8'); + const json = JSON.parse(raw); + const previous = json.version; + json.version = version; + const endsWithNewline = raw.endsWith('\n'); + writeFileSync(path, JSON.stringify(json, null, 2) + (endsWithNewline ? '\n' : '')); + console.log(`bumped ${rel}: ${previous} → ${version}`); +} diff --git a/.claude/skills/release-proprietary/scripts/changelog-next.mjs b/.claude/skills/release-proprietary/scripts/changelog-next.mjs new file mode 100755 index 000000000..1d74c49e5 --- /dev/null +++ b/.claude/skills/release-proprietary/scripts/changelog-next.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// Mutate CHANGELOG.MD for the next development cycle: +// - Prepend a fresh `# [Unreleased]` section with empty Added/Changed/Fixed/Removed +// - Insert `[Unreleased]: ...compare/release/{version}...HEAD` link just above +// the `[{version}]: ...` link in the bottom link section +// +// Usage: node changelog-next.mjs <version> <repo-dir> + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const [, , version, repoDirArg] = process.argv; + +if (!version || !repoDirArg) { + console.error('usage: changelog-next.mjs <version> <repo-dir>'); + process.exit(1); +} + +const path = join(resolve(repoDirArg), 'CHANGELOG.MD'); +const original = readFileSync(path, 'utf8'); + +const firstHeadingMatch = original.match(/^# \[/m); +if (!firstHeadingMatch) { + console.error('CHANGELOG.MD: could not find any `# [` heading'); + process.exit(1); +} +const insertAt = firstHeadingMatch.index; + +const unreleasedBlock = + '# [Unreleased]\n\n## Added\n\n## Changed\n\n## Fixed\n\n## Removed\n\n'; + +let result = original.slice(0, insertAt) + unreleasedBlock + original.slice(insertAt); + +const versionLinkRegex = new RegExp( + `^\\[${version.replace(/\./g, '\\.')}\\]: (https://github\\.com/[^/]+/[^/]+)/compare/release/[0-9A-Za-z._-]+\\.\\.\\.release/${version.replace(/\./g, '\\.')}\\s*$`, + 'm', +); +const versionLinkMatch = result.match(versionLinkRegex); +if (!versionLinkMatch) { + console.error(`CHANGELOG.MD: could not find \`[${version}]: ...\` compare link`); + process.exit(1); +} +const repoUrl = versionLinkMatch[1]; +const unreleasedLink = `[Unreleased]: ${repoUrl}/compare/release/${version}...HEAD`; + +result = result.replace(versionLinkRegex, `${unreleasedLink}\n${versionLinkMatch[0]}`); + +writeFileSync(path, result); +console.log(`changelog: prepared next cycle after ${version}`); diff --git a/.claude/skills/release-proprietary/scripts/changelog-release.mjs b/.claude/skills/release-proprietary/scripts/changelog-release.mjs new file mode 100755 index 000000000..db97d5aea --- /dev/null +++ b/.claude/skills/release-proprietary/scripts/changelog-release.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// Mutate CHANGELOG.MD for a release: +// - Rename `# [Unreleased]` heading to `# [{version}] - {date}` +// - Drop empty `## Added|Changed|Fixed|Removed` subsections inside that block +// - Swap the bottom compare link +// `[Unreleased]: ...compare/release/{prev}...HEAD` +// for +// `[{version}]: ...compare/release/{prev}...release/{version}` +// +// Usage: node changelog-release.mjs <version> <date> <repo-dir> + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const [, , version, date, repoDirArg] = process.argv; + +if (!version || !date || !repoDirArg) { + console.error('usage: changelog-release.mjs <version> <date> <repo-dir>'); + process.exit(1); +} + +const path = join(resolve(repoDirArg), 'CHANGELOG.MD'); +const original = readFileSync(path, 'utf8'); + +const unreleasedHeading = original.match(/^# \[Unreleased\][^\n]*$/m); +if (!unreleasedHeading) { + console.error('CHANGELOG.MD: could not find `# [Unreleased]` heading'); + process.exit(1); +} + +const start = unreleasedHeading.index; +const after = original.slice(start + unreleasedHeading[0].length); +const nextHeadingRel = after.match(/^# \[/m); +if (!nextHeadingRel) { + console.error('CHANGELOG.MD: could not find next `# [` heading after Unreleased'); + process.exit(1); +} +const blockEnd = start + unreleasedHeading[0].length + nextHeadingRel.index; +const block = original.slice(start, blockEnd); + +const lines = block.split('\n'); +const out = [`# [${version}] - ${date}`]; +let i = 1; +while (i < lines.length) { + const line = lines[i]; + if (line.startsWith('## ')) { + let j = i + 1; + while (j < lines.length && !lines[j].startsWith('## ')) j++; + const body = lines.slice(i + 1, j).join('\n').trim(); + if (body.length > 0) { + out.push(line); + for (let k = i + 1; k < j; k++) out.push(lines[k]); + } + i = j; + } else { + out.push(line); + i++; + } +} + +let newBlock = out.join('\n'); +if (!newBlock.endsWith('\n\n')) { + newBlock = newBlock.replace(/\n*$/, '\n\n'); +} + +let result = original.slice(0, start) + newBlock + original.slice(blockEnd); + +const linkRegex = /^\[Unreleased\]: (https:\/\/github\.com\/[^/]+\/[^/]+)\/compare\/release\/([0-9A-Za-z._-]+)\.\.\.HEAD\s*$/m; +const linkMatch = result.match(linkRegex); +if (!linkMatch) { + console.error('CHANGELOG.MD: could not find `[Unreleased]: .../compare/release/<prev>...HEAD` link'); + process.exit(1); +} +const repoUrl = linkMatch[1]; +const prev = linkMatch[2]; +const newLink = `[${version}]: ${repoUrl}/compare/release/${prev}...release/${version}`; +result = result.replace(linkRegex, newLink); + +writeFileSync(path, result); +console.log(`changelog: released ${version} (date ${date}, previous ${prev})`); diff --git a/.claude/skills/release-proprietary/scripts/check-ci.sh b/.claude/skills/release-proprietary/scripts/check-ci.sh new file mode 100755 index 000000000..f9cf727cc --- /dev/null +++ b/.claude/skills/release-proprietary/scripts/check-ci.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Verify that the "Main CI/CD Pipeline" workflow on the latest commit of the +# main branch of a given GitHub repository is green. +# +# Usage: check-ci.sh <owner/repo> +# Exit codes: +# 0 - latest run on main concluded successfully (green) +# 1 - latest run failed / cancelled / no run found / gh auth missing +# 2 - latest run is still in progress or queued (not red, just not done) + +set -euo pipefail + +REPO="${1:?usage: check-ci.sh <owner/repo>}" + +if ! command -v gh >/dev/null 2>&1; then + echo "❌ gh CLI not found in PATH" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "❌ jq not found in PATH" >&2 + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "❌ gh is not authenticated — run \`gh auth login\` first" >&2 + exit 1 +fi + +HEAD_SHA=$(gh api "repos/${REPO}/commits/main" --jq .sha 2>/dev/null || true) +if [ -z "${HEAD_SHA}" ]; then + echo "❌ ${REPO}: could not resolve main HEAD SHA (does the repo exist and do you have access?)" >&2 + exit 1 +fi +SHORT_SHA="${HEAD_SHA:0:7}" + +RUN_JSON=$(gh run list \ + --repo "${REPO}" \ + --workflow=main.yml \ + --branch=main \ + --limit=20 \ + --json databaseId,status,conclusion,headSha,url,createdAt) + +MATCH=$(echo "${RUN_JSON}" | jq --arg sha "${HEAD_SHA}" ' + [ .[] | select(.headSha == $sha) ] | sort_by(.createdAt) | reverse | .[0] // empty +') + +if [ -z "${MATCH}" ] || [ "${MATCH}" = "null" ]; then + echo "❌ ${REPO}: no Main CI/CD Pipeline run found for current main HEAD (${SHORT_SHA})" >&2 + echo " Most recent runs on main:" >&2 + echo "${RUN_JSON}" | jq -r '.[] | " - sha=\(.headSha[0:7]) status=\(.status) conclusion=\(.conclusion) \(.url)"' >&2 + exit 1 +fi + +STATUS=$(echo "${MATCH}" | jq -r '.status') +CONCL=$(echo "${MATCH}" | jq -r '.conclusion') +URL=$(echo "${MATCH}" | jq -r '.url') + +case "${STATUS}" in + completed) + case "${CONCL}" in + success) + echo "✅ ${REPO}: Main CI/CD Pipeline green on main (${SHORT_SHA})" + echo " ${URL}" + exit 0 + ;; + *) + echo "❌ ${REPO}: Main CI/CD Pipeline concluded '${CONCL}' on main (${SHORT_SHA})" >&2 + echo " ${URL}" >&2 + exit 1 + ;; + esac + ;; + queued|in_progress|waiting|requested|pending) + echo "⏳ ${REPO}: Main CI/CD Pipeline still '${STATUS}' on main (${SHORT_SHA})" >&2 + echo " ${URL}" >&2 + echo " This is not a failure — re-run check-ci.sh after the run completes." >&2 + exit 2 + ;; + *) + echo "❌ ${REPO}: Main CI/CD Pipeline status '${STATUS}' on main (${SHORT_SHA}) — unexpected state" >&2 + echo " ${URL}" >&2 + exit 1 + ;; +esac diff --git a/.claude/skills/release-proprietary/scripts/check-oss-sync-pr.sh b/.claude/skills/release-proprietary/scripts/check-oss-sync-pr.sh new file mode 100755 index 000000000..20cac1bae --- /dev/null +++ b/.claude/skills/release-proprietary/scripts/check-oss-sync-pr.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Verify there is no open `oss-sync` PR on the proprietary repository. +# +# Background: the sync-from-oss-repository workflow fast-forwards OSS commits +# onto proprietary main when there are no conflicts. When conflicts occur, it +# pushes branch `oss-sync` and opens a PR for manual review instead. If such +# a PR is open when we run a release, Phase 2 polling will never find the +# release commit on origin/main and will time out. +# +# Usage: check-oss-sync-pr.sh <owner/repo> +# Exit codes: +# 0 - no open oss-sync PR +# 1 - open oss-sync PR found (PR details printed to stderr) +# 2 - gh CLI / auth issue + +set -euo pipefail + +REPO="${1:?usage: check-oss-sync-pr.sh <owner/repo>}" + +if ! command -v gh >/dev/null 2>&1; then + echo "❌ gh CLI not found in PATH" >&2 + exit 2 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "❌ gh is not authenticated — run \`gh auth login\` first" >&2 + exit 2 +fi + +PR_JSON=$(gh pr list \ + --repo "${REPO}" \ + --head oss-sync \ + --state open \ + --json number,url,title) + +COUNT=$(echo "${PR_JSON}" | jq 'length') + +if [ "${COUNT}" -gt 0 ]; then + echo "❌ ${REPO}: an open oss-sync PR is blocking the auto-sync — resolve it before releasing." >&2 + echo "${PR_JSON}" | jq -r '.[] | " - PR #\(.number) \(.title) — \(.url)"' >&2 + exit 1 +fi + +echo "✅ ${REPO}: no open oss-sync PR" diff --git a/.claude/skills/release-proprietary/scripts/commit-next-cycle.sh b/.claude/skills/release-proprietary/scripts/commit-next-cycle.sh new file mode 100755 index 000000000..d03bd627c --- /dev/null +++ b/.claude/skills/release-proprietary/scripts/commit-next-cycle.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Stage CHANGELOG.MD in <repo-dir>, commit with the exact subject +# `chore: prepare next development cycle`, and push main to origin. +# +# Usage: commit-next-cycle.sh <repo-dir> + +set -euo pipefail + +REPO_DIR="${1:?usage: commit-next-cycle.sh <repo-dir>}" + +cd "${REPO_DIR}" + +git add -- CHANGELOG.MD + +if git diff --cached --quiet; then + echo "❌ ${REPO_DIR}: nothing staged for next-cycle commit — did changelog-next.mjs run?" >&2 + exit 1 +fi + +git commit -m "chore: prepare next development cycle" +git push origin main + +echo "✅ ${REPO_DIR}: pushed \"chore: prepare next development cycle\"" diff --git a/.claude/skills/release-proprietary/scripts/commit-release.sh b/.claude/skills/release-proprietary/scripts/commit-release.sh new file mode 100755 index 000000000..166a11d3d --- /dev/null +++ b/.claude/skills/release-proprietary/scripts/commit-release.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Stage the 4 release files in <repo-dir>, commit with the exact subject +# `chore: release <version>`, and push main to origin. +# +# Usage: commit-release.sh <repo-dir> <version> +# +# Never uses --no-verify. If a hook fails, fix the root cause and re-run. + +set -euo pipefail + +REPO_DIR="${1:?usage: commit-release.sh <repo-dir> <version>}" +VERSION="${2:?usage: commit-release.sh <repo-dir> <version>}" + +FILES=( + "package.json" + "apps/api/docker-package.json" + "package-lock.json" + "CHANGELOG.MD" +) + +cd "${REPO_DIR}" + +for f in "${FILES[@]}"; do + if [ ! -f "${f}" ]; then + echo "❌ ${REPO_DIR}: expected release file is missing: ${f}" >&2 + exit 1 + fi +done + +git add -- "${FILES[@]}" + +if git diff --cached --quiet; then + echo "❌ ${REPO_DIR}: nothing staged for release commit — did the bump/changelog scripts run?" >&2 + exit 1 +fi + +git commit -m "chore: release ${VERSION}" +git push origin main + +echo "✅ ${REPO_DIR}: pushed release commit \"chore: release ${VERSION}\"" diff --git a/.claude/skills/release-proprietary/scripts/tag-release.sh b/.claude/skills/release-proprietary/scripts/tag-release.sh new file mode 100755 index 000000000..56ffae1ff --- /dev/null +++ b/.claude/skills/release-proprietary/scripts/tag-release.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Create the `release/<version>` tag in <repo-dir> and push it to origin. +# +# Usage: tag-release.sh <repo-dir> <version> [sha] +# +# If <sha> is provided, the tag is created at that exact commit. The +# commit's subject is verified before tagging — but for proprietary, the +# SHA we tag is a merge commit (subject "Merge remote-tracking branch +# 'upstream/main'") whose 2nd parent is the OSS release commit. So the +# check accepts EITHER: +# - a commit whose subject is `chore: release <version>` (OSS side, or +# a direct release commit), OR +# - a merge commit whose 2nd parent's subject is `chore: release <version>` +# (proprietary sync-merge of the OSS release). +# This safety net ensures we don't tag the wrong commit when the SHA is +# passed in by hand or by a partially recovered flow. +# +# If <sha> is omitted, the tag is created at HEAD (use on the OSS repo +# immediately after pushing the release commit, before any next-cycle commit). +# HEAD's subject must be exactly `chore: release <version>` in that case. + +set -euo pipefail + +REPO_DIR="${1:?usage: tag-release.sh <repo-dir> <version> [sha]}" +VERSION="${2:?usage: tag-release.sh <repo-dir> <version> [sha]}" +SHA_ARG="${3:-}" +TAG="release/${VERSION}" +EXPECTED_SUBJECT="chore: release ${VERSION}" + +cd "${REPO_DIR}" + +# Verify that <sha> is either the release commit itself OR a merge commit +# whose 2nd parent is the release commit. Returns 0 if OK, 1 otherwise, +# and prints a human-readable description of the match on success. +verify_release_target() { + local sha=$1 + local actual_subject + actual_subject=$(git log -1 --format='%s' "${sha}") + if [ "${actual_subject}" = "${EXPECTED_SUBJECT}" ]; then + echo "direct release commit" + return 0 + fi + # Maybe it's a merge commit whose 2nd parent is the release. + local second_parent + second_parent=$(git log -1 --format='%P' "${sha}" | awk '{print $2}') + if [ -n "${second_parent}" ]; then + local parent_subject + parent_subject=$(git log -1 --format='%s' "${second_parent}") + if [ "${parent_subject}" = "${EXPECTED_SUBJECT}" ]; then + echo "merge commit (2nd parent ${second_parent:0:7} is the release)" + return 0 + fi + fi + echo "neither subject \"${actual_subject}\" nor any merged parent matches \"${EXPECTED_SUBJECT}\"" + return 1 +} + +if [ -n "${SHA_ARG}" ]; then + TARGET_SHA=$(git rev-parse --verify "${SHA_ARG}" 2>/dev/null || true) + if [ -z "${TARGET_SHA}" ]; then + echo "❌ ${REPO_DIR}: provided SHA '${SHA_ARG}' is not a valid object" >&2 + exit 1 + fi + if ! MATCH_DESC=$(verify_release_target "${TARGET_SHA}"); then + echo "❌ ${REPO_DIR}: ${TARGET_SHA:0:7} ${MATCH_DESC}" >&2 + exit 1 + fi + echo "ℹ️ ${REPO_DIR}: ${TARGET_SHA:0:7} accepted — ${MATCH_DESC}" +else + TARGET_SHA=$(git rev-parse HEAD) + ACTUAL_SUBJECT=$(git log -1 --format='%s' HEAD) + if [ "${ACTUAL_SUBJECT}" != "${EXPECTED_SUBJECT}" ]; then + echo "❌ ${REPO_DIR}: HEAD subject is \"${ACTUAL_SUBJECT}\", expected \"${EXPECTED_SUBJECT}\"" >&2 + echo " Refusing to tag a commit that isn't the release commit." >&2 + exit 1 + fi +fi + +if git rev-parse --verify --quiet "refs/tags/${TAG}" >/dev/null; then + EXISTING_SHA=$(git rev-parse "refs/tags/${TAG}") + if [ "${EXISTING_SHA}" != "${TARGET_SHA}" ]; then + echo "❌ ${REPO_DIR}: tag ${TAG} already exists at ${EXISTING_SHA:0:7}, refusing to retag at ${TARGET_SHA:0:7}" >&2 + exit 1 + fi + echo "ℹ️ ${REPO_DIR}: tag ${TAG} already exists at target — skipping creation" +else + git tag "${TAG}" "${TARGET_SHA}" +fi + +git push origin "${TAG}" + +echo "✅ ${REPO_DIR}: pushed tag ${TAG} at ${TARGET_SHA:0:7}" diff --git a/.claude/skills/release-proprietary/scripts/wait-for-oss-sync.sh b/.claude/skills/release-proprietary/scripts/wait-for-oss-sync.sh new file mode 100755 index 000000000..f234dee23 --- /dev/null +++ b/.claude/skills/release-proprietary/scripts/wait-for-oss-sync.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Poll the proprietary repo's origin/main until the OSS release commit has +# been merged in by the auto-sync workflow, then emit the SHA of the +# **proprietary merge commit** (NOT the OSS release commit itself). +# +# Why the merge commit and not the release commit: +# The OSS release commit (e.g. `chore: release 1.15.0`) is created on OSS, +# so its tree contains OSS-only files. When the proprietary auto-sync +# brings it into proprietary main, the resulting merge commit's tree +# combines the OSS release with the proprietary-only files. Tagging the +# merge commit is what we want — tagging the raw OSS commit would point +# `release/<version>` at an OSS-only tree, breaking proprietary +# deployments (the build can't find `@packmind/proprietary/*` modules). +# +# The script: +# 1. Polls origin/main for the OSS release commit (by exact subject). +# 2. Once found, locates the proprietary merge commit whose **2nd parent** +# is that OSS commit. The 2nd parent is the upstream side of the sync +# merge. Requiring the OSS release commit to be the *direct* upstream +# parent (not a grandparent) ensures the merge's tree is exactly +# "OSS at release + proprietary state at that moment" — i.e. version +# X.Y.Z, not X.Y.(Z+1)-next. +# 3. Emits the merge SHA on stdout. Progress messages go to stderr. +# +# Usage: wait-for-oss-sync.sh <prop-repo-dir> <version> [timeout-seconds] +# Defaults: timeout-seconds=600 (10 minutes) +# Exit codes: +# 0 - proprietary merge commit detected (SHA on stdout) +# 1 - timed out, or sync arrived but no suitable merge commit exists +# (e.g. fast-forward, or auto-sync batched release + next-cycle into +# one merge — in that case the operator must resolve manually). + +set -euo pipefail + +PROP_DIR="${1:?usage: wait-for-oss-sync.sh <prop-repo-dir> <version> [timeout-seconds]}" +VERSION="${2:?usage: wait-for-oss-sync.sh <prop-repo-dir> <version> [timeout-seconds]}" +TIMEOUT="${3:-600}" + +SUBJECT="chore: release ${VERSION}" +INTERVAL=5 +ELAPSED=0 + +echo "⏳ Polling ${PROP_DIR} origin/main for OSS commit \"${SUBJECT}\" and its proprietary merge (timeout ${TIMEOUT}s)…" >&2 + +while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do + git -C "${PROP_DIR}" fetch --quiet origin main + + # Step 1: find the OSS release commit on proprietary main (by exact subject) + OSS_SHA=$(git -C "${PROP_DIR}" log origin/main --format='%H%x09%s' -500 \ + | awk -F '\t' -v subj="${SUBJECT}" '$2 == subj { print $1; exit }') + + if [ -n "${OSS_SHA}" ]; then + # Step 2: find the proprietary merge commit whose 2nd parent is the OSS release. + # %P emits parents space-separated; on a merge commit, $2 is parent1 + # (prior proprietary tip) and $3 is parent2 (the upstream side). + MERGE_SHA=$(git -C "${PROP_DIR}" log origin/main --merges --format='%H %P' -500 \ + | awk -v oss="${OSS_SHA}" '$3 == oss { print $1; exit }') + + if [ -n "${MERGE_SHA}" ]; then + echo "✅ Found proprietary merge commit: ${MERGE_SHA:0:7} (merges OSS release ${OSS_SHA:0:7})" >&2 + echo "${MERGE_SHA}" + exit 0 + fi + + echo "⚠️ OSS release commit ${OSS_SHA:0:7} is on proprietary origin/main, but no merge commit has it as its direct upstream (2nd) parent." >&2 + echo " This can happen if the auto-sync fast-forwarded (unlikely if proprietary has local commits) or batched the release with later OSS commits into one merge." >&2 + echo " Proprietary cannot be tagged automatically — investigate the sync history before proceeding." >&2 + exit 1 + fi + + sleep "${INTERVAL}" + ELAPSED=$((ELAPSED + INTERVAL)) +done + +echo "❌ Timed out after ${TIMEOUT}s waiting for OSS release commit \"${SUBJECT}\" to land on proprietary origin/main" >&2 +echo " Check the sync-from-oss-repository workflow status and any pending oss-sync PR." >&2 +exit 1 diff --git a/.claude/skills/scripts/__init__.py b/.claude/skills/scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/.claude/skills/scripts/aggregate_benchmark.py b/.claude/skills/scripts/aggregate_benchmark.py new file mode 100755 index 000000000..3e66e8c10 --- /dev/null +++ b/.claude/skills/scripts/aggregate_benchmark.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +""" +Aggregate individual run results into benchmark summary statistics. + +Reads grading.json files from run directories and produces: +- run_summary with mean, stddev, min, max for each metric +- delta between with_skill and without_skill configurations + +Usage: + python aggregate_benchmark.py <benchmark_dir> + +Example: + python aggregate_benchmark.py benchmarks/2026-01-15T10-30-00/ + +The script supports two directory layouts: + + Workspace layout (from skill-creator iterations): + <benchmark_dir>/ + └── eval-N/ + ├── with_skill/ + │ ├── run-1/grading.json + │ └── run-2/grading.json + └── without_skill/ + ├── run-1/grading.json + └── run-2/grading.json + + Legacy layout (with runs/ subdirectory): + <benchmark_dir>/ + └── runs/ + └── eval-N/ + ├── with_skill/ + │ └── run-1/grading.json + └── without_skill/ + └── run-1/grading.json +""" + +import argparse +import json +import math +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def calculate_stats(values: list[float]) -> dict: + """Calculate mean, stddev, min, max for a list of values.""" + if not values: + return {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0} + + n = len(values) + mean = sum(values) / n + + if n > 1: + variance = sum((x - mean) ** 2 for x in values) / (n - 1) + stddev = math.sqrt(variance) + else: + stddev = 0.0 + + return { + "mean": round(mean, 4), + "stddev": round(stddev, 4), + "min": round(min(values), 4), + "max": round(max(values), 4) + } + + +def load_run_results(benchmark_dir: Path) -> dict: + """ + Load all run results from a benchmark directory. + + Returns dict keyed by config name (e.g. "with_skill"/"without_skill", + or "new_skill"/"old_skill"), each containing a list of run results. + """ + # Support both layouts: eval dirs directly under benchmark_dir, or under runs/ + runs_dir = benchmark_dir / "runs" + if runs_dir.exists(): + search_dir = runs_dir + elif list(benchmark_dir.glob("eval-*")): + search_dir = benchmark_dir + else: + print(f"No eval directories found in {benchmark_dir} or {benchmark_dir / 'runs'}") + return {} + + results: dict[str, list] = {} + + for eval_idx, eval_dir in enumerate(sorted(search_dir.glob("eval-*"))): + metadata_path = eval_dir / "eval_metadata.json" + if metadata_path.exists(): + try: + with open(metadata_path) as mf: + eval_id = json.load(mf).get("eval_id", eval_idx) + except (json.JSONDecodeError, OSError): + eval_id = eval_idx + else: + try: + eval_id = int(eval_dir.name.split("-")[1]) + except ValueError: + eval_id = eval_idx + + # Discover config directories dynamically rather than hardcoding names + for config_dir in sorted(eval_dir.iterdir()): + if not config_dir.is_dir(): + continue + # Skip non-config directories (inputs, outputs, etc.) + if not list(config_dir.glob("run-*")): + continue + config = config_dir.name + if config not in results: + results[config] = [] + + for run_dir in sorted(config_dir.glob("run-*")): + run_number = int(run_dir.name.split("-")[1]) + grading_file = run_dir / "grading.json" + + if not grading_file.exists(): + print(f"Warning: grading.json not found in {run_dir}") + continue + + try: + with open(grading_file) as f: + grading = json.load(f) + except json.JSONDecodeError as e: + print(f"Warning: Invalid JSON in {grading_file}: {e}") + continue + + # Extract metrics + result = { + "eval_id": eval_id, + "run_number": run_number, + "pass_rate": grading.get("summary", {}).get("pass_rate", 0.0), + "passed": grading.get("summary", {}).get("passed", 0), + "failed": grading.get("summary", {}).get("failed", 0), + "total": grading.get("summary", {}).get("total", 0), + } + + # Extract timing — check grading.json first, then sibling timing.json + timing = grading.get("timing", {}) + result["time_seconds"] = timing.get("total_duration_seconds", 0.0) + timing_file = run_dir / "timing.json" + if result["time_seconds"] == 0.0 and timing_file.exists(): + try: + with open(timing_file) as tf: + timing_data = json.load(tf) + result["time_seconds"] = timing_data.get("total_duration_seconds", 0.0) + result["tokens"] = timing_data.get("total_tokens", 0) + except json.JSONDecodeError: + pass + + # Extract metrics if available + metrics = grading.get("execution_metrics", {}) + result["tool_calls"] = metrics.get("total_tool_calls", 0) + if not result.get("tokens"): + result["tokens"] = metrics.get("output_chars", 0) + result["errors"] = metrics.get("errors_encountered", 0) + + # Extract expectations — viewer requires fields: text, passed, evidence + raw_expectations = grading.get("expectations", []) + for exp in raw_expectations: + if "text" not in exp or "passed" not in exp: + print(f"Warning: expectation in {grading_file} missing required fields (text, passed, evidence): {exp}") + result["expectations"] = raw_expectations + + # Extract notes from user_notes_summary + notes_summary = grading.get("user_notes_summary", {}) + notes = [] + notes.extend(notes_summary.get("uncertainties", [])) + notes.extend(notes_summary.get("needs_review", [])) + notes.extend(notes_summary.get("workarounds", [])) + result["notes"] = notes + + results[config].append(result) + + return results + + +def aggregate_results(results: dict) -> dict: + """ + Aggregate run results into summary statistics. + + Returns run_summary with stats for each configuration and delta. + """ + run_summary = {} + configs = list(results.keys()) + + for config in configs: + runs = results.get(config, []) + + if not runs: + run_summary[config] = { + "pass_rate": {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0}, + "time_seconds": {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0}, + "tokens": {"mean": 0, "stddev": 0, "min": 0, "max": 0} + } + continue + + pass_rates = [r["pass_rate"] for r in runs] + times = [r["time_seconds"] for r in runs] + tokens = [r.get("tokens", 0) for r in runs] + + run_summary[config] = { + "pass_rate": calculate_stats(pass_rates), + "time_seconds": calculate_stats(times), + "tokens": calculate_stats(tokens) + } + + # Calculate delta between the first two configs (if two exist) + if len(configs) >= 2: + primary = run_summary.get(configs[0], {}) + baseline = run_summary.get(configs[1], {}) + else: + primary = run_summary.get(configs[0], {}) if configs else {} + baseline = {} + + delta_pass_rate = primary.get("pass_rate", {}).get("mean", 0) - baseline.get("pass_rate", {}).get("mean", 0) + delta_time = primary.get("time_seconds", {}).get("mean", 0) - baseline.get("time_seconds", {}).get("mean", 0) + delta_tokens = primary.get("tokens", {}).get("mean", 0) - baseline.get("tokens", {}).get("mean", 0) + + run_summary["delta"] = { + "pass_rate": f"{delta_pass_rate:+.2f}", + "time_seconds": f"{delta_time:+.1f}", + "tokens": f"{delta_tokens:+.0f}" + } + + return run_summary + + +def generate_benchmark(benchmark_dir: Path, skill_name: str = "", skill_path: str = "") -> dict: + """ + Generate complete benchmark.json from run results. + """ + results = load_run_results(benchmark_dir) + run_summary = aggregate_results(results) + + # Build runs array for benchmark.json + runs = [] + for config in results: + for result in results[config]: + runs.append({ + "eval_id": result["eval_id"], + "configuration": config, + "run_number": result["run_number"], + "result": { + "pass_rate": result["pass_rate"], + "passed": result["passed"], + "failed": result["failed"], + "total": result["total"], + "time_seconds": result["time_seconds"], + "tokens": result.get("tokens", 0), + "tool_calls": result.get("tool_calls", 0), + "errors": result.get("errors", 0) + }, + "expectations": result["expectations"], + "notes": result["notes"] + }) + + # Determine eval IDs from results + eval_ids = sorted(set( + r["eval_id"] + for config in results.values() + for r in config + )) + + benchmark = { + "metadata": { + "skill_name": skill_name or "<skill-name>", + "skill_path": skill_path or "<path/to/skill>", + "executor_model": "<model-name>", + "analyzer_model": "<model-name>", + "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "evals_run": eval_ids, + "runs_per_configuration": 3 + }, + "runs": runs, + "run_summary": run_summary, + "notes": [] # To be filled by analyzer + } + + return benchmark + + +def generate_markdown(benchmark: dict) -> str: + """Generate human-readable benchmark.md from benchmark data.""" + metadata = benchmark["metadata"] + run_summary = benchmark["run_summary"] + + # Determine config names (excluding "delta") + configs = [k for k in run_summary if k != "delta"] + config_a = configs[0] if len(configs) >= 1 else "config_a" + config_b = configs[1] if len(configs) >= 2 else "config_b" + label_a = config_a.replace("_", " ").title() + label_b = config_b.replace("_", " ").title() + + lines = [ + f"# Skill Benchmark: {metadata['skill_name']}", + "", + f"**Model**: {metadata['executor_model']}", + f"**Date**: {metadata['timestamp']}", + f"**Evals**: {', '.join(map(str, metadata['evals_run']))} ({metadata['runs_per_configuration']} runs each per configuration)", + "", + "## Summary", + "", + f"| Metric | {label_a} | {label_b} | Delta |", + "|--------|------------|---------------|-------|", + ] + + a_summary = run_summary.get(config_a, {}) + b_summary = run_summary.get(config_b, {}) + delta = run_summary.get("delta", {}) + + # Format pass rate + a_pr = a_summary.get("pass_rate", {}) + b_pr = b_summary.get("pass_rate", {}) + lines.append(f"| Pass Rate | {a_pr.get('mean', 0)*100:.0f}% ± {a_pr.get('stddev', 0)*100:.0f}% | {b_pr.get('mean', 0)*100:.0f}% ± {b_pr.get('stddev', 0)*100:.0f}% | {delta.get('pass_rate', '—')} |") + + # Format time + a_time = a_summary.get("time_seconds", {}) + b_time = b_summary.get("time_seconds", {}) + lines.append(f"| Time | {a_time.get('mean', 0):.1f}s ± {a_time.get('stddev', 0):.1f}s | {b_time.get('mean', 0):.1f}s ± {b_time.get('stddev', 0):.1f}s | {delta.get('time_seconds', '—')}s |") + + # Format tokens + a_tokens = a_summary.get("tokens", {}) + b_tokens = b_summary.get("tokens", {}) + lines.append(f"| Tokens | {a_tokens.get('mean', 0):.0f} ± {a_tokens.get('stddev', 0):.0f} | {b_tokens.get('mean', 0):.0f} ± {b_tokens.get('stddev', 0):.0f} | {delta.get('tokens', '—')} |") + + # Notes section + if benchmark.get("notes"): + lines.extend([ + "", + "## Notes", + "" + ]) + for note in benchmark["notes"]: + lines.append(f"- {note}") + + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser( + description="Aggregate benchmark run results into summary statistics" + ) + parser.add_argument( + "benchmark_dir", + type=Path, + help="Path to the benchmark directory" + ) + parser.add_argument( + "--skill-name", + default="", + help="Name of the skill being benchmarked" + ) + parser.add_argument( + "--skill-path", + default="", + help="Path to the skill being benchmarked" + ) + parser.add_argument( + "--output", "-o", + type=Path, + help="Output path for benchmark.json (default: <benchmark_dir>/benchmark.json)" + ) + + args = parser.parse_args() + + if not args.benchmark_dir.exists(): + print(f"Directory not found: {args.benchmark_dir}") + sys.exit(1) + + # Generate benchmark + benchmark = generate_benchmark(args.benchmark_dir, args.skill_name, args.skill_path) + + # Determine output paths + output_json = args.output or (args.benchmark_dir / "benchmark.json") + output_md = output_json.with_suffix(".md") + + # Write benchmark.json + with open(output_json, "w") as f: + json.dump(benchmark, f, indent=2) + print(f"Generated: {output_json}") + + # Write benchmark.md + markdown = generate_markdown(benchmark) + with open(output_md, "w") as f: + f.write(markdown) + print(f"Generated: {output_md}") + + # Print summary + run_summary = benchmark["run_summary"] + configs = [k for k in run_summary if k != "delta"] + delta = run_summary.get("delta", {}) + + print(f"\nSummary:") + for config in configs: + pr = run_summary[config]["pass_rate"]["mean"] + label = config.replace("_", " ").title() + print(f" {label}: {pr*100:.1f}% pass rate") + print(f" Delta: {delta.get('pass_rate', '—')}") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/scripts/generate_report.py b/.claude/skills/scripts/generate_report.py new file mode 100755 index 000000000..959e30a00 --- /dev/null +++ b/.claude/skills/scripts/generate_report.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Generate an HTML report from run_loop.py output. + +Takes the JSON output from run_loop.py and generates a visual HTML report +showing each description attempt with check/x for each test case. +Distinguishes between train and test queries. +""" + +import argparse +import html +import json +import sys +from pathlib import Path + + +def generate_html(data: dict, auto_refresh: bool = False, skill_name: str = "") -> str: + """Generate HTML report from loop output data. If auto_refresh is True, adds a meta refresh tag.""" + history = data.get("history", []) + holdout = data.get("holdout", 0) + title_prefix = html.escape(skill_name + " \u2014 ") if skill_name else "" + + # Get all unique queries from train and test sets, with should_trigger info + train_queries: list[dict] = [] + test_queries: list[dict] = [] + if history: + for r in history[0].get("train_results", history[0].get("results", [])): + train_queries.append({"query": r["query"], "should_trigger": r.get("should_trigger", True)}) + if history[0].get("test_results"): + for r in history[0].get("test_results", []): + test_queries.append({"query": r["query"], "should_trigger": r.get("should_trigger", True)}) + + refresh_tag = ' <meta http-equiv="refresh" content="5">\n' if auto_refresh else "" + + html_parts = ["""<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +""" + refresh_tag + """ <title>""" + title_prefix + """Skill Description Optimization + + + + + + +

""" + title_prefix + """Skill Description Optimization

+
+ Optimizing your skill's description. This page updates automatically as Claude tests different versions of your skill's description. Each row is an iteration — a new description attempt. The columns show test queries: green checkmarks mean the skill triggered correctly (or correctly didn't trigger), red crosses mean it got it wrong. The "Train" score shows performance on queries used to improve the description; the "Test" score shows performance on held-out queries the optimizer hasn't seen. When it's done, Claude will apply the best-performing description to your skill. +
+"""] + + # Summary section + best_test_score = data.get('best_test_score') + best_train_score = data.get('best_train_score') + html_parts.append(f""" +
+

Original: {html.escape(data.get('original_description', 'N/A'))}

+

Best: {html.escape(data.get('best_description', 'N/A'))}

+

Best Score: {data.get('best_score', 'N/A')} {'(test)' if best_test_score else '(train)'}

+

Iterations: {data.get('iterations_run', 0)} | Train: {data.get('train_size', '?')} | Test: {data.get('test_size', '?')}

+
+""") + + # Legend + html_parts.append(""" +
+ Query columns: + Should trigger + Should NOT trigger + Train + Test +
+""") + + # Table header + html_parts.append(""" +
+ + + + + + + +""") + + # Add column headers for train queries + for qinfo in train_queries: + polarity = "positive-col" if qinfo["should_trigger"] else "negative-col" + html_parts.append(f' \n') + + # Add column headers for test queries (different color) + for qinfo in test_queries: + polarity = "positive-col" if qinfo["should_trigger"] else "negative-col" + html_parts.append(f' \n') + + html_parts.append(""" + + +""") + + # Find best iteration for highlighting + if test_queries: + best_iter = max(history, key=lambda h: h.get("test_passed") or 0).get("iteration") + else: + best_iter = max(history, key=lambda h: h.get("train_passed", h.get("passed", 0))).get("iteration") + + # Add rows for each iteration + for h in history: + iteration = h.get("iteration", "?") + train_passed = h.get("train_passed", h.get("passed", 0)) + train_total = h.get("train_total", h.get("total", 0)) + test_passed = h.get("test_passed") + test_total = h.get("test_total") + description = h.get("description", "") + train_results = h.get("train_results", h.get("results", [])) + test_results = h.get("test_results", []) + + # Create lookups for results by query + train_by_query = {r["query"]: r for r in train_results} + test_by_query = {r["query"]: r for r in test_results} if test_results else {} + + # Compute aggregate correct/total runs across all retries + def aggregate_runs(results: list[dict]) -> tuple[int, int]: + correct = 0 + total = 0 + for r in results: + runs = r.get("runs", 0) + triggers = r.get("triggers", 0) + total += runs + if r.get("should_trigger", True): + correct += triggers + else: + correct += runs - triggers + return correct, total + + train_correct, train_runs = aggregate_runs(train_results) + test_correct, test_runs = aggregate_runs(test_results) + + # Determine score classes + def score_class(correct: int, total: int) -> str: + if total > 0: + ratio = correct / total + if ratio >= 0.8: + return "score-good" + elif ratio >= 0.5: + return "score-ok" + return "score-bad" + + train_class = score_class(train_correct, train_runs) + test_class = score_class(test_correct, test_runs) + + row_class = "best-row" if iteration == best_iter else "" + + html_parts.append(f""" + + + + +""") + + # Add result for each train query + for qinfo in train_queries: + r = train_by_query.get(qinfo["query"], {}) + did_pass = r.get("pass", False) + triggers = r.get("triggers", 0) + runs = r.get("runs", 0) + + icon = "✓" if did_pass else "✗" + css_class = "pass" if did_pass else "fail" + + html_parts.append(f' \n') + + # Add result for each test query (with different background) + for qinfo in test_queries: + r = test_by_query.get(qinfo["query"], {}) + did_pass = r.get("pass", False) + triggers = r.get("triggers", 0) + runs = r.get("runs", 0) + + icon = "✓" if did_pass else "✗" + css_class = "pass" if did_pass else "fail" + + html_parts.append(f' \n') + + html_parts.append(" \n") + + html_parts.append(""" +
IterTrainTestDescription{html.escape(qinfo["query"])}{html.escape(qinfo["query"])}
{iteration}{train_correct}/{train_runs}{test_correct}/{test_runs}{html.escape(description)}{icon}{triggers}/{runs}{icon}{triggers}/{runs}
+
+""") + + html_parts.append(""" + + +""") + + return "".join(html_parts) + + +def main(): + parser = argparse.ArgumentParser(description="Generate HTML report from run_loop output") + parser.add_argument("input", help="Path to JSON output from run_loop.py (or - for stdin)") + parser.add_argument("-o", "--output", default=None, help="Output HTML file (default: stdout)") + parser.add_argument("--skill-name", default="", help="Skill name to include in the report title") + args = parser.parse_args() + + if args.input == "-": + data = json.load(sys.stdin) + else: + data = json.loads(Path(args.input).read_text()) + + html_output = generate_html(data, skill_name=args.skill_name) + + if args.output: + Path(args.output).write_text(html_output) + print(f"Report written to {args.output}", file=sys.stderr) + else: + print(html_output) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/scripts/improve_description.py b/.claude/skills/scripts/improve_description.py new file mode 100755 index 000000000..06bcec761 --- /dev/null +++ b/.claude/skills/scripts/improve_description.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Improve a skill description based on eval results. + +Takes eval results (from run_eval.py) and generates an improved description +by calling `claude -p` as a subprocess (same auth pattern as run_eval.py — +uses the session's Claude Code auth, no separate ANTHROPIC_API_KEY needed). +""" + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +from scripts.utils import parse_skill_md + + +def _call_claude(prompt: str, model: str | None, timeout: int = 300) -> str: + """Run `claude -p` with the prompt on stdin and return the text response. + + Prompt goes over stdin (not argv) because it embeds the full SKILL.md + body and can easily exceed comfortable argv length. + """ + cmd = ["claude", "-p", "--output-format", "text"] + if model: + cmd.extend(["--model", model]) + + # Remove CLAUDECODE env var to allow nesting claude -p inside a + # Claude Code session. The guard is for interactive terminal conflicts; + # programmatic subprocess usage is safe. Same pattern as run_eval.py. + env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"} + + result = subprocess.run( + cmd, + input=prompt, + capture_output=True, + text=True, + env=env, + timeout=timeout, + ) + if result.returncode != 0: + raise RuntimeError( + f"claude -p exited {result.returncode}\nstderr: {result.stderr}" + ) + return result.stdout + + +def improve_description( + skill_name: str, + skill_content: str, + current_description: str, + eval_results: dict, + history: list[dict], + model: str, + test_results: dict | None = None, + log_dir: Path | None = None, + iteration: int | None = None, +) -> str: + """Call Claude to improve the description based on eval results.""" + failed_triggers = [ + r for r in eval_results["results"] + if r["should_trigger"] and not r["pass"] + ] + false_triggers = [ + r for r in eval_results["results"] + if not r["should_trigger"] and not r["pass"] + ] + + # Build scores summary + train_score = f"{eval_results['summary']['passed']}/{eval_results['summary']['total']}" + if test_results: + test_score = f"{test_results['summary']['passed']}/{test_results['summary']['total']}" + scores_summary = f"Train: {train_score}, Test: {test_score}" + else: + scores_summary = f"Train: {train_score}" + + prompt = f"""You are optimizing a skill description for a Claude Code skill called "{skill_name}". A "skill" is sort of like a prompt, but with progressive disclosure -- there's a title and description that Claude sees when deciding whether to use the skill, and then if it does use the skill, it reads the .md file which has lots more details and potentially links to other resources in the skill folder like helper files and scripts and additional documentation or examples. + +The description appears in Claude's "available_skills" list. When a user sends a query, Claude decides whether to invoke the skill based solely on the title and on this description. Your goal is to write a description that triggers for relevant queries, and doesn't trigger for irrelevant ones. + +Here's the current description: + +"{current_description}" + + +Current scores ({scores_summary}): + +""" + if failed_triggers: + prompt += "FAILED TO TRIGGER (should have triggered but didn't):\n" + for r in failed_triggers: + prompt += f' - "{r["query"]}" (triggered {r["triggers"]}/{r["runs"]} times)\n' + prompt += "\n" + + if false_triggers: + prompt += "FALSE TRIGGERS (triggered but shouldn't have):\n" + for r in false_triggers: + prompt += f' - "{r["query"]}" (triggered {r["triggers"]}/{r["runs"]} times)\n' + prompt += "\n" + + if history: + prompt += "PREVIOUS ATTEMPTS (do NOT repeat these — try something structurally different):\n\n" + for h in history: + train_s = f"{h.get('train_passed', h.get('passed', 0))}/{h.get('train_total', h.get('total', 0))}" + test_s = f"{h.get('test_passed', '?')}/{h.get('test_total', '?')}" if h.get('test_passed') is not None else None + score_str = f"train={train_s}" + (f", test={test_s}" if test_s else "") + prompt += f'\n' + prompt += f'Description: "{h["description"]}"\n' + if "results" in h: + prompt += "Train results:\n" + for r in h["results"]: + status = "PASS" if r["pass"] else "FAIL" + prompt += f' [{status}] "{r["query"][:80]}" (triggered {r["triggers"]}/{r["runs"]})\n' + if h.get("note"): + prompt += f'Note: {h["note"]}\n' + prompt += "\n\n" + + prompt += f""" + +Skill content (for context on what the skill does): + +{skill_content} + + +Based on the failures, write a new and improved description that is more likely to trigger correctly. When I say "based on the failures", it's a bit of a tricky line to walk because we don't want to overfit to the specific cases you're seeing. So what I DON'T want you to do is produce an ever-expanding list of specific queries that this skill should or shouldn't trigger for. Instead, try to generalize from the failures to broader categories of user intent and situations where this skill would be useful or not useful. The reason for this is twofold: + +1. Avoid overfitting +2. The list might get loooong and it's injected into ALL queries and there might be a lot of skills, so we don't want to blow too much space on any given description. + +Concretely, your description should not be more than about 100-200 words, even if that comes at the cost of accuracy. There is a hard limit of 1024 characters — descriptions over that will be truncated, so stay comfortably under it. + +Here are some tips that we've found to work well in writing these descriptions: +- The skill should be phrased in the imperative -- "Use this skill for" rather than "this skill does" +- The skill description should focus on the user's intent, what they are trying to achieve, vs. the implementation details of how the skill works. +- The description competes with other skills for Claude's attention — make it distinctive and immediately recognizable. +- If you're getting lots of failures after repeated attempts, change things up. Try different sentence structures or wordings. + +I'd encourage you to be creative and mix up the style in different iterations since you'll have multiple opportunities to try different approaches and we'll just grab the highest-scoring one at the end. + +Please respond with only the new description text in tags, nothing else.""" + + text = _call_claude(prompt, model) + + match = re.search(r"(.*?)", text, re.DOTALL) + description = match.group(1).strip().strip('"') if match else text.strip().strip('"') + + transcript: dict = { + "iteration": iteration, + "prompt": prompt, + "response": text, + "parsed_description": description, + "char_count": len(description), + "over_limit": len(description) > 1024, + } + + # Safety net: the prompt already states the 1024-char hard limit, but if + # the model blew past it anyway, make one fresh single-turn call that + # quotes the too-long version and asks for a shorter rewrite. (The old + # SDK path did this as a true multi-turn; `claude -p` is one-shot, so we + # inline the prior output into the new prompt instead.) + if len(description) > 1024: + shorten_prompt = ( + f"{prompt}\n\n" + f"---\n\n" + f"A previous attempt produced this description, which at " + f"{len(description)} characters is over the 1024-character hard limit:\n\n" + f'"{description}"\n\n' + f"Rewrite it to be under 1024 characters while keeping the most " + f"important trigger words and intent coverage. Respond with only " + f"the new description in tags." + ) + shorten_text = _call_claude(shorten_prompt, model) + match = re.search(r"(.*?)", shorten_text, re.DOTALL) + shortened = match.group(1).strip().strip('"') if match else shorten_text.strip().strip('"') + + transcript["rewrite_prompt"] = shorten_prompt + transcript["rewrite_response"] = shorten_text + transcript["rewrite_description"] = shortened + transcript["rewrite_char_count"] = len(shortened) + description = shortened + + transcript["final_description"] = description + + if log_dir: + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / f"improve_iter_{iteration or 'unknown'}.json" + log_file.write_text(json.dumps(transcript, indent=2)) + + return description + + +def main(): + parser = argparse.ArgumentParser(description="Improve a skill description based on eval results") + parser.add_argument("--eval-results", required=True, help="Path to eval results JSON (from run_eval.py)") + parser.add_argument("--skill-path", required=True, help="Path to skill directory") + parser.add_argument("--history", default=None, help="Path to history JSON (previous attempts)") + parser.add_argument("--model", required=True, help="Model for improvement") + parser.add_argument("--verbose", action="store_true", help="Print thinking to stderr") + args = parser.parse_args() + + skill_path = Path(args.skill_path) + if not (skill_path / "SKILL.md").exists(): + print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr) + sys.exit(1) + + eval_results = json.loads(Path(args.eval_results).read_text()) + history = [] + if args.history: + history = json.loads(Path(args.history).read_text()) + + name, _, content = parse_skill_md(skill_path) + current_description = eval_results["description"] + + if args.verbose: + print(f"Current: {current_description}", file=sys.stderr) + print(f"Score: {eval_results['summary']['passed']}/{eval_results['summary']['total']}", file=sys.stderr) + + new_description = improve_description( + skill_name=name, + skill_content=content, + current_description=current_description, + eval_results=eval_results, + history=history, + model=args.model, + ) + + if args.verbose: + print(f"Improved: {new_description}", file=sys.stderr) + + # Output as JSON with both the new description and updated history + output = { + "description": new_description, + "history": history + [{ + "description": current_description, + "passed": eval_results["summary"]["passed"], + "failed": eval_results["summary"]["failed"], + "total": eval_results["summary"]["total"], + "results": eval_results["results"], + }], + } + print(json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/scripts/package_skill.py b/.claude/skills/scripts/package_skill.py new file mode 100755 index 000000000..f48eac444 --- /dev/null +++ b/.claude/skills/scripts/package_skill.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Skill Packager - Creates a distributable .skill file of a skill folder + +Usage: + python utils/package_skill.py [output-directory] + +Example: + python utils/package_skill.py skills/public/my-skill + python utils/package_skill.py skills/public/my-skill ./dist +""" + +import fnmatch +import sys +import zipfile +from pathlib import Path +from scripts.quick_validate import validate_skill + +# Patterns to exclude when packaging skills. +EXCLUDE_DIRS = {"__pycache__", "node_modules"} +EXCLUDE_GLOBS = {"*.pyc"} +EXCLUDE_FILES = {".DS_Store"} +# Directories excluded only at the skill root (not when nested deeper). +ROOT_EXCLUDE_DIRS = {"evals"} + + +def should_exclude(rel_path: Path) -> bool: + """Check if a path should be excluded from packaging.""" + parts = rel_path.parts + if any(part in EXCLUDE_DIRS for part in parts): + return True + # rel_path is relative to skill_path.parent, so parts[0] is the skill + # folder name and parts[1] (if present) is the first subdir. + if len(parts) > 1 and parts[1] in ROOT_EXCLUDE_DIRS: + return True + name = rel_path.name + if name in EXCLUDE_FILES: + return True + return any(fnmatch.fnmatch(name, pat) for pat in EXCLUDE_GLOBS) + + +def package_skill(skill_path, output_dir=None): + """ + Package a skill folder into a .skill file. + + Args: + skill_path: Path to the skill folder + output_dir: Optional output directory for the .skill file (defaults to current directory) + + Returns: + Path to the created .skill file, or None if error + """ + skill_path = Path(skill_path).resolve() + + # Validate skill folder exists + if not skill_path.exists(): + print(f"❌ Error: Skill folder not found: {skill_path}") + return None + + if not skill_path.is_dir(): + print(f"❌ Error: Path is not a directory: {skill_path}") + return None + + # Validate SKILL.md exists + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + print(f"❌ Error: SKILL.md not found in {skill_path}") + return None + + # Run validation before packaging + print("🔍 Validating skill...") + valid, message = validate_skill(skill_path) + if not valid: + print(f"❌ Validation failed: {message}") + print(" Please fix the validation errors before packaging.") + return None + print(f"✅ {message}\n") + + # Determine output location + skill_name = skill_path.name + if output_dir: + output_path = Path(output_dir).resolve() + output_path.mkdir(parents=True, exist_ok=True) + else: + output_path = Path.cwd() + + skill_filename = output_path / f"{skill_name}.skill" + + # Create the .skill file (zip format) + try: + with zipfile.ZipFile(skill_filename, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Walk through the skill directory, excluding build artifacts + for file_path in skill_path.rglob('*'): + if not file_path.is_file(): + continue + arcname = file_path.relative_to(skill_path.parent) + if should_exclude(arcname): + print(f" Skipped: {arcname}") + continue + zipf.write(file_path, arcname) + print(f" Added: {arcname}") + + print(f"\n✅ Successfully packaged skill to: {skill_filename}") + return skill_filename + + except Exception as e: + print(f"❌ Error creating .skill file: {e}") + return None + + +def main(): + if len(sys.argv) < 2: + print("Usage: python utils/package_skill.py [output-directory]") + print("\nExample:") + print(" python utils/package_skill.py skills/public/my-skill") + print(" python utils/package_skill.py skills/public/my-skill ./dist") + sys.exit(1) + + skill_path = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else None + + print(f"📦 Packaging skill: {skill_path}") + if output_dir: + print(f" Output directory: {output_dir}") + print() + + result = package_skill(skill_path, output_dir) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/scripts/quick_validate.py b/.claude/skills/scripts/quick_validate.py new file mode 100755 index 000000000..ed8e1dddc --- /dev/null +++ b/.claude/skills/scripts/quick_validate.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +Quick validation script for skills - minimal version +""" + +import sys +import os +import re +import yaml +from pathlib import Path + +def validate_skill(skill_path): + """Basic validation of a skill""" + skill_path = Path(skill_path) + + # Check SKILL.md exists + skill_md = skill_path / 'SKILL.md' + if not skill_md.exists(): + return False, "SKILL.md not found" + + # Read and validate frontmatter + content = skill_md.read_text() + if not content.startswith('---'): + return False, "No YAML frontmatter found" + + # Extract frontmatter + match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) + if not match: + return False, "Invalid frontmatter format" + + frontmatter_text = match.group(1) + + # Parse YAML frontmatter + try: + frontmatter = yaml.safe_load(frontmatter_text) + if not isinstance(frontmatter, dict): + return False, "Frontmatter must be a YAML dictionary" + except yaml.YAMLError as e: + return False, f"Invalid YAML in frontmatter: {e}" + + # Define allowed properties + ALLOWED_PROPERTIES = {'name', 'description', 'license', 'allowed-tools', 'metadata', 'compatibility'} + + # Check for unexpected properties (excluding nested keys under metadata) + unexpected_keys = set(frontmatter.keys()) - ALLOWED_PROPERTIES + if unexpected_keys: + return False, ( + f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}. " + f"Allowed properties are: {', '.join(sorted(ALLOWED_PROPERTIES))}" + ) + + # Check required fields + if 'name' not in frontmatter: + return False, "Missing 'name' in frontmatter" + if 'description' not in frontmatter: + return False, "Missing 'description' in frontmatter" + + # Extract name for validation + name = frontmatter.get('name', '') + if not isinstance(name, str): + return False, f"Name must be a string, got {type(name).__name__}" + name = name.strip() + if name: + # Check naming convention (kebab-case: lowercase with hyphens) + if not re.match(r'^[a-z0-9-]+$', name): + return False, f"Name '{name}' should be kebab-case (lowercase letters, digits, and hyphens only)" + if name.startswith('-') or name.endswith('-') or '--' in name: + return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens" + # Check name length (max 64 characters per spec) + if len(name) > 64: + return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters." + + # Extract and validate description + description = frontmatter.get('description', '') + if not isinstance(description, str): + return False, f"Description must be a string, got {type(description).__name__}" + description = description.strip() + if description: + # Check for angle brackets + if '<' in description or '>' in description: + return False, "Description cannot contain angle brackets (< or >)" + # Check description length (max 1024 characters per spec) + if len(description) > 1024: + return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters." + + # Validate compatibility field if present (optional) + compatibility = frontmatter.get('compatibility', '') + if compatibility: + if not isinstance(compatibility, str): + return False, f"Compatibility must be a string, got {type(compatibility).__name__}" + if len(compatibility) > 500: + return False, f"Compatibility is too long ({len(compatibility)} characters). Maximum is 500 characters." + + return True, "Skill is valid!" + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python quick_validate.py ") + sys.exit(1) + + valid, message = validate_skill(sys.argv[1]) + print(message) + sys.exit(0 if valid else 1) \ No newline at end of file diff --git a/.claude/skills/scripts/run_eval.py b/.claude/skills/scripts/run_eval.py new file mode 100755 index 000000000..e58c70bea --- /dev/null +++ b/.claude/skills/scripts/run_eval.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""Run trigger evaluation for a skill description. + +Tests whether a skill's description causes Claude to trigger (read the skill) +for a set of queries. Outputs results as JSON. +""" + +import argparse +import json +import os +import select +import subprocess +import sys +import time +import uuid +from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path + +from scripts.utils import parse_skill_md + + +def find_project_root() -> Path: + """Find the project root by walking up from cwd looking for .claude/. + + Mimics how Claude Code discovers its project root, so the command file + we create ends up where claude -p will look for it. + """ + current = Path.cwd() + for parent in [current, *current.parents]: + if (parent / ".claude").is_dir(): + return parent + return current + + +def run_single_query( + query: str, + skill_name: str, + skill_description: str, + timeout: int, + project_root: str, + model: str | None = None, +) -> bool: + """Run a single query and return whether the skill was triggered. + + Creates a command file in .claude/commands/ so it appears in Claude's + available_skills list, then runs `claude -p` with the raw query. + Uses --include-partial-messages to detect triggering early from + stream events (content_block_start) rather than waiting for the + full assistant message, which only arrives after tool execution. + """ + unique_id = uuid.uuid4().hex[:8] + clean_name = f"{skill_name}-skill-{unique_id}" + project_commands_dir = Path(project_root) / ".claude" / "commands" + command_file = project_commands_dir / f"{clean_name}.md" + + try: + project_commands_dir.mkdir(parents=True, exist_ok=True) + # Use YAML block scalar to avoid breaking on quotes in description + indented_desc = "\n ".join(skill_description.split("\n")) + command_content = ( + f"---\n" + f"description: |\n" + f" {indented_desc}\n" + f"---\n\n" + f"# {skill_name}\n\n" + f"This skill handles: {skill_description}\n" + ) + command_file.write_text(command_content) + + cmd = [ + "claude", + "-p", query, + "--output-format", "stream-json", + "--verbose", + "--include-partial-messages", + ] + if model: + cmd.extend(["--model", model]) + + # Remove CLAUDECODE env var to allow nesting claude -p inside a + # Claude Code session. The guard is for interactive terminal conflicts; + # programmatic subprocess usage is safe. + env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"} + + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + cwd=project_root, + env=env, + ) + + triggered = False + start_time = time.time() + buffer = "" + # Track state for stream event detection + pending_tool_name = None + accumulated_json = "" + + try: + while time.time() - start_time < timeout: + if process.poll() is not None: + remaining = process.stdout.read() + if remaining: + buffer += remaining.decode("utf-8", errors="replace") + break + + ready, _, _ = select.select([process.stdout], [], [], 1.0) + if not ready: + continue + + chunk = os.read(process.stdout.fileno(), 8192) + if not chunk: + break + buffer += chunk.decode("utf-8", errors="replace") + + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if not line: + continue + + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + + # Early detection via stream events + if event.get("type") == "stream_event": + se = event.get("event", {}) + se_type = se.get("type", "") + + if se_type == "content_block_start": + cb = se.get("content_block", {}) + if cb.get("type") == "tool_use": + tool_name = cb.get("name", "") + if tool_name in ("Skill", "Read"): + pending_tool_name = tool_name + accumulated_json = "" + else: + return False + + elif se_type == "content_block_delta" and pending_tool_name: + delta = se.get("delta", {}) + if delta.get("type") == "input_json_delta": + accumulated_json += delta.get("partial_json", "") + if clean_name in accumulated_json: + return True + + elif se_type in ("content_block_stop", "message_stop"): + if pending_tool_name: + return clean_name in accumulated_json + if se_type == "message_stop": + return False + + # Fallback: full assistant message + elif event.get("type") == "assistant": + message = event.get("message", {}) + for content_item in message.get("content", []): + if content_item.get("type") != "tool_use": + continue + tool_name = content_item.get("name", "") + tool_input = content_item.get("input", {}) + if tool_name == "Skill" and clean_name in tool_input.get("skill", ""): + triggered = True + elif tool_name == "Read" and clean_name in tool_input.get("file_path", ""): + triggered = True + return triggered + + elif event.get("type") == "result": + return triggered + finally: + # Clean up process on any exit path (return, exception, timeout) + if process.poll() is None: + process.kill() + process.wait() + + return triggered + finally: + if command_file.exists(): + command_file.unlink() + + +def run_eval( + eval_set: list[dict], + skill_name: str, + description: str, + num_workers: int, + timeout: int, + project_root: Path, + runs_per_query: int = 1, + trigger_threshold: float = 0.5, + model: str | None = None, +) -> dict: + """Run the full eval set and return results.""" + results = [] + + with ProcessPoolExecutor(max_workers=num_workers) as executor: + future_to_info = {} + for item in eval_set: + for run_idx in range(runs_per_query): + future = executor.submit( + run_single_query, + item["query"], + skill_name, + description, + timeout, + str(project_root), + model, + ) + future_to_info[future] = (item, run_idx) + + query_triggers: dict[str, list[bool]] = {} + query_items: dict[str, dict] = {} + for future in as_completed(future_to_info): + item, _ = future_to_info[future] + query = item["query"] + query_items[query] = item + if query not in query_triggers: + query_triggers[query] = [] + try: + query_triggers[query].append(future.result()) + except Exception as e: + print(f"Warning: query failed: {e}", file=sys.stderr) + query_triggers[query].append(False) + + for query, triggers in query_triggers.items(): + item = query_items[query] + trigger_rate = sum(triggers) / len(triggers) + should_trigger = item["should_trigger"] + if should_trigger: + did_pass = trigger_rate >= trigger_threshold + else: + did_pass = trigger_rate < trigger_threshold + results.append({ + "query": query, + "should_trigger": should_trigger, + "trigger_rate": trigger_rate, + "triggers": sum(triggers), + "runs": len(triggers), + "pass": did_pass, + }) + + passed = sum(1 for r in results if r["pass"]) + total = len(results) + + return { + "skill_name": skill_name, + "description": description, + "results": results, + "summary": { + "total": total, + "passed": passed, + "failed": total - passed, + }, + } + + +def main(): + parser = argparse.ArgumentParser(description="Run trigger evaluation for a skill description") + parser.add_argument("--eval-set", required=True, help="Path to eval set JSON file") + parser.add_argument("--skill-path", required=True, help="Path to skill directory") + parser.add_argument("--description", default=None, help="Override description to test") + parser.add_argument("--num-workers", type=int, default=10, help="Number of parallel workers") + parser.add_argument("--timeout", type=int, default=30, help="Timeout per query in seconds") + parser.add_argument("--runs-per-query", type=int, default=3, help="Number of runs per query") + parser.add_argument("--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold") + parser.add_argument("--model", default=None, help="Model to use for claude -p (default: user's configured model)") + parser.add_argument("--verbose", action="store_true", help="Print progress to stderr") + args = parser.parse_args() + + eval_set = json.loads(Path(args.eval_set).read_text()) + skill_path = Path(args.skill_path) + + if not (skill_path / "SKILL.md").exists(): + print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr) + sys.exit(1) + + name, original_description, content = parse_skill_md(skill_path) + description = args.description or original_description + project_root = find_project_root() + + if args.verbose: + print(f"Evaluating: {description}", file=sys.stderr) + + output = run_eval( + eval_set=eval_set, + skill_name=name, + description=description, + num_workers=args.num_workers, + timeout=args.timeout, + project_root=project_root, + runs_per_query=args.runs_per_query, + trigger_threshold=args.trigger_threshold, + model=args.model, + ) + + if args.verbose: + summary = output["summary"] + print(f"Results: {summary['passed']}/{summary['total']} passed", file=sys.stderr) + for r in output["results"]: + status = "PASS" if r["pass"] else "FAIL" + rate_str = f"{r['triggers']}/{r['runs']}" + print(f" [{status}] rate={rate_str} expected={r['should_trigger']}: {r['query'][:70]}", file=sys.stderr) + + print(json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/scripts/run_loop.py b/.claude/skills/scripts/run_loop.py new file mode 100755 index 000000000..30a263d67 --- /dev/null +++ b/.claude/skills/scripts/run_loop.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Run the eval + improve loop until all pass or max iterations reached. + +Combines run_eval.py and improve_description.py in a loop, tracking history +and returning the best description found. Supports train/test split to prevent +overfitting. +""" + +import argparse +import json +import random +import sys +import tempfile +import time +import webbrowser +from pathlib import Path + +from scripts.generate_report import generate_html +from scripts.improve_description import improve_description +from scripts.run_eval import find_project_root, run_eval +from scripts.utils import parse_skill_md + + +def split_eval_set(eval_set: list[dict], holdout: float, seed: int = 42) -> tuple[list[dict], list[dict]]: + """Split eval set into train and test sets, stratified by should_trigger.""" + random.seed(seed) + + # Separate by should_trigger + trigger = [e for e in eval_set if e["should_trigger"]] + no_trigger = [e for e in eval_set if not e["should_trigger"]] + + # Shuffle each group + random.shuffle(trigger) + random.shuffle(no_trigger) + + # Calculate split points + n_trigger_test = max(1, int(len(trigger) * holdout)) + n_no_trigger_test = max(1, int(len(no_trigger) * holdout)) + + # Split + test_set = trigger[:n_trigger_test] + no_trigger[:n_no_trigger_test] + train_set = trigger[n_trigger_test:] + no_trigger[n_no_trigger_test:] + + return train_set, test_set + + +def run_loop( + eval_set: list[dict], + skill_path: Path, + description_override: str | None, + num_workers: int, + timeout: int, + max_iterations: int, + runs_per_query: int, + trigger_threshold: float, + holdout: float, + model: str, + verbose: bool, + live_report_path: Path | None = None, + log_dir: Path | None = None, +) -> dict: + """Run the eval + improvement loop.""" + project_root = find_project_root() + name, original_description, content = parse_skill_md(skill_path) + current_description = description_override or original_description + + # Split into train/test if holdout > 0 + if holdout > 0: + train_set, test_set = split_eval_set(eval_set, holdout) + if verbose: + print(f"Split: {len(train_set)} train, {len(test_set)} test (holdout={holdout})", file=sys.stderr) + else: + train_set = eval_set + test_set = [] + + history = [] + exit_reason = "unknown" + + for iteration in range(1, max_iterations + 1): + if verbose: + print(f"\n{'='*60}", file=sys.stderr) + print(f"Iteration {iteration}/{max_iterations}", file=sys.stderr) + print(f"Description: {current_description}", file=sys.stderr) + print(f"{'='*60}", file=sys.stderr) + + # Evaluate train + test together in one batch for parallelism + all_queries = train_set + test_set + t0 = time.time() + all_results = run_eval( + eval_set=all_queries, + skill_name=name, + description=current_description, + num_workers=num_workers, + timeout=timeout, + project_root=project_root, + runs_per_query=runs_per_query, + trigger_threshold=trigger_threshold, + model=model, + ) + eval_elapsed = time.time() - t0 + + # Split results back into train/test by matching queries + train_queries_set = {q["query"] for q in train_set} + train_result_list = [r for r in all_results["results"] if r["query"] in train_queries_set] + test_result_list = [r for r in all_results["results"] if r["query"] not in train_queries_set] + + train_passed = sum(1 for r in train_result_list if r["pass"]) + train_total = len(train_result_list) + train_summary = {"passed": train_passed, "failed": train_total - train_passed, "total": train_total} + train_results = {"results": train_result_list, "summary": train_summary} + + if test_set: + test_passed = sum(1 for r in test_result_list if r["pass"]) + test_total = len(test_result_list) + test_summary = {"passed": test_passed, "failed": test_total - test_passed, "total": test_total} + test_results = {"results": test_result_list, "summary": test_summary} + else: + test_results = None + test_summary = None + + history.append({ + "iteration": iteration, + "description": current_description, + "train_passed": train_summary["passed"], + "train_failed": train_summary["failed"], + "train_total": train_summary["total"], + "train_results": train_results["results"], + "test_passed": test_summary["passed"] if test_summary else None, + "test_failed": test_summary["failed"] if test_summary else None, + "test_total": test_summary["total"] if test_summary else None, + "test_results": test_results["results"] if test_results else None, + # For backward compat with report generator + "passed": train_summary["passed"], + "failed": train_summary["failed"], + "total": train_summary["total"], + "results": train_results["results"], + }) + + # Write live report if path provided + if live_report_path: + partial_output = { + "original_description": original_description, + "best_description": current_description, + "best_score": "in progress", + "iterations_run": len(history), + "holdout": holdout, + "train_size": len(train_set), + "test_size": len(test_set), + "history": history, + } + live_report_path.write_text(generate_html(partial_output, auto_refresh=True, skill_name=name)) + + if verbose: + def print_eval_stats(label, results, elapsed): + pos = [r for r in results if r["should_trigger"]] + neg = [r for r in results if not r["should_trigger"]] + tp = sum(r["triggers"] for r in pos) + pos_runs = sum(r["runs"] for r in pos) + fn = pos_runs - tp + fp = sum(r["triggers"] for r in neg) + neg_runs = sum(r["runs"] for r in neg) + tn = neg_runs - fp + total = tp + tn + fp + fn + precision = tp / (tp + fp) if (tp + fp) > 0 else 1.0 + recall = tp / (tp + fn) if (tp + fn) > 0 else 1.0 + accuracy = (tp + tn) / total if total > 0 else 0.0 + print(f"{label}: {tp+tn}/{total} correct, precision={precision:.0%} recall={recall:.0%} accuracy={accuracy:.0%} ({elapsed:.1f}s)", file=sys.stderr) + for r in results: + status = "PASS" if r["pass"] else "FAIL" + rate_str = f"{r['triggers']}/{r['runs']}" + print(f" [{status}] rate={rate_str} expected={r['should_trigger']}: {r['query'][:60]}", file=sys.stderr) + + print_eval_stats("Train", train_results["results"], eval_elapsed) + if test_summary: + print_eval_stats("Test ", test_results["results"], 0) + + if train_summary["failed"] == 0: + exit_reason = f"all_passed (iteration {iteration})" + if verbose: + print(f"\nAll train queries passed on iteration {iteration}!", file=sys.stderr) + break + + if iteration == max_iterations: + exit_reason = f"max_iterations ({max_iterations})" + if verbose: + print(f"\nMax iterations reached ({max_iterations}).", file=sys.stderr) + break + + # Improve the description based on train results + if verbose: + print(f"\nImproving description...", file=sys.stderr) + + t0 = time.time() + # Strip test scores from history so improvement model can't see them + blinded_history = [ + {k: v for k, v in h.items() if not k.startswith("test_")} + for h in history + ] + new_description = improve_description( + skill_name=name, + skill_content=content, + current_description=current_description, + eval_results=train_results, + history=blinded_history, + model=model, + log_dir=log_dir, + iteration=iteration, + ) + improve_elapsed = time.time() - t0 + + if verbose: + print(f"Proposed ({improve_elapsed:.1f}s): {new_description}", file=sys.stderr) + + current_description = new_description + + # Find the best iteration by TEST score (or train if no test set) + if test_set: + best = max(history, key=lambda h: h["test_passed"] or 0) + best_score = f"{best['test_passed']}/{best['test_total']}" + else: + best = max(history, key=lambda h: h["train_passed"]) + best_score = f"{best['train_passed']}/{best['train_total']}" + + if verbose: + print(f"\nExit reason: {exit_reason}", file=sys.stderr) + print(f"Best score: {best_score} (iteration {best['iteration']})", file=sys.stderr) + + return { + "exit_reason": exit_reason, + "original_description": original_description, + "best_description": best["description"], + "best_score": best_score, + "best_train_score": f"{best['train_passed']}/{best['train_total']}", + "best_test_score": f"{best['test_passed']}/{best['test_total']}" if test_set else None, + "final_description": current_description, + "iterations_run": len(history), + "holdout": holdout, + "train_size": len(train_set), + "test_size": len(test_set), + "history": history, + } + + +def main(): + parser = argparse.ArgumentParser(description="Run eval + improve loop") + parser.add_argument("--eval-set", required=True, help="Path to eval set JSON file") + parser.add_argument("--skill-path", required=True, help="Path to skill directory") + parser.add_argument("--description", default=None, help="Override starting description") + parser.add_argument("--num-workers", type=int, default=10, help="Number of parallel workers") + parser.add_argument("--timeout", type=int, default=30, help="Timeout per query in seconds") + parser.add_argument("--max-iterations", type=int, default=5, help="Max improvement iterations") + parser.add_argument("--runs-per-query", type=int, default=3, help="Number of runs per query") + parser.add_argument("--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold") + parser.add_argument("--holdout", type=float, default=0.4, help="Fraction of eval set to hold out for testing (0 to disable)") + parser.add_argument("--model", required=True, help="Model for improvement") + parser.add_argument("--verbose", action="store_true", help="Print progress to stderr") + parser.add_argument("--report", default="auto", help="Generate HTML report at this path (default: 'auto' for temp file, 'none' to disable)") + parser.add_argument("--results-dir", default=None, help="Save all outputs (results.json, report.html, log.txt) to a timestamped subdirectory here") + args = parser.parse_args() + + eval_set = json.loads(Path(args.eval_set).read_text()) + skill_path = Path(args.skill_path) + + if not (skill_path / "SKILL.md").exists(): + print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr) + sys.exit(1) + + name, _, _ = parse_skill_md(skill_path) + + # Set up live report path + if args.report != "none": + if args.report == "auto": + timestamp = time.strftime("%Y%m%d_%H%M%S") + live_report_path = Path(tempfile.gettempdir()) / f"skill_description_report_{skill_path.name}_{timestamp}.html" + else: + live_report_path = Path(args.report) + # Open the report immediately so the user can watch + live_report_path.write_text("

Starting optimization loop...

") + webbrowser.open(str(live_report_path)) + else: + live_report_path = None + + # Determine output directory (create before run_loop so logs can be written) + if args.results_dir: + timestamp = time.strftime("%Y-%m-%d_%H%M%S") + results_dir = Path(args.results_dir) / timestamp + results_dir.mkdir(parents=True, exist_ok=True) + else: + results_dir = None + + log_dir = results_dir / "logs" if results_dir else None + + output = run_loop( + eval_set=eval_set, + skill_path=skill_path, + description_override=args.description, + num_workers=args.num_workers, + timeout=args.timeout, + max_iterations=args.max_iterations, + runs_per_query=args.runs_per_query, + trigger_threshold=args.trigger_threshold, + holdout=args.holdout, + model=args.model, + verbose=args.verbose, + live_report_path=live_report_path, + log_dir=log_dir, + ) + + # Save JSON output + json_output = json.dumps(output, indent=2) + print(json_output) + if results_dir: + (results_dir / "results.json").write_text(json_output) + + # Write final HTML report (without auto-refresh) + if live_report_path: + live_report_path.write_text(generate_html(output, auto_refresh=False, skill_name=name)) + print(f"\nReport: {live_report_path}", file=sys.stderr) + + if results_dir and live_report_path: + (results_dir / "report.html").write_text(generate_html(output, auto_refresh=False, skill_name=name)) + + if results_dir: + print(f"Results saved to: {results_dir}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/scripts/utils.py b/.claude/skills/scripts/utils.py new file mode 100644 index 000000000..51b6a07dd --- /dev/null +++ b/.claude/skills/scripts/utils.py @@ -0,0 +1,47 @@ +"""Shared utilities for skill-creator scripts.""" + +from pathlib import Path + + + +def parse_skill_md(skill_path: Path) -> tuple[str, str, str]: + """Parse a SKILL.md file, returning (name, description, full_content).""" + content = (skill_path / "SKILL.md").read_text() + lines = content.split("\n") + + if lines[0].strip() != "---": + raise ValueError("SKILL.md missing frontmatter (no opening ---)") + + end_idx = None + for i, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + end_idx = i + break + + if end_idx is None: + raise ValueError("SKILL.md missing frontmatter (no closing ---)") + + name = "" + description = "" + frontmatter_lines = lines[1:end_idx] + i = 0 + while i < len(frontmatter_lines): + line = frontmatter_lines[i] + if line.startswith("name:"): + name = line[len("name:"):].strip().strip('"').strip("'") + elif line.startswith("description:"): + value = line[len("description:"):].strip() + # Handle YAML multiline indicators (>, |, >-, |-) + if value in (">", "|", ">-", "|-"): + continuation_lines: list[str] = [] + i += 1 + while i < len(frontmatter_lines) and (frontmatter_lines[i].startswith(" ") or frontmatter_lines[i].startswith("\t")): + continuation_lines.append(frontmatter_lines[i].strip()) + i += 1 + description = " ".join(continuation_lines) + continue + else: + description = value.strip('"').strip("'") + i += 1 + + return name, description, content diff --git a/.claude/skills/upgrade-runtime-stack/SKILL.md b/.claude/skills/upgrade-runtime-stack/SKILL.md new file mode 100644 index 000000000..04da3a3de --- /dev/null +++ b/.claude/skills/upgrade-runtime-stack/SKILL.md @@ -0,0 +1,137 @@ +--- +name: 'upgrade-runtime-stack' +description: 'Check whether newer stable versions of Node.js (24.x line), Nx, or Vite are available and, if so, generate a detailed upgrade plan markdown file at the repo root. Use this skill whenever the user asks to "check for runtime upgrades", "upgrade Node/NX/Vite", "is our Node version current", "plan a Node 24 upgrade", "refresh our runtime stack", "monthly stack check", or anything along those lines — even if they don''t name a specific tool. Also use it when the user wants a recurring/cadence check of build-toolchain currency. Output is a plan only — does NOT mutate package.json, Dockerfiles, lockfiles, or any other repo file. CI/CD wrappers can invoke this skill to keep the runtime stack fresh.' +--- + +# Upgrade Runtime Stack + +Goal: detect available stable upgrades of **Node.js (24.x line only)**, **Nx**, and **Vite**, then emit an actionable, ready-to-execute upgrade plan as a markdown file at the repo root. Stop at the plan — never edit application files. + +## Why this skill exists + +Manual runtime upgrades drift months between attempts and produce avoidable risk. This skill captures the canonical file map (mined from the prior Node 22 → 24 migration on `emdash/migration-node24-cc0s9`) so each upgrade run reuses the same checklist instead of rediscovering it. The plan is the deliverable. A human or a CI bot decides whether to act on it. + +## Execution mode + +This skill **must run fully non-interactive**. It is invoked from CI/CD in headless mode where no human can answer prompts. + +- Never call `AskUserQuestion` or any other clarification tool. +- Never ask the user to confirm a choice. The skill makes deterministic decisions from the inputs (baked URLs + repo state) and produces the plan. +- Never wait on long-running interactive commands. Read-only file inspection, `WebFetch`, and `rg` scans are the only actions needed. +- All output goes to a single deterministic artifact: `upgrade-plan.md` at the repo root. The terminal print at the end (Phase 7) is informational only — CI can ignore stdout. + +If any input is missing or ambiguous, the skill records the gap **inside the plan** (e.g. "Vite changelog unreachable this run") and continues. It never blocks on input. + +## Inputs + +The skill takes **no arguments**. Version sources are baked in: + +| Tool | Source URL | What to extract | +|------|------------|-----------------| +| Node.js 24.x | `https://raw.githubusercontent.com/nodejs/node/refs/heads/main/doc/changelogs/CHANGELOG_V24.md` | Latest stable 24.x release | +| Nx | `https://nx.dev/changelog` | Latest stable Nx major.minor.patch | +| Vite | `https://raw.githubusercontent.com/vitejs/vite/refs/heads/main/packages/vite/CHANGELOG.md` | Latest stable Vite release | + +See `references/fetch-versions.md` for the exact parsing rules per source. + +## Workflow + +Execute phases in order. Each phase has a single clear deliverable. Do not skip steps; the value of this skill comes from the consistency of the output. + +### Phase 1 — Read current versions from the repo + +Read these exact locations and record what is currently pinned: + +- `.nvmrc` → exact Node version (e.g. `24.15.0`) +- `package.json` (root) → `engines.node`, `engines.npm`, `devDependencies.nx`, `devDependencies["@nx/*"]`, `devDependencies.vite` +- `apps/api/docker-package.json` → `engines.node`, `engines.npm` +- `dockerfile/Dockerfile.api` and `dockerfile/Dockerfile.mcp` → `FROM node:-alpine@sha256:` +- `docker-compose.yml` and `docker-compose.production.yml` → every `image: node:-alpine` occurrence +- `.github/workflows/*.yml` → default `node-version` inputs + +Record in a single in-memory table; this becomes the "Current versions" section of the plan. + +### Phase 2 — Fetch latest stable versions + +Use `WebFetch` against each source URL listed in **Inputs**. Follow the per-source parsing rules in `references/fetch-versions.md`. + +Filtering rules (apply to every source): + +- **Skip** anything tagged `next`, `beta`, `alpha`, `rc`, `canary`, `preview`, `dev`, or `pre`. +- **Node.js**: only consider `v24.x.y` entries. Ignore 22.x, 26.x, etc. — even if newer. +- **Nx**: take the highest `X.Y.Z` published as a stable release. +- **Vite**: take the highest `X.Y.Z` published as a stable release. + +For each tool, also extract the **published date** if visible and a short summary of headline changes / breaking changes from the changelog body covering the range *(current version, latest]*. This summary feeds the risk section of the plan. + +### Phase 3 — Compute delta and decide + +For each tool, compare current vs. latest stable: + +- `latest == current` → no upgrade needed for that tool. Record as "up to date". +- `latest > current` → upgrade candidate. Classify the bump as `patch`, `minor`, or `major` using semver rules. Note any breaking-change headlines from Phase 2. +- `latest < current` → unusual. Record but do not recommend a downgrade. + +If **all three** tools are up to date, still produce `upgrade-plan.md` but with a single "No upgrades available" section plus the timestamp. This keeps the CI integration deterministic. + +### Phase 4 — Resolve Docker image dependencies + +When Node is being upgraded, the Docker image pin (`node:-alpine@sha256:`) must change in lockstep. The plan must include: + +1. The exact new tag string to use (`node:-alpine`). +2. The current Alpine major (read from existing Dockerfiles) — keep it unless a Node breaking change requires a different base. +3. An explicit instruction line: *"Look up the sha256 digest for `node:-alpine` on Docker Hub before pinning."* Do not fabricate a digest. + +If Node is not being upgraded, the Docker section of the plan only lists which files **would** change in a future Node bump, for reference. + +### Phase 5 — Map files to modify + +Use `references/file-map.md` as the authoritative list of files that any Node / Nx / Vite upgrade must touch in this repo. The map is grouped per tool. Include in the plan only the file groups whose tool has a pending upgrade. + +After listing the bake-in files, run a quick scan to surface drift — files that match the relevant version pattern but are not yet in the map: + +- Node version drift: `rg -n "node:[0-9]+\.[0-9]+\.[0-9]+|node-version: ['\"]?[0-9]+" --hidden -g '!node_modules' -g '!dist'` +- Nx version drift: `rg -n '"nx": "[0-9]+\.[0-9]+\.[0-9]+"|"@nx/[a-z-]+": "[0-9]+\.[0-9]+\.[0-9]+"' --hidden -g '!node_modules' -g '!dist'` +- Vite version drift: `rg -n '"vite": "(\^|~)?[0-9]+\.[0-9]+\.[0-9]+"' --hidden -g '!node_modules' -g '!dist'` + +Add any hits that are not already covered to a "Drift detected" subsection of the plan so a human can decide whether to extend the file map. + +### Phase 6 — Validation harness + +Copy the validation steps from `references/validation.md` into the plan. The harness is the contract for "this upgrade did not break the repo" and must be runnable end-to-end after applying the plan. + +### Phase 7 — Write `upgrade-plan.md` + +Use the exact structure defined in `references/plan-template.md`. Write to the repo root as `upgrade-plan.md`. Overwrite any existing file at that path — the plan is always the latest snapshot. + +The first line of the plan **must** be a machine-readable status comment so CI can branch on it without parsing the body: + +``` + +``` + +- `available` — at least one tool has a stable upgrade. +- `none` — all three tools up to date. +- `partial-fetch-failure` — one or more source URLs could not be fetched this run. + +After writing, print to stdout (informational only — CI may discard): + +- One-line summary per tool (e.g. `Node 24.15.0 → 24.17.0 (patch)`). +- The absolute path of the generated `upgrade-plan.md`. +- Whether any drift was detected (so the file map may need updating). + +Do **not** apply edits. Stop here. + +## Hard rules + +- Never edit `package.json`, `package-lock.json`, `.nvmrc`, Dockerfiles, docker-compose files, CI workflows, or any other source file. The skill produces a plan only. +- Never invent version numbers, dates, or sha256 digests. If a source URL cannot be fetched, mark the tool as "unknown — fetch failed" in the plan and continue with the other tools. +- Never recommend Node majors other than 24. The team is on the 24.x line and a major bump is a separate, deliberate project. +- Never include `rc`, `beta`, `alpha`, `canary`, `next`, `preview`, `dev`, or `pre` versions in the recommendation, even if newer than current. + +## Reference files + +- `references/fetch-versions.md` — per-source parsing rules for the three changelog URLs. +- `references/file-map.md` — canonical list of files an upgrade of each tool touches in this repo. +- `references/plan-template.md` — exact markdown layout of `upgrade-plan.md`. +- `references/validation.md` — lint / test / build commands that act as the safety harness. \ No newline at end of file diff --git a/.claude/skills/upgrade-runtime-stack/references/fetch-versions.md b/.claude/skills/upgrade-runtime-stack/references/fetch-versions.md new file mode 100644 index 000000000..8ca96d4e3 --- /dev/null +++ b/.claude/skills/upgrade-runtime-stack/references/fetch-versions.md @@ -0,0 +1,52 @@ +# Fetching latest stable versions + +Per-source parsing rules. All sources are fetched with `WebFetch`. Always strip pre-release tags (`rc`, `beta`, `alpha`, `canary`, `next`, `preview`, `dev`, `pre`). + +## Node.js (24.x line only) + +- URL: `https://raw.githubusercontent.com/nodejs/node/refs/heads/main/doc/changelogs/CHANGELOG_V24.md` +- The file is the changelog for the entire Node 24 line. Section headers look like: + ``` + + ## 2025-XX-YY, Version 24.15.0 (Current), @ + ``` +- Parse the topmost `## YYYY-MM-DD, Version 24.X.Y` heading whose tag is **not** marked `(Pre-release)`, `(RC)`, etc. +- Output: + - `latest`: e.g. `24.17.0` + - `released`: e.g. `2025-09-12` + - `highlights`: bullet headings between this version's heading and the next version heading. Limit to ~10 short bullets. Skip commit-only bullets. +- The "Current" / "LTS" annotation may be present — record it but do not gate the recommendation on it; the 24.x line moves from Current to LTS during its life. + +## Nx + +- URL: `https://nx.dev/changelog` +- The page lists release entries newest-first. Each entry looks like a section with a version (e.g. `Nx 22.7.2`) and a date. +- Parse the topmost entry whose version is **not** suffixed with `-rc.N`, `-beta.N`, `-alpha.N`, `-canary.N`, `-next.N`. +- Output: + - `latest`: e.g. `22.7.2` + - `released`: e.g. `2025-09-30` + - `highlights`: bullet section titles from that entry (e.g. "Breaking changes", "Features", "Bug Fixes"). Capture any text explicitly under "Breaking changes" verbatim — it drives the risk section. +- If the major changes between current and latest, also fetch the matching `https://nx.dev/changelog#vNN-migration` anchor or the linked migration guide and include the URL in the plan. + +## Vite + +- URL: `https://raw.githubusercontent.com/vitejs/vite/refs/heads/main/packages/vite/CHANGELOG.md` +- Standard `keep-a-changelog` style. Headings look like: + ``` + ## 8.0.3 (YYYY-MM-DD) + ``` +- Parse the topmost `## X.Y.Z` heading without a pre-release tag. +- Output: + - `latest`: e.g. `8.1.0` + - `released`: e.g. `2025-10-04` + - `highlights`: subsection titles (`### Features`, `### Bug Fixes`, `### BREAKING CHANGES`). Capture any `BREAKING CHANGES` section verbatim. + +## Fallback when a source cannot be fetched + +If `WebFetch` returns an error or an empty body, do not block the run. In the plan, record: + +``` +- : unable to fetch — skipped this run. +``` + +The two remaining tools are still processed normally. diff --git a/.claude/skills/upgrade-runtime-stack/references/file-map.md b/.claude/skills/upgrade-runtime-stack/references/file-map.md new file mode 100644 index 000000000..db47a227b --- /dev/null +++ b/.claude/skills/upgrade-runtime-stack/references/file-map.md @@ -0,0 +1,62 @@ +# Canonical file map per tool + +These lists were mined from the Node 22 → 24 migration branch `emdash/migration-node24-cc0s9`. They represent the *minimum* set of files that an upgrade of each tool must touch. The skill's Phase 5 also scans for drift in case the repo gained a new file matching the relevant pattern. + +A file may appear under more than one tool when an upgrade of any of those tools requires editing it. + +## Node.js (24.x) + +When the Node line shifts to a new patch (or minor within 24.x), update every concrete pin so all environments agree. + +**Engine / runtime pins:** +- `.nvmrc` — full `X.Y.Z` Node version, no `v` prefix. +- `package.json` (root) — `engines.node` and `engines.npm`. +- `apps/api/docker-package.json` — `engines.node` and `engines.npm` (this file is shipped inside the API Docker image as `package.json`). + +**Docker images:** +- `dockerfile/Dockerfile.api` — `FROM node:-alpine@sha256:`. The sha256 digest must be looked up on Docker Hub for the new tag; do not invent it. +- `dockerfile/Dockerfile.mcp` — same pattern. +- `docker-compose.yml` — every `image: node:-alpine` line. There are several services. +- `docker-compose.production.yml` — every `image: node:-alpine` line. + +**CI workflows:** +- `.github/workflows/build.yml` — `node-version` workflow input default and every matrix entry. +- `.github/workflows/main.yml` — same. +- `.github/workflows/docker.yml` — same. +- `.github/workflows/publish-cli-release.yml` — same. +- `.github/workflows/tmp-cli-lint-windows.yml` — same. + +**Helper scripts (kept around from the prior migration; usually paired):** +- `migrate_node24.sh` — invoked by humans to switch local env after a bump. +- `downgrade_node22.sh` — escape hatch back to the previous major; only relevant on a *major* Node bump. + +When the Node bump is patch- or minor-only, the two helper scripts can be left as-is. On a major bump (e.g. 24.x → 26.x), the plan must explicitly call out rewriting/removing them. + +## Nx + +When Nx is bumped, every `@nx/*` package and `nx` itself must move to the same version. The Nx CLI's own `nx migrate` workflow handles most edits, but the plan must list: + +- `package.json` (root) — `devDependencies.nx` and every `devDependencies["@nx/*"]`. Currently used scopes: `@nx/devkit`, `@nx/esbuild`, `@nx/eslint`, `@nx/eslint-plugin`, `@nx/jest`, `@nx/js`, `@nx/nest`, `@nx/node`, `@nx/playwright`, `@nx/plugin`, `@nx/react`, `@nx/storybook`, `@nx/vite`, `@nx/vitest`, `@nx/web`, `@nx/webpack`. +- `tools/packmind-plugin/package.json` — `@nx/*` deps referenced by the local Nx plugin. +- `nx.json` — schema / plugin configuration sometimes shifts between minors. +- `package-lock.json` — regenerated by `npm install` after the version bumps. +- `migrations.json` — created by `nx migrate ` at the repo root. The plan must include the explicit command `npx nx migrate ` and a follow-up `npx nx migrate --run-migrations`. + +**ESLint coupling:** the Nx ESLint plugin sometimes lands new flat-config requirements that affect `eslint.config.mjs` and `packages/ui/eslint.config.mjs`. List them as "review only" in the plan unless the changelog explicitly calls them out. + +## Vite + +When Vite is bumped, the consumers of Vite in this repo are: + +- `package.json` (root) — `devDependencies.vite`. +- `apps/frontend/vite.config.ts` — config surface; major bumps often change plugin or build options. +- `packages/ui/vite.config.ts` — same. +- `apps/frontend/package.json` — peer / dev deps may need realignment if the major changes. +- `packages/ui/package.json` — same. +- `package-lock.json` — regenerated. + +**Coupling with Nx:** `@nx/vite` and `@nx/vitest` carry their own Vite peer-dep ranges. On a Vite major bump, the plan must check the Nx changelog for compatibility before recommending the move; if Nx doesn't yet support the new Vite major, the plan recommends *waiting* and notes the blocking constraint. + +## Drift scan + +Phase 5 of the skill also runs three ripgrep scans (see `SKILL.md`) and reports any additional matches. Anything reported there is a candidate for adding to this file map after the upgrade lands. diff --git a/.claude/skills/upgrade-runtime-stack/references/plan-template.md b/.claude/skills/upgrade-runtime-stack/references/plan-template.md new file mode 100644 index 000000000..2ec5bebd9 --- /dev/null +++ b/.claude/skills/upgrade-runtime-stack/references/plan-template.md @@ -0,0 +1,103 @@ +# `upgrade-plan.md` layout + +Write the plan to the **repo root** as `upgrade-plan.md`. Overwrite any existing file at that path. The structure below is mandatory — downstream tooling (and the human reader's mental model) depends on it. + +When a tool has no pending upgrade, still emit its section but populate it with "Up to date — no action required" so the file's shape stays stable. + +```markdown + +# Runtime stack upgrade plan + +_Generated: by the `upgrade-runtime-stack` skill._ + +## Summary + +| Tool | Current | Latest stable | Bump | Action | +|------|---------|---------------|------|--------| +| Node.js 24.x | | | patch / minor / major / none | Upgrade / Skip | +| Nx | | | patch / minor / major / none | Upgrade / Skip | +| Vite | | | patch / minor / major / none | Upgrade / Skip | + + + +## Node.js + +- **Current**: (from `.nvmrc`) +- **Latest stable**: , released +- **Bump type**: patch / minor / major +- **Changelog highlights**: + - + - +- **Breaking changes**: + +### Files to modify + + + +### Docker image pin + +- New tag: `node:-alpine` +- Action: look up the sha256 digest for that tag on Docker Hub before editing `dockerfile/Dockerfile.api` and `dockerfile/Dockerfile.mcp`. Do **not** reuse the previous digest. +- Sample lookup: `docker manifest inspect node:-alpine | jq -r '.manifests[0].digest'` or use the Docker Hub UI. + +## Nx + +- **Current**: +- **Latest stable**: , released +- **Bump type**: patch / minor / major +- **Changelog highlights**: + - +- **Breaking changes**: +- **Migration command**: + ``` + npx nx migrate + npm install + npx nx migrate --run-migrations + ``` + +### Files to modify + + + +## Vite + +- **Current**: +- **Latest stable**: , released +- **Bump type**: patch / minor / major +- **Changelog highlights**: + - +- **Breaking changes**: +- **Nx / Vite compatibility note**: + +### Files to modify + + + +## Drift detected + + + +## Validation harness + + + +## Risks + +- +- + +## Rollback + +- Revert the upgrade commit and run `npm install` to regenerate the lockfile. +- For a Node major rollback, `downgrade_node22.sh` exists for the 22 ↔ 24 transition; on later majors a similar helper must be created before applying. +- Docker images are pinned by `@sha256:...` so previous deploys are reproducible. + +## Suggested commit split + +A single PR is fine for patch/minor bumps of all three tools combined. Split into separate PRs when: + +- Any tool has a **major** bump. +- The Nx and Vite bumps would interact (e.g. Vite major + `@nx/vite` major). + +Suggested split for this run: +``` diff --git a/.claude/skills/upgrade-runtime-stack/references/validation.md b/.claude/skills/upgrade-runtime-stack/references/validation.md new file mode 100644 index 000000000..c26764221 --- /dev/null +++ b/.claude/skills/upgrade-runtime-stack/references/validation.md @@ -0,0 +1,73 @@ +# Validation harness + +After the upgrade plan is applied, these steps must all succeed before merging. Copy this section verbatim into `upgrade-plan.md`. + +The order matters — fail fast on cheap checks before paying for full builds. + +## Local + +1. **Node + npm match the pins** + ``` + node --version # expect: vNEW_NODE + npm --version # expect: NEW_NPM + ``` + If `nvm` is in use: `nvm use` should read the new `.nvmrc` automatically. + +2. **Clean install** + ``` + rm -rf node_modules package-lock.json # only on major bumps; skip for patch/minor + npm install + ``` + For patch/minor bumps prefer `npm install` against the existing lockfile so the diff stays auditable. + +3. **Lint affected** + ``` + npm run lint:staged + ``` + +4. **Test affected** + ``` + npm run test:staged + ``` + +5. **Build the heaviest targets** + ``` + ./node_modules/.bin/nx build api + ./node_modules/.bin/nx build frontend + ./node_modules/.bin/nx build cli + ./node_modules/.bin/nx build mcp-server + ``` + +6. **Docker build smoke test** (only when Node is bumped) + ``` + docker build -f dockerfile/Dockerfile.api -t packmind-api:upgrade-check . + docker build -f dockerfile/Dockerfile.mcp -t packmind-mcp:upgrade-check . + docker compose -f docker-compose.yml up -d api frontend + docker compose -f docker-compose.yml ps + docker compose -f docker-compose.yml down + ``` + +## CI + +The GitHub Actions workflows already test against the `node-version` matrix entries. After the plan is applied, the **Main CI/CD Pipeline** must be green on the branch before merging: + +- `.github/workflows/build.yml` +- `.github/workflows/main.yml` +- `.github/workflows/docker.yml` + +If any workflow runs `nx affected`, it picks up the changed files automatically and runs the relevant projects. + +## Manual smoke (post-merge) + +- Spin up the local stack: `docker compose up -d`. +- Open the frontend on its dev URL and confirm the app loads. +- Hit the API health endpoint. +- Run one MCP server interaction end-to-end. + +## Failure handling + +If any harness step fails: + +1. Capture the exact error in the upgrade PR description. +2. Revert with `git revert ` rather than amending — keeps history auditable. +3. Re-run the skill on the reverted branch to regenerate a fresh plan once the upstream fix lands. diff --git a/.claude/skills/xlsx/LICENSE.txt b/.claude/skills/xlsx/LICENSE.txt new file mode 100644 index 000000000..c55ab4222 --- /dev/null +++ b/.claude/skills/xlsx/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/.claude/skills/xlsx/SKILL.md b/.claude/skills/xlsx/SKILL.md new file mode 100644 index 000000000..c5c881be9 --- /dev/null +++ b/.claude/skills/xlsx/SKILL.md @@ -0,0 +1,292 @@ +--- +name: xlsx +description: "Use this skill any time a spreadsheet file is the primary input or output. This means any task where the user wants to: open, read, edit, or fix an existing .xlsx, .xlsm, .csv, or .tsv file (e.g., adding columns, computing formulas, formatting, charting, cleaning messy data); create a new spreadsheet from scratch or from other data sources; or convert between tabular file formats. Trigger especially when the user references a spreadsheet file by name or path — even casually (like \"the xlsx in my downloads\") — and wants something done to it or produced from it. Also trigger for cleaning or restructuring messy tabular data files (malformed rows, misplaced headers, junk data) into proper spreadsheets. The deliverable must be a spreadsheet file. Do NOT trigger when the primary deliverable is a Word document, HTML report, standalone Python script, database pipeline, or Google Sheets API integration, even if tabular data is involved." +license: Proprietary. LICENSE.txt has complete terms +--- + +# Requirements for Outputs + +## All Excel files + +### Professional Font +- Use a consistent, professional font (e.g., Arial, Times New Roman) for all deliverables unless otherwise instructed by the user + +### Zero Formula Errors +- Every Excel model MUST be delivered with ZERO formula errors (#REF!, #DIV/0!, #VALUE!, #N/A, #NAME?) + +### Preserve Existing Templates (when updating templates) +- Study and EXACTLY match existing format, style, and conventions when modifying files +- Never impose standardized formatting on files with established patterns +- Existing template conventions ALWAYS override these guidelines + +## Financial models + +### Color Coding Standards +Unless otherwise stated by the user or existing template + +#### Industry-Standard Color Conventions +- **Blue text (RGB: 0,0,255)**: Hardcoded inputs, and numbers users will change for scenarios +- **Black text (RGB: 0,0,0)**: ALL formulas and calculations +- **Green text (RGB: 0,128,0)**: Links pulling from other worksheets within same workbook +- **Red text (RGB: 255,0,0)**: External links to other files +- **Yellow background (RGB: 255,255,0)**: Key assumptions needing attention or cells that need to be updated + +### Number Formatting Standards + +#### Required Format Rules +- **Years**: Format as text strings (e.g., "2024" not "2,024") +- **Currency**: Use $#,##0 format; ALWAYS specify units in headers ("Revenue ($mm)") +- **Zeros**: Use number formatting to make all zeros "-", including percentages (e.g., "$#,##0;($#,##0);-") +- **Percentages**: Default to 0.0% format (one decimal) +- **Multiples**: Format as 0.0x for valuation multiples (EV/EBITDA, P/E) +- **Negative numbers**: Use parentheses (123) not minus -123 + +### Formula Construction Rules + +#### Assumptions Placement +- Place ALL assumptions (growth rates, margins, multiples, etc.) in separate assumption cells +- Use cell references instead of hardcoded values in formulas +- Example: Use =B5*(1+$B$6) instead of =B5*1.05 + +#### Formula Error Prevention +- Verify all cell references are correct +- Check for off-by-one errors in ranges +- Ensure consistent formulas across all projection periods +- Test with edge cases (zero values, negative numbers) +- Verify no unintended circular references + +#### Documentation Requirements for Hardcodes +- Comment or in cells beside (if end of table). Format: "Source: [System/Document], [Date], [Specific Reference], [URL if applicable]" +- Examples: + - "Source: Company 10-K, FY2024, Page 45, Revenue Note, [SEC EDGAR URL]" + - "Source: Company 10-Q, Q2 2025, Exhibit 99.1, [SEC EDGAR URL]" + - "Source: Bloomberg Terminal, 8/15/2025, AAPL US Equity" + - "Source: FactSet, 8/20/2025, Consensus Estimates Screen" + +# XLSX creation, editing, and analysis + +## Overview + +A user may ask you to create, edit, or analyze the contents of an .xlsx file. You have different tools and workflows available for different tasks. + +## Important Requirements + +**LibreOffice Required for Formula Recalculation**: You can assume LibreOffice is installed for recalculating formula values using the `scripts/recalc.py` script. The script automatically configures LibreOffice on first run, including in sandboxed environments where Unix sockets are restricted (handled by `scripts/office/soffice.py`) + +## Reading and analyzing data + +### Data analysis with pandas +For data analysis, visualization, and basic operations, use **pandas** which provides powerful data manipulation capabilities: + +```python +import pandas as pd + +# Read Excel +df = pd.read_excel('file.xlsx') # Default: first sheet +all_sheets = pd.read_excel('file.xlsx', sheet_name=None) # All sheets as dict + +# Analyze +df.head() # Preview data +df.info() # Column info +df.describe() # Statistics + +# Write Excel +df.to_excel('output.xlsx', index=False) +``` + +## Excel File Workflows + +## CRITICAL: Use Formulas, Not Hardcoded Values + +**Always use Excel formulas instead of calculating values in Python and hardcoding them.** This ensures the spreadsheet remains dynamic and updateable. + +### ❌ WRONG - Hardcoding Calculated Values +```python +# Bad: Calculating in Python and hardcoding result +total = df['Sales'].sum() +sheet['B10'] = total # Hardcodes 5000 + +# Bad: Computing growth rate in Python +growth = (df.iloc[-1]['Revenue'] - df.iloc[0]['Revenue']) / df.iloc[0]['Revenue'] +sheet['C5'] = growth # Hardcodes 0.15 + +# Bad: Python calculation for average +avg = sum(values) / len(values) +sheet['D20'] = avg # Hardcodes 42.5 +``` + +### ✅ CORRECT - Using Excel Formulas +```python +# Good: Let Excel calculate the sum +sheet['B10'] = '=SUM(B2:B9)' + +# Good: Growth rate as Excel formula +sheet['C5'] = '=(C4-C2)/C2' + +# Good: Average using Excel function +sheet['D20'] = '=AVERAGE(D2:D19)' +``` + +This applies to ALL calculations - totals, percentages, ratios, differences, etc. The spreadsheet should be able to recalculate when source data changes. + +## Common Workflow +1. **Choose tool**: pandas for data, openpyxl for formulas/formatting +2. **Create/Load**: Create new workbook or load existing file +3. **Modify**: Add/edit data, formulas, and formatting +4. **Save**: Write to file +5. **Recalculate formulas (MANDATORY IF USING FORMULAS)**: Use the scripts/recalc.py script + ```bash + python scripts/recalc.py output.xlsx + ``` +6. **Verify and fix any errors**: + - The script returns JSON with error details + - If `status` is `errors_found`, check `error_summary` for specific error types and locations + - Fix the identified errors and recalculate again + - Common errors to fix: + - `#REF!`: Invalid cell references + - `#DIV/0!`: Division by zero + - `#VALUE!`: Wrong data type in formula + - `#NAME?`: Unrecognized formula name + +### Creating new Excel files + +```python +# Using openpyxl for formulas and formatting +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment + +wb = Workbook() +sheet = wb.active + +# Add data +sheet['A1'] = 'Hello' +sheet['B1'] = 'World' +sheet.append(['Row', 'of', 'data']) + +# Add formula +sheet['B2'] = '=SUM(A1:A10)' + +# Formatting +sheet['A1'].font = Font(bold=True, color='FF0000') +sheet['A1'].fill = PatternFill('solid', start_color='FFFF00') +sheet['A1'].alignment = Alignment(horizontal='center') + +# Column width +sheet.column_dimensions['A'].width = 20 + +wb.save('output.xlsx') +``` + +### Editing existing Excel files + +```python +# Using openpyxl to preserve formulas and formatting +from openpyxl import load_workbook + +# Load existing file +wb = load_workbook('existing.xlsx') +sheet = wb.active # or wb['SheetName'] for specific sheet + +# Working with multiple sheets +for sheet_name in wb.sheetnames: + sheet = wb[sheet_name] + print(f"Sheet: {sheet_name}") + +# Modify cells +sheet['A1'] = 'New Value' +sheet.insert_rows(2) # Insert row at position 2 +sheet.delete_cols(3) # Delete column 3 + +# Add new sheet +new_sheet = wb.create_sheet('NewSheet') +new_sheet['A1'] = 'Data' + +wb.save('modified.xlsx') +``` + +## Recalculating formulas + +Excel files created or modified by openpyxl contain formulas as strings but not calculated values. Use the provided `scripts/recalc.py` script to recalculate formulas: + +```bash +python scripts/recalc.py [timeout_seconds] +``` + +Example: +```bash +python scripts/recalc.py output.xlsx 30 +``` + +The script: +- Automatically sets up LibreOffice macro on first run +- Recalculates all formulas in all sheets +- Scans ALL cells for Excel errors (#REF!, #DIV/0!, etc.) +- Returns JSON with detailed error locations and counts +- Works on both Linux and macOS + +## Formula Verification Checklist + +Quick checks to ensure formulas work correctly: + +### Essential Verification +- [ ] **Test 2-3 sample references**: Verify they pull correct values before building full model +- [ ] **Column mapping**: Confirm Excel columns match (e.g., column 64 = BL, not BK) +- [ ] **Row offset**: Remember Excel rows are 1-indexed (DataFrame row 5 = Excel row 6) + +### Common Pitfalls +- [ ] **NaN handling**: Check for null values with `pd.notna()` +- [ ] **Far-right columns**: FY data often in columns 50+ +- [ ] **Multiple matches**: Search all occurrences, not just first +- [ ] **Division by zero**: Check denominators before using `/` in formulas (#DIV/0!) +- [ ] **Wrong references**: Verify all cell references point to intended cells (#REF!) +- [ ] **Cross-sheet references**: Use correct format (Sheet1!A1) for linking sheets + +### Formula Testing Strategy +- [ ] **Start small**: Test formulas on 2-3 cells before applying broadly +- [ ] **Verify dependencies**: Check all cells referenced in formulas exist +- [ ] **Test edge cases**: Include zero, negative, and very large values + +### Interpreting scripts/recalc.py Output +The script returns JSON with error details: +```json +{ + "status": "success", // or "errors_found" + "total_errors": 0, // Total error count + "total_formulas": 42, // Number of formulas in file + "error_summary": { // Only present if errors found + "#REF!": { + "count": 2, + "locations": ["Sheet1!B5", "Sheet1!C10"] + } + } +} +``` + +## Best Practices + +### Library Selection +- **pandas**: Best for data analysis, bulk operations, and simple data export +- **openpyxl**: Best for complex formatting, formulas, and Excel-specific features + +### Working with openpyxl +- Cell indices are 1-based (row=1, column=1 refers to cell A1) +- Use `data_only=True` to read calculated values: `load_workbook('file.xlsx', data_only=True)` +- **Warning**: If opened with `data_only=True` and saved, formulas are replaced with values and permanently lost +- For large files: Use `read_only=True` for reading or `write_only=True` for writing +- Formulas are preserved but not evaluated - use scripts/recalc.py to update values + +### Working with pandas +- Specify data types to avoid inference issues: `pd.read_excel('file.xlsx', dtype={'id': str})` +- For large files, read specific columns: `pd.read_excel('file.xlsx', usecols=['A', 'C', 'E'])` +- Handle dates properly: `pd.read_excel('file.xlsx', parse_dates=['date_column'])` + +## Code Style Guidelines +**IMPORTANT**: When generating Python code for Excel operations: +- Write minimal, concise Python code without unnecessary comments +- Avoid verbose variable names and redundant operations +- Avoid unnecessary print statements + +**For Excel files themselves**: +- Add comments to cells with complex formulas or important assumptions +- Document data sources for hardcoded values +- Include notes for key calculations and model sections \ No newline at end of file diff --git a/.claude/skills/xlsx/scripts/office/helpers/__init__.py b/.claude/skills/xlsx/scripts/office/helpers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/.claude/skills/xlsx/scripts/office/helpers/merge_runs.py b/.claude/skills/xlsx/scripts/office/helpers/merge_runs.py new file mode 100644 index 000000000..ad7c25eec --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/helpers/merge_runs.py @@ -0,0 +1,199 @@ +"""Merge adjacent runs with identical formatting in DOCX. + +Merges adjacent elements that have identical properties. +Works on runs in paragraphs and inside tracked changes (, ). + +Also: +- Removes rsid attributes from runs (revision metadata that doesn't affect rendering) +- Removes proofErr elements (spell/grammar markers that block merging) +""" + +from pathlib import Path + +import defusedxml.minidom + + +def merge_runs(input_dir: str) -> tuple[int, str]: + doc_xml = Path(input_dir) / "word" / "document.xml" + + if not doc_xml.exists(): + return 0, f"Error: {doc_xml} not found" + + try: + dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8")) + root = dom.documentElement + + _remove_elements(root, "proofErr") + _strip_run_rsid_attrs(root) + + containers = {run.parentNode for run in _find_elements(root, "r")} + + merge_count = 0 + for container in containers: + merge_count += _merge_runs_in(container) + + doc_xml.write_bytes(dom.toxml(encoding="UTF-8")) + return merge_count, f"Merged {merge_count} runs" + + except Exception as e: + return 0, f"Error: {e}" + + + + +def _find_elements(root, tag: str) -> list: + results = [] + + def traverse(node): + if node.nodeType == node.ELEMENT_NODE: + name = node.localName or node.tagName + if name == tag or name.endswith(f":{tag}"): + results.append(node) + for child in node.childNodes: + traverse(child) + + traverse(root) + return results + + +def _get_child(parent, tag: str): + for child in parent.childNodes: + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name == tag or name.endswith(f":{tag}"): + return child + return None + + +def _get_children(parent, tag: str) -> list: + results = [] + for child in parent.childNodes: + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name == tag or name.endswith(f":{tag}"): + results.append(child) + return results + + +def _is_adjacent(elem1, elem2) -> bool: + node = elem1.nextSibling + while node: + if node == elem2: + return True + if node.nodeType == node.ELEMENT_NODE: + return False + if node.nodeType == node.TEXT_NODE and node.data.strip(): + return False + node = node.nextSibling + return False + + + + +def _remove_elements(root, tag: str): + for elem in _find_elements(root, tag): + if elem.parentNode: + elem.parentNode.removeChild(elem) + + +def _strip_run_rsid_attrs(root): + for run in _find_elements(root, "r"): + for attr in list(run.attributes.values()): + if "rsid" in attr.name.lower(): + run.removeAttribute(attr.name) + + + + +def _merge_runs_in(container) -> int: + merge_count = 0 + run = _first_child_run(container) + + while run: + while True: + next_elem = _next_element_sibling(run) + if next_elem and _is_run(next_elem) and _can_merge(run, next_elem): + _merge_run_content(run, next_elem) + container.removeChild(next_elem) + merge_count += 1 + else: + break + + _consolidate_text(run) + run = _next_sibling_run(run) + + return merge_count + + +def _first_child_run(container): + for child in container.childNodes: + if child.nodeType == child.ELEMENT_NODE and _is_run(child): + return child + return None + + +def _next_element_sibling(node): + sibling = node.nextSibling + while sibling: + if sibling.nodeType == sibling.ELEMENT_NODE: + return sibling + sibling = sibling.nextSibling + return None + + +def _next_sibling_run(node): + sibling = node.nextSibling + while sibling: + if sibling.nodeType == sibling.ELEMENT_NODE: + if _is_run(sibling): + return sibling + sibling = sibling.nextSibling + return None + + +def _is_run(node) -> bool: + name = node.localName or node.tagName + return name == "r" or name.endswith(":r") + + +def _can_merge(run1, run2) -> bool: + rpr1 = _get_child(run1, "rPr") + rpr2 = _get_child(run2, "rPr") + + if (rpr1 is None) != (rpr2 is None): + return False + if rpr1 is None: + return True + return rpr1.toxml() == rpr2.toxml() + + +def _merge_run_content(target, source): + for child in list(source.childNodes): + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name != "rPr" and not name.endswith(":rPr"): + target.appendChild(child) + + +def _consolidate_text(run): + t_elements = _get_children(run, "t") + + for i in range(len(t_elements) - 1, 0, -1): + curr, prev = t_elements[i], t_elements[i - 1] + + if _is_adjacent(prev, curr): + prev_text = prev.firstChild.data if prev.firstChild else "" + curr_text = curr.firstChild.data if curr.firstChild else "" + merged = prev_text + curr_text + + if prev.firstChild: + prev.firstChild.data = merged + else: + prev.appendChild(run.ownerDocument.createTextNode(merged)) + + if merged.startswith(" ") or merged.endswith(" "): + prev.setAttribute("xml:space", "preserve") + elif prev.hasAttribute("xml:space"): + prev.removeAttribute("xml:space") + + run.removeChild(curr) diff --git a/.claude/skills/xlsx/scripts/office/helpers/simplify_redlines.py b/.claude/skills/xlsx/scripts/office/helpers/simplify_redlines.py new file mode 100644 index 000000000..db963bb99 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/helpers/simplify_redlines.py @@ -0,0 +1,197 @@ +"""Simplify tracked changes by merging adjacent w:ins or w:del elements. + +Merges adjacent elements from the same author into a single element. +Same for elements. This makes heavily-redlined documents easier to +work with by reducing the number of tracked change wrappers. + +Rules: +- Only merges w:ins with w:ins, w:del with w:del (same element type) +- Only merges if same author (ignores timestamp differences) +- Only merges if truly adjacent (only whitespace between them) +""" + +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path + +import defusedxml.minidom + +WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + +def simplify_redlines(input_dir: str) -> tuple[int, str]: + doc_xml = Path(input_dir) / "word" / "document.xml" + + if not doc_xml.exists(): + return 0, f"Error: {doc_xml} not found" + + try: + dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8")) + root = dom.documentElement + + merge_count = 0 + + containers = _find_elements(root, "p") + _find_elements(root, "tc") + + for container in containers: + merge_count += _merge_tracked_changes_in(container, "ins") + merge_count += _merge_tracked_changes_in(container, "del") + + doc_xml.write_bytes(dom.toxml(encoding="UTF-8")) + return merge_count, f"Simplified {merge_count} tracked changes" + + except Exception as e: + return 0, f"Error: {e}" + + +def _merge_tracked_changes_in(container, tag: str) -> int: + merge_count = 0 + + tracked = [ + child + for child in container.childNodes + if child.nodeType == child.ELEMENT_NODE and _is_element(child, tag) + ] + + if len(tracked) < 2: + return 0 + + i = 0 + while i < len(tracked) - 1: + curr = tracked[i] + next_elem = tracked[i + 1] + + if _can_merge_tracked(curr, next_elem): + _merge_tracked_content(curr, next_elem) + container.removeChild(next_elem) + tracked.pop(i + 1) + merge_count += 1 + else: + i += 1 + + return merge_count + + +def _is_element(node, tag: str) -> bool: + name = node.localName or node.tagName + return name == tag or name.endswith(f":{tag}") + + +def _get_author(elem) -> str: + author = elem.getAttribute("w:author") + if not author: + for attr in elem.attributes.values(): + if attr.localName == "author" or attr.name.endswith(":author"): + return attr.value + return author + + +def _can_merge_tracked(elem1, elem2) -> bool: + if _get_author(elem1) != _get_author(elem2): + return False + + node = elem1.nextSibling + while node and node != elem2: + if node.nodeType == node.ELEMENT_NODE: + return False + if node.nodeType == node.TEXT_NODE and node.data.strip(): + return False + node = node.nextSibling + + return True + + +def _merge_tracked_content(target, source): + while source.firstChild: + child = source.firstChild + source.removeChild(child) + target.appendChild(child) + + +def _find_elements(root, tag: str) -> list: + results = [] + + def traverse(node): + if node.nodeType == node.ELEMENT_NODE: + name = node.localName or node.tagName + if name == tag or name.endswith(f":{tag}"): + results.append(node) + for child in node.childNodes: + traverse(child) + + traverse(root) + return results + + +def get_tracked_change_authors(doc_xml_path: Path) -> dict[str, int]: + if not doc_xml_path.exists(): + return {} + + try: + tree = ET.parse(doc_xml_path) + root = tree.getroot() + except ET.ParseError: + return {} + + namespaces = {"w": WORD_NS} + author_attr = f"{{{WORD_NS}}}author" + + authors: dict[str, int] = {} + for tag in ["ins", "del"]: + for elem in root.findall(f".//w:{tag}", namespaces): + author = elem.get(author_attr) + if author: + authors[author] = authors.get(author, 0) + 1 + + return authors + + +def _get_authors_from_docx(docx_path: Path) -> dict[str, int]: + try: + with zipfile.ZipFile(docx_path, "r") as zf: + if "word/document.xml" not in zf.namelist(): + return {} + with zf.open("word/document.xml") as f: + tree = ET.parse(f) + root = tree.getroot() + + namespaces = {"w": WORD_NS} + author_attr = f"{{{WORD_NS}}}author" + + authors: dict[str, int] = {} + for tag in ["ins", "del"]: + for elem in root.findall(f".//w:{tag}", namespaces): + author = elem.get(author_attr) + if author: + authors[author] = authors.get(author, 0) + 1 + return authors + except (zipfile.BadZipFile, ET.ParseError): + return {} + + +def infer_author(modified_dir: Path, original_docx: Path, default: str = "Claude") -> str: + modified_xml = modified_dir / "word" / "document.xml" + modified_authors = get_tracked_change_authors(modified_xml) + + if not modified_authors: + return default + + original_authors = _get_authors_from_docx(original_docx) + + new_changes: dict[str, int] = {} + for author, count in modified_authors.items(): + original_count = original_authors.get(author, 0) + diff = count - original_count + if diff > 0: + new_changes[author] = diff + + if not new_changes: + return default + + if len(new_changes) == 1: + return next(iter(new_changes)) + + raise ValueError( + f"Multiple authors added new changes: {new_changes}. " + "Cannot infer which author to validate." + ) diff --git a/.claude/skills/xlsx/scripts/office/pack.py b/.claude/skills/xlsx/scripts/office/pack.py new file mode 100755 index 000000000..db29ed8b1 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/pack.py @@ -0,0 +1,159 @@ +"""Pack a directory into a DOCX, PPTX, or XLSX file. + +Validates with auto-repair, condenses XML formatting, and creates the Office file. + +Usage: + python pack.py [--original ] [--validate true|false] + +Examples: + python pack.py unpacked/ output.docx --original input.docx + python pack.py unpacked/ output.pptx --validate false +""" + +import argparse +import sys +import shutil +import tempfile +import zipfile +from pathlib import Path + +import defusedxml.minidom + +from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator + +def pack( + input_directory: str, + output_file: str, + original_file: str | None = None, + validate: bool = True, + infer_author_func=None, +) -> tuple[None, str]: + input_dir = Path(input_directory) + output_path = Path(output_file) + suffix = output_path.suffix.lower() + + if not input_dir.is_dir(): + return None, f"Error: {input_dir} is not a directory" + + if suffix not in {".docx", ".pptx", ".xlsx"}: + return None, f"Error: {output_file} must be a .docx, .pptx, or .xlsx file" + + if validate and original_file: + original_path = Path(original_file) + if original_path.exists(): + success, output = _run_validation( + input_dir, original_path, suffix, infer_author_func + ) + if output: + print(output) + if not success: + return None, f"Error: Validation failed for {input_dir}" + + with tempfile.TemporaryDirectory() as temp_dir: + temp_content_dir = Path(temp_dir) / "content" + shutil.copytree(input_dir, temp_content_dir) + + for pattern in ["*.xml", "*.rels"]: + for xml_file in temp_content_dir.rglob(pattern): + _condense_xml(xml_file) + + output_path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + for f in temp_content_dir.rglob("*"): + if f.is_file(): + zf.write(f, f.relative_to(temp_content_dir)) + + return None, f"Successfully packed {input_dir} to {output_file}" + + +def _run_validation( + unpacked_dir: Path, + original_file: Path, + suffix: str, + infer_author_func=None, +) -> tuple[bool, str | None]: + output_lines = [] + validators = [] + + if suffix == ".docx": + author = "Claude" + if infer_author_func: + try: + author = infer_author_func(unpacked_dir, original_file) + except ValueError as e: + print(f"Warning: {e} Using default author 'Claude'.", file=sys.stderr) + + validators = [ + DOCXSchemaValidator(unpacked_dir, original_file), + RedliningValidator(unpacked_dir, original_file, author=author), + ] + elif suffix == ".pptx": + validators = [PPTXSchemaValidator(unpacked_dir, original_file)] + + if not validators: + return True, None + + total_repairs = sum(v.repair() for v in validators) + if total_repairs: + output_lines.append(f"Auto-repaired {total_repairs} issue(s)") + + success = all(v.validate() for v in validators) + + if success: + output_lines.append("All validations PASSED!") + + return success, "\n".join(output_lines) if output_lines else None + + +def _condense_xml(xml_file: Path) -> None: + try: + with open(xml_file, encoding="utf-8") as f: + dom = defusedxml.minidom.parse(f) + + for element in dom.getElementsByTagName("*"): + if element.tagName.endswith(":t"): + continue + + for child in list(element.childNodes): + if ( + child.nodeType == child.TEXT_NODE + and child.nodeValue + and child.nodeValue.strip() == "" + ) or child.nodeType == child.COMMENT_NODE: + element.removeChild(child) + + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + except Exception as e: + print(f"ERROR: Failed to parse {xml_file.name}: {e}", file=sys.stderr) + raise + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Pack a directory into a DOCX, PPTX, or XLSX file" + ) + parser.add_argument("input_directory", help="Unpacked Office document directory") + parser.add_argument("output_file", help="Output Office file (.docx/.pptx/.xlsx)") + parser.add_argument( + "--original", + help="Original file for validation comparison", + ) + parser.add_argument( + "--validate", + type=lambda x: x.lower() == "true", + default=True, + metavar="true|false", + help="Run validation with auto-repair (default: true)", + ) + args = parser.parse_args() + + _, message = pack( + args.input_directory, + args.output_file, + original_file=args.original, + validate=args.validate, + ) + print(message) + + if "Error" in message: + sys.exit(1) diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd new file mode 100644 index 000000000..6454ef9a9 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd @@ -0,0 +1,1499 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd new file mode 100644 index 000000000..afa4f463e --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd new file mode 100644 index 000000000..64e66b8ab --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd @@ -0,0 +1,1085 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd new file mode 100644 index 000000000..687eea829 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd @@ -0,0 +1,11 @@ + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd new file mode 100644 index 000000000..6ac81b06b --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd @@ -0,0 +1,3081 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd new file mode 100644 index 000000000..1dbf05140 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd new file mode 100644 index 000000000..f1af17db4 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd new file mode 100644 index 000000000..0a185ab6e --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd new file mode 100644 index 000000000..14ef48886 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd @@ -0,0 +1,1676 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd new file mode 100644 index 000000000..c20f3bf14 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd new file mode 100644 index 000000000..ac6025226 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd new file mode 100644 index 000000000..424b8ba8d --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd new file mode 100644 index 000000000..2bddce292 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd new file mode 100644 index 000000000..8a8c18ba2 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd new file mode 100644 index 000000000..5c42706a0 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd new file mode 100644 index 000000000..853c341c8 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd new file mode 100644 index 000000000..da835ee82 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd new file mode 100644 index 000000000..87ad2658f --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd @@ -0,0 +1,582 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd new file mode 100644 index 000000000..9e86f1b2b --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd new file mode 100644 index 000000000..d0be42e75 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd @@ -0,0 +1,4439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd new file mode 100644 index 000000000..8821dd183 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd @@ -0,0 +1,570 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd new file mode 100644 index 000000000..ca2575c75 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd @@ -0,0 +1,509 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd new file mode 100644 index 000000000..dd079e603 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd new file mode 100644 index 000000000..3dd6cf625 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd new file mode 100644 index 000000000..f1041e34e --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd new file mode 100644 index 000000000..9c5b7a633 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd @@ -0,0 +1,3646 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd new file mode 100644 index 000000000..0f13678d8 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd @@ -0,0 +1,116 @@ + + + + + + See http://www.w3.org/XML/1998/namespace.html and + http://www.w3.org/TR/REC-xml for information about this namespace. + + This schema document describes the XML namespace, in a form + suitable for import by other schema documents. + + Note that local names in this namespace are intended to be defined + only by the World Wide Web Consortium or its subgroups. The + following names are currently defined in this namespace and should + not be used with conflicting semantics by any Working Group, + specification, or document instance: + + base (as an attribute name): denotes an attribute whose value + provides a URI to be used as the base for interpreting any + relative URIs in the scope of the element on which it + appears; its value is inherited. This name is reserved + by virtue of its definition in the XML Base specification. + + lang (as an attribute name): denotes an attribute whose value + is a language code for the natural language of the content of + any element; its value is inherited. This name is reserved + by virtue of its definition in the XML specification. + + space (as an attribute name): denotes an attribute whose + value is a keyword indicating what whitespace processing + discipline is intended for the content of the element; its + value is inherited. This name is reserved by virtue of its + definition in the XML specification. + + Father (in any context at all): denotes Jon Bosak, the chair of + the original XML Working Group. This name is reserved by + the following decision of the W3C XML Plenary and + XML Coordination groups: + + In appreciation for his vision, leadership and dedication + the W3C XML Plenary on this 10th day of February, 2000 + reserves for Jon Bosak in perpetuity the XML name + xml:Father + + + + + This schema defines attributes and an attribute group + suitable for use by + schemas wishing to allow xml:base, xml:lang or xml:space attributes + on elements they define. + + To enable this, such a schema must import this schema + for the XML namespace, e.g. as follows: + <schema . . .> + . . . + <import namespace="http://www.w3.org/XML/1998/namespace" + schemaLocation="http://www.w3.org/2001/03/xml.xsd"/> + + Subsequently, qualified reference to any of the attributes + or the group defined below will have the desired effect, e.g. + + <type . . .> + . . . + <attributeGroup ref="xml:specialAttrs"/> + + will define a type which will schema-validate an instance + element with any of those attributes + + + + In keeping with the XML Schema WG's standard versioning + policy, this schema document will persist at + http://www.w3.org/2001/03/xml.xsd. + At the date of issue it can also be found at + http://www.w3.org/2001/xml.xsd. + The schema document at that URI may however change in the future, + in order to remain compatible with the latest version of XML Schema + itself. In other words, if the XML Schema namespace changes, the version + of this document at + http://www.w3.org/2001/xml.xsd will change + accordingly; the version at + http://www.w3.org/2001/03/xml.xsd will not change. + + + + + + In due course, we should install the relevant ISO 2- and 3-letter + codes as the enumerated possible values . . . + + + + + + + + + + + + + + + See http://www.w3.org/TR/xmlbase/ for + information about this attribute. + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/.claude/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd new file mode 100644 index 000000000..a6de9d273 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/.claude/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd new file mode 100644 index 000000000..10e978b66 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd b/.claude/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd new file mode 100644 index 000000000..4248bf7a3 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd b/.claude/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd new file mode 100644 index 000000000..564974671 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/mce/mc.xsd b/.claude/skills/xlsx/scripts/office/schemas/mce/mc.xsd new file mode 100644 index 000000000..ef725457c --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/mce/mc.xsd @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd b/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd new file mode 100644 index 000000000..f65f77773 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd @@ -0,0 +1,560 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd b/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd new file mode 100644 index 000000000..6b00755a9 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd b/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd new file mode 100644 index 000000000..f321d333a --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd b/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd new file mode 100644 index 000000000..364c6a9b8 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd b/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd new file mode 100644 index 000000000..fed9d15b7 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd b/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd new file mode 100644 index 000000000..680cf1540 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd @@ -0,0 +1,4 @@ + + + + diff --git a/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd b/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd new file mode 100644 index 000000000..89ada9083 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/.claude/skills/xlsx/scripts/office/soffice.py b/.claude/skills/xlsx/scripts/office/soffice.py new file mode 100644 index 000000000..c7f7e3289 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/soffice.py @@ -0,0 +1,183 @@ +""" +Helper for running LibreOffice (soffice) in environments where AF_UNIX +sockets may be blocked (e.g., sandboxed VMs). Detects the restriction +at runtime and applies an LD_PRELOAD shim if needed. + +Usage: + from office.soffice import run_soffice, get_soffice_env + + # Option 1 – run soffice directly + result = run_soffice(["--headless", "--convert-to", "pdf", "input.docx"]) + + # Option 2 – get env dict for your own subprocess calls + env = get_soffice_env() + subprocess.run(["soffice", ...], env=env) +""" + +import os +import socket +import subprocess +import tempfile +from pathlib import Path + + +def get_soffice_env() -> dict: + env = os.environ.copy() + env["SAL_USE_VCLPLUGIN"] = "svp" + + if _needs_shim(): + shim = _ensure_shim() + env["LD_PRELOAD"] = str(shim) + + return env + + +def run_soffice(args: list[str], **kwargs) -> subprocess.CompletedProcess: + env = get_soffice_env() + return subprocess.run(["soffice"] + args, env=env, **kwargs) + + + +_SHIM_SO = Path(tempfile.gettempdir()) / "lo_socket_shim.so" + + +def _needs_shim() -> bool: + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.close() + return False + except OSError: + return True + + +def _ensure_shim() -> Path: + if _SHIM_SO.exists(): + return _SHIM_SO + + src = Path(tempfile.gettempdir()) / "lo_socket_shim.c" + src.write_text(_SHIM_SOURCE) + subprocess.run( + ["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"], + check=True, + capture_output=True, + ) + src.unlink() + return _SHIM_SO + + + +_SHIM_SOURCE = r""" +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +static int (*real_socket)(int, int, int); +static int (*real_socketpair)(int, int, int, int[2]); +static int (*real_listen)(int, int); +static int (*real_accept)(int, struct sockaddr *, socklen_t *); +static int (*real_close)(int); +static int (*real_read)(int, void *, size_t); + +/* Per-FD bookkeeping (FDs >= 1024 are passed through unshimmed). */ +static int is_shimmed[1024]; +static int peer_of[1024]; +static int wake_r[1024]; /* accept() blocks reading this */ +static int wake_w[1024]; /* close() writes to this */ +static int listener_fd = -1; /* FD that received listen() */ + +__attribute__((constructor)) +static void init(void) { + real_socket = dlsym(RTLD_NEXT, "socket"); + real_socketpair = dlsym(RTLD_NEXT, "socketpair"); + real_listen = dlsym(RTLD_NEXT, "listen"); + real_accept = dlsym(RTLD_NEXT, "accept"); + real_close = dlsym(RTLD_NEXT, "close"); + real_read = dlsym(RTLD_NEXT, "read"); + for (int i = 0; i < 1024; i++) { + peer_of[i] = -1; + wake_r[i] = -1; + wake_w[i] = -1; + } +} + +/* ---- socket ---------------------------------------------------------- */ +int socket(int domain, int type, int protocol) { + if (domain == AF_UNIX) { + int fd = real_socket(domain, type, protocol); + if (fd >= 0) return fd; + /* socket(AF_UNIX) blocked – fall back to socketpair(). */ + int sv[2]; + if (real_socketpair(domain, type, protocol, sv) == 0) { + if (sv[0] >= 0 && sv[0] < 1024) { + is_shimmed[sv[0]] = 1; + peer_of[sv[0]] = sv[1]; + int wp[2]; + if (pipe(wp) == 0) { + wake_r[sv[0]] = wp[0]; + wake_w[sv[0]] = wp[1]; + } + } + return sv[0]; + } + errno = EPERM; + return -1; + } + return real_socket(domain, type, protocol); +} + +/* ---- listen ---------------------------------------------------------- */ +int listen(int sockfd, int backlog) { + if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { + listener_fd = sockfd; + return 0; + } + return real_listen(sockfd, backlog); +} + +/* ---- accept ---------------------------------------------------------- */ +int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) { + if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { + /* Block until close() writes to the wake pipe. */ + if (wake_r[sockfd] >= 0) { + char buf; + real_read(wake_r[sockfd], &buf, 1); + } + errno = ECONNABORTED; + return -1; + } + return real_accept(sockfd, addr, addrlen); +} + +/* ---- close ----------------------------------------------------------- */ +int close(int fd) { + if (fd >= 0 && fd < 1024 && is_shimmed[fd]) { + int was_listener = (fd == listener_fd); + is_shimmed[fd] = 0; + + if (wake_w[fd] >= 0) { /* unblock accept() */ + char c = 0; + write(wake_w[fd], &c, 1); + real_close(wake_w[fd]); + wake_w[fd] = -1; + } + if (wake_r[fd] >= 0) { real_close(wake_r[fd]); wake_r[fd] = -1; } + if (peer_of[fd] >= 0) { real_close(peer_of[fd]); peer_of[fd] = -1; } + + if (was_listener) + _exit(0); /* conversion done – exit */ + } + return real_close(fd); +} +""" + + + +if __name__ == "__main__": + import sys + result = run_soffice(sys.argv[1:]) + sys.exit(result.returncode) diff --git a/.claude/skills/xlsx/scripts/office/unpack.py b/.claude/skills/xlsx/scripts/office/unpack.py new file mode 100755 index 000000000..00152533a --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/unpack.py @@ -0,0 +1,132 @@ +"""Unpack Office files (DOCX, PPTX, XLSX) for editing. + +Extracts the ZIP archive, pretty-prints XML files, and optionally: +- Merges adjacent runs with identical formatting (DOCX only) +- Simplifies adjacent tracked changes from same author (DOCX only) + +Usage: + python unpack.py [options] + +Examples: + python unpack.py document.docx unpacked/ + python unpack.py presentation.pptx unpacked/ + python unpack.py document.docx unpacked/ --merge-runs false +""" + +import argparse +import sys +import zipfile +from pathlib import Path + +import defusedxml.minidom + +from helpers.merge_runs import merge_runs as do_merge_runs +from helpers.simplify_redlines import simplify_redlines as do_simplify_redlines + +SMART_QUOTE_REPLACEMENTS = { + "\u201c": "“", + "\u201d": "”", + "\u2018": "‘", + "\u2019": "’", +} + + +def unpack( + input_file: str, + output_directory: str, + merge_runs: bool = True, + simplify_redlines: bool = True, +) -> tuple[None, str]: + input_path = Path(input_file) + output_path = Path(output_directory) + suffix = input_path.suffix.lower() + + if not input_path.exists(): + return None, f"Error: {input_file} does not exist" + + if suffix not in {".docx", ".pptx", ".xlsx"}: + return None, f"Error: {input_file} must be a .docx, .pptx, or .xlsx file" + + try: + output_path.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(input_path, "r") as zf: + zf.extractall(output_path) + + xml_files = list(output_path.rglob("*.xml")) + list(output_path.rglob("*.rels")) + for xml_file in xml_files: + _pretty_print_xml(xml_file) + + message = f"Unpacked {input_file} ({len(xml_files)} XML files)" + + if suffix == ".docx": + if simplify_redlines: + simplify_count, _ = do_simplify_redlines(str(output_path)) + message += f", simplified {simplify_count} tracked changes" + + if merge_runs: + merge_count, _ = do_merge_runs(str(output_path)) + message += f", merged {merge_count} runs" + + for xml_file in xml_files: + _escape_smart_quotes(xml_file) + + return None, message + + except zipfile.BadZipFile: + return None, f"Error: {input_file} is not a valid Office file" + except Exception as e: + return None, f"Error unpacking: {e}" + + +def _pretty_print_xml(xml_file: Path) -> None: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + xml_file.write_bytes(dom.toprettyxml(indent=" ", encoding="utf-8")) + except Exception: + pass + + +def _escape_smart_quotes(xml_file: Path) -> None: + try: + content = xml_file.read_text(encoding="utf-8") + for char, entity in SMART_QUOTE_REPLACEMENTS.items(): + content = content.replace(char, entity) + xml_file.write_text(content, encoding="utf-8") + except Exception: + pass + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Unpack an Office file (DOCX, PPTX, XLSX) for editing" + ) + parser.add_argument("input_file", help="Office file to unpack") + parser.add_argument("output_directory", help="Output directory") + parser.add_argument( + "--merge-runs", + type=lambda x: x.lower() == "true", + default=True, + metavar="true|false", + help="Merge adjacent runs with identical formatting (DOCX only, default: true)", + ) + parser.add_argument( + "--simplify-redlines", + type=lambda x: x.lower() == "true", + default=True, + metavar="true|false", + help="Merge adjacent tracked changes from same author (DOCX only, default: true)", + ) + args = parser.parse_args() + + _, message = unpack( + args.input_file, + args.output_directory, + merge_runs=args.merge_runs, + simplify_redlines=args.simplify_redlines, + ) + print(message) + + if "Error" in message: + sys.exit(1) diff --git a/.claude/skills/xlsx/scripts/office/validate.py b/.claude/skills/xlsx/scripts/office/validate.py new file mode 100755 index 000000000..03b01f6e3 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/validate.py @@ -0,0 +1,111 @@ +""" +Command line tool to validate Office document XML files against XSD schemas and tracked changes. + +Usage: + python validate.py [--original ] [--auto-repair] [--author NAME] + +The first argument can be either: +- An unpacked directory containing the Office document XML files +- A packed Office file (.docx/.pptx/.xlsx) which will be unpacked to a temp directory + +Auto-repair fixes: +- paraId/durableId values that exceed OOXML limits +- Missing xml:space="preserve" on w:t elements with whitespace +""" + +import argparse +import sys +import tempfile +import zipfile +from pathlib import Path + +from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator + + +def main(): + parser = argparse.ArgumentParser(description="Validate Office document XML files") + parser.add_argument( + "path", + help="Path to unpacked directory or packed Office file (.docx/.pptx/.xlsx)", + ) + parser.add_argument( + "--original", + required=False, + default=None, + help="Path to original file (.docx/.pptx/.xlsx). If omitted, all XSD errors are reported and redlining validation is skipped.", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output", + ) + parser.add_argument( + "--auto-repair", + action="store_true", + help="Automatically repair common issues (hex IDs, whitespace preservation)", + ) + parser.add_argument( + "--author", + default="Claude", + help="Author name for redlining validation (default: Claude)", + ) + args = parser.parse_args() + + path = Path(args.path) + assert path.exists(), f"Error: {path} does not exist" + + original_file = None + if args.original: + original_file = Path(args.original) + assert original_file.is_file(), f"Error: {original_file} is not a file" + assert original_file.suffix.lower() in [".docx", ".pptx", ".xlsx"], ( + f"Error: {original_file} must be a .docx, .pptx, or .xlsx file" + ) + + file_extension = (original_file or path).suffix.lower() + assert file_extension in [".docx", ".pptx", ".xlsx"], ( + f"Error: Cannot determine file type from {path}. Use --original or provide a .docx/.pptx/.xlsx file." + ) + + if path.is_file() and path.suffix.lower() in [".docx", ".pptx", ".xlsx"]: + temp_dir = tempfile.mkdtemp() + with zipfile.ZipFile(path, "r") as zf: + zf.extractall(temp_dir) + unpacked_dir = Path(temp_dir) + else: + assert path.is_dir(), f"Error: {path} is not a directory or Office file" + unpacked_dir = path + + match file_extension: + case ".docx": + validators = [ + DOCXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), + ] + if original_file: + validators.append( + RedliningValidator(unpacked_dir, original_file, verbose=args.verbose, author=args.author) + ) + case ".pptx": + validators = [ + PPTXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), + ] + case _: + print(f"Error: Validation not supported for file type {file_extension}") + sys.exit(1) + + if args.auto_repair: + total_repairs = sum(v.repair() for v in validators) + if total_repairs: + print(f"Auto-repaired {total_repairs} issue(s)") + + success = all(v.validate() for v in validators) + + if success: + print("All validations PASSED!") + + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/xlsx/scripts/office/validators/__init__.py b/.claude/skills/xlsx/scripts/office/validators/__init__.py new file mode 100644 index 000000000..db092ece7 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/validators/__init__.py @@ -0,0 +1,15 @@ +""" +Validation modules for Word document processing. +""" + +from .base import BaseSchemaValidator +from .docx import DOCXSchemaValidator +from .pptx import PPTXSchemaValidator +from .redlining import RedliningValidator + +__all__ = [ + "BaseSchemaValidator", + "DOCXSchemaValidator", + "PPTXSchemaValidator", + "RedliningValidator", +] diff --git a/.claude/skills/xlsx/scripts/office/validators/base.py b/.claude/skills/xlsx/scripts/office/validators/base.py new file mode 100644 index 000000000..db4a06a22 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/validators/base.py @@ -0,0 +1,847 @@ +""" +Base validator with common validation logic for document files. +""" + +import re +from pathlib import Path + +import defusedxml.minidom +import lxml.etree + + +class BaseSchemaValidator: + + IGNORED_VALIDATION_ERRORS = [ + "hyphenationZone", + "purl.org/dc/terms", + ] + + UNIQUE_ID_REQUIREMENTS = { + "comment": ("id", "file"), + "commentrangestart": ("id", "file"), + "commentrangeend": ("id", "file"), + "bookmarkstart": ("id", "file"), + "bookmarkend": ("id", "file"), + "sldid": ("id", "file"), + "sldmasterid": ("id", "global"), + "sldlayoutid": ("id", "global"), + "cm": ("authorid", "file"), + "sheet": ("sheetid", "file"), + "definedname": ("id", "file"), + "cxnsp": ("id", "file"), + "sp": ("id", "file"), + "pic": ("id", "file"), + "grpsp": ("id", "file"), + } + + EXCLUDED_ID_CONTAINERS = { + "sectionlst", + } + + ELEMENT_RELATIONSHIP_TYPES = {} + + SCHEMA_MAPPINGS = { + "word": "ISO-IEC29500-4_2016/wml.xsd", + "ppt": "ISO-IEC29500-4_2016/pml.xsd", + "xl": "ISO-IEC29500-4_2016/sml.xsd", + "[Content_Types].xml": "ecma/fouth-edition/opc-contentTypes.xsd", + "app.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd", + "core.xml": "ecma/fouth-edition/opc-coreProperties.xsd", + "custom.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd", + ".rels": "ecma/fouth-edition/opc-relationships.xsd", + "people.xml": "microsoft/wml-2012.xsd", + "commentsIds.xml": "microsoft/wml-cid-2016.xsd", + "commentsExtensible.xml": "microsoft/wml-cex-2018.xsd", + "commentsExtended.xml": "microsoft/wml-2012.xsd", + "chart": "ISO-IEC29500-4_2016/dml-chart.xsd", + "theme": "ISO-IEC29500-4_2016/dml-main.xsd", + "drawing": "ISO-IEC29500-4_2016/dml-main.xsd", + } + + MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006" + XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" + + PACKAGE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/relationships" + ) + OFFICE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + ) + CONTENT_TYPES_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/content-types" + ) + + MAIN_CONTENT_FOLDERS = {"word", "ppt", "xl"} + + OOXML_NAMESPACES = { + "http://schemas.openxmlformats.org/officeDocument/2006/math", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "http://schemas.openxmlformats.org/schemaLibrary/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/chart", + "http://schemas.openxmlformats.org/drawingml/2006/chartDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/diagram", + "http://schemas.openxmlformats.org/drawingml/2006/picture", + "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "http://schemas.openxmlformats.org/presentationml/2006/main", + "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + "http://schemas.openxmlformats.org/officeDocument/2006/sharedTypes", + "http://www.w3.org/XML/1998/namespace", + } + + def __init__(self, unpacked_dir, original_file=None, verbose=False): + self.unpacked_dir = Path(unpacked_dir).resolve() + self.original_file = Path(original_file) if original_file else None + self.verbose = verbose + + self.schemas_dir = Path(__file__).parent.parent / "schemas" + + patterns = ["*.xml", "*.rels"] + self.xml_files = [ + f for pattern in patterns for f in self.unpacked_dir.rglob(pattern) + ] + + if not self.xml_files: + print(f"Warning: No XML files found in {self.unpacked_dir}") + + def validate(self): + raise NotImplementedError("Subclasses must implement the validate method") + + def repair(self) -> int: + return self.repair_whitespace_preservation() + + def repair_whitespace_preservation(self) -> int: + repairs = 0 + + for xml_file in self.xml_files: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + modified = False + + for elem in dom.getElementsByTagName("*"): + if elem.tagName.endswith(":t") and elem.firstChild: + text = elem.firstChild.nodeValue + if text and (text.startswith((' ', '\t')) or text.endswith((' ', '\t'))): + if elem.getAttribute("xml:space") != "preserve": + elem.setAttribute("xml:space", "preserve") + text_preview = repr(text[:30]) + "..." if len(text) > 30 else repr(text) + print(f" Repaired: {xml_file.name}: Added xml:space='preserve' to {elem.tagName}: {text_preview}") + repairs += 1 + modified = True + + if modified: + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + + except Exception: + pass + + return repairs + + def validate_xml(self): + errors = [] + + for xml_file in self.xml_files: + try: + lxml.etree.parse(str(xml_file)) + except lxml.etree.XMLSyntaxError as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {e.lineno}: {e.msg}" + ) + except Exception as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Unexpected error: {str(e)}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} XML violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All XML files are well-formed") + return True + + def validate_namespaces(self): + errors = [] + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + declared = set(root.nsmap.keys()) - {None} + + for attr_val in [ + v for k, v in root.attrib.items() if k.endswith("Ignorable") + ]: + undeclared = set(attr_val.split()) - declared + errors.extend( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Namespace '{ns}' in Ignorable but not declared" + for ns in undeclared + ) + except lxml.etree.XMLSyntaxError: + continue + + if errors: + print(f"FAILED - {len(errors)} namespace issues:") + for error in errors: + print(error) + return False + if self.verbose: + print("PASSED - All namespace prefixes properly declared") + return True + + def validate_unique_ids(self): + errors = [] + global_ids = {} + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + file_ids = {} + + mc_elements = root.xpath( + ".//mc:AlternateContent", namespaces={"mc": self.MC_NAMESPACE} + ) + for elem in mc_elements: + elem.getparent().remove(elem) + + for elem in root.iter(): + tag = ( + elem.tag.split("}")[-1].lower() + if "}" in elem.tag + else elem.tag.lower() + ) + + if tag in self.UNIQUE_ID_REQUIREMENTS: + in_excluded_container = any( + ancestor.tag.split("}")[-1].lower() in self.EXCLUDED_ID_CONTAINERS + for ancestor in elem.iterancestors() + ) + if in_excluded_container: + continue + + attr_name, scope = self.UNIQUE_ID_REQUIREMENTS[tag] + + id_value = None + for attr, value in elem.attrib.items(): + attr_local = ( + attr.split("}")[-1].lower() + if "}" in attr + else attr.lower() + ) + if attr_local == attr_name: + id_value = value + break + + if id_value is not None: + if scope == "global": + if id_value in global_ids: + prev_file, prev_line, prev_tag = global_ids[ + id_value + ] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Global ID '{id_value}' in <{tag}> " + f"already used in {prev_file} at line {prev_line} in <{prev_tag}>" + ) + else: + global_ids[id_value] = ( + xml_file.relative_to(self.unpacked_dir), + elem.sourceline, + tag, + ) + elif scope == "file": + key = (tag, attr_name) + if key not in file_ids: + file_ids[key] = {} + + if id_value in file_ids[key]: + prev_line = file_ids[key][id_value] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Duplicate {attr_name}='{id_value}' in <{tag}> " + f"(first occurrence at line {prev_line})" + ) + else: + file_ids[key][id_value] = elem.sourceline + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} ID uniqueness violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All required IDs are unique") + return True + + def validate_file_references(self): + errors = [] + + rels_files = list(self.unpacked_dir.rglob("*.rels")) + + if not rels_files: + if self.verbose: + print("PASSED - No .rels files found") + return True + + all_files = [] + for file_path in self.unpacked_dir.rglob("*"): + if ( + file_path.is_file() + and file_path.name != "[Content_Types].xml" + and not file_path.name.endswith(".rels") + ): + all_files.append(file_path.resolve()) + + all_referenced_files = set() + + if self.verbose: + print( + f"Found {len(rels_files)} .rels files and {len(all_files)} target files" + ) + + for rels_file in rels_files: + try: + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + rels_dir = rels_file.parent + + referenced_files = set() + broken_refs = [] + + for rel in rels_root.findall( + ".//ns:Relationship", + namespaces={"ns": self.PACKAGE_RELATIONSHIPS_NAMESPACE}, + ): + target = rel.get("Target") + if target and not target.startswith( + ("http", "mailto:") + ): + if target.startswith("/"): + target_path = self.unpacked_dir / target.lstrip("/") + elif rels_file.name == ".rels": + target_path = self.unpacked_dir / target + else: + base_dir = rels_dir.parent + target_path = base_dir / target + + try: + target_path = target_path.resolve() + if target_path.exists() and target_path.is_file(): + referenced_files.add(target_path) + all_referenced_files.add(target_path) + else: + broken_refs.append((target, rel.sourceline)) + except (OSError, ValueError): + broken_refs.append((target, rel.sourceline)) + + if broken_refs: + rel_path = rels_file.relative_to(self.unpacked_dir) + for broken_ref, line_num in broken_refs: + errors.append( + f" {rel_path}: Line {line_num}: Broken reference to {broken_ref}" + ) + + except Exception as e: + rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append(f" Error parsing {rel_path}: {e}") + + unreferenced_files = set(all_files) - all_referenced_files + + if unreferenced_files: + for unref_file in sorted(unreferenced_files): + unref_rel_path = unref_file.relative_to(self.unpacked_dir) + errors.append(f" Unreferenced file: {unref_rel_path}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship validation errors:") + for error in errors: + print(error) + print( + "CRITICAL: These errors will cause the document to appear corrupt. " + + "Broken references MUST be fixed, " + + "and unreferenced files MUST be referenced or removed." + ) + return False + else: + if self.verbose: + print( + "PASSED - All references are valid and all files are properly referenced" + ) + return True + + def validate_all_relationship_ids(self): + import lxml.etree + + errors = [] + + for xml_file in self.xml_files: + if xml_file.suffix == ".rels": + continue + + rels_dir = xml_file.parent / "_rels" + rels_file = rels_dir / f"{xml_file.name}.rels" + + if not rels_file.exists(): + continue + + try: + rels_root = lxml.etree.parse(str(rels_file)).getroot() + rid_to_type = {} + + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rid = rel.get("Id") + rel_type = rel.get("Type", "") + if rid: + if rid in rid_to_type: + rels_rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append( + f" {rels_rel_path}: Line {rel.sourceline}: " + f"Duplicate relationship ID '{rid}' (IDs must be unique)" + ) + type_name = ( + rel_type.split("/")[-1] if "/" in rel_type else rel_type + ) + rid_to_type[rid] = type_name + + xml_root = lxml.etree.parse(str(xml_file)).getroot() + + r_ns = self.OFFICE_RELATIONSHIPS_NAMESPACE + rid_attrs_to_check = ["id", "embed", "link"] + for elem in xml_root.iter(): + for attr_name in rid_attrs_to_check: + rid_attr = elem.get(f"{{{r_ns}}}{attr_name}") + if not rid_attr: + continue + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + elem_name = ( + elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag + ) + + if rid_attr not in rid_to_type: + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> r:{attr_name} references non-existent relationship '{rid_attr}' " + f"(valid IDs: {', '.join(sorted(rid_to_type.keys())[:5])}{'...' if len(rid_to_type) > 5 else ''})" + ) + elif attr_name == "id" and self.ELEMENT_RELATIONSHIP_TYPES: + expected_type = self._get_expected_relationship_type( + elem_name + ) + if expected_type: + actual_type = rid_to_type[rid_attr] + if expected_type not in actual_type.lower(): + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> references '{rid_attr}' which points to '{actual_type}' " + f"but should point to a '{expected_type}' relationship" + ) + + except Exception as e: + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + errors.append(f" Error processing {xml_rel_path}: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship ID reference errors:") + for error in errors: + print(error) + print("\nThese ID mismatches will cause the document to appear corrupt!") + return False + else: + if self.verbose: + print("PASSED - All relationship ID references are valid") + return True + + def _get_expected_relationship_type(self, element_name): + elem_lower = element_name.lower() + + if elem_lower in self.ELEMENT_RELATIONSHIP_TYPES: + return self.ELEMENT_RELATIONSHIP_TYPES[elem_lower] + + if elem_lower.endswith("id") and len(elem_lower) > 2: + prefix = elem_lower[:-2] + if prefix.endswith("master"): + return prefix.lower() + elif prefix.endswith("layout"): + return prefix.lower() + else: + if prefix == "sld": + return "slide" + return prefix.lower() + + if elem_lower.endswith("reference") and len(elem_lower) > 9: + prefix = elem_lower[:-9] + return prefix.lower() + + return None + + def validate_content_types(self): + errors = [] + + content_types_file = self.unpacked_dir / "[Content_Types].xml" + if not content_types_file.exists(): + print("FAILED - [Content_Types].xml file not found") + return False + + try: + root = lxml.etree.parse(str(content_types_file)).getroot() + declared_parts = set() + declared_extensions = set() + + for override in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Override" + ): + part_name = override.get("PartName") + if part_name is not None: + declared_parts.add(part_name.lstrip("/")) + + for default in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Default" + ): + extension = default.get("Extension") + if extension is not None: + declared_extensions.add(extension.lower()) + + declarable_roots = { + "sld", + "sldLayout", + "sldMaster", + "presentation", + "document", + "workbook", + "worksheet", + "theme", + } + + media_extensions = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "bmp": "image/bmp", + "tiff": "image/tiff", + "wmf": "image/x-wmf", + "emf": "image/x-emf", + } + + all_files = list(self.unpacked_dir.rglob("*")) + all_files = [f for f in all_files if f.is_file()] + + for xml_file in self.xml_files: + path_str = str(xml_file.relative_to(self.unpacked_dir)).replace( + "\\", "/" + ) + + if any( + skip in path_str + for skip in [".rels", "[Content_Types]", "docProps/", "_rels/"] + ): + continue + + try: + root_tag = lxml.etree.parse(str(xml_file)).getroot().tag + root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag + + if root_name in declarable_roots and path_str not in declared_parts: + errors.append( + f" {path_str}: File with <{root_name}> root not declared in [Content_Types].xml" + ) + + except Exception: + continue + + for file_path in all_files: + if file_path.suffix.lower() in {".xml", ".rels"}: + continue + if file_path.name == "[Content_Types].xml": + continue + if "_rels" in file_path.parts or "docProps" in file_path.parts: + continue + + extension = file_path.suffix.lstrip(".").lower() + if extension and extension not in declared_extensions: + if extension in media_extensions: + relative_path = file_path.relative_to(self.unpacked_dir) + errors.append( + f' {relative_path}: File with extension \'{extension}\' not declared in [Content_Types].xml - should add: ' + ) + + except Exception as e: + errors.append(f" Error parsing [Content_Types].xml: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} content type declaration errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print( + "PASSED - All content files are properly declared in [Content_Types].xml" + ) + return True + + def validate_file_against_xsd(self, xml_file, verbose=False): + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + + is_valid, current_errors = self._validate_single_file_xsd( + xml_file, unpacked_dir + ) + + if is_valid is None: + return None, set() + elif is_valid: + return True, set() + + original_errors = self._get_original_file_errors(xml_file) + + assert current_errors is not None + new_errors = current_errors - original_errors + + new_errors = { + e for e in new_errors + if not any(pattern in e for pattern in self.IGNORED_VALIDATION_ERRORS) + } + + if new_errors: + if verbose: + relative_path = xml_file.relative_to(unpacked_dir) + print(f"FAILED - {relative_path}: {len(new_errors)} new error(s)") + for error in list(new_errors)[:3]: + truncated = error[:250] + "..." if len(error) > 250 else error + print(f" - {truncated}") + return False, new_errors + else: + if verbose: + print( + f"PASSED - No new errors (original had {len(current_errors)} errors)" + ) + return True, set() + + def validate_against_xsd(self): + new_errors = [] + original_error_count = 0 + valid_count = 0 + skipped_count = 0 + + for xml_file in self.xml_files: + relative_path = str(xml_file.relative_to(self.unpacked_dir)) + is_valid, new_file_errors = self.validate_file_against_xsd( + xml_file, verbose=False + ) + + if is_valid is None: + skipped_count += 1 + continue + elif is_valid and not new_file_errors: + valid_count += 1 + continue + elif is_valid: + original_error_count += 1 + valid_count += 1 + continue + + new_errors.append(f" {relative_path}: {len(new_file_errors)} new error(s)") + for error in list(new_file_errors)[:3]: + new_errors.append( + f" - {error[:250]}..." if len(error) > 250 else f" - {error}" + ) + + if self.verbose: + print(f"Validated {len(self.xml_files)} files:") + print(f" - Valid: {valid_count}") + print(f" - Skipped (no schema): {skipped_count}") + if original_error_count: + print(f" - With original errors (ignored): {original_error_count}") + print( + f" - With NEW errors: {len(new_errors) > 0 and len([e for e in new_errors if not e.startswith(' ')]) or 0}" + ) + + if new_errors: + print("\nFAILED - Found NEW validation errors:") + for error in new_errors: + print(error) + return False + else: + if self.verbose: + print("\nPASSED - No new XSD validation errors introduced") + return True + + def _get_schema_path(self, xml_file): + if xml_file.name in self.SCHEMA_MAPPINGS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.name] + + if xml_file.suffix == ".rels": + return self.schemas_dir / self.SCHEMA_MAPPINGS[".rels"] + + if "charts/" in str(xml_file) and xml_file.name.startswith("chart"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["chart"] + + if "theme/" in str(xml_file) and xml_file.name.startswith("theme"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["theme"] + + if xml_file.parent.name in self.MAIN_CONTENT_FOLDERS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.parent.name] + + return None + + def _clean_ignorable_namespaces(self, xml_doc): + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + for elem in xml_copy.iter(): + attrs_to_remove = [] + + for attr in elem.attrib: + if "{" in attr: + ns = attr.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + attrs_to_remove.append(attr) + + for attr in attrs_to_remove: + del elem.attrib[attr] + + self._remove_ignorable_elements(xml_copy) + + return lxml.etree.ElementTree(xml_copy) + + def _remove_ignorable_elements(self, root): + elements_to_remove = [] + + for elem in list(root): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + + tag_str = str(elem.tag) + if tag_str.startswith("{"): + ns = tag_str.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + elements_to_remove.append(elem) + continue + + self._remove_ignorable_elements(elem) + + for elem in elements_to_remove: + root.remove(elem) + + def _preprocess_for_mc_ignorable(self, xml_doc): + root = xml_doc.getroot() + + if f"{{{self.MC_NAMESPACE}}}Ignorable" in root.attrib: + del root.attrib[f"{{{self.MC_NAMESPACE}}}Ignorable"] + + return xml_doc + + def _validate_single_file_xsd(self, xml_file, base_path): + schema_path = self._get_schema_path(xml_file) + if not schema_path: + return None, None + + try: + with open(schema_path, "rb") as xsd_file: + parser = lxml.etree.XMLParser() + xsd_doc = lxml.etree.parse( + xsd_file, parser=parser, base_url=str(schema_path) + ) + schema = lxml.etree.XMLSchema(xsd_doc) + + with open(xml_file, "r") as f: + xml_doc = lxml.etree.parse(f) + + xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc) + xml_doc = self._preprocess_for_mc_ignorable(xml_doc) + + relative_path = xml_file.relative_to(base_path) + if ( + relative_path.parts + and relative_path.parts[0] in self.MAIN_CONTENT_FOLDERS + ): + xml_doc = self._clean_ignorable_namespaces(xml_doc) + + if schema.validate(xml_doc): + return True, set() + else: + errors = set() + for error in schema.error_log: + errors.add(error.message) + return False, errors + + except Exception as e: + return False, {str(e)} + + def _get_original_file_errors(self, xml_file): + if self.original_file is None: + return set() + + import tempfile + import zipfile + + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + relative_path = xml_file.relative_to(unpacked_dir) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + with zipfile.ZipFile(self.original_file, "r") as zip_ref: + zip_ref.extractall(temp_path) + + original_xml_file = temp_path / relative_path + + if not original_xml_file.exists(): + return set() + + is_valid, errors = self._validate_single_file_xsd( + original_xml_file, temp_path + ) + return errors if errors else set() + + def _remove_template_tags_from_text_nodes(self, xml_doc): + warnings = [] + template_pattern = re.compile(r"\{\{[^}]*\}\}") + + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + def process_text_content(text, content_type): + if not text: + return text + matches = list(template_pattern.finditer(text)) + if matches: + for match in matches: + warnings.append( + f"Found template tag in {content_type}: {match.group()}" + ) + return template_pattern.sub("", text) + return text + + for elem in xml_copy.iter(): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + tag_str = str(elem.tag) + if tag_str.endswith("}t") or tag_str == "t": + continue + + elem.text = process_text_content(elem.text, "text content") + elem.tail = process_text_content(elem.tail, "tail content") + + return lxml.etree.ElementTree(xml_copy), warnings + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/.claude/skills/xlsx/scripts/office/validators/docx.py b/.claude/skills/xlsx/scripts/office/validators/docx.py new file mode 100644 index 000000000..fec405e69 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/validators/docx.py @@ -0,0 +1,446 @@ +""" +Validator for Word document XML files against XSD schemas. +""" + +import random +import re +import tempfile +import zipfile + +import defusedxml.minidom +import lxml.etree + +from .base import BaseSchemaValidator + + +class DOCXSchemaValidator(BaseSchemaValidator): + + WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + W14_NAMESPACE = "http://schemas.microsoft.com/office/word/2010/wordml" + W16CID_NAMESPACE = "http://schemas.microsoft.com/office/word/2016/wordml/cid" + + ELEMENT_RELATIONSHIP_TYPES = {} + + def validate(self): + if not self.validate_xml(): + return False + + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + if not self.validate_unique_ids(): + all_valid = False + + if not self.validate_file_references(): + all_valid = False + + if not self.validate_content_types(): + all_valid = False + + if not self.validate_against_xsd(): + all_valid = False + + if not self.validate_whitespace_preservation(): + all_valid = False + + if not self.validate_deletions(): + all_valid = False + + if not self.validate_insertions(): + all_valid = False + + if not self.validate_all_relationship_ids(): + all_valid = False + + if not self.validate_id_constraints(): + all_valid = False + + if not self.validate_comment_markers(): + all_valid = False + + self.compare_paragraph_counts() + + return all_valid + + def validate_whitespace_preservation(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"): + if elem.text: + text = elem.text + if re.search(r"^[ \t\n\r]", text) or re.search( + r"[ \t\n\r]$", text + ): + xml_space_attr = f"{{{self.XML_NAMESPACE}}}space" + if ( + xml_space_attr not in elem.attrib + or elem.attrib[xml_space_attr] != "preserve" + ): + text_preview = ( + repr(text)[:50] + "..." + if len(repr(text)) > 50 + else repr(text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} whitespace preservation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All whitespace is properly preserved") + return True + + def validate_deletions(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + for t_elem in root.xpath(".//w:del//w:t", namespaces=namespaces): + if t_elem.text: + text_preview = ( + repr(t_elem.text)[:50] + "..." + if len(repr(t_elem.text)) > 50 + else repr(t_elem.text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {t_elem.sourceline}: found within : {text_preview}" + ) + + for instr_elem in root.xpath( + ".//w:del//w:instrText", namespaces=namespaces + ): + text_preview = ( + repr(instr_elem.text or "")[:50] + "..." + if len(repr(instr_elem.text or "")) > 50 + else repr(instr_elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {instr_elem.sourceline}: found within (use ): {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} deletion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:t elements found within w:del elements") + return True + + def count_paragraphs_in_unpacked(self): + count = 0 + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + except Exception as e: + print(f"Error counting paragraphs in unpacked document: {e}") + + return count + + def count_paragraphs_in_original(self): + original = self.original_file + if original is None: + return 0 + + count = 0 + + try: + with tempfile.TemporaryDirectory() as temp_dir: + with zipfile.ZipFile(original, "r") as zip_ref: + zip_ref.extractall(temp_dir) + + doc_xml_path = temp_dir + "/word/document.xml" + root = lxml.etree.parse(doc_xml_path).getroot() + + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + + except Exception as e: + print(f"Error counting paragraphs in original document: {e}") + + return count + + def validate_insertions(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + invalid_elements = root.xpath( + ".//w:ins//w:delText[not(ancestor::w:del)]", namespaces=namespaces + ) + + for elem in invalid_elements: + text_preview = ( + repr(elem.text or "")[:50] + "..." + if len(repr(elem.text or "")) > 50 + else repr(elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: within : {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} insertion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:delText elements within w:ins elements") + return True + + def compare_paragraph_counts(self): + original_count = self.count_paragraphs_in_original() + new_count = self.count_paragraphs_in_unpacked() + + diff = new_count - original_count + diff_str = f"+{diff}" if diff > 0 else str(diff) + print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})") + + def _parse_id_value(self, val: str, base: int = 16) -> int: + return int(val, base) + + def validate_id_constraints(self): + errors = [] + para_id_attr = f"{{{self.W14_NAMESPACE}}}paraId" + durable_id_attr = f"{{{self.W16CID_NAMESPACE}}}durableId" + + for xml_file in self.xml_files: + try: + for elem in lxml.etree.parse(str(xml_file)).iter(): + if val := elem.get(para_id_attr): + if self._parse_id_value(val, base=16) >= 0x80000000: + errors.append( + f" {xml_file.name}:{elem.sourceline}: paraId={val} >= 0x80000000" + ) + + if val := elem.get(durable_id_attr): + if xml_file.name == "numbering.xml": + try: + if self._parse_id_value(val, base=10) >= 0x7FFFFFFF: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} >= 0x7FFFFFFF" + ) + except ValueError: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} must be decimal in numbering.xml" + ) + else: + if self._parse_id_value(val, base=16) >= 0x7FFFFFFF: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} >= 0x7FFFFFFF" + ) + except Exception: + pass + + if errors: + print(f"FAILED - {len(errors)} ID constraint violations:") + for e in errors: + print(e) + elif self.verbose: + print("PASSED - All paraId/durableId values within constraints") + return not errors + + def validate_comment_markers(self): + errors = [] + + document_xml = None + comments_xml = None + for xml_file in self.xml_files: + if xml_file.name == "document.xml" and "word" in str(xml_file): + document_xml = xml_file + elif xml_file.name == "comments.xml": + comments_xml = xml_file + + if not document_xml: + if self.verbose: + print("PASSED - No document.xml found (skipping comment validation)") + return True + + try: + doc_root = lxml.etree.parse(str(document_xml)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + range_starts = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentRangeStart", namespaces=namespaces + ) + } + range_ends = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentRangeEnd", namespaces=namespaces + ) + } + references = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentReference", namespaces=namespaces + ) + } + + orphaned_ends = range_ends - range_starts + for comment_id in sorted( + orphaned_ends, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + errors.append( + f' document.xml: commentRangeEnd id="{comment_id}" has no matching commentRangeStart' + ) + + orphaned_starts = range_starts - range_ends + for comment_id in sorted( + orphaned_starts, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + errors.append( + f' document.xml: commentRangeStart id="{comment_id}" has no matching commentRangeEnd' + ) + + comment_ids = set() + if comments_xml and comments_xml.exists(): + comments_root = lxml.etree.parse(str(comments_xml)).getroot() + comment_ids = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in comments_root.xpath( + ".//w:comment", namespaces=namespaces + ) + } + + marker_ids = range_starts | range_ends | references + invalid_refs = marker_ids - comment_ids + for comment_id in sorted( + invalid_refs, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + if comment_id: + errors.append( + f' document.xml: marker id="{comment_id}" references non-existent comment' + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append(f" Error parsing XML: {e}") + + if errors: + print(f"FAILED - {len(errors)} comment marker violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All comment markers properly paired") + return True + + def repair(self) -> int: + repairs = super().repair() + repairs += self.repair_durableId() + return repairs + + def repair_durableId(self) -> int: + repairs = 0 + + for xml_file in self.xml_files: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + modified = False + + for elem in dom.getElementsByTagName("*"): + if not elem.hasAttribute("w16cid:durableId"): + continue + + durable_id = elem.getAttribute("w16cid:durableId") + needs_repair = False + + if xml_file.name == "numbering.xml": + try: + needs_repair = ( + self._parse_id_value(durable_id, base=10) >= 0x7FFFFFFF + ) + except ValueError: + needs_repair = True + else: + try: + needs_repair = ( + self._parse_id_value(durable_id, base=16) >= 0x7FFFFFFF + ) + except ValueError: + needs_repair = True + + if needs_repair: + value = random.randint(1, 0x7FFFFFFE) + if xml_file.name == "numbering.xml": + new_id = str(value) + else: + new_id = f"{value:08X}" + + elem.setAttribute("w16cid:durableId", new_id) + print( + f" Repaired: {xml_file.name}: durableId {durable_id} → {new_id}" + ) + repairs += 1 + modified = True + + if modified: + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + + except Exception: + pass + + return repairs + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/.claude/skills/xlsx/scripts/office/validators/pptx.py b/.claude/skills/xlsx/scripts/office/validators/pptx.py new file mode 100644 index 000000000..09842aa99 --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/validators/pptx.py @@ -0,0 +1,275 @@ +""" +Validator for PowerPoint presentation XML files against XSD schemas. +""" + +import re + +from .base import BaseSchemaValidator + + +class PPTXSchemaValidator(BaseSchemaValidator): + + PRESENTATIONML_NAMESPACE = ( + "http://schemas.openxmlformats.org/presentationml/2006/main" + ) + + ELEMENT_RELATIONSHIP_TYPES = { + "sldid": "slide", + "sldmasterid": "slidemaster", + "notesmasterid": "notesmaster", + "sldlayoutid": "slidelayout", + "themeid": "theme", + "tablestyleid": "tablestyles", + } + + def validate(self): + if not self.validate_xml(): + return False + + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + if not self.validate_unique_ids(): + all_valid = False + + if not self.validate_uuid_ids(): + all_valid = False + + if not self.validate_file_references(): + all_valid = False + + if not self.validate_slide_layout_ids(): + all_valid = False + + if not self.validate_content_types(): + all_valid = False + + if not self.validate_against_xsd(): + all_valid = False + + if not self.validate_notes_slide_references(): + all_valid = False + + if not self.validate_all_relationship_ids(): + all_valid = False + + if not self.validate_no_duplicate_slide_layouts(): + all_valid = False + + return all_valid + + def validate_uuid_ids(self): + import lxml.etree + + errors = [] + uuid_pattern = re.compile( + r"^[\{\(]?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}[\}\)]?$" + ) + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + for elem in root.iter(): + for attr, value in elem.attrib.items(): + attr_name = attr.split("}")[-1].lower() + if attr_name == "id" or attr_name.endswith("id"): + if self._looks_like_uuid(value): + if not uuid_pattern.match(value): + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: ID '{value}' appears to be a UUID but contains invalid hex characters" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} UUID ID validation errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All UUID-like IDs contain valid hex values") + return True + + def _looks_like_uuid(self, value): + clean_value = value.strip("{}()").replace("-", "") + return len(clean_value) == 32 and all(c.isalnum() for c in clean_value) + + def validate_slide_layout_ids(self): + import lxml.etree + + errors = [] + + slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml")) + + if not slide_masters: + if self.verbose: + print("PASSED - No slide masters found") + return True + + for slide_master in slide_masters: + try: + root = lxml.etree.parse(str(slide_master)).getroot() + + rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels" + + if not rels_file.exists(): + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Missing relationships file: {rels_file.relative_to(self.unpacked_dir)}" + ) + continue + + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + valid_layout_rids = set() + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "slideLayout" in rel_type: + valid_layout_rids.add(rel.get("Id")) + + for sld_layout_id in root.findall( + f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId" + ): + r_id = sld_layout_id.get( + f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id" + ) + layout_id = sld_layout_id.get("id") + + if r_id and r_id not in valid_layout_rids: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Line {sld_layout_id.sourceline}: sldLayoutId with id='{layout_id}' " + f"references r:id='{r_id}' which is not found in slide layout relationships" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} slide layout ID validation errors:") + for error in errors: + print(error) + print( + "Remove invalid references or add missing slide layouts to the relationships file." + ) + return False + else: + if self.verbose: + print("PASSED - All slide layout IDs reference valid slide layouts") + return True + + def validate_no_duplicate_slide_layouts(self): + import lxml.etree + + errors = [] + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + for rels_file in slide_rels_files: + try: + root = lxml.etree.parse(str(rels_file)).getroot() + + layout_rels = [ + rel + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ) + if "slideLayout" in rel.get("Type", "") + ] + + if len(layout_rels) > 1: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: has {len(layout_rels)} slideLayout references" + ) + + except Exception as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print("FAILED - Found slides with duplicate slideLayout references:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All slides have exactly one slideLayout reference") + return True + + def validate_notes_slide_references(self): + import lxml.etree + + errors = [] + notes_slide_references = {} + + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + if not slide_rels_files: + if self.verbose: + print("PASSED - No slide relationship files found") + return True + + for rels_file in slide_rels_files: + try: + root = lxml.etree.parse(str(rels_file)).getroot() + + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "notesSlide" in rel_type: + target = rel.get("Target", "") + if target: + normalized_target = target.replace("../", "") + + slide_name = rels_file.stem.replace( + ".xml", "" + ) + + if normalized_target not in notes_slide_references: + notes_slide_references[normalized_target] = [] + notes_slide_references[normalized_target].append( + (slide_name, rels_file) + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + for target, references in notes_slide_references.items(): + if len(references) > 1: + slide_names = [ref[0] for ref in references] + errors.append( + f" Notes slide '{target}' is referenced by multiple slides: {', '.join(slide_names)}" + ) + for slide_name, rels_file in references: + errors.append(f" - {rels_file.relative_to(self.unpacked_dir)}") + + if errors: + print( + f"FAILED - Found {len([e for e in errors if not e.startswith(' ')])} notes slide reference validation errors:" + ) + for error in errors: + print(error) + print("Each slide may optionally have its own slide file.") + return False + else: + if self.verbose: + print("PASSED - All notes slide references are unique") + return True + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/.claude/skills/xlsx/scripts/office/validators/redlining.py b/.claude/skills/xlsx/scripts/office/validators/redlining.py new file mode 100644 index 000000000..71c81b6bf --- /dev/null +++ b/.claude/skills/xlsx/scripts/office/validators/redlining.py @@ -0,0 +1,247 @@ +""" +Validator for tracked changes in Word documents. +""" + +import subprocess +import tempfile +import zipfile +from pathlib import Path + + +class RedliningValidator: + + def __init__(self, unpacked_dir, original_docx, verbose=False, author="Claude"): + self.unpacked_dir = Path(unpacked_dir) + self.original_docx = Path(original_docx) + self.verbose = verbose + self.author = author + self.namespaces = { + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + } + + def repair(self) -> int: + return 0 + + def validate(self): + modified_file = self.unpacked_dir / "word" / "document.xml" + if not modified_file.exists(): + print(f"FAILED - Modified document.xml not found at {modified_file}") + return False + + try: + import xml.etree.ElementTree as ET + + tree = ET.parse(modified_file) + root = tree.getroot() + + del_elements = root.findall(".//w:del", self.namespaces) + ins_elements = root.findall(".//w:ins", self.namespaces) + + author_del_elements = [ + elem + for elem in del_elements + if elem.get(f"{{{self.namespaces['w']}}}author") == self.author + ] + author_ins_elements = [ + elem + for elem in ins_elements + if elem.get(f"{{{self.namespaces['w']}}}author") == self.author + ] + + if not author_del_elements and not author_ins_elements: + if self.verbose: + print(f"PASSED - No tracked changes by {self.author} found.") + return True + + except Exception: + pass + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + try: + with zipfile.ZipFile(self.original_docx, "r") as zip_ref: + zip_ref.extractall(temp_path) + except Exception as e: + print(f"FAILED - Error unpacking original docx: {e}") + return False + + original_file = temp_path / "word" / "document.xml" + if not original_file.exists(): + print( + f"FAILED - Original document.xml not found in {self.original_docx}" + ) + return False + + try: + import xml.etree.ElementTree as ET + + modified_tree = ET.parse(modified_file) + modified_root = modified_tree.getroot() + original_tree = ET.parse(original_file) + original_root = original_tree.getroot() + except ET.ParseError as e: + print(f"FAILED - Error parsing XML files: {e}") + return False + + self._remove_author_tracked_changes(original_root) + self._remove_author_tracked_changes(modified_root) + + modified_text = self._extract_text_content(modified_root) + original_text = self._extract_text_content(original_root) + + if modified_text != original_text: + error_message = self._generate_detailed_diff( + original_text, modified_text + ) + print(error_message) + return False + + if self.verbose: + print(f"PASSED - All changes by {self.author} are properly tracked") + return True + + def _generate_detailed_diff(self, original_text, modified_text): + error_parts = [ + f"FAILED - Document text doesn't match after removing {self.author}'s tracked changes", + "", + "Likely causes:", + " 1. Modified text inside another author's or tags", + " 2. Made edits without proper tracked changes", + " 3. Didn't nest inside when deleting another's insertion", + "", + "For pre-redlined documents, use correct patterns:", + " - To reject another's INSERTION: Nest inside their ", + " - To restore another's DELETION: Add new AFTER their ", + "", + ] + + git_diff = self._get_git_word_diff(original_text, modified_text) + if git_diff: + error_parts.extend(["Differences:", "============", git_diff]) + else: + error_parts.append("Unable to generate word diff (git not available)") + + return "\n".join(error_parts) + + def _get_git_word_diff(self, original_text, modified_text): + try: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + original_file = temp_path / "original.txt" + modified_file = temp_path / "modified.txt" + + original_file.write_text(original_text, encoding="utf-8") + modified_file.write_text(modified_text, encoding="utf-8") + + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "--word-diff-regex=.", + "-U0", + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + lines = result.stdout.split("\n") + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + + if content_lines: + return "\n".join(content_lines) + + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "-U0", + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + lines = result.stdout.split("\n") + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + return "\n".join(content_lines) + + except (subprocess.CalledProcessError, FileNotFoundError, Exception): + pass + + return None + + def _remove_author_tracked_changes(self, root): + ins_tag = f"{{{self.namespaces['w']}}}ins" + del_tag = f"{{{self.namespaces['w']}}}del" + author_attr = f"{{{self.namespaces['w']}}}author" + + for parent in root.iter(): + to_remove = [] + for child in parent: + if child.tag == ins_tag and child.get(author_attr) == self.author: + to_remove.append(child) + for elem in to_remove: + parent.remove(elem) + + deltext_tag = f"{{{self.namespaces['w']}}}delText" + t_tag = f"{{{self.namespaces['w']}}}t" + + for parent in root.iter(): + to_process = [] + for child in parent: + if child.tag == del_tag and child.get(author_attr) == self.author: + to_process.append((child, list(parent).index(child))) + + for del_elem, del_index in reversed(to_process): + for elem in del_elem.iter(): + if elem.tag == deltext_tag: + elem.tag = t_tag + + for child in reversed(list(del_elem)): + parent.insert(del_index, child) + parent.remove(del_elem) + + def _extract_text_content(self, root): + p_tag = f"{{{self.namespaces['w']}}}p" + t_tag = f"{{{self.namespaces['w']}}}t" + + paragraphs = [] + for p_elem in root.findall(f".//{p_tag}"): + text_parts = [] + for t_elem in p_elem.findall(f".//{t_tag}"): + if t_elem.text: + text_parts.append(t_elem.text) + paragraph_text = "".join(text_parts) + if paragraph_text: + paragraphs.append(paragraph_text) + + return "\n".join(paragraphs) + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/.claude/skills/xlsx/scripts/recalc.py b/.claude/skills/xlsx/scripts/recalc.py new file mode 100755 index 000000000..f472e9a5d --- /dev/null +++ b/.claude/skills/xlsx/scripts/recalc.py @@ -0,0 +1,184 @@ +""" +Excel Formula Recalculation Script +Recalculates all formulas in an Excel file using LibreOffice +""" + +import json +import os +import platform +import subprocess +import sys +from pathlib import Path + +from office.soffice import get_soffice_env + +from openpyxl import load_workbook + +MACRO_DIR_MACOS = "~/Library/Application Support/LibreOffice/4/user/basic/Standard" +MACRO_DIR_LINUX = "~/.config/libreoffice/4/user/basic/Standard" +MACRO_FILENAME = "Module1.xba" + +RECALCULATE_MACRO = """ + + + Sub RecalculateAndSave() + ThisComponent.calculateAll() + ThisComponent.store() + ThisComponent.close(True) + End Sub +""" + + +def has_gtimeout(): + try: + subprocess.run( + ["gtimeout", "--version"], capture_output=True, timeout=1, check=False + ) + return True + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def setup_libreoffice_macro(): + macro_dir = os.path.expanduser( + MACRO_DIR_MACOS if platform.system() == "Darwin" else MACRO_DIR_LINUX + ) + macro_file = os.path.join(macro_dir, MACRO_FILENAME) + + if ( + os.path.exists(macro_file) + and "RecalculateAndSave" in Path(macro_file).read_text() + ): + return True + + if not os.path.exists(macro_dir): + subprocess.run( + ["soffice", "--headless", "--terminate_after_init"], + capture_output=True, + timeout=10, + env=get_soffice_env(), + ) + os.makedirs(macro_dir, exist_ok=True) + + try: + Path(macro_file).write_text(RECALCULATE_MACRO) + return True + except Exception: + return False + + +def recalc(filename, timeout=30): + if not Path(filename).exists(): + return {"error": f"File {filename} does not exist"} + + abs_path = str(Path(filename).absolute()) + + if not setup_libreoffice_macro(): + return {"error": "Failed to setup LibreOffice macro"} + + cmd = [ + "soffice", + "--headless", + "--norestore", + "vnd.sun.star.script:Standard.Module1.RecalculateAndSave?language=Basic&location=application", + abs_path, + ] + + if platform.system() == "Linux": + cmd = ["timeout", str(timeout)] + cmd + elif platform.system() == "Darwin" and has_gtimeout(): + cmd = ["gtimeout", str(timeout)] + cmd + + result = subprocess.run(cmd, capture_output=True, text=True, env=get_soffice_env()) + + if result.returncode != 0 and result.returncode != 124: + error_msg = result.stderr or "Unknown error during recalculation" + if "Module1" in error_msg or "RecalculateAndSave" not in error_msg: + return {"error": "LibreOffice macro not configured properly"} + return {"error": error_msg} + + try: + wb = load_workbook(filename, data_only=True) + + excel_errors = [ + "#VALUE!", + "#DIV/0!", + "#REF!", + "#NAME?", + "#NULL!", + "#NUM!", + "#N/A", + ] + error_details = {err: [] for err in excel_errors} + total_errors = 0 + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + for row in ws.iter_rows(): + for cell in row: + if cell.value is not None and isinstance(cell.value, str): + for err in excel_errors: + if err in cell.value: + location = f"{sheet_name}!{cell.coordinate}" + error_details[err].append(location) + total_errors += 1 + break + + wb.close() + + result = { + "status": "success" if total_errors == 0 else "errors_found", + "total_errors": total_errors, + "error_summary": {}, + } + + for err_type, locations in error_details.items(): + if locations: + result["error_summary"][err_type] = { + "count": len(locations), + "locations": locations[:20], + } + + wb_formulas = load_workbook(filename, data_only=False) + formula_count = 0 + for sheet_name in wb_formulas.sheetnames: + ws = wb_formulas[sheet_name] + for row in ws.iter_rows(): + for cell in row: + if ( + cell.value + and isinstance(cell.value, str) + and cell.value.startswith("=") + ): + formula_count += 1 + wb_formulas.close() + + result["total_formulas"] = formula_count + + return result + + except Exception as e: + return {"error": str(e)} + + +def main(): + if len(sys.argv) < 2: + print("Usage: python recalc.py [timeout_seconds]") + print("\nRecalculates all formulas in an Excel file using LibreOffice") + print("\nReturns JSON with error details:") + print(" - status: 'success' or 'errors_found'") + print(" - total_errors: Total number of Excel errors found") + print(" - total_formulas: Number of formulas in the file") + print(" - error_summary: Breakdown by error type with locations") + print(" - #VALUE!, #DIV/0!, #REF!, #NAME?, #NULL!, #NUM!, #N/A") + sys.exit(1) + + filename = sys.argv[1] + timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 30 + + result = recalc(filename, timeout) + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/.claude/specs/2026-04-20-artifact-download-archive-name-design.md b/.claude/specs/2026-04-20-artifact-download-archive-name-design.md new file mode 100644 index 000000000..c1a5adac4 --- /dev/null +++ b/.claude/specs/2026-04-20-artifact-download-archive-name-design.md @@ -0,0 +1,85 @@ +# Artifact Download Archive Name — Design Spec + +**Goal:** When directly downloading a rendered artifact from the change proposal page, name the downloaded `.zip` after the artifact's slug instead of the generic `preview`. + +**Scope:** +- **In:** filename of the zip delivered by the `POST /change-proposals/preview-rendering` endpoint; frontend filename applied by `DownloadAsAgentButton`. +- **Out:** zip contents, agent deployer logic, any other download flows, any multi-artifact aggregated download path (no UI currently triggers it — current fallback preserved). + +## Source +None (fresh idea). + +## Architecture + +Two touch points, one source of truth (backend). + +- **Backend (`PreviewArtifactRenderingUseCase`, `packages/coding-agent`)** computes the archive filename from the command. When the command contains exactly one artifact across `recipeVersions` / `standardVersions` / `skillVersions`, use that artifact's `slug`. Fallback to the artifact's slugified `name` if `slug` is missing/empty. Otherwise (zero or multiple artifacts — no current UI path), fall back to the existing `packmind-${agent}-preview.zip` name. +- **Backend controller** already emits `Content-Disposition: attachment; filename="${result.fileName}"` — no change. +- **Frontend (`DownloadAsAgentButton`)** stops hardcoding the filename. It parses `Content-Disposition` from the response and uses its `filename` as `a.download`. If parsing fails (defensive), falls back to the existing hardcoded name. + +## Data Model + +No domain changes. Existing fields used: +- `RecipeVersion.slug: string`, `RecipeVersion.name: string` +- `StandardVersion.slug: string`, `StandardVersion.name: string` +- `SkillVersion.slug: string`, `SkillVersion.name: string` + +## Use Cases / Services + +### `PreviewArtifactRenderingUseCase.execute` — modified + +Filename computation: + +1. Collect the union of the three version arrays from the command. +2. If exactly **one** artifact: `slug = artifact.slug || slugify(artifact.name)`. +3. Filename: `packmind-${codingAgent}-${slug}.zip`. +4. Otherwise: `packmind-${codingAgent}-preview.zip` (unchanged). + +Slugify helper (local to the use case file, private function): +- Lowercase. +- Replace any run of characters outside `[a-z0-9]` with a single `-`. +- Trim leading/trailing `-`. +- If the result is empty, return `"preview"`. + +No new public API. No ports / no events / no DB. + +## API / CLI / Frontend Surface + +### Backend +- No change to request shape. +- No change to response shape (`PreviewArtifactRenderingResponse.fileName` already carries the filename). +- `Content-Disposition: attachment; filename="${result.fileName}"` — unchanged. The value is now slug-based. + +### Frontend — `DownloadAsAgentButton` +- In `handleDownload`, after a successful `fetch`, read `response.headers.get('Content-Disposition')`. +- Extract the filename via a local parser handling both `filename="..."` and `filename=...` forms. Return the trimmed value or `null`. +- `a.download = parsedName ?? \`packmind-${agent}-preview.zip\``. + +No change to component props, usage sites (`ArtifactResultFilePreview`, `CreationReviewHeader`), or the popover UI. + +## Testing Approach + +### Backend — `PreviewArtifactRenderingUseCase.spec.ts` +Update and add cases: +- Existing "returns a zip with the correct filename" (single recipe `test-command`, agent `claude`) — expected filename changes to `packmind-claude-test-command.zip`. +- New: single standard — `packmind-claude-test-standard.zip`. +- New: single skill — `packmind-${agent}-${skill-slug}.zip`. +- New: artifact with empty `slug` and `name: "My Cool Thing!"` — filename uses slugified name: `packmind-claude-my-cool-thing.zip`. +- New: artifact with empty `slug` and empty `name` — falls back to `packmind-claude-preview.zip`. +- Existing "when rendering multiple artifacts" — assert filename is `packmind-claude-preview.zip` (multi-artifact fallback explicit). +- Existing "gets the deployer for the correct agent" (zero artifacts) — assert filename is `packmind-copilot-preview.zip`. + +### Frontend — `DownloadAsAgentButton.spec.tsx` (new file) +- When the server responds with `Content-Disposition: attachment; filename="packmind-claude-my-slug.zip"`, the anchor's `download` attribute is set to `packmind-claude-my-slug.zip`. +- When the response has no `Content-Disposition`, the anchor falls back to `packmind-claude-preview.zip`. +- When `Content-Disposition` is malformed (no `filename=` token), same fallback applies. + +Use `fetch` mocking via `jest.spyOn(globalThis, 'fetch')` returning a `Response` with a blob body and the relevant header. Assert on a spied `HTMLAnchorElement.click` + the anchor's `download` attribute. + +## Out of Scope + +- Any download flow other than `preview-rendering`. +- Multi-artifact naming strategies (concatenation, zipped-of-zips, etc.). +- Renaming the files *inside* the zip. +- i18n / localization of the filename. +- Analytics events. diff --git a/.claude/specs/2026-04-27-orga-space-management-design.md b/.claude/specs/2026-04-27-orga-space-management-design.md new file mode 100644 index 000000000..d71460ac0 --- /dev/null +++ b/.claude/specs/2026-04-27-orga-space-management-design.md @@ -0,0 +1,276 @@ +# Organization Spaces Management Page — Design Spec + +**Goal:** Wire `/org/{orgSlug}/settings/spaces` to a dedicated, paginated, org-admin-only listing endpoint with aggregated admins, member counts, and artifact counts; remove bulk-delete UI; add a delete confirmation flow; keep `+ New space` on-page. + +**Scope:** In — new backend use case + endpoint + repositories aggregations; route + query refactor; row actions (View, Delete) with confirmation; cleanup of bulk-action UI. Out — search/filter behavior, Edit row action, bulk delete, color persistence, page-size customization, navigation entry-point work. + +## Source + +EM spec: `specs/orga-space-management.md` + +## Prior Knowledge + +No `.claude/docs/` knowledge base exists in this project. Authoritative inputs consulted during brainstorming: +- `apps/CLAUDE.md` and `apps/frontend/CLAUDE.md` — app boundaries and conventions. +- `apps/frontend/.claude/rules/packmind/standard-frontend-data-flow.md` — clientLoader + queryClient + query options/hooks pattern. +- `.claude/rules/packmind/standard-typescript-good-practices.md` — intersection types for DTO enrichment, no `Object.setPrototypeOf` in errors. +- `.claude/rules/packmind/standard-compliance-logging-personal-information.md` — never log PII (e.g. emails) in clear text. +- Existing route: `apps/frontend/app/routes/org.$orgSlug._protected.settings.spaces._index.tsx` (already gated by `ORGA_SPACE_MANAGEMENT_FEATURE_KEY`). +- Existing partial page: `apps/frontend/src/domain/spaces/components/SpacesManagementPage/` (table, mappers, types, toolbar, pagination, bulk-action bar — bulk-action bar is removed by this design). +- Existing CreateSpaceDialog: `apps/frontend/src/domain/spaces-management/components/CreateSpaceDialog.tsx` (already supports `redirectAfterCreate={false}`). +- Existing backend domain packages: `packages/spaces` (entities, memberships, schemas) and `packages/spaces-management` (use cases incl. `ListUserSpacesUseCase`, `DeleteSpaceUseCase`; errors incl. `CannotDeleteDefaultSpaceError`, `SpaceDeletionForbiddenError`). +- Related design specs (cross-checked for sort/listing decisions): + - `.claude/specs/2026-04-13-spaces-alphabetical-sort-design.md` + - `.claude/specs/2026-04-08-admin-browse-join-all-spaces-design.md` + - `.claude/specs/2026-04-07-space-creation-discoverability-design.md` + +## Architecture + +A new dedicated endpoint serves the management page. The existing `GET /organizations/:orgId/spaces` endpoint (user-spaces query) is left untouched and continues to power the sidebar, pickers, and other consumers. + +**Backend:** +- Use case `ListOrganizationSpacesForManagementUseCase` in `packages/spaces-management/src/application/usecases/`. +- Endpoint `GET /organizations/:orgId/spaces/management?page=N` on the existing `OrganizationsSpacesController` (same controller, distinct path). +- Authorization: org admin only. The use case calls the existing org-admin assertion (pattern already used in the codebase — implementer uses the same approach as comparable admin-only use cases). +- Aggregation: a single `findAndCount` for the page of `Space`s, then five parallel queries via `Promise.all` against memberships (admins + member counts) and artifacts (standards / recipes / skills counts). Results are stitched in the use case. No N+1. +- Verify (and add if missing) `space_id` indexes on `user_space_memberships`, `standards`, `recipes`, `skills`. + +**Frontend:** +- New gateway method on `SpacesGatewayApi`, typed via `Gateway`. +- New query options + hook under `apps/frontend/src/domain/spaces/api/queries/SpacesQueries.ts` with `keepPreviousData` for smooth page transitions and a 30s stale time. +- Route `clientLoader` prefetches page 1 of the new query and exposes `totalCount` to the route component for the subtitle. +- `SpacesManagementPage.tsx` reads via `useGetOrganizationSpacesForManagementQuery(orgId, page)` with local `useState` for the current page. Pagination becomes server-side. +- `SpacesBulkActionBar.tsx` is deleted; row checkboxes and selection state are removed from the table and page. +- New `DeleteSpaceConfirmDialog.tsx` invoked from `SpaceRowActions`. View action navigates to `/org/{orgSlug}/spaces/{slug}` when slug is present. +- `+ New space` opens the existing `CreateSpaceDialog` with `redirectAfterCreate={false}`; success handler closes the dialog and invalidates the new query. + +## Data Model + +### Use case contract (shared backend ↔ frontend gateway) + +```ts +// packages/spaces-management/src/domain/usecases/IListOrganizationSpacesForManagementUseCase.ts +export type SpaceManagementListItem = Space & { + admins: Array<{ id: UserId; displayName: string }>; + membersCount: number; // role = MEMBER (admins excluded) + artifactsCount: number; // sum of standards + recipes + skills +}; + +export type ListOrganizationSpacesForManagementCommand = { + organizationId: OrganizationId; + userId: UserId; // for org-admin authz + page: number; // 1-based +}; + +export type ListOrganizationSpacesForManagementResponse = { + items: SpaceManagementListItem[]; + totalCount: number; + page: number; + pageSize: number; // const 8 +}; + +export interface IListOrganizationSpacesForManagementUseCase { + execute( + cmd: ListOrganizationSpacesForManagementCommand, + ): Promise; +} +``` + +`SpaceManagementListItem` extends `Space` per the TypeScript standard (intersection over re-declaration). `displayName` is sourced from the existing User entity field used elsewhere — no new column. + +### New repository methods + +| Repo | Method | Signature | +|---|---|---| +| `SpaceRepository` | `findOrgPagePaginated` | `(orgId, page, pageSize) → { items: Space[]; totalCount: number }` ordered `isDefaultSpace DESC, createdAt ASC` | +| `UserSpaceMembershipRepository` | `findAdminsForSpaceIds` | `(spaceIds) → Array<{ spaceId; user: { id; displayName } }>` joined with User | +| `UserSpaceMembershipRepository` | `countByRoleForSpaceIds` | `(spaceIds, role) → Map` | +| `StandardRepository` | `countBySpaceIds` | `(spaceIds) → Map` | +| `RecipeRepository` | `countBySpaceIds` | `(spaceIds) → Map` | +| `SkillRepository` | `countBySpaceIds` | `(spaceIds) → Map` | + +Count methods return `Map` so the use case can do O(1) lookups; missing entries default to `0` at the use case layer. + +### Frontend type cleanup + +```ts +// apps/frontend/src/domain/spaces/components/SpacesManagementPage/types.ts +export type SpaceListItem = Space & { + colorToken: SpaceColorToken; + isOrgWide: boolean; + admins: SpaceAdminAvatar[]; + membersCount: number; // was number | null + artifactsCount: number; // was number | null + createdAt: string; // was string | null +}; +``` + +The `null` placeholders are removed because the new endpoint always populates these fields and the page no longer renders mocked rows. + +The mapper `toSpaceListItem` now takes a `SpaceManagementListItem` (not a raw `Space`); existing usage is local to `SpacesManagementPage/`. + +## Use Cases / Services + +### `ListOrganizationSpacesForManagementUseCase.execute(cmd)` + +Steps: +1. Validate `page` is a positive integer; otherwise throw `InvalidPageError(page)` (new error in `packages/spaces-management/src/domain/errors/`, follows the existing error pattern — no `Object.setPrototypeOf`). +2. Assert the caller is an org admin of `organizationId`. If not, the existing org-admin assertion error is thrown unchanged. +3. Fetch the page: `spaceRepository.findOrgPagePaginated(orgId, page, PAGE_SIZE)`. +4. If `items.length === 0`, return `{ items: [], totalCount, page, pageSize }`. +5. Otherwise, run 5 aggregations in parallel: + - `userSpaceMembershipRepository.findAdminsForSpaceIds(spaceIds)` + - `userSpaceMembershipRepository.countByRoleForSpaceIds(spaceIds, MEMBER)` + - `standardRepository.countBySpaceIds(spaceIds)` + - `recipeRepository.countBySpaceIds(spaceIds)` + - `skillRepository.countBySpaceIds(spaceIds)` +6. Stitch each space row: attach admins (defaulting to `[]`), `membersCount` (defaulting to `0`), `artifactsCount = standards + recipes + skills` (defaulting each to `0`). +7. Return `{ items, totalCount, page, pageSize: PAGE_SIZE }`. + +### Constants + +`PAGE_SIZE = 8` is exported from a shared module reachable by both the use case and the frontend (so the gateway/query layer doesn't hardcode `8` independently). + +### Errors + +| Condition | Error | HTTP | +|---|---|---| +| `page` invalid | `InvalidPageError(page)` | 400 | +| Caller not org admin | reuse existing org-admin error | 403 | +| Org not found (defensive) | reuse existing pattern | 404 | + +Out-of-range pages (`page > ceil(totalCount / pageSize)`): return empty `items` with the actual `totalCount`. Not an error. + +### Logging + +INFO log on success with `organizationId`, `page`, `itemsCount`, `totalCount`. No PII. The org-admin assertion handles its own forbidden logging per existing pattern. + +### Adapters + +Wire `ListOrganizationSpacesForManagementUseCase` into `SpacesManagementHexa` constructor following the existing adapter pattern (`ListUserSpacesUseCase` is the closest sibling to copy from). + +## API / CLI / Frontend Surface + +### Backend + +- Route: `GET /organizations/:orgId/spaces/management?page=N`. +- Controller: `OrganizationsSpacesController` (existing). New method calls `SpacesManagementHexa.useCases.listOrganizationSpacesForManagement.execute(...)` and returns the response shape verbatim. +- Auth guard: existing JWT/session guard — same as the sibling `listSpaces` action. The use case enforces org-admin separately. + +### Frontend gateway and query + +```ts +// apps/frontend/src/domain/spaces/api/gateways/SpacesGatewayApi.ts +listOrganizationSpacesForManagement: Gateway; +// implemented as: GET /organizations/:orgId/spaces/management?page=N +``` + +```ts +// apps/frontend/src/domain/spaces/api/queries/SpacesQueries.ts +export function getOrganizationSpacesForManagementQueryOptions(orgId: string, page: number) { + return queryOptions({ + queryKey: ['organizations', orgId, 'spaces', 'management', page], + queryFn: () => spacesGateway.listOrganizationSpacesForManagement(orgId, page), + placeholderData: keepPreviousData, + staleTime: 30_000, + }); +} + +export function useGetOrganizationSpacesForManagementQuery(orgId: string, page: number) { + return useQuery(getOrganizationSpacesForManagementQueryOptions(orgId, page)); +} +``` + +### Route refactor + +`apps/frontend/app/routes/org.$orgSlug._protected.settings.spaces._index.tsx`: +- `clientLoader` calls `queryClient.ensureQueryData(getOrganizationSpacesForManagementQueryOptions(orgId, 1))` and returns `{ totalCount }`. +- The route component uses `totalCount` (replacing `spaceCount`) for the subtitle. Null-fallback wording is preserved. + +### Page changes + +`SpacesManagementPage.tsx`: +- Remove `selectedRows` state and any selection callbacks. +- Add `const [page, setPage] = useState(1);` and call `useGetOrganizationSpacesForManagementQuery(orgId, page)`. +- Map `data.items.map(toSpaceListItem)` into ``. +- Pass `page`, `setPage`, `totalCount`, `pageSize` to ``. +- Loading and error states preserved (existing behavior). + +`SpacesTable.tsx`: +- Drop checkbox column and the related props. +- Drop `` import (file deleted). +- Columns kept: Name, Admins, Members, Artifacts, Created, Actions. + +`SpacesPagination.tsx`: +- Refactor to a controlled component: props `{ page, pageSize, totalCount, onPageChange }`. `totalPages = Math.ceil(totalCount / pageSize)`. Hide entirely when `totalCount <= pageSize`. + +`SpaceRowActions.tsx`: +- Items: **View** (hidden if no `slug`), **Delete** (hidden if `isDefaultSpace`). Edit hidden. +- View navigates with `useNavigate()` to `/org/{orgSlug}/spaces/{slug}`. +- Delete opens ``. + +`DeleteSpaceConfirmDialog.tsx` (new, under `SpacesManagementPage/`): +- Modal copy: `Delete space '{name}'? This action is irreversible.` Buttons: `Cancel` / `Delete`. +- Wires existing `useDeleteSpaceMutation`. +- Success: close, success toast, invalidate `['organizations', orgId, 'spaces', 'management']` (broad key invalidates all cached pages so the table refreshes). +- Error: error toast, modal stays open. Microcopy reviewed via `ux-microcopy`. + +`SpacesToolbar.tsx`: +- Search and Admin/Member dropdowns: visual-only (no behavior wired). +- `+ New space` opens the existing ``. +- The `onCreated` handler closes the dialog and invalidates the new query. + +`CreateSpaceDialog.tsx` (existing): no contract change beyond confirming/wiring the `onCreated` callback if not already exposed; the dialog itself already supports staying on the page after create. + +### Files deleted + +- `apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesBulkActionBar.tsx` +- Any colocated test for it. + +## Testing Approach + +### Backend + +| Layer | Coverage | +|---|---| +| `ListOrganizationSpacesForManagementUseCase.spec.ts` | Authz pass/fail; `page < 1` throws `InvalidPageError`; out-of-range page returns `{items: [], totalCount}`; happy path returns 8 rows ordered with default-space-first; aggregation defaults missing entries to `0`; stitching maps each row's counts to the correct space. | +| `SpaceRepository.findOrgPagePaginated` | Sort order with mixed default + non-default spaces; `totalCount` accuracy; page 2 offset. | +| `UserSpaceMembershipRepository.findAdminsForSpaceIds` | Returns ADMIN rows only; joins User; multi-spaceId input. | +| `UserSpaceMembershipRepository.countByRoleForSpaceIds` | Counts per role; honors any soft-delete on memberships. | +| `Standard / Recipe / Skill Repository.countBySpaceIds` | Returns `Map`; spaces with zero items absent from map. | +| `OrganizationsSpacesController` (controller test) | New route returns 200 and shape; 403 for non-admin; 400 for invalid page. | + +Real-DB integration tests follow `repository-implementation-and-testing-pattern` (factories, no mocks). + +### Frontend + +| File | Coverage | +|---|---| +| `toSpaceListItem.test.ts` (update) | Mapper takes `SpaceManagementListItem`, output has correct `colorToken`, `isOrgWide`, and pre-aggregated counts. | +| `SpacesManagementPage.test.tsx` (update) | Renders rows from new query (mocked); pagination triggers re-fetch with new page; bulk-action UI absent; loading/error preserved. | +| `DeleteSpaceConfirmDialog.test.tsx` (new) | Renders with name; Cancel closes; Delete calls mutation, invalidates, fires success toast, closes; on error, toast + modal stays open. | +| `SpacesPagination.test.tsx` (new or update) | Hides when `totalCount <= pageSize`; calls `onPageChange` with right page; disables prev/next at boundaries. | +| `SpaceRowActions.test.tsx` (new or update) | Default space hides Delete; non-default shows it; View hidden when slug missing; View navigates. | + +### E2E (Playwright) + +- **Rule 3 Example 1**: navigate to `/org/{slug}/settings/spaces`, click `+ New space`, fill name + visibility, submit. Assert dialog closes, URL still `/settings/spaces`, new space visible in table. +- **Rule 4 Example 1**: open row actions on a non-default space, click Delete, confirm. Assert modal closes, success toast, row removed. + +E2E follows the existing Page Object Model pattern under `apps/e2e-tests/`. + +### Index verification + +Plan includes a step to grep migrations for `space_id` index creation on `user_space_memberships`, `standards`, `recipes`, `skills`. If any are missing, add a TypeORM migration following `typeorm-migrations` standard with proper `down()`. + +## Out of Scope + +- Functional Search input (visual-only). +- Functional Admin/Member filter dropdowns (visual-only). +- Row `Edit` action (hidden). +- Bulk-delete UI (removed; no toggle). +- Persisted space color (derived deterministically from `space.id`). +- Page-size customization (fixed 8). +- Sidebar/navigation entry-point to `/settings/spaces`. +- Folder reorganization of `apps/frontend/src/domain/spaces-management/`. +- User Events / analytics (none defined for this iteration). diff --git a/.claude/specs/2026-04-29-space-identity-and-rights-management-design.md b/.claude/specs/2026-04-29-space-identity-and-rights-management-design.md new file mode 100644 index 000000000..224259a89 --- /dev/null +++ b/.claude/specs/2026-04-29-space-identity-and-rights-management-design.md @@ -0,0 +1,253 @@ +# Space Identity and Rights Management (Org Admin Drawer) — Design Spec + +**Goal:** From `/org/:orgSlug/settings/spaces`, let an org admin click a space row to open a side drawer that manages the space's identity (name, color), members (add / remove / change role), and deletion — without leaving the listing. + +**Scope:** In — drawer container with three tabs (General / Members / Danger); refactor of three existing section components to be prop-driven; backend authz extension so org admins can manage members of any space. Out — visibility/type editing in the drawer; deep-linking the drawer; replacing the existing per-space `/space/:slug/settings` page; toolbar search/filter behaviour; autosave; URL state for the drawer. + +## Source + +None (fresh idea, derived from `apps/playground/src/prototypes/spaces-management/` and the user's confirmation of the four design choices: drawer UX, org-admin authz extension, per-section save buttons, name+color only in General, hide Danger on default space, prop-refactor of existing sections). + +## Prior Knowledge + +- `.claude/specs/2026-04-27-orga-space-management-design.md` — design for the paginated org-admin spaces listing this drawer plugs into. +- `.claude/docs/discoveries/2026-04-28-session-orga-space-management.md` — implementation notes from the listing build: + - PMTable Jest mock requirement (`.claude/docs/patterns/frontend/pmtable-jest-mock.md`) + - `UserSpaceRole` enum (never raw `'admin'`/`'member'` strings) at the port boundary; the frontend `SpaceMemberRole` type uses `'admin' | 'member'` literals (separate concern) + - Use cases create their own `PackmindLogger` — adapters never inject one + - Plural query key gap: `['organizations', orgId, 'spaces', 'management']` must be invalidated explicitly after mutations that affect listing rows + - `CreateSpaceDialog.onCreated` callback contract — same pattern reused here for the drawer's `onDeleted` +- `.claude/docs/domain-map.md` — `ISpacesPort` methods, `SpaceManagementListItem` shape, the listing's controller test style (unit-style, no supertest module). +- Backend authz pattern in `UpdateSpaceUseCase.executeForMembers`: short-circuits to `executeForSpaceAdmins` when `command.membership.role === 'admin'`. The three membership use cases (`AddMembersToSpaceUseCase`, `RemoveMemberFromSpaceUseCase`, `UpdateMemberRoleUseCase`) currently lack this override — we add it. +- Existing prop-ready components: `SpaceIdentitySection({ space, canEdit })` already takes data via props and uses `useUpdateSpaceMutation` internally. +- Existing data-coupled components to refactor: `SpaceMembersList` and `SpaceDangerZoneSection` both call `useCurrentSpace()`, which only works inside the `_space-protected` route. They become prop-driven so they can render in the drawer too. + +## Architecture + +The feature is a thin composition layer on top of existing use cases, mutations, and section components. No new endpoints, schemas, or migrations. + +### Backend + +The three space-membership use cases gain the same authz override that `UpdateSpaceUseCase` already has: + +```ts +protected override async executeForMembers( + command: & MemberContext, +): Promise<> { + if (command.membership.role === 'admin') { // org admin + return this.executeForSpaceAdmins(command); + } + return super.executeForMembers(command); +} +``` + +The downstream `executeForSpaceAdmins` body is unchanged — every domain rule (cannot remove self, cannot remove from default space, cannot update own role, member-not-found) still applies. Org admins acting on a space they aren't in skip only the membership-lookup that would otherwise raise `SpaceAdminRequiredError`. + +Side effects are unchanged: `SpaceMembersAddedEvent`, `SpaceMembersRemovedEvent`, `SpaceMembersRoleUpdatedEvent`, `SpaceRenamedEvent` continue to emit with `command.userId` (the org admin's id) as the actor. + +The HTTP layer (`SpaceMembersController`, `SpacesManagementController`) needs no changes. `SpaceAdminRequiredError → 403` mapping stays — it just stops firing for org admins. + +### Frontend + +Three section components get a small prop refactor; one new container component composes them inside a drawer. + +**Refactored components** (under `apps/frontend/src/domain/spaces/components/`): +- `SpaceMembersList.tsx` — accepts `{ space: Space, isSpaceAdmin: boolean }`. Drops `useCurrentSpace`. The "is admin?" computation lives in the parent so the drawer can OR with org-admin role. The existing `SpaceSettingsPage.tsx` adapts: it pulls `space` from `useCurrentSpace` and computes `isSpaceAdmin` locally before passing into `SpaceMembersList`. +- `SpaceDangerZoneSection.tsx` — accepts `{ space: Space, canDelete: boolean, onDeleted?: () => void }`. Drops `useCurrentSpace`. The optional `onDeleted` lets the drawer close + invalidate the listing on success. The existing caller `SpaceGeneralSettings` adapts: it pulls `space` from `useCurrentSpace` and passes it down (no `onDeleted` — default redirect behaviour preserved). +- `SpaceGeneralSettings.tsx` — minimal change: it stays where it is in the per-space page tree; it now reads `space` from `useCurrentSpace` and forwards it to `SpaceDangerZoneSection`. Its props are unchanged from outside callers. +- `SpaceSettingsPage.tsx` — minimal change: still uses `useCurrentSpace` + `useGetSpaceMembersQuery` + `useAuthContext` internally; now passes the derived `space` and `isSpaceAdmin` into `SpaceMembersList` directly. + +**New components** (under `apps/frontend/src/domain/spaces/components/SpacesManagementPage/`): +- `SpaceManagementDrawer.tsx` — drawer container. Props: `{ space: SpaceManagementListItem | null; onClose: () => void }`. Internals: + - `PMDrawer.Root open={!!space} placement="end" size="md"` (matches the playground's drawer) + - Header row: status dot in `space.color` + name + subtitle "`createdAt` · N members · M artifacts" + - `PMTabs` with values `general` / `members` / `danger`. Danger tab is omitted from the array entirely when `space.isDefaultSpace` is true. + - Each tab renders the corresponding refactored section, with `space` and the derived flags passed as props. + - The drawer fetches `useGetSpaceMembersQuery(space.id)` at its top level so the General tab and the Members tab share one cache entry. The current-user role lookup powering `isSpaceAdmin` is done here too. +- Modifications: + - `SpacesTable.tsx` — adds an `onSelectSpace(space)` callback fired on row body click; clicks on the kebab menu/buttons are stopPropagated. + - `SpacesManagementPage.tsx` — owns `[selectedSpace, setSelectedSpace] = useState(null)` and renders ` setSelectedSpace(null)} />` alongside the table. + +### Authorization summary (post-change) + +| Operation | Allowed roles | +|---|---| +| Read listing | org admin (unchanged) | +| Rename / change color | space admin OR org admin (already implemented) | +| Add / remove members | space admin OR **org admin** (this is the new permission) | +| Change member role | space admin OR **org admin** (this is the new permission) | +| Delete space | space admin OR org admin (already implemented; default space blocked) | + +## Data Model + +No backend schema changes. No migration. Both `Space` (id, name, slug, type, organizationId, isDefaultSpace, color) and `UserSpaceMembership` (userId, spaceId, role, pinned, createdBy, updatedBy) carry every field this feature reads or writes. + +No new contracts. The four use-case contracts already exist in `packages/types/src/spaces/contracts/`: +- `IUpdateSpaceUseCase` (rename + color + type) +- `IAddMembersToSpaceUseCase` +- `IRemoveMemberFromSpaceUseCase` +- `IUpdateMemberRoleUseCase` +- `IListSpaceMembersUseCase` + +### Frontend types + +`SpaceManagementListItem` (already in `IListOrganizationSpacesForManagementUseCase`) is the input type for the drawer. It carries everything the drawer header needs (`id`, `name`, `slug`, `color`, `isDefaultSpace`, `admins[]`, `membersCount`, `artifactsCount`, `createdAt`) without requiring a second fetch. + +The refactored section components take `Space` (the entity), not `SpaceManagementListItem`. Conversion is trivial because `SpaceManagementListItem extends Space` (per the existing listing design); the drawer passes the same object. + +### Authorization derivation in the drawer + +```ts +const { data: membersData } = useGetSpaceMembersQuery(space.id); +const { user, organization } = useAuthContext(); +const currentUserMember = membersData?.members?.find((m) => m.userId === user?.id); +const isSpaceAdmin = currentUserMember?.role === 'admin'; +const isOrgAdmin = organization?.role === 'admin'; +const canEdit = isSpaceAdmin || isOrgAdmin; +const canDelete = isSpaceAdmin || isOrgAdmin; +``` + +These flow into the section components as `canEdit` / `canDelete` / `isSpaceAdmin`. + +### Query keys impacted + +| Key | Invalidated on | +|---|---| +| `['organizations', orgId, 'spaces', 'management']` (listing) | identity update, member add/remove/role-change, delete | +| `spacesQueryKeys.members(orgId, spaceId)` | member add/remove/role-change (already wired in existing mutations) | +| `spacesQueryKeys.list(orgId)` (sidebar list) | rename, color change (already wired in existing mutations) | + +The listing-key invalidation is added explicitly inside the drawer's `onSuccess` handlers — matching the pattern already used by `DeleteSpaceConfirmDialog` and flagged in the discoveries doc as the current convention (until centralised). + +## Use Cases / Services + +### Backend (3 use cases — same one-line override each) + +`AddMembersToSpaceUseCase`, `RemoveMemberFromSpaceUseCase`, `UpdateMemberRoleUseCase`: add `executeForMembers` override that short-circuits when the caller is an org admin. The pattern is copied verbatim from `UpdateSpaceUseCase.ts:39–46`. + +Errors and HTTP mappings unchanged: + +| Condition | Error | HTTP | +|---|---|---| +| Caller not space admin AND not org admin | `SpaceAdminRequiredError` | 403 | +| Default space, removeMember | `CannotRemoveFromDefaultSpaceError` | 400 | +| Self-removal | `CannotRemoveSelfError` | 400 | +| Self-role-change | `CannotUpdateOwnRoleError` | 400 | +| Target user not a member | `MemberNotFoundError` | 400 | + +Logging unchanged. PII rule (`standard-compliance-logging-personal-information`) remains satisfied — none of these use cases log emails. + +Adapters and services: no change. The use-case constructors are unchanged. `SpacesAdapter` continues to compose them as today. + +### Frontend (composition only — no new mutations) + +The drawer is a pure composition of existing hooks: + +| Tab | Hooks used (all existing) | +|---|---| +| General | `useUpdateSpaceMutation` | +| Members | `useGetSpaceMembersQuery`, `useAddMembersToSpaceMutation`, `useRemoveMemberFromSpaceMutation`, `useUpdateMemberRoleMutation` | +| Danger | `useDeleteSpaceMutation` | + +Drawer-local state: `activeTab` (`'general' | 'members' | 'danger'`), `memberToRemove` (lifted from existing `SpaceMembersList`). Closing the drawer clears both. `selectedSpace` lives one level up in `SpacesManagementPage`. + +The new hook to add: an explicit listing-key invalidation inside the drawer's `onSuccess` handlers for identity update, member changes, and delete: + +```ts +queryClient.invalidateQueries({ queryKey: ['organizations', orgId, 'spaces', 'management'] }); +``` + +This is a 3-line block per mutation site, not a new hook. + +## API / CLI / Frontend Surface + +### Backend + +No new endpoints. No request/response shape changes. The four affected endpoints are listed below for reference. + +| Method | Path | Effective change | +|---|---|---| +| `POST /organizations/:orgId/spaces/:spaceId/members` | Org admins (not space members) succeed | +| `DELETE /organizations/:orgId/spaces/:spaceId/members/:targetUserId` | Org admins (not space members) succeed | +| `PATCH /organizations/:orgId/spaces/:spaceId/members/:targetUserId` | Org admins (not space members) succeed | +| `PATCH /organizations/:orgId/spaces-management/:spaceId` | (already supported org admins; unchanged) | + +### Frontend + +**Files refactored** (`apps/frontend/src/domain/spaces/components/`): +- `SpaceMembersList.tsx` — props change to `{ space: Space, isSpaceAdmin: boolean }`; `useCurrentSpace` removed; existing toaster + confirmation modal logic preserved. `useGetSpaceMembersQuery(space.id)` continues to be called internally (TanStack dedupes against the drawer's own call). +- `SpaceDangerZoneSection.tsx` — props change to `{ space: Space, canDelete: boolean, onDeleted?: () => void }`; `useCurrentSpace` removed. Existing wiring (`useDeleteSpaceMutation` + redirect on success) preserved when `onDeleted` is omitted; when `onDeleted` is provided, it runs after the mutation resolves and the default redirect is skipped. +- `SpaceGeneralSettings.tsx` — minimal adapter change: continues to call `useCurrentSpace`; now passes `space` to `SpaceDangerZoneSection` explicitly. External prop API unchanged. +- `SpaceSettingsPage.tsx` — minimal adapter change: passes `space` + `isSpaceAdmin` (derived locally) into `SpaceMembersList`. Tabs structure unchanged. + +**Files created** (`apps/frontend/src/domain/spaces/components/SpacesManagementPage/`): +- `SpaceManagementDrawer.tsx` — drawer container described in Architecture. Composes `SpaceIdentitySection`, `SpaceMembersList` (with its existing add/remove machinery), `SpaceDangerZoneSection` inside `PMDrawer` + `PMTabs`. Hides Danger tab when `space.isDefaultSpace`. +- `SpaceManagementDrawer.test.tsx`. + +**Files modified** (`apps/frontend/src/domain/spaces/components/SpacesManagementPage/`): +- `SpacesTable.tsx` — adds `onSelectSpace(space)` callback prop fired on row body click; existing `onView` / `onDelete` row actions preserved. Click-target isolation: clicks on buttons inside the row don't bubble to the row handler. +- `SpacesManagementPage.tsx` — owns `selectedSpace` state; renders the drawer. + +**Files deleted**: none. + +### Routing + +No URL change. The drawer is local UI state on `/org/:orgSlug/settings/spaces`. (Tradeoff acknowledged: drawer doesn't deep-link.) + +### Toolbar / pagination / row actions + +Unchanged. Search / filter dropdowns remain visual-only (already out of scope per the listing spec). Pagination behaviour unchanged. + +### Feature flag + +The whole `/settings/spaces` route is already gated by `ORGA_SPACE_MANAGEMENT_FEATURE_KEY`. The drawer inherits this gate — no new flag. + +## Testing Approach + +### Backend + +| File | Coverage | +|---|---| +| `AddMembersToSpaceUseCase.spec.ts` (update) | New: org admin (no space membership) successfully adds members; emits `SpaceMembersAddedEvent` with org admin's `userId`. Existing space-admin path still passes. Existing failure cases (no permissions, etc.) preserved. | +| `RemoveMemberFromSpaceUseCase.spec.ts` (update) | New: org admin (no space membership) removes any member of a non-default space. Existing `CannotRemoveFromDefaultSpaceError` and `CannotRemoveSelfError` paths still raise. | +| `UpdateMemberRoleUseCase.spec.ts` (update) | New: org admin (no space membership) updates any member's role. `CannotUpdateOwnRoleError` still raised when org admin equals target. `MemberNotFoundError` still raised when target isn't in space. | +| `members.controller.spec.ts` (extend) | One additional case: org-admin caller with no space membership returns 200 from `addMembers`, `removeMember`, `updateMemberRole`. Match the existing unit-style controller test pattern (no Nest test module). | + +No repository / DB integration test changes — schema unchanged. + +### Frontend + +| File | Coverage | +|---|---| +| `SpaceManagementDrawer.test.tsx` (new) | Renders header (color dot + name + subtitle "`createdAt` · N members · M artifacts"); shows three tabs for non-default space and two tabs (no Danger) for default space; switching tabs renders the right section component; `onClose` clears `activeTab`/`memberToRemove`. Uses the documented PMTable Jest mock for the Members tab. | +| `SpaceMembersList.test.tsx` (update) | Now driven by props; existing role-change + remove paths still pass; new assertion: when `isSpaceAdmin=false`, Add button is hidden and role/remove controls are disabled. | +| `SpaceDangerZoneSection.test.tsx` (update) | Prop-driven; on delete success with `onDeleted` provided, the callback is invoked (and the default redirect is skipped). When `onDeleted` is omitted, behaviour is unchanged. | +| `SpaceGeneralSettings.test.tsx` (update if exists) | Verify the adapter still passes `space` correctly into `SpaceDangerZoneSection` after the refactor; otherwise covered by `SpaceSettingsPage` integration tests. | +| `SpacesManagementPage.test.tsx` (update) | Row click opens drawer; existing kebab menu still functions; identity update / member change / delete invalidate the listing key (mock mutation `onSuccess` to assert `queryClient.invalidateQueries` was called with the right key). | +| `SpacesTable.test.tsx` (update) | New `onSelectSpace` callback fired on row body click; not fired when clicking the kebab menu or row action buttons (event isolation). | + +### E2E (Playwright, under `apps/e2e-tests/`) + +- **Identity edit** — org admin opens `/settings/spaces`, clicks a non-default space row, renames it in the General tab, saves; the listing row reflects the new name. +- **Color change** — org admin picks a new color in the General tab, saves; the row's color dot updates. +- **Promote member** — org admin opens the drawer for a space they are NOT a member of, switches to Members tab, changes a member's role from member → admin; the listing row's admin column reflects the new admin. +- **Remove member** — org admin removes a non-default-space member; drawer refreshes, listing member count drops by 1. +- **Default space** — open drawer on the org's default space; assert no Danger tab is rendered, the name input is disabled, and the color picker remains enabled. + +E2E follows the existing Page Object Model pattern. + +### Test data and authz fixtures + +Backend specs need fixtures where the caller is an org admin who is NOT a member of the target space. The existing `AbstractSpaceAdminUseCase.spec.ts` already exercises this access matrix — copy its setup. Use-case specs continue to mock ports per the established convention (no real DB). + +## Out of Scope + +- Visibility/type editing in the drawer (stays only in `/space/:slug/settings`). +- Drawer URL deep-linking and back-button history. +- Replacing or merging the existing per-space `/space/:slug/settings` page. +- Toolbar search / filter functionality (visual-only). +- Autosave / drawer-level dirty state. +- Bulk-edit operations across multiple spaces. +- Centralising the listing-key invalidation in shared mutation `onSuccess` helpers. +- Folder reorganisation of `apps/frontend/src/domain/spaces-management/` vs `apps/frontend/src/domain/spaces/`. +- New analytics events (existing space + membership events keep emitting unchanged). diff --git a/.cursor/rules/packmind/standard-amplitude-analytics-usage.mdc b/.cursor/rules/packmind/standard-amplitude-analytics-usage.mdc new file mode 100644 index 000000000..dccbf2178 --- /dev/null +++ b/.cursor/rules/packmind/standard-amplitude-analytics-usage.mdc @@ -0,0 +1,11 @@ +--- +alwaysApply: true +--- +# Standard: Amplitude analytics usage + +* We use Amplitude to get insights about users' behavior when using our product with the different UI (CLI / MCP / web app). : +* Event name ends with the verb (e.g 'standard_created', 'user_signed_up') +* Property name should be in lower camel case +* Tracked event name should be snake cased + +Full standard is available here for further request: [Amplitude analytics usage](../../../.packmind/standards/amplitude-analytics-usage.md) \ No newline at end of file diff --git a/.cursor/rules/packmind/standard-backend-tests-redaction.mdc b/.cursor/rules/packmind/standard-backend-tests-redaction.mdc index b15406200..012bd6b89 100644 --- a/.cursor/rules/packmind/standard-backend-tests-redaction.mdc +++ b/.cursor/rules/packmind/standard-backend-tests-redaction.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: Backend Tests Redaction -Enforce Jest backend test conventions in Packmind **/*.spec.ts (verb-first names, behavioral assertions, nested `describe('when...')`, one `expect`, `afterEach` cleanup with `datasource.destroy()` and `jest.clearAllMocks()`, `toEqual` for arrays, and `stubLogger()` for typed `PackmindLogger` stubs) to improve readability, consistency, and debuggability while preventing inter-test pollution. : +This standard establishes best practices for writing backend tests using Jest in the Packmind monorepo. It focuses on clarity, maintainability, and consistency across test suites by emphasizing behavi... : * Avoid asserting on stubbed logger output like specific messages or call counts; instead verify observable behavior or return values * Avoid testing that a method is a function; instead invoke the method and assert its observable behavior * Avoid testing that registry components are defined; instead test the actual behavior and functionality of the registry methods like registration, retrieval, and error handling diff --git a/.cursor/rules/packmind/standard-changelog.mdc b/.cursor/rules/packmind/standard-changelog.mdc index 269e8353c..594aaeec3 100644 --- a/.cursor/rules/packmind/standard-changelog.mdc +++ b/.cursor/rules/packmind/standard-changelog.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: Changelog -Maintain CHANGELOG.MD using Keep a Changelog format with a top [Unreleased] section linked to HEAD, ISO 8601 dates (YYYY-MM-DD), and per-release comparison links like [X.Y.Z]: https://github.com/PackmindHub/packmind/compare/release/...release/X.Y.Z to ensure accurate, consistent release documentation and version links. : +Maintain a consistent and well-structured CHANGELOG.MD file following the Keep a Changelog format to ensure all releases are properly documented with accurate version links and dates. This standard ap... : * Ensure all released versions have their corresponding comparison links defined at the bottom of the CHANGELOG.MD file in the format [X.Y.Z]: https://github.com/PackmindHub/packmind/compare/release/...release/X.Y.Z * Format all release dates using the ISO 8601 date format YYYY-MM-DD (e.g., 2025-11-21) to ensure consistent and internationally recognized date representation * Maintain an [Unreleased] section at the top of the changelog with its corresponding link at the bottom pointing to HEAD to track ongoing changes between releases diff --git a/.cursor/rules/packmind/standard-compliance-logging-personal-information.mdc b/.cursor/rules/packmind/standard-compliance-logging-personal-information.mdc index 94ba081b2..68ff857c9 100644 --- a/.cursor/rules/packmind/standard-compliance-logging-personal-information.mdc +++ b/.cursor/rules/packmind/standard-compliance-logging-personal-information.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: Compliance - Logging Personal Information -Enforce masking of personal information in TypeScript logs, using a standard first-6-characters-plus-* format for emails and similar patterns for other identifiers, to protect user privacy, comply with data protection regulations, and reduce security risks when handling user-related log entries. : +This standard ensures personal information is not exposed in application logs across all environments (development, staging, and production). Logs are often forwarded to external processors such as Da... : * Never log personal information in clear text across all log levels. Always mask sensitive data such as emails, phone numbers, IP addresses, and other personally identifiable information before logging. * Use the standard masking format of first 6 characters followed by "*" for logging user emails. This ensures consistency across the codebase and makes it easier to audit logs for compliance. diff --git a/.cursor/rules/packmind/standard-packmind-proprietary.mdc b/.cursor/rules/packmind/standard-packmind-proprietary.mdc new file mode 100644 index 000000000..e6d65fed6 --- /dev/null +++ b/.cursor/rules/packmind/standard-packmind-proprietary.mdc @@ -0,0 +1,9 @@ +--- +alwaysApply: true +--- +# Standard: Packmind Proprietary + +. : +* Never import something from '@packmind/editions', this is for OSS only + +Full standard is available here for further request: [Packmind Proprietary](../../../.packmind/standards/packmind-proprietary.md) \ No newline at end of file diff --git a/.cursor/rules/packmind/standard-typescript-good-practices.mdc b/.cursor/rules/packmind/standard-typescript-good-practices.mdc index 270a5d140..735a100d0 100644 --- a/.cursor/rules/packmind/standard-typescript-good-practices.mdc +++ b/.cursor/rules/packmind/standard-typescript-good-practices.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: Typescript good practices -Enforce TypeScript error and DTO conventions by prohibiting Object.setPrototypeOf in custom errors and requiring intersection types (DomainType & { extraField: T }) for presentation DTO enrichment to improve reliability and catch domain-field drift at compile time. : +Generic practices that can be applied for all TS code in our app : * Do not use `Object.setPrototypeOf` when defining errors. * When defining a presentation DTO that enriches a domain type, use an intersection type (`DomainType & { extraField: T }`) instead of manually re-declaring the domain type's fields, so that structural drift is caught at compile time. diff --git a/.cursor/skills/agent-skill-frontmatter-audit/SKILL.md b/.cursor/skills/agent-skill-frontmatter-audit/SKILL.md new file mode 100644 index 000000000..d1ad40de0 --- /dev/null +++ b/.cursor/skills/agent-skill-frontmatter-audit/SKILL.md @@ -0,0 +1,203 @@ +--- +name: 'agent-skill-frontmatter-audit' +description: 'Audit per-agent SKILL.md frontmatter support in Packmind against the current Agent Skills baseline specification and the latest official docs for each AI coding agent.' +--- + +# Agent Skill Frontmatter Audit + +Packmind renders skills (one of the three artefact types along with standards and commands) as a `SKILL.md` file with YAML frontmatter deployed into each AI coding agent's expected folder (`.claude/skills/`, `.cursor/skills/`, `.github/skills/`, `.agents/skills/`, `.opencode/skills/`, etc.). + +There is a shared **Agent Skills baseline specification** that all supporting agents commit to (the "core" fields every agent understands), and then each agent may publish its own documentation extending that baseline with agent-specific "additional properties". Both layers evolve over time: the baseline spec ships new versions, and vendors add, rename, or deprecate extensions. + +This skill audits Packmind's rendering against both layers and produces a dated report at the project root so drift can be tracked release over release. + +## Context + +The audit is a **four-way comparison** per agent: + +1. **Baseline spec** — the current Agent Skills specification at . Defines the set of fields every compliant agent is expected to accept (typically `name`, `description`, and similar core keys). +2. **Upstream agent spec** — the agent's own public documentation page. May extend the baseline with agent-specific properties, or may be baseline-only. +3. **Packmind declared support** — the per-agent constants in `packages/types/src/skills/skillAdditionalProperties.ts`. +4. **Packmind actual rendering** — what each agent's deployer in `packages/coding-agent/src/infra/repositories/{agent}/{Agent}Deployer.ts` actually writes into the YAML frontmatter. + +The four pairs can disagree silently: + +- **Baseline vs. upstream** — a vendor page that omits a field the baseline requires is a vendor bug to note, not a Packmind action item. +- **Upstream vs. constants** — Packmind is behind (or ahead of) the vendor on agent-specific fields. +- **Constants vs. deployer** — internal drift; the "supported" promise in the codebase is a lie. +- **A dead doc URL** — the audit can no longer be trusted automatically for that agent. + +None of these produce runtime errors. A deprecated field is silently ignored by the agent; a missing field is a silent feature loss for users. The only way to catch either is an audit against the upstream spec — which is what this skill automates. + +### Core principle: baseline-only is fine + +Some agents choose to support **only** the baseline spec — no additional properties on top. That is a legitimate design choice and **must not be reported as a warning, gap, or drift**. Concretely: if an agent has no `_ADDITIONAL_FIELDS` constant in Packmind, no `filterAdditionalProperties` call in its deployer, and no agent-specific properties documented upstream, that agent is "baseline-only" and the report should mark it in sync with a short note like *"no additional properties — baseline spec only"*. + +Only flag a missing constant / missing filter **when the agent's own upstream documentation lists properties beyond the baseline**. Otherwise the absence is correct. + +## Sources in scope + +The baseline spec is fetched once. Each of the five agents is fetched individually. Keep this list verbatim — adding, removing, or re-pointing an entry is a skill update, not an ad-hoc decision. + +| Source | URL | Codebase locations | +| --- | --- | --- | +| **Baseline spec** | `https://agentskills.io/specification.md` | *(no single file — the baseline names map to core frontmatter emitted by every deployer)* | +| Claude Code | `https://code.claude.com/docs/en/skills` | `CLAUDE_CODE_ADDITIONAL_FIELDS` + `packages/coding-agent/src/infra/repositories/claude/ClaudeDeployer.ts` | +| GitHub Copilot | `https://code.visualstudio.com/docs/copilot/customization/agent-skills` | `COPILOT_ADDITIONAL_FIELDS` + `packages/coding-agent/src/infra/repositories/copilot/CopilotDeployer.ts` | +| Cursor | `https://cursor.com/docs/skills#frontmatter-fields` | `CURSOR_ADDITIONAL_FIELDS` + `packages/coding-agent/src/infra/repositories/cursor/CursorDeployer.ts` | +| OpenAI Codex | `https://developers.openai.com/codex/skills` | *(constant optional — declare one only if upstream lists additional properties)* + `packages/coding-agent/src/infra/repositories/codex/CodexDeployer.ts` | +| OpenCode | `https://opencode.ai/docs/skills/` | *(constant optional — declare one only if upstream lists additional properties)* + `packages/coding-agent/src/infra/repositories/opencode/OpenCodeDeployer.ts` | + +The canonical constants file is `packages/types/src/skills/skillAdditionalProperties.ts`. + +## Workflow + +Run the steps in order. Parallelise fetches and codebase reads whenever one step doesn't depend on another — the audit is network-heavy and the user is waiting for a report. + +### Step 1 — Fetch the baseline spec and all upstream docs + +Issue `WebFetch` calls in parallel: one for the baseline spec, plus one per agent URL. + +**For the baseline spec**, extract: +- The version identifier of the spec (if stated on the page), so the report can cite which baseline was used. +- The complete list of baseline frontmatter keys with whether each is required or optional, and a one-line description quoted from the spec. + +**For each agent doc**, extract: +- The complete list of YAML frontmatter keys the agent documents on `SKILL.md`. Separate baseline fields (those already in the baseline spec) from agent-specific additions. +- Any explicit deprecation markers (words like "deprecated", "removed", "no longer supported", "legacy", or strikethrough styling the page renders). +- A short one-line description of each agent-specific key, quoted from the vendor wording rather than invented. +- Whether the agent's docs explicitly declare "this agent supports only the baseline" or equivalent. If so, note it — it's the cleanest signal for the baseline-only classification. + +Classify the fetch result of every URL as one of: +- ✅ **Reachable and relevant** — the page loads and documents skills/frontmatter. +- ⚠️ **Redirected** — the URL resolves but lands on a different, still-relevant page. Record both the requested and resolved URLs. +- ❌ **Dead** — 404, 403, empty body, or the landing page no longer mentions skills/frontmatter. Record the exact failure signal. + +Do **not** fall back to training-data knowledge for a dead URL. If the baseline spec itself is dead, note that the entire audit cannot separate baseline from agent-specific fields and mark every per-agent classification as `(uncertain — baseline source unreachable)`. If an agent doc is dead, only that agent's findings are marked uncertain. + +### Step 2 — Read Packmind's declared support + +Read `packages/types/src/skills/skillAdditionalProperties.ts`. Extract: + +1. Each per-agent constant that exists (today: `CLAUDE_CODE_ADDITIONAL_FIELDS`, `COPILOT_ADDITIONAL_FIELDS`, `CURSOR_ADDITIONAL_FIELDS`). For each, capture the YAML key ↔ camelCase storage key mapping. Claude Code declares its fields as a `Record` (YAML→camel); Cursor and Copilot declare a flat `string[]` of camelCase keys. Do not assume the shape is uniform. +2. `CLAUDE_CODE_ADDITIONAL_FIELDS_ORDER` — the canonical rendering order for Claude. A key that's in `CLAUDE_CODE_ADDITIONAL_FIELDS` but not in the order array (or vice-versa) is a **low-severity internal drift** worth mentioning. +3. Any new constant that may have been introduced for Codex or OpenCode since this skill was last updated. + +**The absence of a constant for Codex or OpenCode is not automatically a finding.** Whether it's a gap depends entirely on Step 1: if the agent's upstream doc lists no properties beyond the baseline, the absence is correct. If it does list extensions, then the absence is a missing-support finding. + +### Step 3 — Read the deployer for each agent + +For each agent, open its deployer file listed in the scope table. In the file, locate: + +- The method that produces the frontmatter block (typically `generateSkillMdContent` or similar). Read which keys it writes and in what order. +- Any call to `filterAdditionalProperties(...)`: which constant does it pass in? +- Any call to `sortAdditionalPropertiesKeys(...)`: missing here means non-deterministic YAML output for that agent's frontmatter. + +For Codex and OpenCode, the current pattern is to delegate multi-file skill deployment to `SingleFileDeployer`/base-class logic (see `packages/coding-agent/src/infra/repositories/utils/` and `defaultSkillsDeployer/`). If the deployer has no skill-specific frontmatter method at all, record `(inherits base deployer; baseline fields only)` as the deployer behaviour. + +Treat a missing `filterAdditionalProperties` call as a concern **only when** the agent's upstream doc lists additional properties — in that case, the deployer either bypasses the declared list (leaking everything) or emits nothing agent-specific (leaking nothing, failing to expose supported fields). Figure out which from the emitted-keys list and classify accordingly. If the agent is baseline-only upstream, no filter is needed and no finding should be raised. + +### Step 4 — Classify every property + +For each agent, build a single combined set of keys = (baseline keys) ∪ (agent-upstream keys) ∪ (constant keys) ∪ (deployer-emitted keys). For each key, assign exactly one classification: + +- ✅ **In sync (baseline)** — a baseline key, supported by the agent upstream and emitted by the deployer. Expected state; keep it concise in the table. +- ✅ **In sync (agent-specific)** — an agent-specific key that upstream lists, the constant declares, and the deployer emits. +- ➕ **Missing in Packmind** — upstream lists it (baseline or agent-specific) but Packmind doesn't support it. This is the primary "we're behind the spec" bucket. +- ➖ **Deprecated / removed upstream** — the constant or deployer supports it, but upstream no longer lists it (or explicitly deprecates it). Packmind is still shipping a field the agent will ignore. +- 🔀 **Internal drift** — constant and deployer disagree with each other, independent of upstream. Examples: the constant declares a key the deployer never emits; the deployer emits a key the constant never declared; the `_ORDER` array and the `_FIELDS` map disagree. +- ❓ **Uncertain** — the relevant upstream source is unreachable (from Step 1). Do not guess. + +**Do not classify baseline-only as a gap.** If the agent's upstream lists no extensions and Packmind declares none, there is nothing to report for additional properties beyond a single one-line note per agent saying *"baseline spec only — no additional properties"*. + +Prefer concrete, linkable evidence for every classification. "Missing" must cite the exact vendor section that lists the property; "Deprecated" must cite the exact file:line that still supports it. + +### Step 5 — Structural gaps (conditional) + +Beyond per-property drift, surface structural gaps at the agent level **only when the evidence warrants it**. Do not raise any of the following for an agent that is legitimately baseline-only: + +- Raise *"no constant declared"* **only if** upstream lists additional properties for that agent and Packmind has no way to emit them. An agent with no upstream extensions and no Packmind constant is in sync, not gapped. +- Raise *"deployer bypasses `filterAdditionalProperties`"* **only if** upstream lists additional properties and the deployer writes the additional-props blob without filtering (so unrelated fields could leak through). +- Raise *"deployer renders non-deterministic frontmatter order"* **only if** the deployer actually emits additional properties without a sort call. If there are no additional properties to sort, the absence of `sortAdditionalPropertiesKeys` is fine. +- Raise *"upstream URL redirected or 404d"* whenever Step 1 classified the URL as ⚠️ or ❌ — this is always a finding independent of property state. + +If all structural checks pass for every agent, write *"None — all agents structurally aligned with their upstream commitments."* The empty section is still worth keeping so the reader knows the check happened. + +### Step 6 — Write the report + +Produce a single markdown file at the project root, named using the current date from the system (today is e.g. `2026-04-21` → `skills_properties_2026_04_21.md`). The filename uses underscores between year, month, and day to match the user's convention. Inside the file, the heading date can use dashes (`2026-04-21`) for readability. + +Use this exact structure: + +```markdown +# Agent Skill Frontmatter Audit — YYYY-MM-DD + +## Summary + +- Baseline spec version: +- Agents audited: N +- Upstream URL health: X/(N+1) reachable and relevant (including baseline) +- Baseline-only agents: … +- Properties in sync: … +- Missing in Packmind: … +- Deprecated / removed upstream: … +- Internal drift findings: … +- Structural gaps: … + +## Upstream URL health + +| Source | Requested URL | Status | Resolved URL / Notes | +| --- | --- | --- | --- | +| Baseline spec | … | ✅/⚠️/❌ | … | +| | … | ✅/⚠️/❌ | … | + +## Baseline spec + +- **Source:** () +- **Version / fetched at:** … +- **Core fields:** list the baseline keys with required/optional and a one-line description each. + +## Per-agent findings + +### +- **Upstream docs:** () +- **Baseline-only?** Yes / No +- **Declared constant:** `` — `packages/types/src/skills/skillAdditionalProperties.ts:` *(or "none declared — expected for baseline-only agents")* +- **Deployer:** `packages/coding-agent/src/infra/repositories//Deployer.ts:` — filters via `` / inherits base / no filter + +If the agent is baseline-only and in sync, a single sentence is enough: *"Baseline spec only — no additional properties. Packmind emits the baseline fields via the path; no further action needed."* + +Otherwise, include the property table: + +| Property | Classification | Baseline | Upstream | Constant | Deployer | Notes | +| --- | --- | --- | --- | --- | --- | --- | +| … | ✅/➕/➖/🔀/❓ | ✓/✗ | ✓/✗ | ✓/✗ | ✓/✗ | short rationale / vendor quote | + +**Action items** (omit section if empty) +- ➕ Add `` to `` and emit in `` — upstream lists it under "
". +- ➖ Remove `` from `` (and deployer if applicable) — upstream no longer documents it. +- 🔀 `` is declared in `` but never emitted by ``; pick one source of truth. + +*(Repeat for each of the five agents, even if findings are empty — an empty section still documents that the agent was checked.)* + +## Structural gaps + +(List only the conditional gaps from Step 5. If none, write "None — all agents structurally aligned with their upstream commitments.") + +## Recommendations + +Prioritised, plain-language next steps. For each, name the file(s) to touch and why. When a property is newly deprecated upstream, prefer a deprecation path (leave supported for one release, emit a warning, remove next release) rather than immediate removal — users may have existing skill artefacts that rely on the field. +``` + +Write only the report — no preamble, no "here is the report" sentence. When the skill is invoked the user wants the report itself at the project root, plus a one-line confirmation of where the file was written. + +## Notes and edge cases + +- **Baseline-only is a valid state.** The skill must never treat the absence of additional properties as a problem. Codex and OpenCode today are typical examples — if their vendor docs don't list extensions beyond the baseline, they're in sync. +- **Dates use underscores in the filename.** `skills_properties_2026_04_21.md`, not `-2026-04-21-` and not without a date. If the user asks for the audit twice the same day, overwrite the file — the day's audit is the authoritative snapshot. +- **Do not invent URLs.** If the user supplies a replacement URL for a dead one, use it and note the substitution in the URL-health table. Otherwise, mark the source uncertain. +- **Do not scan deployed example SKILL.md files** (e.g. what's under `.claude/skills/` in the user's projects) to infer support. The contract is the deployer code and the constants, not the artefacts they happen to have produced so far — an unused supported field would be invisible in that sample. +- **Claude Code is the reference implementation.** Its constants map (`CLAUDE_CODE_ADDITIONAL_FIELDS`) and order array (`CLAUDE_CODE_ADDITIONAL_FIELDS_ORDER`) are the richest and most mature. When in doubt about how a new Codex/OpenCode constant should be shaped (in the event they ever need one), mirror the Claude Code pattern rather than inventing a third shape. +- **Core / baseline fields are tracked by the baseline source, not per-agent.** Put baseline-level observations in the "Baseline spec" section, not duplicated under every agent. Per-agent sections only need to confirm baseline support and then focus on additional properties. +- **Changes in scope (new agents, removed agents, URL updates) are skill edits.** Don't silently drop an agent because its URL 404s this week — that's a finding, not a scope change. +- **Run sequencing.** Fetches (Step 1, including the baseline) and codebase reads (Steps 2–3) don't depend on each other; kick them off in parallel. Steps 4–6 depend on both, so they run after. \ No newline at end of file diff --git a/.cursor/skills/amplitude-listener-events-adoption/SKILL.md b/.cursor/skills/amplitude-listener-events-adoption/SKILL.md new file mode 100644 index 000000000..950bbcf1e --- /dev/null +++ b/.cursor/skills/amplitude-listener-events-adoption/SKILL.md @@ -0,0 +1,173 @@ +--- +name: 'amplitude-listener-events-adoption' +description: 'Report organization adoption in Amplitude for the most recently added domain events in the AmplitudeEventListener. Walks git history on `packages/amplitude/src/application/AmplitudeEventListener.ts` to find the last 15 still-subscribed events, queries the Packmind V3 [CLOUD] Amplitude project via the MCP, and produces a markdown report grouped by domain prefix (text before the first underscore). Triggers when the user asks to audit recent amplitude listener events, check which orgs use new domain events, measure adoption of recently added events, or requests an amplitude report on space/playbook/standard/etc. events.' +--- + +# Amplitude Listener Events Adoption + +Produce a markdown adoption report for the **last 15 domain events** subscribed in the Amplitude listener, grouped by domain, listing organizations that triggered each event and how many times. + +## Prerequisites + +- The Amplitude MCP must be connected (tools prefixed `mcp__amplitude__`). If not, prompt the user to run `/mcp`. +- Target project: **Packmind V3 [CLOUD]**, `appId = 100032310`. Never query the DEV or legacy projects unless the user explicitly asks. + +## Inputs + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `N` | 15 | Number of most-recently-added events to report on | +| `range` | `Last 30 Days` | Amplitude date range | +| Excluded orgs | see below | Defaults applied automatically; user may extend the list | +| Excluded events | see below | Defaults applied automatically; user may extend the list | + +## Organizations exclusion + +Always exclude these internal/test organizations from the report (apply **after** querying Amplitude, before aggregation): + +- `AI Configuration orga` +- `test_package_standards` +- `packmind` +- `Joan's orga` +- `demo-orga` +- `VIncent test` +- `cedric.teyton's organization` +- `cedric-test` +- `test-skills` + +Match on the exact organization `grp:name` value returned by Amplitude. Extend this list if the user provides additional orgs to skip. + +## Events exclusion + +Always exclude these Amplitude event names from the report even if they are among the last N subscribed in the listener: + +- `user_signed_in` +- `new_organization_created` +- `user_signed_up` + +Rationale: these three events fire once per user/organization creation and produce extreme long tails (hundreds of orgs with a single event each), drowning out the actual feature-adoption signal the report is built for. + +Drop excluded events from the event-name list **before** building the Amplitude query (do not count them toward `N` — select the next most recent event to fill the quota). Extend this list if the user provides additional events to skip. + +## Workflow + +### Step 1 — Identify the last N still-subscribed events + +Source file: `packages/amplitude/src/application/AmplitudeEventListener.ts`. + +1. Read the current file and collect the set of event **class names** that appear in `this.subscribe(, ...)` inside `registerHandlers()`. This is the "still-subscribed" set. +2. For each class, also extract the corresponding **Amplitude event name** (the string literal passed as the 2nd argument to `this.emitAmplitudeEvent(event, '', ...)` in the handler). This is what Amplitude stores. +3. Walk the file's git history newest→oldest: + + ```bash + git log --all --follow --format="%h|%ai|%s" -- packages/amplitude/src/application/AmplitudeEventListener.ts + ``` + +4. For each commit, extract added `this.subscribe(` lines: + + ```bash + git show --format="" -- packages/amplitude/src/application/AmplitudeEventListener.ts \ + | grep -E "^\+ *this\.subscribe\(" + ``` + +5. Walk commits in order; for every added class name that is also in the still-subscribed set, record `(amplitude_event_name, class_name, commit_date, commit_sha)` once (deduplicate by class — only keep the **most recent** addition). Stop once `N` unique events are collected. +6. Discard entries for events that were later removed or renamed out of the current file (e.g. `SpaceRenamedEvent` → filtered because no longer in the current `registerHandlers`). + +Multi-line `this.subscribe(\n EventName,\n ...)` must also be captured — widen the grep context (`grep -A2`) when a line ends just after `this.subscribe(`. + +**Efficient commit walk**: rather than running `git show` once per commit interactively, batch the first ~40 commits returned by `git log` and loop in a single `bash` call: + +```bash +for c in $(git log --all --follow --format="%h" -- packages/amplitude/src/application/AmplitudeEventListener.ts | head -40); do + echo "=== $c ===" + git show --format="" $c -- packages/amplitude/src/application/AmplitudeEventListener.ts \ + | grep -E "^\+ *this\.subscribe\(" -A1 +done +``` + +This surfaces all added `this.subscribe(` lines with commit boundaries in one tool call, which keeps token cost low and preserves the newest→oldest ordering needed for dedupe. + +### Step 2 — Query Amplitude + +Use `mcp__amplitude__query_dataset` with: + +- `projectId`: `"100032310"` +- `definition`: + ```json + { + "type": "eventsSegmentation", + "app": "100032310", + "params": { + "range": "Last 30 Days", + "events": [ /* one entry per amplitude event name */ + { "event_type": "", "filters": [], "group_by": [] } + ], + "metric": "totals", + "countGroup": "organization", + "groupBy": [{ "type": "group", "group_type": "organization", "value": "grp:name" }], + "interval": 30, + "segments": [{ "conditions": [] }] + } + } + ``` +- `groupByLimit`: `200` +- `timeSeriesLimit`: `0` + +**Critical gotcha**: the CSV returned by `query_dataset` *omits* the organization name column when `groupBy` is set on a group property. Re-query using `mcp__amplitude__query_chart` with the `chartEditId` returned by `query_dataset`; that response includes the real org-name column. Always do this second call to get usable data. + +If the response payload is large, rely on `timeSeriesLimit: 0` (totals-only) to keep it compact. + +### Step 3 — Aggregate and format + +Parse the CSV rows from `query_chart`. The response contains header rows (chart name, list of events), an empty separator row, and then a data section starting with a `["Event", "name", "", "", ...]` row followed by data rows. + +- **Row shape with `timeSeriesLimit: 0`**: each data row is `[event_name, org_name, , , ...]`. There is **no trailing grand-total column** in that mode — sum the interval columns yourself to get the per-org total for the window. A 30-day range typically yields two interval columns (one per calendar month touched). +- **Ignore blank or placeholder org rows**: rows with `org_name == ""` or `org_name == "(none)"` represent events fired outside any organization context and must be skipped. + +Aggregation is mechanical and repetitive across 10+ events — use a small inline Python script via `Bash` with `python3 -` or a heredoc to load the parsed rows, apply the exclusion list, sum intervals, sort, and slice top-10 + `+K`. Doing this by hand for long tails (e.g. `standard_sample_selected` with 60+ orgs) is error-prone and wastes tokens. + +- Group events by **domain prefix** = substring before the first underscore in the Amplitude event name (`space_created` → `space`, `playbook_artefact_moved` → `playbook`, `change_proposal_submitted` → `change`, etc.). +- Inside each domain, list events in the order they appear in the source file (stable for the reader). +- Apply the **org exclusion list** before totaling. If no orgs remain for an event, treat it as zero-orgs. +- For each event: + 1. Start the line with the total number of **distinct organizations** that triggered it (after exclusions): `- (N orgs): ...`. + 2. Sort organizations by their event count, descending; list at most the **top 10** with their counts: `orgA (42), orgB (18), ...`. + 3. If more than 10 orgs remain, append a trailing `+K` token where `K` = remaining org count. Example: `- space_created (23 orgs): orgA (9), orgB (7), ... orgJ (2) +13`. Never enumerate those remaining orgs by name; the `+K` is a trend signal only. +- **Collapse zero-adoption events into a single summary line** at the end of each domain (or at the end of the full report if the user prefers global grouping): `- No adoption (0 orgs): event_a, event_b, event_c`. + +### Step 4 — Emit the report + +Write the report to `amplitude-listener-events-adoption.md` at the project root (overwrite if it already exists). Also display the highlights inline in the chat reply so the user can scan them without opening the file. + +Skeleton: + +```markdown +# Amplitude adoption — last N listener events (Packmind V3 [CLOUD], ) + +Excluded orgs: . Excluded events: . + +## Domain: space +- space_created (23 orgs): orgA (9), orgB (7), orgC (6), orgD (5), orgE (4), orgF (3), orgG (3), orgH (2), orgI (2), orgJ (2) +13 +- space_members_added (4 orgs): orgA (18), orgB (12), orgC (5), orgD (1) +- ... +- No adoption (0 orgs): space_pinned, space_unpinned + +## Domain: playbook +- playbook_artefact_moved (2 orgs): orgA (40), orgC (4) + +... + +Source chart: [Open in Amplitude]() +``` + +Keep the report concise — no per-month breakdown, no narrative paragraphs. The user wants a scan-friendly list. + +## Edge cases + +- **Fewer than N events in history**: report whatever is available and note the shortfall in one line. +- **Event renamed in listener but kept in Amplitude** (e.g. renaming `user_signup` → `user_signed_up`): trust the current file; query only the current Amplitude event name. +- **Event subscribed in listener but never emitted in prod**: it will appear as a zero-adoption event — that is the correct signal. +- **Duplicate commits touching the same subscribe line** (merges, reverts): keep the most recent non-reverted addition. +- **Group property is empty or placeholder** (`""` or `"(none)"` org name row): ignore that row — the event fired outside any org context. +- **Event also tracked with an identify call** (e.g. `OrganizationCreatedEvent` triggers `identifyOrganizationGroup` in addition to the tracked event): this does not affect the adoption query but explains why some org names appear populated only after the event fires once. +- **All remaining orgs are in the exclusion list**: emit the event under the `No adoption (0 orgs): …` summary line — do not emit a misleading `(N orgs)` header when the real external count is zero. \ No newline at end of file diff --git a/.cursor/skills/datadog-analysis/SKILL.md b/.cursor/skills/datadog-analysis/SKILL.md new file mode 100644 index 000000000..54d6d9d0b --- /dev/null +++ b/.cursor/skills/datadog-analysis/SKILL.md @@ -0,0 +1,102 @@ +--- +name: 'datadog-analysis' +description: 'Analyze Datadog error logs for Packmind production services (api-proprietary, mcp-proprietary, frontend-proprietary), group them into patterns, root-cause against the codebase, and produce a structured bug report. Triggers on Datadog, production logs, prod errors, service health, or periodic error reviews.' +--- + +# Datadog Analysis + +Analyze production error logs from Packmind Datadog services, group them into patterns, cross-reference stack traces with the codebase, and produce a structured markdown report with root causes and Datadog search patterns. + +## Prerequisites + +- The Datadog MCP server must be connected. If not connected, prompt the user to run `/mcp` first. +- Read `references/datadog_mcp.md` before making any MCP tool calls for guidance on tool usage, gotchas, and known pitfalls. + +## Services + +The analysis covers three production services. Each maps to a Datadog service name, a codebase location, and a Dockerfile: + +| Datadog service | App | Codebase | Dockerfile | Runtime | +|----------------|-----|----------|------------|---------| +| `api-proprietary` | API | `apps/api/` + all `packages/` | `dockerfile/Dockerfile.api` | Node.js (NestJS, TypeORM, Redis/ioredis, BullMQ) | +| `mcp-proprietary` | MCP Server | `apps/mcp-server/` + all `packages/` | `dockerfile/Dockerfile.mcp` | Node.js (tree-sitter, SSE) | +| `frontend-proprietary` | Frontend | `apps/frontend/` | `dockerfile/Dockerfile.frontend` | Nginx (static SPA serving) | + +Root cause analysis should trace errors back to source files in the monorepo. For Nginx (frontend), also check the Nginx configs in `dockerfile/nginx.*.conf` and the entrypoint `dockerfile/nginx-entrypoint.sh`. + +## Parameters + +| Parameter | Default | Description | +|-----------|---------|-------------| +| Days to analyze | 7 | Number of past days to look at. Override by user request (e.g., "last 3 days") | + +## Exclusions + +The following log patterns should be **discarded** and not included in the report. Skip them during pattern discovery and do not count them as errors: + +- `(node:1) [DEP0060] DeprecationWarning: The util._extend API is deprecated. Please use Object.assign() instead.` -- Known Node.js deprecation from a transitive dependency. Noise, not actionable. Filter with `-DEP0060`. + +- Nginx stale asset 404s (`open() "/usr/share/nginx/html/assets/..." failed (2: No such file or directory)`) -- Expected SPA behavior after deployments. Browsers with a cached `index.html` request old hashed JS chunks that no longer exist. Not a bug. Filter with `-"No such file or directory" -"/assets/"` on `frontend-proprietary`. + +When filtering in Phase 1, exclude these patterns from the analysis by appending the exclusion terms to Datadog queries, or remove them during report consolidation. + +## Workflow + +### Phase 1: Discover Error Patterns (all services in parallel) + +For **each** of the three services, launch two parallel MCP calls (6 calls total, all in parallel). If rate-limited by the MCP server, fall back to batching 2 calls per service sequentially. + +Every Datadog MCP call requires a `telemetry` object with an `intent` string describing the call's purpose (e.g., `{"intent": "Discover error patterns for api-proprietary over last 7 days"}`). Keep intents concise and avoid including PII or secrets. + +1. **Pattern discovery** -- Use `mcp__datadog-mcp__search_datadog_logs` with: + - `query`: `service:{service_name} status:(error OR critical OR emergency)` + - `from`: `now-{N}d` (where N = number of days, default 7) + - `use_log_patterns`: `true` + - `max_tokens`: `10000` + +2. **Error message counts** -- Use `mcp__datadog-mcp__analyze_datadog_logs` with: + - `filter`: `service:{service_name} status:(error OR critical OR emergency)` + - `sql_query`: `SELECT message, count(*) as cnt FROM logs GROUP BY message ORDER BY cnt DESC LIMIT 50` + - `from`: `now-{N}d` + - `max_tokens`: `10000` + +From these results, identify the distinct error groups per service. If a service has zero errors in the period, mention "No issues found" in the report and skip Phases 2-3 for that service. + +### Phase 2: Deep Dive Each Error Group + +For each distinct error group identified in Phase 1: + +1. **Fetch raw logs** -- Use `search_datadog_logs` with a targeted query to get full stack traces and context. Use `extra_fields: ["*"]` for tag metadata when useful. + +2. **Get daily distribution** -- Use `analyze_datadog_logs` with: + - `sql_query`: `SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt FROM logs WHERE message LIKE '%%' GROUP BY DATE_TRUNC('day', timestamp) ORDER BY DATE_TRUNC('day', timestamp)` + +3. **Count occurrences** -- Use `analyze_datadog_logs` to get total unique occurrences grouped by message. + +Parallelize independent MCP calls wherever possible to save time. + +#### Frontend-Specific Notes + +For `frontend-proprietary`, Nginx writes all `error_log` output (including `[notice]`) to stderr. Datadog classifies stderr as `status:error`. Filter out Nginx lifecycle noise: +- Ignore patterns containing `[notice]` (worker start/stop, SIGQUIT, SIGCHLD, SIGIO) -- these are normal Nginx operations misclassified as errors +- Focus on `[error]` (404s for missing files) and `[alert]` (permission issues, config errors) + +### Phase 3: Codebase Root Cause Analysis + +For each application-level error (not infra/external): + +Before grepping, consult `references/known_patterns.md` — if the error matches a catalogued pattern, jump straight to its entry point and skip to step 2. + +1. **Grep for the error class or message** in the codebase using the Grep tool (e.g., `SpaceMembershipRequiredError`, `Recipe.*not found`) +2. **Read the source files** where the error is thrown +3. **Trace the call chain**: error class -> service/use case -> controller/adapter +4. **Identify the root cause**: missing error handling, wrong HTTP status, race condition, missing validation, Dockerfile misconfiguration, etc. + +For frontend Nginx errors, check: +- `dockerfile/Dockerfile.frontend` for permission/ownership issues +- `dockerfile/nginx.k8s.conf`, `dockerfile/nginx.k8s.no-ingress.conf`, `dockerfile/nginx.compose.conf` for config issues +- `dockerfile/nginx-entrypoint.sh` for entrypoint issues + +### Phase 4: Generate Report + +Read `references/report-template.md` before writing the report for the output path, scaffold, severity ordering, occurrence labels, and final summary table. \ No newline at end of file diff --git a/.cursor/skills/datadog-analysis/references/datadog_mcp.md b/.cursor/skills/datadog-analysis/references/datadog_mcp.md new file mode 100644 index 000000000..c69e11aae --- /dev/null +++ b/.cursor/skills/datadog-analysis/references/datadog_mcp.md @@ -0,0 +1,158 @@ +# Datadog MCP Server Reference + +Reference guide for using the Datadog MCP server tools. Read this before making any Datadog MCP calls. + +## Available Tools + +### `mcp__datadog-mcp__search_datadog_logs` + +**Purpose**: Search and retrieve raw log entries or log patterns. Best for viewing raw logs, discovering patterns, and discovering custom attributes. + +**Key parameters**: +- `query` (required): Datadog search query using `key:value` syntax +- `from` / `to`: Time range. Use relative format `now-Xh`, `now-Xd`. Default: last 1 hour +- `use_log_patterns` (boolean): When `true`, clusters similar log messages into patterns with counts instead of returning raw logs. Essential for the initial discovery phase. +- `extra_fields` (array): Include extra attributes/tags. Use `["*"]` to get all metadata (tags, pod names, container info, etc.) +- `max_tokens`: Cap response size. Default 5000, use 10000 for pattern discovery +- `start_at`: Pagination offset for large result sets + +**When to use**: +- Initial pattern discovery (`use_log_patterns: true`) +- Fetching full stack traces for specific errors +- Getting tag metadata with `extra_fields: ["*"]` + +### `mcp__datadog-mcp__analyze_datadog_logs` + +**Purpose**: Run SQL queries against logs. Best for aggregations, counts, group-bys, and time-series analysis. + +**Key parameters**: +- `sql_query` (required): SQL against a virtual `logs` table +- `filter`: Datadog search query to pre-filter logs before SQL +- `from` / `to`: Time range (same format as search) +- `extra_columns`: Extend the `logs` table with typed columns from log attributes +- `max_tokens`: Default 10000 + +**Default columns**: `timestamp`, `host`, `service`, `env`, `version`, `status`, `message` + +**When to use**: +- Counting errors by message, day, or host +- Time-series aggregations (`DATE_TRUNC`) +- Top-N queries + +## Query Syntax + +### Datadog Search Query (for `query` / `filter` parameters) + +``` +service:api-proprietary status:error # Basic tag filtering +service:api-proprietary status:(error OR critical) # OR within a tag +"exact phrase match" # Quoted exact match +service:api-proprietary "Recipe" "not found" # Multiple terms (AND) +@http.status_code:[400 TO 499] # Range on raw attribute +-version:beta # Exclusion +service:web* # Wildcard +``` + +### DDSQL (for `sql_query` parameter) + +DDSQL is a PostgreSQL subset with restrictions: +- Every non-aggregated `SELECT` column must appear in `GROUP BY` +- `SELECT` aliases **cannot** be reused in `WHERE`, `GROUP BY`, or `HAVING` -- repeat the full expression instead +- Only declared table columns may be referenced +- Use `->` or `json_extract_path_text` for JSON access (cast as needed) +- Column names with special characters like `@` must be quoted: `SELECT "@foo" FROM logs` + +**Unsupported**: `ANY()`, `->>`, `information_schema`, `current_timestamp` + +**Common patterns**: + +```sql +-- Count by message +SELECT message, count(*) as cnt FROM logs GROUP BY message ORDER BY cnt DESC LIMIT 50 + +-- Daily distribution +SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt +FROM logs +GROUP BY DATE_TRUNC('day', timestamp) +ORDER BY DATE_TRUNC('day', timestamp) + +-- Filter with LIKE +SELECT message, count(*) as cnt +FROM logs +WHERE message LIKE '%SpaceMembershipRequiredError%' +GROUP BY message ORDER BY cnt DESC LIMIT 10 + +-- Hourly breakdown +SELECT DATE_TRUNC('hour', timestamp) as hour, count(*) as cnt +FROM logs +GROUP BY DATE_TRUNC('hour', timestamp) +ORDER BY DATE_TRUNC('hour', timestamp) +``` + +## Gotchas and Lessons Learned + +### 1. Pattern search vs raw search return different formats + +- `use_log_patterns: true` returns **TSV_DATA** with columns: `first_seen`, `last_seen`, `count`, `status`, `pattern` +- Raw search returns **TSV_DATA** or **YAML_DATA** with individual log entries +- Pattern search is much more useful for initial discovery -- always start with it + +### 2. Datadog search does NOT support regex -- use LIKE in SQL as fallback + +Queries like `"Recipe * not found"` with wildcards in quoted strings will return 0 results. Instead: +- Use multiple quoted terms: `"Recipe" "not found"` (matches logs containing both) +- Or use unquoted keywords: `Recipe not found` (matches as AND) +- Wildcards only work on tag values: `service:web*` + +When `search_datadog_logs` still returns 0 results for a complex pattern, fall back to `analyze_datadog_logs` with `WHERE message LIKE '%pattern%'`. The SQL LIKE operator works on the raw message text and is more flexible than the search query syntax. + +### 3. Multi-line stack traces are separate log entries + +In Datadog, each line of a stack trace is typically a separate log entry. A single exception with a 10-line stack trace produces 10 log entries. This means: +- Raw log counts are inflated (one error = many log lines) +- Pattern discovery helps group these related lines +- To find the actual error message, filter for the line containing the exception class name (e.g., `ExceptionsHandler`) + +### 4. ANSI escape codes appear in log messages + +Production NestJS logs contain ANSI color codes like `[31m`, `[39m`, `[38;5;3m`. These appear in raw log output. When searching, ignore these codes -- they won't affect keyword matching but make messages harder to read visually. + +### 5. The `extra_fields: ["*"]` option returns extensive tag metadata + +Using `["*"]` returns all Kubernetes/cloud metadata (pod name, node, container, cluster, etc.). This is useful for: +- Identifying which pods are affected +- Determining if errors are node-specific +- Cross-referencing with infrastructure incidents + +But it significantly increases response size -- only use when needed. + +### 6. Time range format + +- Relative: Must start with `now-` (e.g., `now-5d`, `now-24h`, `now-30m`) +- Absolute: ISO 8601 (e.g., `2026-04-10T00:00:00Z`) +- Default is `now-1h` which is too short for most analyses -- always set explicitly + +### 7. Token limits and pagination + +- Default `max_tokens` is 5000 for search and 10000 for analyze +- If results are truncated, the response includes `is_truncated: true` and a `truncation_message` with the next `start_at` offset +- For pattern discovery, 10000 tokens is usually sufficient +- For raw log fetching with `extra_fields`, reduce the number of results or increase max_tokens + +### 8. DDSQL GROUP BY alias trap + +This fails: +```sql +SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt +FROM logs GROUP BY day -- ERROR: 'day' alias not recognized +``` + +This works: +```sql +SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt +FROM logs GROUP BY DATE_TRUNC('day', timestamp) -- Repeat full expression +``` + +### 9. Storage tier for older logs + +For logs older than the default retention period, use `storage_tier: "flex_and_indexes"` to also search Flex storage. This is only needed for the analyze tool; the search tool supports both `flex_and_indexes` and `online_archives_and_indexes`. diff --git a/.cursor/skills/datadog-analysis/references/known_patterns.md b/.cursor/skills/datadog-analysis/references/known_patterns.md new file mode 100644 index 000000000..18d908a7d --- /dev/null +++ b/.cursor/skills/datadog-analysis/references/known_patterns.md @@ -0,0 +1,23 @@ +# Known Error Patterns + +Pre-catalogued recurring error patterns and their codebase entry points. Consult during Phase 3 when an error matches a known signature — jump straight to the listed entry point and skip ad-hoc grepping. + +## API (`api-proprietary`) + +| Pattern | Datadog query | Codebase entry point | +|---------|--------------|---------------------| +| Redis connection failure | `service:api-proprietary status:error ETIMEDOUT OR ECONNREFUSED` | `ioredis` client, infra-level | +| Space membership check | `service:api-proprietary status:error SpaceMembershipRequiredError` | `packages/node-utils/src/application/AbstractSpaceMemberUseCase.ts` | +| Recipe not found | `service:api-proprietary status:error "Recipe" "not found"` | `packages/recipes/src/application/services/RecipeService.ts` | +| Artefact not found | `service:api-proprietary status:error "Artefact" "not found"` | `packages/playbook-change-management/src/application/services/validateArtefactInSpace.ts` | +| Sign-in failure | `service:api-proprietary status:error "Failed to sign in"` | `apps/api/src/app/auth/auth.controller.ts` | +| Onboarding status failure | `service:api-proprietary status:error "onboarding status"` | `apps/api/src/app/auth/auth.service.ts` | +| Amplitude tracking failure | `service:api-proprietary status:error Amplitude` | Amplitude Node.js SDK (external) | +| PG concurrent query | `service:api-proprietary status:error "client.query() when the client is already executing"` | `pg` driver through TypeORM | + +## Frontend (`frontend-proprietary`) + +| Pattern | Datadog query | Codebase entry point | +|---------|--------------|---------------------| +| Nginx PID unlink permission denied | `service:frontend-proprietary "unlink" "nginx.pid"` | `dockerfile/Dockerfile.frontend:17` + `dockerfile/nginx.*.conf:3` | +| Nginx notice logs misclassified | `service:frontend-proprietary status:error "[notice]"` | Datadog log pipeline config (not code) | diff --git a/.cursor/skills/datadog-analysis/references/report-template.md b/.cursor/skills/datadog-analysis/references/report-template.md new file mode 100644 index 000000000..2e18b42f6 --- /dev/null +++ b/.cursor/skills/datadog-analysis/references/report-template.md @@ -0,0 +1,101 @@ +# Datadog Report Template + +Output path, scaffold, ordering, and labels for the Phase 4 report. + +## Output path + +Write the report to `datadog_{YYYY_MM_DD}.md` at the project root, where the date is today's date. + +## Report structure + +The report is organized with **one top-level section per application**, each containing its own issues: + +````markdown +# Datadog Error Report + +**Period**: {start_date} to {end_date} ({N} days) + +--- + +# API (`api-proprietary`) + +**Total error log lines**: ~{count} + +| Day | Error count | +|-----------|------------| +| {date} | {count} | + +## 1. {Issue Title} + +**Occurrences**: {frequency description} + +**Datadog search pattern**: +``` +service:api-proprietary status:error {specific pattern keywords} +``` + +**Description**: {What the error is and how it manifests} + +**Root cause**: {Analysis with source file paths and line numbers from the codebase.} + +**Source**: `{file_path}:{line_number}` + +--- + +## 2. {Next Issue} +... + +--- + +# MCP Server (`mcp-proprietary`) + +**Total error log lines**: ~{count} + +| Day | Error count | +|-----------|------------| +| {date} | {count} | + +## 1. {Issue Title} +... + +--- + +# Frontend (`frontend-proprietary`) + +**Total error log lines**: ~{count} (excluding Nginx [notice] noise) + +| Day | Error count | +|-----------|------------| +| {date} | {count} | + +## 1. {Issue Title} +... +```` + +## Ordering (within each section) + +Sort issues by severity: +1. Infrastructure errors (Redis, database connectivity, Nginx permission errors) +2. Application bugs (unhandled exceptions returning 500, missing static assets) +3. External service failures (Amplitude, third-party APIs) +4. Deprecation warnings (Node.js, library deprecations) +5. Expected user errors logged at wrong level (failed logins) + +## Occurrence labels + +Use these labels based on the pattern: +- **ONCE** -- single occurrence in the period +- **{N} TIMES** -- N occurrences total, no clear daily pattern +- **{N} DAYS THIS WEEK** -- recurring across N distinct days +- **ALL {N} DAYS** -- present every day in the period +- **{N} lines, {M} day(s)** -- for high-volume infra errors, specify raw log line count and day spread + +## Summary table + +After writing the report, print a summary table covering all services: + +| Service | # | Issue | Occurrences | Severity | +|---------|---|-------|-------------|----------| +| API | 1 | ... | ... | High/Medium/Low | +| MCP | 1 | ... | ... | High/Medium/Low | +| Frontend | 1 | ... | ... | High/Medium/Low | diff --git a/.cursor/skills/doc-audit/SKILL.md b/.cursor/skills/doc-audit/SKILL.md new file mode 100644 index 000000000..c5ffcc215 --- /dev/null +++ b/.cursor/skills/doc-audit/SKILL.md @@ -0,0 +1,140 @@ +--- +name: 'doc-audit' +description: 'Audit Packmind end-user documentation (apps/doc/) for broken links, outdated CLI references, non-existent concepts, misleading information, and missing coverage. Produces a structured markdown report at project root. Use when docs may have drifted from the codebase, before a release, or on a regular cadence.' +--- + +# Documentation Audit + +Detect outdated, broken, or misleading documentation by cross-referencing MDX pages against the actual codebase. Produces a structured `doc-audit-report.md` at the project root. + +**This skill only detects issues — it does not fix them.** + +## Phase 1: Build Ground Truth + +Before launching any sub-agents, build a concise ground truth summary by gathering these four data sources: + +1. **Navigation structure** — Read `apps/doc/docs.json` and extract all navigation groups with their page lists +2. **CLI commands** — List files in `apps/cli/src/infra/commands/` to get current command files +3. **Domain packages** — List directories in `packages/` to get current package names +4. **Doc MDX files** — Glob `apps/doc/**/*.mdx` to get all actual pages on disk + +Compile these into a **ground truth summary** string formatted as: + +``` +## Ground Truth + +### Navigation Groups (from docs.json) +- Getting Started: index, getting-started/gs-install-cloud, ... +- Concepts: concepts/standards-management, ... +[list all groups] + +### CLI Commands (from apps/cli/src/infra/commands/) +[list all *Command.ts and *Handler.ts files] + +### Domain Packages (from packages/) +[list all package directory names] + +### MDX Files on Disk (from apps/doc/**/*.mdx) +[list all .mdx file paths relative to apps/doc/] + +### Current Date +{today's date} +``` + +## Phase 2: Launch Parallel Sub-Agents + +Launch **5 Explore sub-agents** in parallel (`subagent_type: Explore`), one per section group. Each agent receives: +- The ground truth summary from Phase 1 +- The full contents of `references/section-audit-instructions.md` (read this file and include its contents in each prompt) +- Its assigned section and list of MDX files to audit + +### Agent Assignments + +| Agent | Sections | Pages to Audit | +|-------|----------|----------------| +| 1 | Getting Started + root pages | `index.mdx`, `home.mdx` + all `getting-started/*.mdx` | +| 2 | Concepts | All `concepts/*.mdx` + `tools/import-from-knowledge-base.mdx` | +| 3 | Tools & Integrations | `tools/cli.mdx` | +| 4 | Governance + Playbook Maintenance + Linter | All `governance/*.mdx` + `playbook-maintenance/*.mdx` + `linter/*.mdx` | +| 5 | Administration + Security | All `administration/*.mdx` + `security/*.mdx` | + +### Agent Prompt Template + +Each agent's prompt should follow this structure: + +``` +You are auditing the {section_name} section of the Packmind documentation. + +## Your Assigned Pages +{list of MDX file paths to read and audit} + +## Ground Truth +{ground truth summary from Phase 1} + +## Audit Instructions +{full contents of references/section-audit-instructions.md} + +Read each assigned MDX page completely and apply all detection categories. Return your findings in the exact format specified in the instructions. +``` + +### Sequential Fallback + +If the Agent tool is unavailable, perform the audit sequentially: read each section's pages one by one and apply the same checks from `references/section-audit-instructions.md` directly. + +## Phase 3: Consolidate Report + +After all sub-agents complete: + +1. **Collect** all findings from the 5 agents +2. **Deduplicate** — remove exact duplicates (same page, same line, same issue) +3. **Sort** by severity: ERROR first, then WARNING, then INFO +4. **Group** by category within each severity level +5. **Write** the report to `doc-audit-report.md` at the project root + +### Report Format + +```markdown +# Documentation Audit Report +Generated: {date} | Pages audited: {count} + +## Summary +| Severity | Count | +|----------|-------| +| ERROR | N | +| WARNING | N | +| INFO | N | + +## Errors + +### [A] Broken Internal Links +- **{page}** (line ~{N}): Link to `{target}` — no matching MDX file exists +[... more findings] + +### [B] Outdated CLI Commands +- **{page}** (line ~{N}): References `packmind-cli {cmd}` — command not found in CLI source +[... more findings] + +### [C] Non-Existent Concepts +- **{page}** (line ~{N}): References `{concept}` — not found in codebase +[... more findings] + +## Warnings + +### [D] Misleading Information +- **{page}** (line ~{N}): "{quoted text}" — {reason} +[... more findings] + +## Info + +### [E] Missing Documentation Coverage +- CLI command `{cmd}` has no documentation +- Package `{pkg}` has no documentation page +[... more findings] +``` + +**Omit any category section that has zero findings.** Only include sections with actual results. + +After writing the report, print a brief summary: +- Total issues found per severity +- Top 3 most problematic pages (by issue count) +- The report file path \ No newline at end of file diff --git a/.cursor/skills/doc-audit/references/section-audit-instructions.md b/.cursor/skills/doc-audit/references/section-audit-instructions.md new file mode 100644 index 000000000..61ee255f0 --- /dev/null +++ b/.cursor/skills/doc-audit/references/section-audit-instructions.md @@ -0,0 +1,122 @@ +# Section Audit Instructions + +You are auditing a section of the Packmind end-user documentation. Your job is to cross-reference claims in the MDX pages against the actual codebase and ground truth data to find **concrete, verifiable issues only**. + +## Golden Rule + +**Only flag issues where you can point to a specific codebase artifact that contradicts the documentation.** Do not flag stylistic issues, subjective concerns, or things that "might" be wrong. Every finding must be backed by evidence. + +## Detection Categories + +### Category A: Broken Internal Links (ERROR) + +**What to check:** Links in MDX files that reference other doc pages (e.g., `/concepts/standards-management`, `getting-started/gs-cli-setup`). + +**How to verify:** Check if the link target resolves to an actual MDX file in the ground truth MDX file list. Mintlify resolves links relative to the `apps/doc/` root — a link to `/concepts/foo` should map to `apps/doc/concepts/foo.mdx`. + +**Valid finding:** +- Page links to `/concepts/workflow-management` but no `apps/doc/concepts/workflow-management.mdx` exists + +**Not a finding (false positive):** +- Links to external URLs (https://...) — do not check these +- Links to anchors within the same page (#section) +- Links listed in the `redirects` section of `docs.json` — these are handled by Mintlify + +### Category B: Outdated CLI Commands (ERROR) + +**What to check:** References to `packmind-cli ` or CLI command names in the documentation. + +**How to verify:** Check if the referenced command exists in the CLI commands ground truth (file list from `apps/cli/src/infra/commands/`). Command files follow the pattern `*Command.ts` or `*Handler.ts`. + +**Valid finding:** +- Doc references `packmind-cli migrate` but no `MigrateCommand.ts` or `migrateHandler.ts` exists in CLI source + +**Not a finding (false positive):** +- CLI flags or options (e.g., `--verbose`) — only check command names +- Generic references to "the CLI" without a specific command name + +### Category C: Non-Existent Concepts (ERROR) + +**What to check:** References to specific Packmind features, domain packages, or integrations that should exist in the codebase. + +**How to verify:** Check against the ground truth package list and codebase. Look for references to packages (e.g., `@packmind/some-package`), specific integration names, or feature names that imply codebase support. + +**Valid finding:** +- Doc describes a "Bitbucket integration" but no bitbucket-related package or code exists +- Doc references `@packmind/analytics` but that package doesn't exist in `packages/` + +**Not a finding (false positive):** +- High-level conceptual descriptions (e.g., "Packmind helps teams share knowledge") +- References to third-party tools that Packmind integrates with via configuration (not code) +- UI features that exist in the frontend but not as standalone packages +- Do not infer feature availability per edition (OSS vs. Enterprise vs. Cloud) from package paths. Edition gating is controlled by TypeScript path aliases in `tsconfig.paths.oss.json` vs `tsconfig.paths.proprietary.json`, not by directory structure — see Category D for the full explanation + +### Category D: Misleading Information (WARNING) + +**What to check:** +- Dates in the past presented as future events (e.g., "coming in Q2 2024" when it's 2026) +- Direct contradictions between two doc pages about the same feature +- References to deprecated or removed features still presented as current + +**How to verify:** Compare dates against the current date (provided in your context). Compare claims across pages for consistency. + +**Valid finding:** +- "This feature will be available in Q3 2024" — that date has passed +- Page A says "standards support Markdown only" while page B says "standards support Markdown and YAML" + +**Not a finding (false positive):** +- Vague future references ("we plan to add...") +- Minor wording differences between pages about the same concept +- Claims about feature availability per edition (e.g., "Enterprise only", "OSS only", "available in the Cloud version") cannot be verified by inspecting package directories. Edition gating in this repo is done at build time via TypeScript path aliases — `tsconfig.paths.oss.json` redirects `@packmind/` imports to `packages/editions/src/index.ts` (a **stubs aggregator** for the OSS build), while `tsconfig.paths.proprietary.json` redirects the same imports to the real top-level package such as `packages/linter/src/index.ts`. Consequences: (a) the presence of `packages/editions/src/oss//` only means an OSS-build stub exists — it is **not evidence** the feature works in OSS; (b) the existence of a top-level `packages//` package does **not** mean the feature ships in OSS, because the OSS tsconfig points elsewhere. Do not flag edition-availability claims based on either of these paths — verify against the two `tsconfig.paths.*.json` files if a check is unavoidable, otherwise skip. + +### Category E: Missing Documentation Coverage (INFO) + +**What to check:** CLI commands or domain packages that exist in the codebase but have no corresponding documentation page or section. + +**How to verify:** Compare the ground truth CLI commands and packages against what the documentation covers. A command is "covered" if it's mentioned on any doc page (typically `tools/cli.mdx`). A package is "covered" if its functionality is described somewhere in the docs. + +**Valid finding:** +- CLI has `SyncCommand.ts` but no doc page mentions the `sync` command +- Package `packages/linter` exists but no linter documentation page exists + +**Not a finding (false positive):** +- Internal/infrastructure packages (e.g., `node-utils`, `test-helpers`) — these are not user-facing +- Commands that are clearly internal or development-only + +## Expected Output Format + +Return your findings as a structured list, one per line, in this exact format: + +``` +[SEVERITY] [CATEGORY] **{page-path}** (line ~{N}): {description} +``` + +Where: +- `SEVERITY` is one of: `ERROR`, `WARNING`, `INFO` +- `CATEGORY` is one of: `A`, `B`, `C`, `D`, `E` +- `page-path` is the relative path from `apps/doc/` (e.g., `tools/cli.mdx`) +- `line ~{N}` is the approximate line number (use `~` since MDX rendering may shift lines) +- `description` is a concise explanation of the issue with evidence + +**Example output:** + +``` +[ERROR] [A] **getting-started/gs-onboarding.mdx** (line ~45): Link to `/concepts/workflow-management` — no matching MDX file exists in apps/doc/concepts/ +[ERROR] [B] **tools/cli.mdx** (line ~120): References `packmind-cli migrate` — no MigrateCommand.ts found in CLI source +[WARNING] [D] **concepts/standards-management.mdx** (line ~30): "Coming in Q2 2024" — date has passed (current date: 2026-03-12) +[INFO] [E] **N/A**: CLI command `SyncCommand.ts` has no documentation coverage +``` + +If you find **no issues** in your assigned section, return: + +``` +NO_ISSUES_FOUND +``` + +## Important Reminders + +- Read each MDX file **completely** — don't skip content +- Be thorough but precise — false positives waste time +- Include approximate line numbers to help locate issues +- For Category E, you only need to check commands/packages relevant to your assigned section +- Do NOT suggest improvements or rewrites — this is detection only diff --git a/.cursor/skills/feature-spec/SKILL.md b/.cursor/skills/feature-spec/SKILL.md new file mode 100644 index 000000000..7ee6ce9eb --- /dev/null +++ b/.cursor/skills/feature-spec/SKILL.md @@ -0,0 +1,135 @@ +--- +name: 'feature-spec' +description: 'Generate a Packmind feature specification from a GitHub issue, file, URL, or direct description. Breaks Phase 3 into 4 fine-grained approval gates (domain data structures, ports/use cases/routes, frontend components, implementation plan) to catch problems early. Routes work between the OSS sibling (../packmind) and the proprietary repo (cwd). Outputs spec artifacts under tmp/feature-specs/{slug}/ for use with /feature-sprint.' +--- + +# Feature Spec Skill + +Generate a comprehensive feature specification grounded in Packmind's hexagonal architecture, frontend gateway/PM-UI conventions, and the OSS/proprietary fork boundary. + +## When to Use + +Use this skill when the user wants to: +- Plan a new feature, fix, or refactor that spans Packmind's stack +- Convert a GitHub issue, design doc, or rough idea into a structured spec +- Get fine-grained validation of data structures, routes, and components before committing to a plan + +**Do NOT use** for trivial one-line fixes or pure dependency bumps. + +## Packmind Repo Layout (essential context) + +- Closed-source fork (`packmind-proprietary`): the current working directory when this skill runs. +- Open-source repo (`packmind`): the sibling directory at `../packmind` (cloned next to the proprietary repo). Resolve to an absolute path at runtime with `realpath ../packmind`. +- **Most feature work happens on the OSS repo and auto-merges into the proprietary fork.** The proprietary fork only contains paid/closed features (e.g. `packages/editions`, certain `packages/deployments` extensions). After an OSS merge, you typically pull on the proprietary side. +- Architecture: hexagonal (`packages/{domain}/{domain,application,infra}`), NestJS API at `apps/api`, React+`@packmind/ui` frontend at `apps/frontend`, TypeORM, Jest+@swc/jest. + +## Input Sources + +| Source | Examples | Detection | +|--------|----------|-----------| +| GitHub | `#123`, `owner/repo#123`, full issue URL | Pattern `#\d+` or `github.com/.../issues/` | +| File | `file:spec.md`, `path/to/spec.md` | Prefix `file:` or readable file | +| URL | `https://...` | HTTP(S) URL (non-GitHub) | +| Prompt | Any other text | Default | + +## Workflow Phases + +Execute phases **sequentially**. Phases 1 and 4 run seamlessly (no approval gate). Phases 2 and 3 require user approval before proceeding. + +| Phase | File | Purpose | Gate | +|-------|------|---------|------| +| 1 | `phase-1-resolve-source.md` | Detect source type, fetch content, decide OSS-vs-proprietary routing, create state | seamless | +| 2 | `phase-2-discovery-and-spec.md` | Research Packmind patterns (reference domain, hex layers, frontend conventions), draft acceptance criteria, generate functional spec | approval | +| 3 | `phase-3-implementation.md` | Technical approach + implementation plan (4 subtasks below) | 4 approvals | +| 3A | ↳ Subtask 3A | Validate domain data structures (entities, value objects, events, migrations) | approval | +| 3B | ↳ Subtask 3B | Validate ports, contracts, use cases, services, adapters, NestJS routes | approval | +| 3C | ↳ Subtask 3C | Validate frontend components, gateways, query hooks, PM-UI usage | approval | +| 3D | ↳ Subtask 3D | Generate full implementation plan (task breakdown, grouping, coverage) | approval | +| 4 | `phase-4-finalize.md` | Generate `context.md`, mark spec complete, report (no commit — artifacts live in `tmp/`) | seamless | + +## State Management + +State is persisted to markdown files under a git-ignored `tmp/` directory for cross-session continuity. Phase progress is inferred from which output files exist — no separate state file needed. + +``` +tmp/feature-specs/{slug}/ +├── {slug}.md # Main spec (status: DRAFT → COMPLETE in frontmatter) +├── discovery.md # Phase 2 output (YAML frontmatter + markdown) +├── functional-spec.md # Phase 2 output (informed by discovery) +├── implementation-plan.md # Phase 3 output (includes parallel groups section) +└── context.md # Phase 4 output (YAML frontmatter + markdown) +``` + +### Phase Detection (Resume Logic) + +| Files Present | Completed Phase | Resume At | +|---------------|----------------|-----------| +| `{slug}.md` only | Phase 1 | Phase 2 | +| + `discovery.md`, `functional-spec.md` | Phase 2 | Phase 3 | +| + `implementation-plan.md` | Phase 3 | Phase 4 | +| + `context.md` (status: COMPLETE) | Phase 4 | Done | + +## Invocation + +### Auto-Detection (Resuming) + +Before starting, check for an existing task directory at `tmp/feature-specs/{slug}/`: + +1. Check which output files exist to determine current phase (see Phase Detection table above) +2. Read the main spec file's YAML frontmatter for `status: DRAFT|COMPLETE` +3. If resuming, display: + ``` + **Resuming Feature Spec**: {slug} + **Current Phase**: {detected_phase} + **Status**: {status from frontmatter} + + Continue from Phase {detected_phase}? (Y/n) + ``` +4. If user confirms, proceed to the appropriate phase file +5. If no task directory found, start a new feature spec + +### Starting New + +To start a new feature spec: Read `phase-1-resolve-source.md` and follow its instructions. + + + Execute phases SEQUENTIALLY — never skip phases + Phases 1 and 4 proceed automatically — no approval gate + Phases 2 and 3 require USER APPROVAL via AskUserQuestion before proceeding + Save all artifacts under tmp/feature-specs/{slug}/ — never under tracked folders + Use Task tool with Explore subagent for codebase research; never read 50 files yourself + Agent NEVER implements code in this skill — output ONLY specification documents + Main spec file has status DRAFT until Phase 4 completes + Always record target_repo (oss | proprietary | both) decided in Phase 1; consumed by /feature-sprint + Never invent OSS/proprietary boundaries — if unsure, ask the user + + +## Output + +Final deliverable: `tmp/feature-specs/{slug}/{slug}.md` + sibling artifacts. + +Contains: +- Functional specification with acceptance criteria +- Technical approach grounded in Packmind reference patterns +- Implementation plan with phased tasks (each task has reference `file:line` patterns) +- Parallel Groups section (for parallel execution via `/feature-sprint`) +- `target_repo`: oss | proprietary | both + +## Parallel Execution Support + +Phase 3 includes a grouping step that analyzes task dependencies and creates parallel execution groups: + +**How grouping works:** +1. After generating the implementation plan, a Plan subagent analyzes tasks +2. Tasks are grouped by file dependencies (tasks sharing files go together) +3. Groups are non-overlapping (no file belongs to multiple groups) +4. Results are stored in `implementation-plan.md` (Parallel Groups section) and `context.md` (YAML frontmatter) + +**When grouping is skipped:** +- Tasks have no clear file dependencies +- Single-file or trivial implementations +- User opts out during Phase 3 review + +## Handoff + +When the spec is complete (Phase 4), the next step is `/feature-sprint {slug}` — the companion skill that executes the implementation plan. \ No newline at end of file diff --git a/.cursor/skills/feature-spec/phase-1-resolve-source.md b/.cursor/skills/feature-spec/phase-1-resolve-source.md new file mode 100644 index 000000000..6f21a1240 --- /dev/null +++ b/.cursor/skills/feature-spec/phase-1-resolve-source.md @@ -0,0 +1,171 @@ +# Phase 1: Resolve Source & Decide Fork Routing + +Detect the source type, fetch content, decide where the work will land (OSS fork or proprietary), and create the initial state directory. + +## Input + +User provides `task_input` — one of: +- GitHub issue: `#123`, `owner/repo#123`, or `https://github.com/.../issues/123` +- File path: `file:spec.md`, `path/to/spec.md` +- URL: `https://example.com/...` +- Direct prompt: any other text + +## Steps + +### 1.1 Detect Source Type + +| Pattern | Source Type | +|---------|-------------| +| `#\d+` or `owner/repo#\d+` or `github.com/.../issues/` | github | +| `file:` prefix or readable file path with `.md`/`.txt`/`.json` extension | file | +| `https?://` (non-GitHub) | url | +| Anything else | prompt | + +### 1.2 Generate Task Slug + +Create `task_slug` from the user-provided title or first meaningful phrase: +- Convert to lowercase +- Replace spaces and special characters with hyphens +- Strip leading/trailing hyphens, collapse consecutive hyphens +- Keep it under 60 characters + +### 1.3 Create State Directory + +```bash +mkdir -p tmp/feature-specs/{task_slug}/ +``` + +`tmp/` is git-ignored — these files never get committed. + +### 1.4 Create Initial Spec File (DRAFT) + +Write `tmp/feature-specs/{task_slug}/{task_slug}.md`: + +```markdown +--- +status: DRAFT +task_slug: {task_slug} +created: {ISO-8601 timestamp} +finished: null +--- + +# {task_name} + +_Specification in progress. See state directory for current phase._ +``` + +### 1.5 Fetch Content + +Use the appropriate tool based on source type: + +**GitHub:** +- If `gh` is available, use `gh issue view {issue_ref} --json title,body,labels,url,author` +- Otherwise use WebFetch on the issue URL +- Extract: `task_id` (e.g. `GH-{number}`), `task_name`, `task_description`, `task_url`, `labels` + +**File:** +- Read the file with the Read tool +- `task_name` = first heading or filename; `task_description` = full body + +**URL:** +- Use WebFetch +- Extract: `task_name`, `task_description`, `task_url` + +**Prompt:** +- `task_description` = the raw input +- `task_name` = first line (truncated to ~80 chars) +- `task_id` = `PROMPT-{timestamp}` + +### 1.6 Decide Fork Routing (Required) + +Packmind ships from two repos: +- **OSS** (`../packmind`): public packages and features. **Most code changes go here.** After merge, the proprietary fork pulls from upstream automatically. +- **Proprietary** (this repo): closed-source extensions. Examples: `packages/editions` (forbidden import elsewhere), some `packages/deployments` proprietary flows, billing, enterprise auth. + +Before continuing, classify the work using these heuristics: + +| Signal | Likely target | +|--------|---------------| +| Touches `packages/editions/`, billing, license, enterprise auth, paid deploy flows | proprietary | +| Touches `apps/api`, `apps/frontend`, `apps/cli`, `apps/mcp-server`, or any package present in BOTH repos | oss | +| Mentions enterprise-only features or paid customers in the issue | likely proprietary | +| Public bug, generic feature, OSS-visible UI | oss | +| Unsure | ask the user | + +Use `AskUserQuestion` to confirm: + +```json +{ + "questions": [{ + "question": "Where should this feature be implemented?", + "header": "Fork routing", + "multiSelect": false, + "options": [ + {"label": "OSS (../packmind) (Recommended)", "description": "Default for most work. Auto-merges into proprietary; you pull afterward."}, + {"label": "Proprietary (this repo)", "description": "Closed-source only: editions, paid deployments, enterprise features."}, + {"label": "Both (split)", "description": "Some tasks land on OSS, others on proprietary. Spec will split them in Phase 3."} + ] + }] +} +``` + +Record the answer as `target_repo` in `oss | proprietary | both`. + +**If `target_repo == "oss"`**: verify the OSS repo exists at `../packmind`. If missing, warn the user and ask whether to proceed targeting proprietary instead. + +### 1.7 Check for Existing Documentation + +Look for prior work on the same topic: +- Glob: `tmp/feature-specs/*{task_slug_keyword}*/` +- Glob: `.claude/specs/*{task_slug_keyword}*.md` (existing design specs) +- Glob: `.claude/plans/*{task_slug_keyword}*.md` (existing plans) + +If matches are found, read them and surface them in the summary so the user can decide whether to extend or supersede. + +### 1.8 Update Spec Frontmatter + +Update `tmp/feature-specs/{task_slug}/{task_slug}.md` with full metadata: + +```markdown +--- +status: DRAFT +task_slug: {task_slug} +task_id: {task_id} +source_type: {github | file | url | prompt} +source_url: {url or null} +task_name: {task_name} +target_repo: {oss | proprietary | both} +created: {ISO-8601 timestamp} +finished: null +--- + +# {task_name} + +{task_description} + +## Related Prior Work + +{Optional: list of existing specs/plans found in step 1.7, or "None found."} +``` + +This frontmatter is the source of truth for task metadata. Phase progress is inferred from which output files exist in the directory — no separate state file needed. + +## Task Summary + +Display before proceeding: + +``` +**Phase 1 complete.** Source resolved. + +**Task:** {task_name} +**ID:** {task_id} +**Source:** {source_type} +**Target repo:** {target_repo} +**Prior work:** {none | list of matched files} + +{task_description (first 500 chars)} +``` + +## Next Phase + +Proceed automatically to `phase-2-discovery-and-spec.md`. diff --git a/.cursor/skills/feature-spec/phase-2-discovery-and-spec.md b/.cursor/skills/feature-spec/phase-2-discovery-and-spec.md new file mode 100644 index 000000000..ec9a0dd34 --- /dev/null +++ b/.cursor/skills/feature-spec/phase-2-discovery-and-spec.md @@ -0,0 +1,443 @@ +# Phase 2: Discovery & Functional Spec + +Discover how Packmind actually does this kind of thing, then draft requirements grounded in that reality. + +## Purpose + +Two goals in one phase: +1. Find **the closest existing implementation** in Packmind — which domain package, which use case, which frontend component — and trace its full hex stack +2. Draft **functional requirements** informed by that discovery, so acceptance criteria match what Packmind's architecture can actually support + +## Prerequisites + +- Phase 1 completed (source resolved, `target_repo` decided) +- `tmp/feature-specs/{task_slug}/{task_slug}.md` exists with `status: DRAFT` + +## Steps + +### 2.1 Load Source Context + +Read `tmp/feature-specs/{task_slug}/{task_slug}.md`: +- YAML frontmatter: `task_name`, `source_type`, `target_repo` +- Body: `task_description` + +Extract key terms from `task_description`: +- **Domain entities** mentioned (e.g., "standard", "recipe", "space", "deployment", "user") +- **Actions/verbs** (e.g., "create", "import", "deploy", "rename", "preview") +- **Cross-cutting concerns** (e.g., "event", "migration", "permission", "rate limit") + +### 2.2 Pick the Discovery Root + +Based on `target_repo`: +- `oss` → run discovery in `../packmind` (most patterns live there) +- `proprietary` → run discovery in `.` (this repo) +- `both` → discovery in `../packmind` first; if a needed reference doesn't exist there, also search `.` + +Subagents should be told explicitly which root(s) to search. + +### 2.3 Launch Research Subagents (PARALLEL) + +Launch **TWO** subagents in parallel using the Task tool. Both should run concurrently in the same message. + +#### Subagent A: Packmind Pattern Finder + +Use `Agent` tool with `subagent_type="Explore"`, `description="Find Packmind reference patterns"`. Run "very thorough" search: + +``` +Find Packmind reference patterns for: {task_name} + +Search root: {discovery_root absolute path} +Key terms: {extracted_terms} +Domain entities mentioned: {entities} +Task description: {task_description} + +## What to find + +Trace ACTUAL code paths for the closest similar feature in Packmind. Don't just list files — explain HOW the feature flows through the hex layers. + +### 1. Reference domain package +Find the closest existing `packages/{domain}/` whose problem matches. Example domains: accounts, deployments, spaces, standards, recipes, skills, coding-agent, playbook-change-management. + +For the chosen reference domain, document: +- **Domain layer** (`packages/{domain}/src/domain/`): + - Entities used: file path + key fields + - Repository interfaces: `IFooRepository` file path + - Events: any domain events emitted + - Errors: domain error classes +- **Application layer** (`packages/{domain}/src/application/`): + - Closest use case: `useCases/{name}/{name}.usecase.ts` with the abstract base it extends (AbstractMemberUseCase, AbstractSpaceMemberUseCase, AbstractAdminUseCase) + - Services involved + - Adapter: `adapter/{Domain}Adapter.ts` and which ports it exposes +- **Infrastructure layer** (`packages/{domain}/src/infra/`): + - Repository impl + TypeORM Schema + - Migration pattern reference (under `packages/migrations/`) +- **Types package** (`packages/types/src/{domain}/`): + - Port interface + - Contract for the use case (request/response shapes) +- **Hexa facade** (`{Domain}Hexa.ts`) and `index.ts` exports + +Capture every `file:line` reference. + +### 2. Reference API route +Search `apps/api/src/` for the closest NestJS controller method. Document: +- Controller file + method name + decorators +- DTO file (if any) under `apps/api/src/` or types package +- How it resolves the use case (`HexaRegistry.getAdapter(...)`) + +### 3. Reference frontend feature +Search `apps/frontend/` for the closest existing component that does this kind of thing. Document: +- **Gateway** (`apps/frontend/src/.../gateways/{Name}Gateway.ts`) — how it wraps the API call +- **Query/mutation hook** (TanStack Query or local equivalent) +- **Page / container component** that orchestrates queries + UI +- **UI components** built from `@packmind/ui` (PM-prefixed wrappers around Chakra) +- Form/validation library, if any (Zod, react-hook-form, etc.) + +### 4. Test patterns +For each layer touched, find the matching test file pattern (e.g. `*.usecase.spec.ts`, `*.repository.spec.ts`, `*.tsx` with React Testing Library). Note where integration tests live (`packages/integration-tests/`). + +### 5. Conventions +Cross-reference 2-3 similar features and note: +- Naming conventions for use cases, ports, contracts +- Authorization base class typically used +- Error-mapping approach (domain errors → HTTP) +- How events propagate cross-domain +- Frontend data-flow shape (gateway → query hook → component) + +### 6. Constraints +Note any: +- Files under `packages/editions/` (forbidden to import from elsewhere when on proprietary) +- Feature flags involved +- Existing migrations that overlap + +## Output Format + +Return JSON: +{ + "discovery_root": "absolute path searched", + "reference_domain": { + "package": "packages/standards", + "domain": [{"file": "", "line": 0, "role": ""}], + "application": [{"file": "", "line": 0, "role": ""}], + "infra": [{"file": "", "line": 0, "role": ""}], + "types": [{"file": "", "line": 0, "role": ""}], + "hexa_facade": {"file": "", "line": 0} + }, + "reference_route": {"controller": "", "method": "", "file": "", "line": 0, "uses_port": ""}, + "reference_frontend": { + "gateway": {"file": "", "line": 0}, + "queries": [{"file": "", "line": 0}], + "page": {"file": "", "line": 0}, + "ui_components": [{"name": "", "file": "", "line": 0}] + }, + "test_patterns": {"usecase_spec": "", "repository_spec": "", "frontend_component_spec": "", "integration_tests_dir": ""}, + "conventions": { + "naming": "", + "authorization": "", + "error_mapping": "", + "events": "", + "frontend_data_flow": "" + }, + "constraints": ["..."] +} +``` + +#### Subagent B: Documentation Researcher + +Use `Agent` tool with `subagent_type="Explore"`, `description="Search Packmind docs for context"`, "medium" thoroughness: + +``` +Search documentation for context on: {task_name} + +Search roots: {discovery_root}, and also the proprietary repo at {proprietary_root} if different + +## Where to look + +1. `apps/doc/` — public Mintlify end-user docs +2. `AGENTS.md`, `CLAUDE.md`, root `README.md` +3. `.claude/specs/` — prior design specs in this repo +4. `.claude/plans/` — prior implementation plans in this repo +5. `.claude/rules/packmind/*.md` — coding standards (frontmatter `paths` shows which paths each governs) +6. `tmp/feature-specs/` — earlier feature specs (any directory matching the task keywords) +7. Package-level `*.md` files (e.g., `packages/standards/README.md`) + +## Find + +- Related specifications, RFCs, or design docs +- Coding standards whose `paths` glob will match the files this feature touches +- Planned work that might overlap or conflict +- Historical context or decisions + +## Output Format + +Return JSON: +{ + "related_docs": [{"path": "", "relevance": "high|medium|low", "summary": ""}], + "applicable_standards": [{"path": ".claude/rules/packmind/foo.md", "paths_glob": "", "summary": ""}], + "planned_work": [{"path": "", "description": "", "potential_overlap": ""}], + "historical_context": [""] +} +``` + +Wait for BOTH subagents to complete before proceeding. + +### 2.4 Synthesize & Present Discovery + +Combine subagent results and present to the user (informational — no approval gate here): + +``` +## Discovery Summary + +### Target repo +{target_repo} (discovery root: {discovery_root}) + +### Reference Domain Package: `packages/{name}/` +Full hex stack trace: + +**Domain layer** +- `{file}:{line}` — {role} + +**Application layer** +- `{file}:{line}` — {role} + +**Infrastructure layer** +- `{file}:{line}` — {role} + +**Types package** (`packages/types/src/{domain}/`) +- `{file}:{line}` — {role} + +**Hexa facade** +- `{file}:{line}` + +### Reference API Route +- `{controller_file}:{line}` — `{METHOD} {path}` → uses port `{port_name}` + +### Reference Frontend Feature +- Gateway: `{file}:{line}` +- Queries: `{file}:{line}` +- Page: `{file}:{line}` +- UI components: {list of PM-* components used} + +### Test Patterns +- Use case spec: `{file_pattern}` +- Repository spec: `{file_pattern}` +- Frontend component spec: `{file_pattern}` +- Integration tests: `{dir}` + +### Conventions Found +- Naming: {conventions.naming} +- Authorization: {conventions.authorization} +- Error mapping: {conventions.error_mapping} +- Events: {conventions.events} +- Frontend data flow: {conventions.frontend_data_flow} + +### Applicable Coding Standards +{For each from documentation researcher:} +- `{path}` (governs: `{paths_glob}`) — {summary} + +### Related Documentation +{For each:} +- `{path}` ({relevance}) — {summary} + +### Constraints +{constraints list, including OSS/proprietary boundary callouts if relevant} +``` + +### 2.5 Draft Acceptance Criteria + +Using the discovery context, analyze `task_description` for: +- Existing acceptance criteria (checkboxes, numbered lists) +- User stories or personas mentioned +- Technical constraints or requirements +- Scope boundaries (what's included/excluded) + +Validate patterns against the task: + +1. **Relevance check** — Does the reference domain cover the layers this task needs? +2. **Consistency check** — Do similar features follow the same conventions? +3. **Gap check** — Does the task require something no existing feature does (e.g., a new port type, a new event)? + +Then draft acceptance criteria and present everything for approval: + +``` +### Pattern Validation + +**Reference template:** `packages/{name}` — {applicable | partially applicable | not applicable} +**Convention consistency:** {consistent across N features | divergences noted: ...} +**Gaps:** {none | list of things no existing feature covers} + +**Derived approach:** Follow `packages/{name}` pattern across {layers}. {Gap handling, if any.} + +## Draft Acceptance Criteria + +Based on the source description and discovery analysis: + +- [ ] {Criterion 1 — extracted from source} +- [ ] {Criterion 2 — extracted from source} +- [ ] {Criterion N — inferred from discovery patterns, e.g., "Soft delete supported following standard repository pattern"} + +### Potential Gaps +{List anything the source doesn't cover but the reference feature handles, e.g., authorization, events, error mapping} + +### Potential Over-scope +{List anything in the source that may be too broad or vague} +``` + +### 2.6 Approval Gate + +Use the `AskUserQuestion` tool: + +```json +{ + "questions": [{ + "question": "Do the discovered patterns and draft acceptance criteria look correct?", + "header": "Phase 2", + "multiSelect": false, + "options": [ + {"label": "Yes, proceed", "description": "Patterns and criteria are accurate, continue to generate the full functional spec"}, + {"label": "Needs adjustment", "description": "I'll clarify what's wrong or missing"} + ] + }] +} +``` + +If the user needs adjustment, incorporate their feedback and re-present step 2.5. + +### 2.7 Generate Functional Specification + +Create `tmp/feature-specs/{task_slug}/functional-spec.md`: + +```markdown +## Functional Specification + +### Overview +{1-2 paragraphs: what the feature does and why} +{Reference the derived approach from pattern validation} + +### Target Repo +{target_repo} — most changes land in {discovery_root}. Note any tasks that must live in the proprietary fork (e.g., editions, paid deployments). + +### Business Requirements +1. {Requirement 1} +2. {Requirement 2} +3. {Requirement 3} + +### User Stories +- As a {role}, I want to {action}, so that {benefit} +- As a {role}, I want to {action}, so that {benefit} + +### Acceptance Criteria +- [ ] {Criterion 1 — specific, measurable} +- [ ] {Criterion 2 — specific, measurable} +- [ ] {Criterion 3 — specific, measurable} + +### Constraints +- {Technical constraint, e.g., "Must follow AbstractSpaceMemberUseCase authorization pattern"} +- {Performance requirement} +- {Compatibility requirement} +- {OSS/proprietary boundary — e.g., "No imports from @packmind/editions in OSS tasks"} +{Include constraints from discovery if relevant} + +### Out of Scope +- {Explicitly excluded item} + +### Dependencies +{List known dependencies — other packages, ports, planned work that must precede this} +``` + +### 2.8 Save Discovery + +Write `tmp/feature-specs/{task_slug}/discovery.md` with YAML frontmatter capturing everything the implementation plan subagent will need: + +```markdown +--- +target_repo: {oss | proprietary | both} +discovery_root: "{absolute path}" + +reference_domain: + package: "packages/{name}" + domain_files: + - file: "packages/{name}/src/domain/entities/{Entity}.ts" + line: 1 + role: "Main entity" + application_files: + - file: "packages/{name}/src/application/useCases/{name}/{name}.usecase.ts" + line: 1 + role: "Closest existing use case" + infra_files: + - file: "packages/{name}/src/infra/repositories/{Entity}Repository.ts" + line: 1 + role: "Repository implementation" + types_files: + - file: "packages/types/src/{name}/ports/I{Name}Port.ts" + line: 1 + role: "Port interface" + hexa_facade: + file: "packages/{name}/src/{Name}Hexa.ts" + line: 1 + +reference_route: + controller: "apps/api/src/controllers/{Name}Controller.ts" + method: "create" + line: 42 + http: "POST /api/{path}" + uses_port: "I{Name}Port" + +reference_frontend: + gateway: + file: "apps/frontend/src/.../{Name}Gateway.ts" + line: 1 + queries: + - file: "apps/frontend/src/.../use{Name}.ts" + line: 1 + page: + file: "apps/frontend/src/.../{Name}Page.tsx" + line: 1 + ui_components: + - name: "PMButton" + file: "packages/ui/src/PMButton.tsx" + +test_patterns: + usecase_spec: "packages/{name}/src/application/useCases/{name}/{name}.usecase.spec.ts" + repository_spec: "packages/{name}/src/infra/repositories/{Entity}Repository.spec.ts" + frontend_component_spec: "apps/frontend/src/.../{Name}Page.spec.tsx" + integration_tests_dir: "packages/integration-tests" + +conventions: + naming: "{from subagent}" + authorization: "AbstractSpaceMemberUseCase | AbstractMemberUseCase | AbstractAdminUseCase" + error_mapping: "{from subagent}" + events: "{from subagent}" + frontend_data_flow: "gateway → query hook → component, PM-prefixed UI from @packmind/ui" + +applicable_standards: + - path: ".claude/rules/packmind/packmind-proprietary.md" + paths_glob: "**/*" + summary: "Forbid imports from @packmind/editions in proprietary code" + +constraints: + - "{e.g., must use soft-delete-aware AbstractRepository}" + +pattern_validation: + reference_applicable: true + convention_consistency: "consistent across N features" + gaps: [] + derived_approach: "Follow packages/{name} pattern across {layers}" + +related_documentation: + - path: "{path}" + relevance: "high" + summary: "..." +--- + +## Discovery Summary + +{Mirror the human-readable view from step 2.4} + +## Pattern Validation + +{Mirror the pattern-validation block from step 2.5} +``` + +## Next Phase + +Proceed automatically to `phase-3-implementation.md`. diff --git a/.cursor/skills/feature-spec/phase-3-implementation.md b/.cursor/skills/feature-spec/phase-3-implementation.md new file mode 100644 index 000000000..290349702 --- /dev/null +++ b/.cursor/skills/feature-spec/phase-3-implementation.md @@ -0,0 +1,403 @@ +# Phase 3: Implementation Plan + +Generate the technical approach and detailed task breakdown, with Packmind-specific reference patterns and file targets. + +Phase 3 is split into **four subtasks**, each with its own approval gate. Foundational decisions are validated before generating the full plan, so problems get caught early. + +| Subtask | Purpose | Gate | +|---------|---------|------| +| 3A | Validate **domain data structures** (entities, value objects, events, repositories, schemas, migrations) | approval | +| 3B | Validate **ports, contracts, use cases, services, adapters, NestJS routes** | approval | +| 3C | Validate **frontend components** (page/container, gateway, query hooks, PM-UI usage, forms) | approval | +| 3D | Generate full **implementation plan** (task breakdown, grouping, coverage) | approval | + +## Prerequisites + +- Phase 2 completed +- If resuming a fresh session, read `tmp/feature-specs/{task_slug}/functional-spec.md` and `discovery.md` + +## Steps + +### 3.1 Present Technical Approach + +Using functional spec + discovery, present a brief technical approach to the user: + +``` +## Technical Approach + +### Target Repo +{target_repo} — implementation root: `{discovery_root}`. Tasks landing in this fork (proprietary) will be tagged; otherwise default to OSS. + +### Stack +- API: NestJS (`apps/api`) +- Domain packages: hexagonal layout under `packages/{domain}/src/{domain,application,infra}` +- Types/ports/contracts: `packages/types/src/{domain}` +- Frontend: React + `@packmind/ui` (PM-prefixed Chakra wrappers) (`apps/frontend`) +- Tests: Jest + `@swc/jest`; integration tests in `packages/integration-tests` +- DB: TypeORM + PostgreSQL; migrations under `packages/migrations` + +### Reference Patterns (from discovery) +- Reference domain: `{reference_domain.package}` +- Reference use case: `{reference_domain.application_files[0].file}:{line}` +- Reference route: `{reference_route.controller}:{line}` (`{reference_route.http}`) +- Reference frontend page: `{reference_frontend.page.file}:{line}` + +### Architecture Decisions +- {derived_approach from pattern_validation} +- Authorization: {convention chosen, e.g. AbstractSpaceMemberUseCase} +- Error mapping: {convention} +- Cross-domain: {via injected port or via domain event} + +### New Patterns Required +{If any:} +- {e.g., "New port `IFooPort` — no existing port covers this responsibility"} +- {e.g., "New domain event `FooDeletedEvent` — to notify deployments domain"} + +{Else: "None — fully covered by existing patterns."} +``` + +If new patterns are identified, use `AskUserQuestion` for each decision (one question per decision): + +```json +{ + "questions": [{ + "question": "{Describe the specific new pattern decision}", + "header": "New pattern", + "multiSelect": false, + "options": [ + {"label": "{Option A}", "description": "{What this means concretely}"}, + {"label": "{Option B}", "description": "{What this means concretely}"} + ] + }] +} +``` + +If no new patterns are needed, proceed directly. + +### 3.2 Append Technical Approach to discovery.md + +Append the finalized technical approach to `tmp/feature-specs/{task_slug}/discovery.md` after the YAML frontmatter: + +```markdown +## Technical Approach + +### Target Repo +... + +### Stack +... + +### Reference Patterns +... + +### Architecture Decisions +... +``` + +--- + +## Subtask 3A: Validate Domain Data Structures + +### 3A.1 Extract Data Structure Changes + +From functional spec + discovery, identify ALL data-structure impacts in the **domain** and **infra** layers: + +**New entities** (`packages/{domain}/src/domain/entities/`) — domain objects that don't exist yet: +- Name, purpose, key fields with TS types +- Relationships to existing entities (1:1, 1:N, N:M) +- Whether it carries an ID type (`{Entity}Id` branded type in `packages/types`) + +**Entity modifications** — changes to existing domain objects: +- New fields (name, type, nullable, default) +- Modified fields (what changes and why) +- Removed fields (what + migration impact) + +**Value objects / branded ID types** (`packages/types/src/{domain}/`): +- Name, shape + +**Domain events** (`packages/types/src/events/{EventName}.ts`): +- Event name, payload shape +- Which domain emits it, which listeners consume it +- Reference the `event.md` component guide + +**Repository interfaces** (`packages/{domain}/src/domain/repositories/I{Entity}Repository.ts`): +- Methods needed (findById, findByX, etc.) +- Whether `AbstractRepository` (soft-delete) is appropriate + +**TypeORM schemas** (`packages/{domain}/src/infra/schemas/{Entity}Schema.ts`): +- Columns + types + indexes + foreign keys + +**Migrations** (`packages/migrations/src/migrations/`): +- New tables, altered columns, new indexes +- Reference the `how-to-write-typeorm-migrations-in-packmind` skill +- Down-migration plan + +**Domain errors** (`packages/{domain}/src/domain/errors/`): +- New error classes for invariant violations + +Use `discovery.md` reference paths to ground every naming and structure decision. + +### 3A.2 Present Data Structures for Validation + +Render the **Subtask 3A** presentation template from `references/spec-templates.md`, substituting the items extracted in 3A.1. Do not paraphrase the section headings — they keep 3A → 3D consistent. + +### 3A.3 Approval Gate — Data Structures + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Are the proposed domain data structures correct? (entities, events, repositories, schemas, migrations)", + "header": "Subtask 3A", + "multiSelect": false, + "options": [ + {"label": "Approved", "description": "Data structures are correct, proceed to ports/use cases/routes"}, + {"label": "Needs changes", "description": "I'll specify what to adjust"} + ] + }] +} +``` + +If "Needs changes", incorporate feedback and re-present 3A.2. + +If there are no data-structure changes for this task, state so briefly and skip the approval gate — proceed directly to Subtask 3B. + +--- + +## Subtask 3B: Validate Ports, Contracts, Use Cases, Services, Adapters, Routes + +### 3B.1 Extract Application + API Changes + +From functional spec + discovery, identify ALL impacts in the **application** layer and **API** layer: + +**New ports** (`packages/types/src/{domain}/ports/I{Domain}Port.ts`): +- Port name, methods exposed +- Which adapter implements it +- Justify: why a new port (vs. extending an existing one) + +**New use case contracts** (`packages/types/src/{domain}/contracts/{UseCaseName}.ts`): +- Request shape, Response shape +- Which port method returns these + +**New use cases** (`packages/{domain}/src/application/useCases/{name}/{name}.usecase.ts`): +- Name, purpose +- Which `Abstract*UseCase` base class (`AbstractMemberUseCase`, `AbstractSpaceMemberUseCase`, `AbstractAdminUseCase`) +- Authorization rules (who can call) +- Which services / repos it uses +- Which events it emits + +**Modified use cases** — changes to existing use cases: +- What changes, backward-compatibility notes + +**New services** (`packages/{domain}/src/application/services/{Name}Service.ts`): +- Name, methods, callers (use cases) + +**New adapter methods** (`packages/{domain}/src/application/adapter/{Domain}Adapter.ts`): +- Which port methods are now implemented + +**New listeners** (`packages/{domain}/src/application/listeners/{Domain}Listener.ts`): +- Which events listened to, what it does + +**New / modified NestJS routes** (`apps/api/src/`): +- Method + path (e.g., `POST /api/standards/{id}/preview`) +- Request/response DTOs (referencing contracts from above) +- Controller file + method +- How it resolves the port: `HexaRegistry.getAdapter('foo')` + +**Removed routes**: +- Path + reason + +Use `discovery.md` references for path conventions, base classes, and adapter patterns. + +### 3B.2 Present for Validation + +Render the **Subtask 3B** presentation template from `references/spec-templates.md`, substituting the items extracted in 3B.1. + +### 3B.3 Approval Gate — Application + Routes + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Are the proposed ports, contracts, use cases, services, adapters, and routes correct?", + "header": "Subtask 3B", + "multiSelect": false, + "options": [ + {"label": "Approved", "description": "Application + API layer is correct, proceed to frontend"}, + {"label": "Needs changes", "description": "I'll specify what to adjust"} + ] + }] +} +``` + +If "Needs changes", incorporate feedback and re-present 3B.2. + +If nothing changes in this layer, state so and skip the gate — proceed to Subtask 3C. + +--- + +## Subtask 3C: Validate Frontend Components + +### 3C.1 Extract Frontend Changes + +From functional spec + discovery, identify ALL frontend impacts in `apps/frontend/`: + +**New page / route components** — top-level orchestrators: +- File path under `apps/frontend/src/` +- Route it mounts on (React Router or app router) +- Which gateway queries/mutations it owns +- Which container/domain components it renders + +**New domain / container components** — feature-scoped UI: +- File path, props interface (TypeScript) +- Pure-display vs. stateful (owns queries?) +- Form components: validation lib + schema, submit callback shape +- List components: item shape, empty state, loading skeleton +- Detail components: data shown, actions + +**Modified domain components**: +- File + line, what changes, props added/removed + +**New gateways** (`apps/frontend/src/.../gateways/{Name}Gateway.ts`): +- Method signatures wrapping API calls +- Reference the existing gateway pattern (see `gateway-pattern-implementation-in-packmind-frontend` command) +- Type mapping (contract → frontend type) + +**New query / mutation hooks**: +- Hook name, query key, return shape +- Cache invalidation rules + +**`@packmind/ui` usage** (PM-prefixed Chakra wrappers): +- Which existing PM-* components are used (PMButton, PMDialog, PMField, etc.) +- Whether any new wrapper is needed (see `wrapping-chakra-ui-with-slot-components` command and `working-with-pm-design-kit` skill) + +**Layout / navigation changes**: +- New nav links, sidebar items, page tabs + +**Microcopy**: +- New user-facing strings — flag for `ux-microcopy` skill if there are non-trivial messages (errors, empty states, dialogs) + +Use `discovery.md`'s reference frontend feature to ground naming and structure decisions. + +### 3C.2 Present for Validation + +Render the **Subtask 3C** presentation template from `references/spec-templates.md`, substituting the items extracted in 3C.1. + +### 3C.3 Approval Gate — Frontend Components + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Are the proposed frontend components correct? (pages, containers, gateways, queries, PM-UI usage)", + "header": "Subtask 3C", + "multiSelect": false, + "options": [ + {"label": "Approved", "description": "Frontend is correct, proceed to full implementation plan"}, + {"label": "Needs changes", "description": "I'll specify what to adjust"} + ] + }] +} +``` + +If "Needs changes", incorporate feedback and re-present 3C.2. + +If no frontend changes, state so and skip — proceed to Subtask 3D. + +--- + +## Subtask 3D: Generate Implementation Plan + +### 3D.1 Skill Loading (conditional) + +Load Packmind skills based on which layers the task touches: + +**Backend (domain / application / infra) is touched:** +1. Read `.claude/skills/hexagonal-architecture/SKILL.md` +2. Read `.claude/skills/hexagonal-architecture/components/usecase.md` +3. Read `.claude/skills/hexagonal-architecture/components/repository.md` +4. Read `.claude/skills/repository-implementation-and-testing-pattern/SKILL.md` (if it exists) + +**Migrations are touched:** +5. Read `.claude/skills/how-to-write-typeorm-migrations-in-packmind/SKILL.md` +6. Read `.claude/skills/create-or-update-model-and-typeorm-schemas/SKILL.md` + +**Frontend is touched:** +7. Read `.claude/skills/working-with-pm-design-kit/SKILL.md` +8. Read `.claude/commands/gateway-pattern-implementation-in-packmind-frontend.md` +9. Read `.claude/commands/wrapping-chakra-ui-with-slot-components.md` (only if new PM wrappers needed) + +**CLI tests are touched:** +10. Read `.claude/skills/cli-e2e-test-authoring/SKILL.md` + +Skip a category entirely if no task in that layer. + +### 3D.2 Launch Implementation Plan Subagent + +Read the bundled prompt template at `.claude/skills/feature-spec/references/implementation-plan-prompt.md`. Substitute its placeholders (`{task_name}`, `{task_slug}`, `{target_repo}`, and the three `{APPROVED_3*_BLOCK}` blocks pasted verbatim from the prior approval gates). Include or omit the Backend / Frontend / Migration constraint sections based on which layers the prior subtasks produced work for — guidance is at the top of the reference file. + +Invoke `Agent` with `subagent_type="general-purpose"` and the substituted prompt. Wait for the subagent to complete. + +Wait for the subagent to complete. + +### 3D.3 Validate Plan Output + +Read `tmp/feature-specs/{task_slug}/implementation-plan.md` and verify: +- Every acceptance criterion is covered in the Coverage Matrix +- Every task has a reference pattern (`file:line`) +- Every task has target files and a `repo` tag +- Plan is consistent with 3A, 3B, 3C decisions (no extra entities, no missing routes) +- Parallel Groups section exists with at least 1 group +- All tasks belong to exactly one group; no file appears in multiple groups + +If validation fails: tell the user what's missing and ask whether to refine manually or relaunch the subagent. + +### 3D.4 Approval Gate — Implementation Plan + +Display the summary: + +``` +**Subtask 3D complete.** Implementation plan generated. + +**Phases:** {count} +**Total tasks:** {count} +**Parallel groups:** {group_count} +**Repos used:** {oss only | proprietary only | both, with count} + +Review the plan in `tmp/feature-specs/{task_slug}/implementation-plan.md` + +Questions to consider: +- Does each task follow the identified patterns? +- Are file references specific enough? +- Are inter-group dependencies clear? +- Are OSS-vs-proprietary tags correct? +``` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Is the implementation plan approved? Proceed to finalize?", + "header": "Subtask 3D", + "multiSelect": false, + "options": [ + {"label": "Yes, proceed", "description": "Approve plan and continue to Phase 4: Finalize"}, + {"label": "Need changes", "description": "I need to modify the implementation plan first"} + ] + }] +} +``` + +Do not continue until user confirms via AskUserQuestion response. + +### 3.7 Phase Complete + +The existence of `implementation-plan.md` marks Phase 3 as complete. No separate state update needed. + +## Next Phase + +After approval, proceed automatically to `phase-4-finalize.md`. diff --git a/.cursor/skills/feature-spec/phase-4-finalize.md b/.cursor/skills/feature-spec/phase-4-finalize.md new file mode 100644 index 000000000..842d1d6da --- /dev/null +++ b/.cursor/skills/feature-spec/phase-4-finalize.md @@ -0,0 +1,89 @@ +# Phase 4: Finalize + +Generate the implementation context, mark the spec as complete, and report. This phase runs seamlessly — no approval gate. + +Artifacts live in `tmp/feature-specs/{task_slug}/` which is git-ignored, so **no commit step** here. The user commits implementation code via `/feature-sprint`. + +## Prerequisites + +- Phases 1-3 completed +- If resuming a fresh session, read `discovery.md`, `functional-spec.md`, and `implementation-plan.md` from `tmp/feature-specs/{task_slug}/` + +## Steps + +### 4.1 Generate Implementation Context + +**Purpose**: produce a structured `context.md` that `/feature-sprint` will read to set up parallel execution. + +Read `.claude/skills/feature-spec/references/context-schema.md` — it contains the full YAML template, the body template, and the ordered extraction process (sources: `discovery.md` frontmatter, main spec frontmatter, `functional-spec.md` body, `implementation-plan.md` Parallel Groups table). + +Write the substituted result to `tmp/feature-specs/{task_slug}/context.md`. + +### 4.2 Mark Spec Complete + +Update the main spec file's YAML frontmatter: + +```markdown +--- +status: COMPLETE +... +finished: {ISO-8601 timestamp} +--- +``` + +The body of the main spec should now include a brief summary (one paragraph) and links to the sibling artifacts: + +```markdown +# {task_name} + +{1-paragraph summary of the feature} + +## Artifacts + +- [Discovery](./discovery.md) — reference patterns and conventions +- [Functional Spec](./functional-spec.md) — acceptance criteria and scope +- [Implementation Plan](./implementation-plan.md) — task breakdown and parallel groups +- [Context](./context.md) — machine-readable handoff for `/feature-sprint` + +## Next Step + +``` +/feature-sprint {task_slug} +``` +``` + +### 4.3 Completion Report + +Output to the user: + +``` +## ✅ FEATURE SPEC COMPLETE + +**Task**: {task_name} +**Slug**: {task_slug} +**Target repo**: {target_repo} + +### Summary +- Acceptance criteria: {count} items +- Implementation phases: {phase_count} +- Total tasks: {task_count} +- Parallel groups: {group_count} + +### Artifacts +- `tmp/feature-specs/{task_slug}/{task_slug}.md` +- `tmp/feature-specs/{task_slug}/discovery.md` +- `tmp/feature-specs/{task_slug}/functional-spec.md` +- `tmp/feature-specs/{task_slug}/implementation-plan.md` +- `tmp/feature-specs/{task_slug}/context.md` + +### Reminder +Artifacts live in `tmp/` (git-ignored). They will NOT be committed automatically — they're scratch state for `/feature-sprint`. + +### Next Steps +1. Review the artifacts above +2. Execute: `/feature-sprint {task_slug}` +``` + +## End of Workflow + +The feature spec is ready. The companion skill `/feature-sprint` will pick it up and execute the implementation plan. diff --git a/.cursor/skills/feature-spec/references/context-schema.md b/.cursor/skills/feature-spec/references/context-schema.md new file mode 100644 index 000000000..a3175418c --- /dev/null +++ b/.cursor/skills/feature-spec/references/context-schema.md @@ -0,0 +1,141 @@ +# context.md Schema + +Loaded by `phase-4-finalize.md` step 4.1. Defines the structured artifact +that `/feature-sprint` consumes to set up parallel execution. + +## Extraction process (in order) + +1. Read `discovery.md` YAML frontmatter → `target_repo`, `discovery_root`, + `reference_domain.*`, `reference_route.*`, `reference_frontend.*`, + `applicable_standards`. +2. Read `{task_slug}.md` YAML frontmatter → `task_id`, `task_name`, + `source_type`, `source_url`. +3. Parse `functional-spec.md` body → scope (`in_scope`, `out_of_scope`, + `constraints`). These need light prose-to-list conversion; keep items + short and verbatim where possible. +4. Parse `implementation-plan.md` "Parallel Groups" table → populate + `parallel_groups[]`. Each row maps to one entry; `task_ids` is the + comma-split task IDs column. +5. Set `version: "1.1"` if `parallel_groups[]` is non-empty, else `"1.0"`. + +## Template + +Write to `tmp/feature-specs/{task_slug}/context.md`: + +```markdown +--- +version: "1.0" # bump to "1.1" if parallel_groups non-empty +task_slug: "{task_slug}" +task_id: "{from main spec frontmatter}" +task_name: "{task_name}" + +source: + type: "{source_type from main spec}" + url: "{source_url from main spec}" + +target_repo: "{oss | proprietary | both}" +discovery_root: "{absolute path from discovery.md}" +proprietary_root: "{absolute path of the proprietary repo — i.e. `realpath .` when running this skill from the proprietary repo root}" +oss_root: "{absolute path of the OSS sibling — i.e. `realpath ../packmind`}" + +stack: + api: "NestJS (apps/api)" + domain_packages: "packages/{domain}/src/{domain,application,infra}" + types: "packages/types" + frontend: "React + @packmind/ui (apps/frontend)" + tests: "Jest + @swc/jest" + integration_tests: "packages/integration-tests" + db: "TypeORM + PostgreSQL" + migrations: "packages/migrations" + +reference: + domain_package: "{from discovery.reference_domain.package}" + usecase_file: "{from discovery}" + api_controller: "{from discovery}" + frontend_page: "{from discovery}" + +discovery_summary: + patterns: "{1-2 sentence summary of the derived approach}" + applicable_standards: + - path: ".claude/rules/packmind/packmind-proprietary.md" + summary: "Forbid imports from @packmind/editions" + +scope_definition: + in_scope: + - "{extracted from functional-spec.md}" + out_of_scope: + - "{extracted from functional-spec.md}" + constraints: + - "{extracted from functional-spec.md}" + +quality_gates: + # Nx targets to run for the projects this feature touches. + # /feature-sprint will resolve which projects need each target. + lint: true + test: true + build: true + extra: [] + +parallel_groups: # Populate from implementation-plan.md "Parallel Groups" section + - group_id: "A" + group_name: "backend-{domain}" + task_ids: ["1.1", "1.2", "1.3"] + target_files: ["packages/{domain}/..."] + repo: "oss" + blocked_by: [] + - group_id: "B" + group_name: "frontend-{domain}" + task_ids: ["2.1", "2.2"] + target_files: ["apps/frontend/..."] + repo: "oss" + blocked_by: [] + - group_id: "C" + group_name: "tests" + task_ids: ["3.1", "3.2"] + target_files: ["packages/integration-tests/..."] + repo: "oss" + blocked_by: ["A", "B"] +--- + +## Implementation Context + +**Task:** {task_name} +**Slug:** {task_slug} +**Target repo:** {target_repo} + +### Discovery summary +- Reference domain: `{reference.domain_package}` +- Reference use case: `{reference.usecase_file}` +- Reference API: `{reference.api_controller}` +- Reference frontend: `{reference.frontend_page}` + +### Scope +**In scope:** +- {items} + +**Out of scope:** +- {items} + +**Constraints:** +- {items} + +### Quality gates +- Lint: nx lint on affected projects +- Test: nx test on affected projects +- Build: nx build on affected projects + +### Parallel groups +| Group | Name | Tasks | Repo | Blocked by | +|-------|------|-------|------|------------| +| A | {name} | 1.1, 1.2, 1.3 | oss | — | +| B | {name} | 2.1, 2.2 | oss | — | +| C | {name} | 3.1, 3.2 | oss | A, B | +``` + +## Notes + +- `parallel_groups[*].repo` is independent of the top-level `target_repo`: + one feature can have OSS and proprietary groups in `both` mode. +- `blocked_by` may be empty `[]`. Don't omit the key. +- If a group has no obvious file area (e.g., cross-cutting tests), name it + by purpose (`tests`, `wiring`) rather than by file path. diff --git a/.cursor/skills/feature-spec/references/implementation-plan-prompt.md b/.cursor/skills/feature-spec/references/implementation-plan-prompt.md new file mode 100644 index 000000000..96254d2fd --- /dev/null +++ b/.cursor/skills/feature-spec/references/implementation-plan-prompt.md @@ -0,0 +1,138 @@ +# Implementation Plan Subagent Prompt Template + +Loaded by `phase-3-implementation.md` step 3D.2 to generate +`tmp/feature-specs/{task_slug}/implementation-plan.md`. The orchestrator +substitutes every `{placeholder}` and then passes the result as the `prompt` +to `Agent` with `subagent_type="general-purpose"`. + +## Placeholders + +| Placeholder | Source | +|-------------|--------| +| `{task_name}` | main spec frontmatter | +| `{task_slug}` | spec directory name | +| `{target_repo}` | discovery.md frontmatter (`oss \| proprietary \| both`) | +| `{APPROVED_3A_BLOCK}` | the approved 3A.2 summary, pasted verbatim | +| `{APPROVED_3B_BLOCK}` | the approved 3B.2 summary, pasted verbatim | +| `{APPROVED_3C_BLOCK}` | the approved 3C.2 summary, pasted verbatim | + +## Conditional sections + +The template includes layer-specific constraint sections. Include or skip each +based on whether any task in that layer exists: + +- Backend section → include if 3A or 3B produced any work +- Frontend section → include if 3C produced any work +- Migration section → include if 3A produced any migration entries + +**Do not** paste the contents of referenced skills inline. The subagent has +access to the project's `.claude/skills/` directory and should `Read` them +directly. Inline pastes would force the orchestrator to grow with every +upstream skill change. + +## Template + +``` +Design implementation tasks for: {task_name} + +IMPORTANT: Do NOT use EnterPlanMode. Write the plan directly to the file path in the Output section below. + +## Context files (Read these first) +- tmp/feature-specs/{task_slug}/functional-spec.md (requirements, acceptance criteria) +- tmp/feature-specs/{task_slug}/discovery.md (patterns, reference paths in YAML frontmatter + Technical Approach) + +## Validated decisions (from earlier subtasks — treat as LOCKED) + +The following have been reviewed and approved by the user. The implementation plan MUST be consistent — do not add, remove, or rename anything listed here. + +### Domain Data Structures (Subtask 3A) +{APPROVED_3A_BLOCK} + +### Ports, Contracts, Use Cases, Routes (Subtask 3B) +{APPROVED_3B_BLOCK} + +### Frontend Components (Subtask 3C) +{APPROVED_3C_BLOCK} + +## Architecture Constraints — Backend (include only if backend touched) + +Read these skills before producing backend tasks. Do NOT paste their contents into this prompt — they are loaded conditionally and may evolve: + +- `.claude/skills/hexagonal-architecture/SKILL.md` +- `.claude/skills/hexagonal-architecture/components/usecase.md` +- `.claude/skills/hexagonal-architecture/components/repository.md` +- `.claude/skills/repository-implementation-and-testing-pattern/SKILL.md` (if it exists) + +For each backend task ensure: +- Layer is explicit (domain, application, infra) +- Cross-domain communication goes through a port in `packages/types/` or a domain event — NEVER direct cross-package imports +- The right `Abstract*UseCase` base class is used for authorization +- Domain errors are mapped at the API layer, not raised as HTTP exceptions inside the domain +- New repositories use `AbstractRepository` with soft-delete support unless explicitly justified otherwise + +## Testing Constraints + +For each backend task involving logic, plan a sibling `*.spec.ts` task using Jest + `@swc/jest`. For repositories, follow the test pattern from `repository-implementation-and-testing-pattern` skill (factory-driven tests). For end-to-end behavior, add an `integration-tests` task in `packages/integration-tests`. + +## Frontend Constraints (include only if frontend touched) + +Read these skills before producing frontend tasks: + +- `.claude/skills/working-with-pm-design-kit/SKILL.md` +- `.claude/commands/gateway-pattern-implementation-in-packmind-frontend.md` + +For each frontend task ensure: +- UI is built from `@packmind/ui` PM-prefixed components, NOT raw Chakra primitives (unless wrapping a new slot component — then plan a slot-wrapping task referencing `.claude/commands/wrapping-chakra-ui-with-slot-components.md`) +- API calls go through a Gateway, not directly from components or hooks +- TanStack Query (or the project's equivalent) is used via the existing hook pattern from discovery +- Tests use React Testing Library at the component layer; integration scenarios go to e2e + +## Migration Constraints (include only if migrations touched) + +Read: `.claude/skills/how-to-write-typeorm-migrations-in-packmind/SKILL.md` + +Each migration task must: +- Create both `up` and `down` methods +- Use the standard logger +- Land under `packages/migrations/src/migrations/{timestamp}-{name}.ts` + +## OSS / Proprietary Constraints + +`target_repo` for this spec: `{target_repo}`. + +- If `target_repo == "oss"`, all tasks must be implementable in `../packmind` (the OSS sibling next to the proprietary repo). Verify no task references `packages/editions/` or paid-only paths. +- If `target_repo == "proprietary"`, mark each task with its repo. Never import from `@packmind/editions` outside files that already live in the editions package (see `.claude/rules/packmind/packmind-proprietary.md`). +- If `target_repo == "both"`, tag every task with `repo: oss | proprietary`. Place OSS tasks first (they unblock proprietary ones after auto-merge). + +## Requirements + +Generate a phased implementation plan that: +1. Maps each acceptance criterion to specific tasks (Coverage Matrix at end) +2. Groups tasks logically: domain entities/events → repositories/migrations → use cases → API routes → frontend gateway/queries → frontend components → tests +3. Uses reference patterns from discovery.md for every task (`file:line`) +4. Specifies target files (existing or new) for every task +5. Includes test tasks for every behavioral change +6. Groups tasks into parallel execution groups by file dependencies + +## Task format + +See `.claude/skills/feature-spec/references/spec-templates.md` for the canonical task block shape and section structure. The format must match exactly so `/feature-sprint`'s parse_implementation_plan.py can read it. + +## Parallel grouping rules + +After defining all tasks, append a "Parallel Groups" section. + +- Tasks sharing the SAME target files MUST be in the same group +- Tasks where A writes a file B reads MUST be in the same group +- Backend domain/application tasks usually share `packages/{domain}/`, so they often go in one group +- Frontend tasks under `apps/frontend/` often share the gateway file and form a second group +- Migration tasks usually stand alone +- Name groups by dominant file area (e.g. `backend-{domain}`, `frontend-{domain}`, `migrations`) +- Maximize parallelism while respecting file conflicts + +## Output + +Write to: `tmp/feature-specs/{task_slug}/implementation-plan.md` + +Use the section structure documented in `.claude/skills/feature-spec/references/spec-templates.md` (Implementation Plan section). Then return a summary: `{phase_count}` phases, `{task_count}` tasks, `{group_count}` parallel groups, coverage complete/incomplete. +``` diff --git a/.cursor/skills/feature-spec/references/spec-templates.md b/.cursor/skills/feature-spec/references/spec-templates.md new file mode 100644 index 000000000..77b7530a9 --- /dev/null +++ b/.cursor/skills/feature-spec/references/spec-templates.md @@ -0,0 +1,253 @@ +# Spec Presentation & Plan Templates + +Reusable markdown skeletons for Phase 3 (Subtasks 3A/3B/3C) and Phase 3D +(implementation plan + task format). Keeps `phase-3-implementation.md` lean +and procedural; this file holds the bulky presentation structure. + +The templates are fill-in-the-blank — substitute the `{placeholder}` tokens +from `discovery.md` + the prior subtask's approved decisions. Always preserve +the exact section ordering: `/feature-sprint` parses the resulting +`implementation-plan.md` and the order matters for it. + +## Subtask 3A: Domain Data Structures (presentation template) + +``` +## Subtask 3A: Domain Data Structures + +### Target Repo +{target_repo} — these changes live in {oss | proprietary}. + +### New Entities +{For each:} +- **`{EntityName}`** — {purpose} + - File: `packages/{domain}/src/domain/entities/{EntityName}.ts` + - Fields: + - `id: {EntityName}Id` (branded type in `packages/types/src/{domain}/`) + - `{field}: {type}` {constraints} + - Relationships: {description} + - **Reference**: `{discovery.reference_domain.domain_files[*].file}:{line}` — follows pattern + +### Entity Modifications +{For each:} +- **`{EntityName}`** (`{existing_file}:{line}`) + - Add: `{field}: {type}` — {reason} + - Modify: `{field}: {old}` → `{new}` — {reason + migration impact} + - Remove: `{field}` — {reason + migration note} + +### New Value Objects / Branded IDs +{For each:} +- **`{Name}`** (`packages/types/src/{domain}/{Name}.ts`) + +### New Domain Events +{For each:} +- **`{EventName}`** (`packages/types/src/events/{EventName}.ts`) + - Payload: `{shape}` + - Emitted by: `{Domain}` (use case `{name}`) + - Listened by: `{ListenerDomain}` — `application/listeners/{Name}Listener.ts` + +### New Repository Interfaces +{For each:} +- **`I{Entity}Repository`** (`packages/{domain}/src/domain/repositories/`) + - Methods: `{methods}` + - Extends `AbstractRepository<{Entity}>`: yes/no + +### TypeORM Schemas +{For each:} +- **`{Entity}Schema`** (`packages/{domain}/src/infra/schemas/`) + - Table: `{table_name}` + - Columns: {list} + - Indexes: {list} + - FKs: {list} + +### Migrations +{For each:} +- **`{TimestampMigrationName}`** under `packages/migrations/src/migrations/` + - Up: {description} + - Down: {description} + - Reference pattern: `{existing_migration_file}` + +### New Domain Errors +{For each:} +- **`{ErrorClass}`** — thrown when {invariant} + +{If no data-structure changes: "No domain data-structure changes — this task only touches application/UI behavior."} +``` + +## Subtask 3B: Ports, Contracts, Use Cases, Routes (presentation template) + +``` +## Subtask 3B: Ports, Contracts, Use Cases, Routes + +### Target Repo +{target_repo} + +### New Ports +{For each:} +- **`I{Name}Port`** (`packages/types/src/{domain}/ports/`) + - Methods: `{signatures}` + - Implemented by: `{Domain}Adapter` + - Why new: {justification} + +### New Contracts +{For each:} +- **`{UseCaseName}`** (`packages/types/src/{domain}/contracts/`) + - Request: `{shape}` + - Response: `{shape}` + +### New Use Cases +{For each:} +- **`{name}.usecase.ts`** (`packages/{domain}/src/application/useCases/{name}/`) — {purpose} + - Base class: `AbstractSpaceMemberUseCase` (etc.) + - Authorization: {who can call} + - Uses repos: {list} + - Uses services: {list} + - Emits events: {list, referencing 3A} + - **Reference**: `{discovery.reference_domain.application_files[*].file}:{line}` + +### Modified Use Cases +{For each:} +- **`{file}:{line}`** — {change description} + +### New Services +{For each:} +- **`{Name}Service`** (`packages/{domain}/src/application/services/`) + - Methods: {signatures} + - Used by: {use cases} + +### New Adapter Methods +{For each:} +- **`{Domain}Adapter.{method}()`** (`packages/{domain}/src/application/adapter/`) + - Implements port: `I{Name}Port.{method}` + +### New Listeners +{For each:} +- **`{Domain}Listener.handle{Event}()`** — reacts to `{EventName}` + +### New API Routes +{For each:} +- **`{METHOD} {path}`** — {purpose} + - Controller: `apps/api/src/controllers/{Name}Controller.ts` (new or extends existing) + - Request DTO: `{shape or file}` + - Response DTO: `{shape or file}` + - Resolves port: `IFooPort.{method}` via `HexaRegistry` + - **Reference**: `{discovery.reference_route.controller}:{line}` + +### Modified API Routes +{For each:} +- **`{METHOD} {path}`** (`{file}:{line}`) + - Change: {description} + - Backward compatible: yes/no + detail + +### Removed Routes +{For each:} +- **`{METHOD} {path}`** — {reason + deprecation plan} + +{If no application/API changes: "No application or API changes — this task only modifies data or UI."} +``` + +## Subtask 3C: Frontend Components (presentation template) + +``` +## Subtask 3C: Frontend Components + +### Target Repo +{target_repo} + +### New Page / Route Components +{For each:} +- **`{ComponentName}`** (`apps/frontend/src/.../{ComponentName}.tsx`) — {purpose} + - Route: `{path}` + - Queries/mutations owned: {list} + - Renders: {list of child components} + - **Reference**: `{discovery.reference_frontend.page.file}:{line}` + +### New Domain / Container Components +{For each:} +- **`{ComponentName}`** (`apps/frontend/src/.../{ComponentName}.tsx`) — {purpose} + - Props: `{prop}: {type}` + - Behavior: pure display | form (validation: {schema}) | stateful with queries + - **Reference**: `{similar_component_file}:{line}` + +### Modified Domain Components +{For each:} +- **`{ComponentName}`** (`{file}:{line}`) — {change} + +### New Gateways +{For each:} +- **`{Name}Gateway`** (`apps/frontend/src/.../gateways/{Name}Gateway.ts`) + - Methods: `{signatures}` wrapping `{contract from 3B}` + - **Reference**: `{discovery.reference_frontend.gateway.file}:{line}` + +### New Query / Mutation Hooks +{For each:} +- **`use{Name}`** (`apps/frontend/src/.../{name}.ts`) + - Query key: `{key}` + - Returns: `{shape}` + - Invalidates: `{keys}` + +### `@packmind/ui` Usage +- Reused PM components: {list} +- New PM wrapper components needed: {list or "none"} +- **If new wrappers**: see `wrapping-chakra-ui-with-slot-components` command before implementing + +### Layout / Navigation +{For each change:} +- **`{file}`** — {description} + +### Microcopy +- {Flag any non-trivial user-facing strings that should be reviewed with the `ux-microcopy` skill} + +{If no frontend changes: "No frontend changes — this task only touches backend/data."} +``` + +## 3D: Task & Implementation Plan Format + +The implementation-plan.md generated by 3D must use this exact shape so that +`/feature-sprint`'s `parse_implementation_plan.py` can read it. + +### Task block + +``` +- [ ] **{PHASE}.{TASK}: {short description}** + - Repo: `oss | proprietary` + - Layer: `domain | application | infra | api | frontend | tests | migrations` + - Files: `{paths}` + - Reference: `{file}:{line}` — {pattern_role} + - Notes: {short implementation notes} +``` + +Phases are numbered (1, 2, 3...). Tasks within a phase use decimals (1.1, +1.2, 2.1). Indentation is exactly two spaces for the metadata lines — the +parser depends on it. + +### Plan-level structure + +``` +# Implementation Plan: {task_name} + +## Phase 1: {short label} +- [ ] **1.1: ...** + - Repo, Layer, Files, Reference, Notes +- [ ] **1.2: ...** + ... + +## Phase 2: ... + +## Coverage Matrix + +| Acceptance Criterion | Task IDs | +|----------------------|----------| +| {criterion} | 1.1, 2.3 | + +## Parallel Groups + +| group_id | group_name | task_ids | target_files | rationale | +|----------|-----------|----------|--------------|-----------| +| A | backend-standards | 1.1, 1.2, 1.3 | packages/standards/... | shared domain package | +| B | frontend-standards | 2.1, 2.2 | apps/frontend/src/standards/... | shared gateway + page | +| C | tests | 3.1, 3.2 | varies | after A and B | + +## Dependency Notes +- Group C blocked by Groups A and B +- ... +``` diff --git a/.cursor/skills/feature-sprint/SKILL.md b/.cursor/skills/feature-sprint/SKILL.md new file mode 100644 index 000000000..06e1aa8a6 --- /dev/null +++ b/.cursor/skills/feature-sprint/SKILL.md @@ -0,0 +1,165 @@ +--- +name: 'feature-sprint' +description: 'Execute the implementation plan produced by /feature-spec. Loads tasks from tmp/feature-specs/{slug}/implementation-plan.md, hydrates them as Claude Tasks, runs parallel groups via subagents in the correct repo (OSS sibling at ../packmind or the proprietary repo in cwd), syncs progress back to checkboxes, commits per group, and runs Nx quality gates. Resumable across sessions via checkbox state.' +--- + +# Feature Sprint Skill + +Execute a Packmind feature implementation plan via subagents with automatic parallel execution and sync-back. Companion to `/feature-spec`. + +## When to Use + +| Scenario | Use feature-sprint | Use plan + architect-executor | +|----------|--------------------|-----------------------------------| +| Fast execution of an already-specified feature | ✅ | ❌ | +| Parallel group execution | ✅ | partial | +| Multi-repo (OSS + proprietary) execution | ✅ | ❌ | +| Heavy TDD enforcement, per-task escalation | ❌ | ✅ | +| Specs in `tmp/feature-specs/` (this flow) | ✅ | ❌ | +| Specs in `.claude/specs/` and `.claude/plans/` | ❌ | ✅ | + +**Rule of thumb**: `/feature-sprint` is the executor for `/feature-spec`. For more careful, single-task-at-a-time execution with the global `plan` skill, use `architect-executor` instead. + +## Prerequisites + +A completed feature spec at `tmp/feature-specs/{slug}/`: +- `{slug}.md` with `status: COMPLETE` in frontmatter +- `context.md` with YAML frontmatter (version 1.0+) +- `implementation-plan.md` with task checkboxes + +If missing or DRAFT: tell the user to run `/feature-spec {source}` first. + +## Architecture: Orchestration + Subagents + Sync-Back + +**Core Principle**: The main session **orchestrates only**. All implementation happens in Task subagents — that keeps the main context clean and lets agents do heavy reading without poisoning your conversation. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Persistent Layer (tmp/, git-ignored) │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ implementation-plan.md │ │ +│ │ Source of truth: task checkboxes [ ] / [x] │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ ▲ + │ Hydrate (session start) │ Sync-back (per group) + ▼ │ +┌─────────────────────────────────────────────────────────────────┐ +│ Main Session (ORCHESTRATION ONLY) │ +│ • Load spec & state • Spawn subagents (one per group) │ +│ • Parse agent output • Sync checkboxes │ +│ • Run Nx quality gates • Commit per group │ +│ • NEVER implement tasks directly │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ Spawn (parallel or sequential) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Subagent Layer (IMPLEMENTATION) │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────┐ │ +│ │ Group A agent │ │ Group B agent │ │ Group C agent │ │ +│ │ Backend tasks │ │ Frontend tasks │ │ Tests / integration│ │ +│ │ cwd: OSS repo │ │ cwd: OSS repo │ │ cwd: depends │ │ +│ └────────────────┘ └────────────────┘ └────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Hydration (session start) +1. Read `implementation-plan.md` → extract tasks with checkbox state (`[ ]` pending, `[x]` done) +2. Read `context.md` → extract `parallel_groups`, `target_repo`, `quality_gates` +3. Use `TaskCreate` for each pending task (visual tracking) +4. Wire `addBlockedBy` from `parallel_groups[*].blocked_by` + +### Subagent delegation +Every group runs in a Task subagent — parallel or sequential. The main session never touches code, never reads source files, never runs Nx commands itself. + +### Sync-back (automatic) +After each group's subagent completes: +1. Parse output for `TASK_COMPLETE: {id}` markers +2. Update `implementation-plan.md` checkboxes: `[ ]` → `[x]` +3. Update Claude Tasks status + +Progress is durable: kill the session, restart `/feature-sprint {slug}`, and it resumes from where checkboxes left off. + +## Workflow Phases + +| Phase | File | Purpose | +|-------|------|---------| +| 1 | `phase-1-init.md` | Load spec, hydrate tasks, get approval | +| 2 | `phase-2-execute.md` | Parallel execution via subagents with auto sync-back + per-group commits | +| 3 | `phase-3-finalize.md` | Run Nx quality gates, final report, optional archive | + +## State Management + +``` +tmp/feature-specs/{slug}/ +├── {slug}.md # status: COMPLETE +├── context.md # parallel_groups, target_repo, quality_gates +├── implementation-plan.md # tasks with checkboxes — SOURCE OF TRUTH for progress +├── discovery.md # reference patterns +└── functional-spec.md # acceptance criteria +``` + +Progress is tracked entirely through `implementation-plan.md` checkboxes — no separate state file. + +## OSS / Proprietary Routing + +`context.md` declares `target_repo` and each parallel group declares its `repo` field. The main session sets `cwd` for each subagent accordingly: + +- `repo: oss` → `cwd: ../packmind` (the OSS sibling cloned next to the proprietary repo) +- `repo: proprietary` → `cwd: .` (this repo) + +After all OSS work is committed and merged upstream, remind the user to `git pull` here so the proprietary fork picks up the auto-merge before any proprietary tasks run. + +## Commit Strategy + +**Each parallel group gets its own commit** when its subagent reports success (one commit per group, not per task). This matches the Packmind CLAUDE.md rule "Each sub-task should have its own commit" — we treat a parallel group as a coherent sub-task unit. + +Commits follow the `git-commit-guidelines` skill format (gitmoji + Conventional Commits, never auto-closing issues). The main session always shows the proposed message and asks for approval before running `git commit`. + +## Invocation + +### New sprint + +``` +/feature-sprint {task_slug} +``` + +If `{task_slug}` is omitted, the skill lists available specs in `tmp/feature-specs/` and asks the user to pick one. + +### Resume + +Same command. The skill detects existing checkbox state and offers to continue. + +## Status + +Show progress across active sprints: + +``` +/feature-sprint status +``` + +(Implemented by listing `tmp/feature-specs/*/implementation-plan.md` and counting checkbox state in each.) + +## Error Handling + +| Situation | Action | +|-----------|--------| +| Spec not found | Error: "Run /feature-spec first" | +| Spec DRAFT | Error: "Complete the spec first — `status` is DRAFT" | +| No parallel groups | Fall back to single-group sequential execution | +| `repo: oss` but `../packmind` missing | Ask user — switch to proprietary or abort | +| Agent fails | Sync completed tasks, pause sprint, report error | +| Quality gate fails | Sync progress, report failures, prompt for fix-and-retry | + +## Integration with /feature-spec + +``` +/feature-spec #123 # produces tmp/feature-specs/{slug}/ +/feature-sprint {slug} # executes the plan +``` + +Sprint reads: +- `context.md` → `parallel_groups`, `target_repo`, `quality_gates` +- `implementation-plan.md` → task list with checkboxes (source of truth) +- `{slug}.md` → verify `status: COMPLETE` \ No newline at end of file diff --git a/.cursor/skills/feature-sprint/phase-1-init.md b/.cursor/skills/feature-sprint/phase-1-init.md new file mode 100644 index 000000000..4cdd6225c --- /dev/null +++ b/.cursor/skills/feature-sprint/phase-1-init.md @@ -0,0 +1,267 @@ +# Phase 1: Initialize Sprint + +Load the feature spec, hydrate Claude Tasks, detect parallel groups, decide OSS-vs-proprietary execution roots, get user approval. + +## Prerequisites + +- Feature spec exists at `tmp/feature-specs/{task_slug}/` +- Spec `status: COMPLETE` + +## Steps + +### 1.1 Resolve Task Slug + +Extract `{task_slug}` from user input. If not provided: + +```bash +ls tmp/feature-specs/ +``` + +For each candidate directory, read `{slug}.md` YAML frontmatter: +- Skip directories without `status: COMPLETE` +- Capture `task_name`, `source_type`, `target_repo` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Which feature spec do you want to sprint?", + "header": "Task", + "multiSelect": false, + "options": [ + {"label": "{slug-1}", "description": "[{target_repo}] {task_name}"}, + {"label": "{slug-2}", "description": "[{target_repo}] {task_name}"} + ] + }] +} +``` + +If `tmp/feature-specs/` has no COMPLETE specs, tell the user to run `/feature-spec` first. + +### 1.2 Load Specification Files + +Read from `tmp/feature-specs/{task_slug}/`: + +1. **`{task_slug}.md`** — Read YAML frontmatter, verify `status: COMPLETE`. If DRAFT or missing, error and stop. + +2. **`context.md`** — Extract YAML frontmatter fields: + - `version` (≥ 1.0) + - `target_repo` (`oss | proprietary | both`) + - `oss_root`, `proprietary_root`, `discovery_root` + - `parallel_groups[]` (may be empty for trivial features) + - `quality_gates` (`lint`, `test`, `build`, `extra`) + +3. **`implementation-plan.md`** — Parse via the bundled script (do NOT hand-roll the regex; it must handle the `_(BLOCKED: …)_` annotation and indented metadata blocks): + + ```bash + python3 .claude/skills/feature-sprint/scripts/parse_implementation_plan.py \ + tmp/feature-specs/{task_slug}/implementation-plan.md + ``` + + Output is JSON: `{tasks: [{id, completed, description, metadata, blocked_reason, raw_block}], summary}`. Keep each task's `raw_block` — Phase 2 pastes it verbatim into the subagent prompt. + +### 1.3 Verify Repos Exist + +For each parallel group, check the repo it targets exists: + +- `repo: oss` → confirm `oss_root` directory exists (the OSS sibling, typically `realpath ../packmind` from the proprietary repo) +- `repo: proprietary` → confirm `proprietary_root` exists (the current working directory when /feature-spec was run) + +If a required repo is missing, ask the user: + +```json +{ + "questions": [{ + "question": "OSS repo at `{oss_root}` is missing. How to proceed?", + "header": "Missing repo", + "multiSelect": false, + "options": [ + {"label": "Abort", "description": "Cancel sprint; user will set up the repo"}, + {"label": "Switch to proprietary", "description": "Run all groups in this repo regardless of repo tag"}, + {"label": "Skip OSS groups", "description": "Only execute groups tagged proprietary"} + ] + }] +} +``` + +### 1.4 Map Tasks to Groups + +If `context.md` has non-empty `parallel_groups`: + +```javascript +const groupedTasks = {}; +for (const group of parallel_groups) { + groupedTasks[group.group_id] = { + name: group.group_name, + tasks: group.task_ids, + target_files: group.target_files, + repo: group.repo, + blocked_by: group.blocked_by ?? [], + status: 'pending', + }; +} +``` + +If `parallel_groups` is empty, create a single sequential group covering all tasks, using `target_repo` from context for `repo`. + +### 1.5 Detect Resume State + +Use the plan-wide totals (`completed`, `pending`, `total`, `percent`) from the `summary` field already returned by `parse_implementation_plan.py`. + +For the **per-group** breakdown table below, also run the readiness resolver and use each group's `completed_ids`/`task_ids` lengths: + +```bash +python3 .claude/skills/feature-sprint/scripts/resolve_ready_groups.py \ + tmp/feature-specs/{task_slug}/context.md \ + tmp/feature-specs/{task_slug}/implementation-plan.md +``` + +Map each `groups[*]` entry to a row — `Tasks = len(task_ids)`, `Completed = len(completed_ids)`, `Status` from `status` (`ready` → ✅ Ready / 🔄 Partial / ⏳ Blocked / ✅ Done). + +If any tasks are already completed: + +``` +**Resuming Sprint: {task_slug}** + +Previous progress: +- Completed: {n}/{total} ({percent}%) +- Pending: {pending} + +| Group | Repo | Tasks | Completed | Status | +|-------|------|-------|-----------|--------| +| A: {name} | oss | 3 | 3 | ✅ Done | +| B: {name} | oss | 2 | 1 | 🔄 Partial | +| C: {name} | oss | 2 | 0 | ⏳ Blocked (after A, B) | +``` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Resume sprint from previous progress?", + "header": "Resume", + "multiSelect": false, + "options": [ + {"label": "Continue (Recommended)", "description": "Resume from {completed}/{total} done"}, + {"label": "Restart", "description": "Reset all checkboxes to [ ] and start over"} + ] + }] +} +``` + +If "Restart": rewrite `implementation-plan.md` replacing every `- [x]` with `- [ ]` and stripping any `_(BLOCKED: …)_` annotations (a single `sed` pass is fine since this is destructive on purpose). + +### 1.6 Pull-Before-Sprint (proprietary only) + +If `target_repo` is `proprietary` or `both`: + +Remind the user (informational, no gate): +``` +Heads up: most OSS work auto-merges into this proprietary fork. +Before starting, you may want to run `git pull` here so the fork is up to date. +``` + +If `target_repo` is `oss` only, skip this reminder. + +### 1.7 Hydrate Claude Tasks + +For visual feedback, use `TaskCreate` for each pending task: + +``` +TaskCreate({ + subject: `${task.id}: ${task.description}`, + description: `Repo: ${task.repo} | Layer: ${task.layer} | Files: ${task.files}`, + activeForm: `Implementing ${task.id}...`, +}) +``` + +Capture each returned task ID into a `sessionTasks` map keyed by `task.id`. + +### 1.8 Set Up Task Dependencies + +For each group with `blocked_by`: + +``` +for blockerGroupId in group.blocked_by: + for taskId in group.tasks: + TaskUpdate( + taskId: sessionTasks[taskId], + addBlockedBy: blockerGroup.tasks.map(t => sessionTasks[t]) + ) +``` + +This is purely visual — the actual execution gating is enforced in Phase 2 by ready-group selection. + +### 1.9 Display Execution Plan + +``` +**Sprint Ready: {task_slug}** + +**Target repo:** {target_repo} +**Tasks:** {pending}/{total} pending ({completed} already done) + +**Parallel Execution Plan:** +| Group | Repo | Tasks | Status | Dependencies | +|-------|------|-------|--------|--------------| +| A: {name} | oss | {ids} | ✅ Ready | None | +| B: {name} | oss | {ids} | ✅ Ready | None | +| C: {name} | oss | {ids} | ⏳ Blocked | After A, B | + +**Quality gates (final phase):** Nx lint / test / build on affected projects +**Commit strategy:** one commit per group via git-commit-guidelines +``` + +### 1.10 Authorize Subagent File Edits + +Phase 2 spawns Agent subagents that call Edit / Write / Bash on files in the target repo. **Subagents inherit the parent session's permission mode** — in default mode every tool call pauses for approval, which serializes parallel execution and stalls the sprint. + +**Before continuing, the user must enable auto-accept mode in the Claude Code TUI** (`Shift+Tab` cycles through modes; pick "accept edits" or "bypass permissions"). The orchestrator cannot toggle this on the user's behalf. + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Confirm auto-accept (Shift+Tab) is enabled so subagents can edit files without prompts?", + "header": "Authorize", + "multiSelect": false, + "options": [ + {"label": "Yes, ready to sprint", "description": "Auto-accept is on; subagents will edit files without per-call prompts"}, + {"label": "Not yet — cancel", "description": "Stop the sprint; I'll toggle auto-accept and re-run /feature-sprint"} + ] + }] +} +``` + +If "Not yet — cancel": stop and leave state untouched. Otherwise continue to §1.11. + +### 1.11 Get Approval + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Start sprint execution?", + "header": "Sprint", + "multiSelect": false, + "options": [ + {"label": "Start parallel (Recommended)", "description": "Execute all ready groups in parallel via subagents"}, + {"label": "Sequential only", "description": "One group at a time — slower but easier to debug"}, + {"label": "Cancel", "description": "Don't start"} + ] + }] +} +``` + +Do not continue until the user confirms. + +- "Start parallel" → set `execution_mode = "parallel"` +- "Sequential only" → set `execution_mode = "sequential"` +- "Cancel" → stop, leave state untouched + +### 1.12 Proceed + +Read `phase-2-execute.md` and continue. diff --git a/.cursor/skills/feature-sprint/phase-2-execute.md b/.cursor/skills/feature-sprint/phase-2-execute.md new file mode 100644 index 000000000..5507d67c5 --- /dev/null +++ b/.cursor/skills/feature-sprint/phase-2-execute.md @@ -0,0 +1,222 @@ +# Phase 2: Execute Sprint + +Execute tasks via subagents with automatic sync-back and per-group commits. Runs autonomously after Phase 1 approval. + +## Critical Rule: Subagent-Only Execution + +**The main session MUST NOT implement tasks directly.** All implementation lives in Task subagents. + +- Main session = orchestration (spawn agents, parse output, sync state, run commits) +- Subagents = implementation (read specs, write code, run small validation commands) + +This keeps the main context clean and lets agents read heavily without poisoning your conversation. + +## Prerequisites + +- Phase 1 completed +- `execution_mode` chosen (parallel or sequential) +- `sessionTasks` map populated +- `groupedTasks` populated from `parallel_groups` + +## Execution Modes + +### Parallel Mode (Default) + +Spawn Task subagents for ALL ready groups **simultaneously in a single message**. Agents run concurrently. + +### Sequential Mode + +Spawn Task subagents **one at a time**. Wait for each to complete before spawning the next. Used when: +- No `parallel_groups` defined (single sequential group) +- User chose "Sequential only" in Phase 1 +- Only one ready group exists + +## Steps + +### 2.1 Identify Ready Groups + +Call the bundled script — it reads `context.md` + `implementation-plan.md` and classifies every group as `ready | completed | partial | blocked`: + +```bash +python3 .claude/skills/feature-sprint/scripts/resolve_ready_groups.py \ + tmp/feature-specs/{task_slug}/context.md \ + tmp/feature-specs/{task_slug}/implementation-plan.md +``` + +Output JSON contains `ready_group_ids[]` and per-group `status`/`completed_ids`/`blocked_ids`. Use this every iteration of the loop in step 2.10 — never hand-compute the readiness check. + +### 2.2 Pick Working Directory per Group + +For each ready group: + +| group.repo | cwd | +|------------|-----| +| `oss` | `oss_root` from context (the OSS sibling at `../packmind`, resolved to absolute path at spec time) | +| `proprietary` | `proprietary_root` from context (the proprietary repo, the cwd when /feature-spec ran) | + +This `cwd` is passed to the subagent's prompt as the *implementation root*. The subagent must Bash with absolute paths so it works regardless of the agent's own cwd. + +### 2.3 Build Subagent Prompt + +Read the bundled template once: `.claude/skills/feature-sprint/references/group-prompt-template.md`. Substitute the placeholders documented at the top of that file. For `{TASKS_BLOCK}`, concatenate the `raw_block` of each task in `group.task_ids` (from the Phase 1 `parse_implementation_plan.py` output) separated by blank lines. + +Do not paraphrase the template — it is the authoritative contract subagents respond to (`TASK_COMPLETE`, `GROUP_COMPLETE`, `BLOCKED`, `FILES_MODIFIED` markers are parsed in step 2.5). + +### 2.4 Spawn Subagents + +**Parallel mode**: send a single message containing one `Agent` tool call per ready group. They run concurrently. + +**Sequential mode**: send one `Agent` call, wait for it to return, then move to the next. + +In both cases, set `subagent_type="general-purpose"`. + +### 2.5 Parse Subagent Output + +For each agent that returns, parse: + +- `TASK_COMPLETE: {id}` markers — collect into `completed[]` +- `GROUP_COMPLETE: {id}` marker — group finished cleanly +- `BLOCKED: {id} - {reason}` markers — collect into `blocked[]` +- `FILES_MODIFIED: ...` — capture for the commit step + +### 2.6 Sync Checkboxes to implementation-plan.md + +Apply both completed and blocked updates in one pass via the bundled script: + +```bash +python3 .claude/skills/feature-sprint/scripts/sync_checkboxes.py \ + tmp/feature-specs/{task_slug}/implementation-plan.md \ + --complete {comma-separated ids from completed[]} \ + --blocked "{id1}:{reason1}" "{id2}:{reason2}" +``` + +`--blocked` takes one `id:reason` per argument (so reasons may contain commas freely). `--complete` is a single comma-joined ID list. + +**Omit a flag entirely when its list is empty** — do not pass an unquoted empty value (`--complete `) since argparse will treat the next flag as the value. If `completed[]` and `blocked[]` are both empty, skip this step (nothing to sync). + +The script returns JSON with `applied_complete`, `applied_blocked`, and `missing` (any task ID that didn't match — surface these as a warning; don't retry the agent). Exit code is `1` if anything was missing, `0` otherwise. + +### 2.7 Update Claude Tasks + +For every task in `completed[]`: + +``` +TaskUpdate({ taskId: sessionTasks[task_id], status: "completed" }) +``` + +For tasks currently being executed but not yet complete (shouldn't normally happen here since groups are atomic), mark them `in_progress`. + +### 2.8 Commit the Group + +After the agent finishes (whether all tasks completed or some blocked), commit the changes if anything was modified: + +1. Switch to the group's working repo (use absolute path): + ``` + git -C {repo_root} status --short + ``` + If nothing changed, skip the commit step for this group. + +2. Stage only the files listed in `FILES_MODIFIED` (don't `git add -A`): + ``` + git -C {repo_root} add {files...} + ``` + +3. Build the commit message using the `git-commit-guidelines` skill conventions: + - Gitmoji prefix (✨ for new features, 🐛 for fixes, ♻️ for refactor, ✅ for tests, etc.) + - Conventional Commits format: `(): ` + - NEVER use `Close`/`Fix #` keywords (no auto-closing of issues) + - Body lists completed task IDs and references the spec slug + - Include `Co-Authored-By: Claude ` + + Template: + ``` + {emoji} {type}({scope}): {one-line subject derived from group_name} + + Sprint group {group_id} ({group_name}) for feature `{task_slug}`. + + Completed tasks: + - {1.1}: {description} + - {1.2}: {description} + + Refs: tmp/feature-specs/{task_slug}/ + + Co-Authored-By: Claude + ``` + +4. Show the message to the user and ask for approval: + + ```json + { + "questions": [{ + "question": "Commit group {group_id} ({group_name})?", + "header": "Commit", + "multiSelect": false, + "options": [ + {"label": "Commit (Recommended)", "description": "Apply the proposed message"}, + {"label": "Edit message", "description": "Provide a new commit message"}, + {"label": "Skip commit", "description": "Leave changes staged for manual commit later"} + ] + }] + } + ``` + +5. On approval, run: + ``` + git -C {repo_root} commit -m "$(cat <<'EOF' + {message} + EOF + )" + ``` + + If a pre-commit hook fails, do NOT use `--no-verify`. Fix the underlying issue or pass control back to the user. + +### 2.9 Refresh Group Status + +Group status is derived from checkbox state on the next call to `resolve_ready_groups.py` — no in-memory bookkeeping required. The sync step in 2.6 is the only state mutation. + +### 2.10 Loop Until No Ready Groups Remain + +After each round, re-run `resolve_ready_groups.py` (it reads from disk, so it picks up the sync from 2.6): + +1. If `ready_group_ids[]` is non-empty → repeat steps 2.3–2.8 for the new ready groups +2. If `ready_group_ids[]` is empty AND `all_completed` is false → every remaining group is blocked (status `blocked` or `partial`). Report blockers and exit to Phase 3. +3. If `all_completed` is true → proceed to Phase 3. + +### 2.11 Final Sync Summary + +Before handing off to Phase 3, print a summary: + +``` +**Execution Phase Complete** + +| Group | Repo | Tasks | Status | Commit | +|-------|------|-------|--------|--------| +| A: backend-{domain} | oss | 3/3 | ✅ Complete | abc123 | +| B: frontend-{domain} | oss | 2/2 | ✅ Complete | def456 | +| C: tests | oss | 1/2 | 🔄 Partial | ghi789 | + +Total: {completed}/{total} tasks done +Blocked: {list of blocked task IDs with reasons} +``` + +## Error Recovery + +| Scenario | Action | +|----------|--------| +| Agent times out | Sync completed tasks, mark group `partial`, continue | +| Agent crashes | Sync completed tasks, log the error, pause sprint and ask user | +| Lint/test fails inside subagent | Subagent should self-correct; if it can't, it emits `BLOCKED` | +| File conflict (group A writes while group B is reading) | Shouldn't happen if Phase 3D grouping was correct. If it does: pause, report, ask user to resolve | +| All ready groups are blocked | Pause sprint, report blockers, suggest fixes | +| `git commit` fails | Surface the error verbatim; do NOT retry with `--no-verify` | + +## Handling Interruption + +If the session is killed mid-sprint: +- Completed tasks already have `[x]` in `implementation-plan.md` +- Blocked tasks have the `_(BLOCKED: ...)_` annotation +- Re-running `/feature-sprint {task_slug}` resumes from those checkboxes + +## Next Phase + +After all reachable groups are completed or blocked, read `phase-3-finalize.md`. diff --git a/.cursor/skills/feature-sprint/phase-3-finalize.md b/.cursor/skills/feature-sprint/phase-3-finalize.md new file mode 100644 index 000000000..ac7f3f426 --- /dev/null +++ b/.cursor/skills/feature-sprint/phase-3-finalize.md @@ -0,0 +1,227 @@ +# Phase 3: Finalize Sprint + +Run Nx quality gates on affected projects, produce the final report, optionally archive the spec. + +## Prerequisites + +- Phase 2 completed (all reachable groups completed or blocked) +- Per-group commits already created (or skipped) in each working repo + +## Steps + +### 3.1 Load Final State + +Read `tmp/feature-specs/{task_slug}/implementation-plan.md`: +- Count `[x]` vs `[ ]` +- Find any `_(BLOCKED: ...)_` annotations + +Read `tmp/feature-specs/{task_slug}/context.md`: +- `quality_gates` (lint/test/build flags + extras) +- `parallel_groups[]` to derive which Nx projects were touched + +### 3.2 Resolve Affected Nx Projects + +For each completed group, look at `target_files`: +- A path under `packages/{name}/` → Nx project `{name}` +- A path under `apps/{name}/` → Nx project `{name}` + +Build a deduplicated set of `affected_projects[]`. Also note which **repo** each project belongs to (`oss` or `proprietary`) — gates run in the right repo. + +If unsure or you want a broader gate, the Nx convention is: +``` +./node_modules/.bin/nx affected -t lint +./node_modules/.bin/nx affected -t test +./node_modules/.bin/nx affected -t build +``` +But narrowly targeting the specific projects is faster and clearer when you know them. + +### 3.3 Run Quality Gates + +For each `affected_project` × each gate flag in `quality_gates`: + +```bash +# Always cd to the repo via -C; use absolute paths. +{ensure node version per .nvmrc, e.g. via nvm if available} + +# Lint +./node_modules/.bin/nx lint {project} + +# Test +./node_modules/.bin/nx test {project} + +# Build (only if quality_gates.build is true) +./node_modules/.bin/nx build {project} +``` + +Set `PACKMIND_EDITION=proprietary` in the environment when running gates against the proprietary repo (per `CLAUDE.md`). + +Capture for each: `passed: bool`, `duration`, truncated output. + +If any extras are listed in `quality_gates.extra`, run them too (e.g., a project-specific e2e command). + +### 3.4 Handle Gate Failures + +If any gate fails, display the failure: + +``` +**Quality Gate Failed: {project} / {gate}** + +Repo: {repo} +Command: {command} +Exit: {code} + +{Last 30 lines of output} +``` + +Then use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Quality gate '{project}/{gate}' failed. How to proceed?", + "header": "Gate Failed", + "multiSelect": false, + "options": [ + {"label": "Fix and retry (Recommended)", "description": "I'll fix the issue, then retry the gate"}, + {"label": "Spawn fixer subagent", "description": "Delegate the fix to a fresh general-purpose subagent in the right repo"}, + {"label": "Skip gate", "description": "Continue without this gate passing (risky)"}, + {"label": "Pause sprint", "description": "Stop here, investigate manually"} + ] + }] +} +``` + +- "Fix and retry" → wait for the user to make changes, then re-run the gate +- "Spawn fixer subagent" → launch an `Agent` with `subagent_type="general-purpose"`. Prompt: working repo, the failed command and output, the relevant files. Instruct it to make the smallest fix and re-run the gate. On success, commit the fix as a follow-up commit (with user approval, per the per-group commit policy). +- "Skip gate" → record skipped, continue +- "Pause sprint" → exit; progress is preserved in checkboxes + commits + +### 3.5 Verify Final Checkbox State + +Re-read `implementation-plan.md`: +- Count completed (`[x]`) +- Count remaining (`[ ]`) — these should all be annotated `_(BLOCKED: ...)_` or the sprint is incomplete + +If any unblocked `[ ]` tasks remain, the sprint is not finished. Tell the user and offer to resume Phase 2. + +### 3.6 Offer to Archive + +``` +**Sprint Complete: {task_slug}** + +All reachable tasks executed, gates run, commits created. + +Move spec artifacts to an archive folder? (Still in tmp/ — git-ignored.) +- From: tmp/feature-specs/{task_slug}/ +- To: tmp/feature-specs/done/{task_slug}/ +``` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Move completed spec to tmp/feature-specs/done/?", + "header": "Archive", + "multiSelect": false, + "options": [ + {"label": "Yes, archive", "description": "Move under tmp/feature-specs/done/ so /feature-spec sees a clean pending list"}, + {"label": "Keep in place (Recommended)", "description": "Leave it; you may want to revisit it"} + ] + }] +} +``` + +If "Yes, archive": +```bash +mkdir -p tmp/feature-specs/done/ +mv tmp/feature-specs/{task_slug} tmp/feature-specs/done/{task_slug} +``` + +(No commit needed — `tmp/` is git-ignored.) + +### 3.7 Pull Reminder (OSS → proprietary) + +If any commits landed in the OSS repo (`oss_root`), remind the user: + +``` +✅ OSS commits created in {oss_root}. + +When upstream auto-merges into the proprietary fork, run: + git -C {proprietary_root} pull +to pick up the changes here. + +If this feature has proprietary-only tasks pending (e.g., editions wiring), do that pull before resuming /feature-sprint {task_slug}. +``` + +Skip this section if `target_repo` was `proprietary`. + +### 3.8 Final Report + +``` +## ✅ SPRINT COMPLETE + +**Task**: {task_name} +**Slug**: {task_slug} +**Target repo**: {target_repo} + +### Execution Summary + +| Metric | Value | +|--------|-------| +| Tasks completed | {n}/{total} | +| Parallel groups | {completed_groups}/{total_groups} | +| Commits created | {commit_count} | +| Files modified | {file_count} | +| Quality gates | {passed}/{total} passed{, X skipped if any} | + +### Commits + +| Repo | Group | SHA | Message | +|------|-------|-----|---------| +| oss | A: backend-... | abc123 | ✨ feat(...): ... | +| oss | B: frontend-... | def456 | ✨ feat(...): ... | + +### Quality Gates + +| Project | Gate | Status | Duration | +|---------|------|--------|----------| +| standards | lint | ✅ | 4s | +| standards | test | ✅ | 22s | +| frontend | lint | ✅ | 8s | +| frontend | test | ✅ | 31s | + +### Blocked Tasks + +{if any:} +| ID | Reason | +|----|--------| +| 3.2 | {reason} | + +{else: "None."} + +### Files Modified + +{abbreviated git status -s output per repo} + +--- + +**Spec**: `tmp/feature-specs/{pending|done}/{task_slug}/` +**Plan**: `tmp/feature-specs/{pending|done}/{task_slug}/implementation-plan.md` + +Sprint complete. Changes committed locally (not pushed). +``` + +### 3.9 Cleanup Session Tasks + +``` +for (taskId, sessionTaskId) in sessionTasks: + TaskUpdate({ taskId: sessionTaskId, status: "completed" }) +``` + +`sessionTasks` is ephemeral — nothing else to clean up. + +## End of Sprint + +To run another: `/feature-sprint {another_task_slug}` +To check overall progress: `/feature-sprint status` diff --git a/.cursor/skills/feature-sprint/references/group-prompt-template.md b/.cursor/skills/feature-sprint/references/group-prompt-template.md new file mode 100644 index 000000000..d928f20ba --- /dev/null +++ b/.cursor/skills/feature-sprint/references/group-prompt-template.md @@ -0,0 +1,84 @@ +# Per-Group Subagent Prompt Template + +Loaded by `phase-2-execute.md` step 2.3. The orchestrator substitutes every +`{placeholder}` (and the `{TASKS_BLOCK}` section) with concrete values, then +passes the result as the `prompt` argument to `Agent` with +`subagent_type="general-purpose"`. + +## Placeholders + +| Placeholder | Source | +|-------------|--------| +| `{group_id}` | `context.md` → `parallel_groups[*].group_id` | +| `{group_name}` | `context.md` → `parallel_groups[*].group_name` | +| `{task_slug}` | spec directory name (`tmp/feature-specs/{slug}/`) | +| `{repo}` | `parallel_groups[*].repo` (`oss` or `proprietary`) | +| `{repo_root}` | absolute path: `oss_root` or `proprietary_root` from `context.md` | +| `{proprietary_root}` | always `context.md` → `proprietary_root` (spec artifacts live here) | +| `{TASKS_BLOCK}` | concatenation of each task's `raw_block` from `parse_implementation_plan.py` output | + +## Template + +``` +Execute sprint group {group_id}: {group_name} for feature {task_slug}. + +## Working repo +- Repo: {repo} +- Root (absolute path): {repo_root} +- All file paths in tasks are relative to this root unless otherwise noted. + +## Context files (read in this exact order) +1. {proprietary_root}/tmp/feature-specs/{task_slug}/context.md +2. {proprietary_root}/tmp/feature-specs/{task_slug}/functional-spec.md +3. {proprietary_root}/tmp/feature-specs/{task_slug}/discovery.md +4. {proprietary_root}/tmp/feature-specs/{task_slug}/implementation-plan.md + +Note: spec artifacts live in the PROPRIETARY repo's tmp/ even when implementation +targets OSS. Always read from `{proprietary_root}/tmp/feature-specs/{task_slug}/`. + +## Your tasks (in this exact order) + +{TASKS_BLOCK} + +## Rules + +1. For each task in order: + a. Re-read the task block in implementation-plan.md for full detail + b. Open the Reference file:line — that is your pattern template + c. Implement the task following the Packmind conventions established in discovery.md + d. If the task is a backend domain/application/infra task, respect the hexagonal-architecture skill at `{proprietary_root}/.claude/skills/hexagonal-architecture/` + e. If the task is a frontend task, respect `working-with-pm-design-kit` and `gateway-pattern-implementation-in-packmind-frontend` + f. If the task is a migration, respect `how-to-write-typeorm-migrations-in-packmind` + g. After finishing the task, output exactly: `TASK_COMPLETE: {task_id}` + +2. NEVER import from `@packmind/editions` unless the file you're editing already lives inside `packages/editions/` (this is enforced by `.claude/rules/packmind/packmind-proprietary.md`). + +3. Run `./node_modules/.bin/nx lint ` and `./node_modules/.bin/nx test ` after finishing each Nx project's tasks within the group. If anything fails, fix it before moving on. + +4. After ALL tasks in this group are complete, output exactly: + ``` + GROUP_COMPLETE: {group_id} + COMPLETED_TASKS: + FILES_MODIFIED: + ``` + +5. If you encounter a blocker: + - Output: `BLOCKED: - ` + - Continue with later tasks in this group only if they don't depend on the blocked task + - At the end, list blocked tasks in your group summary + +## Hard constraints + +- Do NOT commit changes — the main session handles commits per group +- Do NOT modify `implementation-plan.md` checkboxes — the main session handles sync-back +- Do NOT modify any file in `{proprietary_root}/tmp/feature-specs/{task_slug}/` +- Focus only on implementing the listed task IDs; do not "improve" unrelated files +- Use absolute paths in your Bash calls so they work regardless of your cwd + +## Tools you should rely on + +- Read, Edit, Write for code changes +- Bash with absolute paths for `nx lint`, `nx test`, `nx build` in the working repo +- Glob/Grep within the working repo for navigation +- Avoid spawning further subagents (`Agent`) — flatten everything in this one +``` diff --git a/.cursor/skills/feature-sprint/scripts/parse_implementation_plan.py b/.cursor/skills/feature-sprint/scripts/parse_implementation_plan.py new file mode 100644 index 000000000..b8e6068c2 --- /dev/null +++ b/.cursor/skills/feature-sprint/scripts/parse_implementation_plan.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Parse a feature-sprint implementation-plan.md into structured JSON. + +Reads checkbox tasks of the form: + + - [ ] **1.2: short description** + - Repo: oss + - Layer: application + - Files: packages/foo/... + - Reference: packages/bar/baz.ts:42 + - Notes: free text + +and emits JSON to stdout: + + { + "tasks": [ + { + "id": "1.2", + "completed": false, + "description": "short description", + "metadata": { + "repo": "oss", + "layer": "application", + "files": "packages/foo/...", + "reference": "packages/bar/baz.ts:42", + "notes": "free text" + }, + "blocked_reason": null, + "raw_block": "..." + } + ], + "summary": {"total": 1, "completed": 0, "pending": 1, "blocked": 0} + } + +Tasks annotated with `_(BLOCKED: reason)_` after the description carry that +reason on `blocked_reason`. The full markdown block (including indented +metadata lines) is preserved on `raw_block` so the orchestrator can paste it +verbatim into subagent prompts without re-reading the file. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +TASK_LINE = re.compile( + r"""^-\s\[(?P[ x])\]\s\*\* + (?P\d+(?:\.\d+)+):\s + (?P.+?)\*\* + (?:\s+_\(BLOCKED:\s(?P.+?)\)_)? + \s*$""", + re.VERBOSE, +) +META_LINE = re.compile(r"^\s+-\s(?P[A-Za-z]+):\s*(?P.*)$") + + +def parse(content: str) -> dict: + tasks: list[dict] = [] + current: dict | None = None + raw_lines: list[str] = [] + + def flush() -> None: + if current is None: + return + current["raw_block"] = "\n".join(raw_lines).rstrip() + tasks.append(current) + + for line in content.splitlines(): + task_match = TASK_LINE.match(line) + if task_match: + flush() + raw_lines = [line] + current = { + "id": task_match.group("id"), + "completed": task_match.group("state") == "x", + "description": task_match.group("desc").strip(), + "metadata": {}, + "blocked_reason": task_match.group("blocked"), + "raw_block": "", + } + continue + + if current is None: + continue + + meta_match = META_LINE.match(line) + if meta_match: + raw_lines.append(line) + key = meta_match.group("key").lower() + value = meta_match.group("value").strip() + current["metadata"][key] = value + continue + + if line.startswith("- [") or (line.strip() == "" and len(raw_lines) > 1): + flush() + current = None + raw_lines = [] + continue + + if line.strip(): + raw_lines.append(line) + + flush() + + completed = sum(1 for t in tasks if t["completed"]) + blocked = sum(1 for t in tasks if t["blocked_reason"]) + return { + "tasks": tasks, + "summary": { + "total": len(tasks), + "completed": completed, + "pending": len(tasks) - completed, + "blocked": blocked, + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("plan", help="Path to implementation-plan.md") + parser.add_argument( + "--ids-only", + action="store_true", + help="Print only the task IDs (one per line) instead of JSON", + ) + parser.add_argument( + "--pending-only", + action="store_true", + help="Filter to non-completed tasks before printing", + ) + args = parser.parse_args() + + path = Path(args.plan) + if not path.exists(): + print(f"Error: plan file not found: {path}", file=sys.stderr) + return 1 + + result = parse(path.read_text(encoding="utf-8")) + + if args.pending_only: + result["tasks"] = [t for t in result["tasks"] if not t["completed"]] + + if args.ids_only: + for task in result["tasks"]: + print(task["id"]) + return 0 + + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.cursor/skills/feature-sprint/scripts/resolve_ready_groups.py b/.cursor/skills/feature-sprint/scripts/resolve_ready_groups.py new file mode 100644 index 000000000..b6da2306a --- /dev/null +++ b/.cursor/skills/feature-sprint/scripts/resolve_ready_groups.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Compute which parallel groups are ready to execute next. + +Reads: +- context.md (YAML frontmatter with `parallel_groups[]`) +- implementation-plan.md (checkbox state per task) + +A group is **completed** when every task in `task_ids` is checked `[x]` AND +not annotated `_(BLOCKED: ...)_`. A group is **partial** when some tasks are +done and some are blocked. A group is **ready** when its status is not yet +`completed` and every group it depends on (`blocked_by`) has status +`completed`. + +Output (stdout, JSON): + + { + "groups": [ + { + "group_id": "A", + "group_name": "backend-foo", + "repo": "oss", + "status": "ready" | "completed" | "partial" | "blocked", + "task_ids": ["1.1", "1.2"], + "completed_ids": ["1.1"], + "blocked_ids": [], + "blocked_by": [] + } + ], + "ready_group_ids": ["A"], + "all_completed": false + } + +Requires PyYAML. Exit 1 on missing files or malformed YAML. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +try: + import yaml +except ImportError: + print( + "Error: PyYAML is required (pip install pyyaml)", + file=sys.stderr, + ) + sys.exit(1) + + +FRONTMATTER = re.compile(r"^---\n(.*?)\n---", re.DOTALL) +TASK_STATE = re.compile( + r"^-\s\[(?P[ x])\]\s\*\*(?P\d+(?:\.\d+)+):\s.+?\*\*" + r"(?P\s+_\(BLOCKED:.+?\)_)?\s*$", + re.MULTILINE, +) + + +def load_frontmatter(path: Path) -> dict: + text = path.read_text(encoding="utf-8") + match = FRONTMATTER.match(text) + if not match: + print(f"Error: no YAML frontmatter in {path}", file=sys.stderr) + sys.exit(1) + try: + data = yaml.safe_load(match.group(1)) + except yaml.YAMLError as exc: + print(f"Error: invalid YAML in {path}: {exc}", file=sys.stderr) + sys.exit(1) + if not isinstance(data, dict): + print(f"Error: frontmatter must be a mapping in {path}", file=sys.stderr) + sys.exit(1) + return data + + +def extract_task_state(plan_path: Path) -> dict[str, dict]: + states: dict[str, dict] = {} + for match in TASK_STATE.finditer(plan_path.read_text(encoding="utf-8")): + states[match.group("id")] = { + "completed": match.group("state") == "x", + "blocked": bool(match.group("blocked")), + } + return states + + +def classify_group(group: dict, task_state: dict[str, dict]) -> dict: + task_ids = group.get("task_ids", []) or [] + completed: list[str] = [] + blocked: list[str] = [] + for task_id in task_ids: + info = task_state.get(task_id) + if info is None: + continue + if info["completed"]: + completed.append(task_id) + elif info["blocked"]: + blocked.append(task_id) + + if task_ids and len(completed) == len(task_ids): + status = "completed" + elif blocked and (len(completed) + len(blocked)) == len(task_ids): + status = "partial" + else: + status = "pending" # may become "ready" after dep check + + return { + "group_id": group.get("group_id"), + "group_name": group.get("group_name"), + "repo": group.get("repo"), + "task_ids": task_ids, + "blocked_by": group.get("blocked_by", []) or [], + "target_files": group.get("target_files", []) or [], + "status": status, + "completed_ids": completed, + "blocked_ids": blocked, + } + + +def resolve_readiness(groups: list[dict]) -> None: + by_id = {g["group_id"]: g for g in groups} + for group in groups: + if group["status"] in ("completed", "partial"): + continue + deps_done = all( + by_id.get(dep, {}).get("status") == "completed" + for dep in group["blocked_by"] + ) + group["status"] = "ready" if deps_done else "blocked" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("context", help="Path to context.md") + parser.add_argument("plan", help="Path to implementation-plan.md") + args = parser.parse_args() + + context_path = Path(args.context) + plan_path = Path(args.plan) + for path, label in [(context_path, "context"), (plan_path, "plan")]: + if not path.exists(): + print(f"Error: {label} file not found: {path}", file=sys.stderr) + return 1 + + frontmatter = load_frontmatter(context_path) + parallel_groups = frontmatter.get("parallel_groups") or [] + if not isinstance(parallel_groups, list): + print("Error: parallel_groups must be a list", file=sys.stderr) + return 1 + + task_state = extract_task_state(plan_path) + groups = [classify_group(g, task_state) for g in parallel_groups] + resolve_readiness(groups) + + ready_ids = [g["group_id"] for g in groups if g["status"] == "ready"] + all_completed = bool(groups) and all(g["status"] == "completed" for g in groups) + + result = { + "groups": groups, + "ready_group_ids": ready_ids, + "all_completed": all_completed, + } + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.cursor/skills/feature-sprint/scripts/sync_checkboxes.py b/.cursor/skills/feature-sprint/scripts/sync_checkboxes.py new file mode 100644 index 000000000..0253477fa --- /dev/null +++ b/.cursor/skills/feature-sprint/scripts/sync_checkboxes.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Batch-update task checkboxes in a feature-sprint implementation-plan.md. + +Supports two operations in a single pass: + + --complete 1.1,1.2,2.3 Flip `- [ ] **{id}:` to `- [x] **{id}:` + --blocked '3.1:reason' '3.2:r2' Annotate `- [ ] **{id}: desc**` with + `_(BLOCKED: reason)_` (keeps the checkbox + unchecked so resume picks it up). + +`--blocked` accepts one pair per argument so reasons can contain commas +freely (e.g. `'3.1:waiting on API, see #123'`). Pass either flag without +values, or omit it entirely, when the corresponding list is empty. + +Already-completed tasks (`[x]`) are left alone. Tasks already annotated +BLOCKED have their annotation replaced if a new reason is supplied. Any task +ID passed but not found in the file is reported on stderr; exit code is 1 if +any IDs were not applied, 0 otherwise. + +Output (stdout, JSON): + + { + "applied_complete": ["1.1", "1.2"], + "applied_blocked": [{"id": "3.1", "reason": "..."}], + "missing": ["9.9"] + } +""" + +import argparse +import json +import re +import sys +from pathlib import Path + + +def parse_id_list(raw: str | None) -> list[str]: + if not raw: + return [] + return [item.strip() for item in raw.split(",") if item.strip()] + + +def parse_blocked_list(items: list[str] | None) -> list[tuple[str, str]]: + if not items: + return [] + pairs = [] + for item in items: + item = item.strip() + if not item: + continue + if ":" not in item: + print( + f"Error: --blocked entry '{item}' missing ':reason'", + file=sys.stderr, + ) + sys.exit(2) + task_id, reason = item.split(":", 1) + pairs.append((task_id.strip(), reason.strip())) + return pairs + + +def task_line_pattern(task_id: str) -> re.Pattern[str]: + # Match the exact task line, preserving the description and any trailing + # markup. Captures: 1=state ([ ] or [x]), 2=description, 3=optional + # BLOCKED annotation already present. + return re.compile( + rf"^-\s\[(?P[ x])\]\s\*\*{re.escape(task_id)}:\s(?P.+?)\*\*" + r"(?P\s+_\(BLOCKED:.+?\)_)?\s*$", + re.MULTILINE, + ) + + +def apply_complete(content: str, ids: list[str]) -> tuple[str, list[str], list[str]]: + applied: list[str] = [] + missing: list[str] = [] + for task_id in ids: + pattern = task_line_pattern(task_id) + match = pattern.search(content) + if not match: + missing.append(task_id) + continue + if match.group("state") == "x": + applied.append(task_id) # idempotent — already complete + continue + # Replace the matched line: flip state, strip any BLOCKED annotation + new_line = f"- [x] **{task_id}: {match.group('desc')}**" + content = content[: match.start()] + new_line + content[match.end():] + applied.append(task_id) + return content, applied, missing + + +def apply_blocked( + content: str, pairs: list[tuple[str, str]] +) -> tuple[str, list[dict], list[str]]: + applied: list[dict] = [] + missing: list[str] = [] + for task_id, reason in pairs: + pattern = task_line_pattern(task_id) + match = pattern.search(content) + if not match: + missing.append(task_id) + continue + if match.group("state") == "x": + # Already complete — don't re-annotate + continue + new_line = ( + f"- [ ] **{task_id}: {match.group('desc')}** " + f"_(BLOCKED: {reason})_" + ) + content = content[: match.start()] + new_line + content[match.end():] + applied.append({"id": task_id, "reason": reason}) + return content, applied, missing + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("plan", help="Path to implementation-plan.md") + parser.add_argument( + "--complete", + help="Comma-separated task IDs to mark complete (e.g. 1.1,1.2)", + ) + parser.add_argument( + "--blocked", + nargs="*", + default=[], + help=( + "One or more 'id:reason' pairs, each as a single argument so " + "reasons may contain commas " + "(e.g. --blocked '3.1:waiting on API, see #123' '3.2:other')" + ), + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would change without writing the file", + ) + args = parser.parse_args() + + path = Path(args.plan) + if not path.exists(): + print(f"Error: plan file not found: {path}", file=sys.stderr) + return 1 + + completes = parse_id_list(args.complete) + blocks = parse_blocked_list(args.blocked) + + if not completes and not blocks: + print( + "Error: at least one of --complete or --blocked is required", + file=sys.stderr, + ) + return 2 + + content = path.read_text(encoding="utf-8") + content, applied_complete, missing_complete = apply_complete(content, completes) + content, applied_blocked, missing_blocked = apply_blocked(content, blocks) + + if not args.dry_run: + path.write_text(content, encoding="utf-8") + + result = { + "applied_complete": applied_complete, + "applied_blocked": applied_blocked, + "missing": sorted(set(missing_complete + missing_blocked)), + "dry_run": args.dry_run, + } + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + + return 1 if result["missing"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.cursor/skills/packmind-onboard/steps/edge-cases.md b/.cursor/skills/packmind-onboard/steps/edge-cases.md deleted file mode 100644 index d50f21737..000000000 --- a/.cursor/skills/packmind-onboard/steps/edge-cases.md +++ /dev/null @@ -1,37 +0,0 @@ -## Edge Cases - -### Package creation fails - -If `packmind-cli packages create` fails: - -``` -❌ Failed to create package: [error message] - -Please check: - - You are logged in: `packmind-cli login` - - Your network connection is working - - The package name is valid - -Cannot proceed with onboarding until package is created. -``` - -Exit the skill. Do not proceed to analysis. - -### Not logged in - -If CLI commands fail with authentication errors: - -``` -❌ Not logged in to Packmind - -Please run: - packmind-cli login - -Then re-run this skill. -``` - -### No packages available - -If the package listing command returns no packages: - -Auto-create a package using the repository name. diff --git a/.cursor/skills/packmind-onboard/steps/step-0-introduction.md b/.cursor/skills/packmind-onboard/steps/step-0-introduction.md deleted file mode 100644 index 093426137..000000000 --- a/.cursor/skills/packmind-onboard/steps/step-0-introduction.md +++ /dev/null @@ -1,7 +0,0 @@ -## Step 0 — Introduction - -Print exactly: - -``` -I'll start the Packmind onboarding process. I'll create your first standards and commands and send them to your Packmind organization. This usually takes ~3 minutes. -``` diff --git a/.cursor/skills/packmind-onboard/steps/step-1-get-repository-name.md b/.cursor/skills/packmind-onboard/steps/step-1-get-repository-name.md deleted file mode 100644 index 3377064ab..000000000 --- a/.cursor/skills/packmind-onboard/steps/step-1-get-repository-name.md +++ /dev/null @@ -1,11 +0,0 @@ -## Step 1 — Get Repository Name - -Get the repository name for package naming: - -```bash -basename "$(git rev-parse --show-toplevel)" -``` - -Remember this as the repository name for package creation in Step 2. - -Also run `packmind-cli whoami` and extract the `Host:` value from the output. Remember this URL for the completion summary. diff --git a/.cursor/skills/packmind-onboard/steps/step-10-completion-summary.md b/.cursor/skills/packmind-onboard/steps/step-10-completion-summary.md deleted file mode 100644 index 966eb70a8..000000000 --- a/.cursor/skills/packmind-onboard/steps/step-10-completion-summary.md +++ /dev/null @@ -1,3 +0,0 @@ -## Step 10 — Completion Summary - -Follow [`packmind-versions/$PACKMIND_CLI_VERSION/completion-summary.md`](packmind-versions/$PACKMIND_CLI_VERSION/completion-summary.md). diff --git a/.cursor/skills/packmind-onboard/steps/step-2-package-handling.md b/.cursor/skills/packmind-onboard/steps/step-2-package-handling.md deleted file mode 100644 index ba7b1e4a6..000000000 --- a/.cursor/skills/packmind-onboard/steps/step-2-package-handling.md +++ /dev/null @@ -1,46 +0,0 @@ -## Step 2 — Package Handling - -Handle package creation or selection. - -### Check existing packages - -List available packages by following [`packmind-versions/$PACKMIND_CLI_VERSION/list-packages.md`](packmind-versions/$PACKMIND_CLI_VERSION/list-packages.md). - -Parse the output to get package names and slugs. - -### No packages exist - -Auto-create a package using the repository name. Follow [`packmind-versions/$PACKMIND_CLI_VERSION/create-package.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-package.md) using `${REPO_NAME}-standards` as the package name. - -The create-package step will determine the space. Capture the chosen space slug as `$SPACE_SLUG` and the new package slug as `$PACKAGE_SLUG`. - -Print: -``` -No existing packages found — created a new one: ${REPO_NAME}-standards -``` - -### One package exists - -Ask via AskUserQuestion: -- "Add to `{package-name}`?" -- "Create new package instead" - -### Multiple packages exist - -Ask via AskUserQuestion: -- List each existing package as an option -- Include "Create new package" option - -### If "Create new package" is selected - -- Ask for package name (suggest `${REPO_NAME}-standards` as default) -- Follow [`packmind-versions/$PACKMIND_CLI_VERSION/create-package.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-package.md) using the chosen name. -- The create-package step will determine the space. Capture the chosen space slug as `$SPACE_SLUG` and the new package slug as `$PACKAGE_SLUG`. - -### If an existing package is selected - -Follow [`packmind-versions/$PACKMIND_CLI_VERSION/select-package.md`](packmind-versions/$PACKMIND_CLI_VERSION/select-package.md). - -### After this step - -Remember `$PACKAGE_SLUG` (the slug of the selected/created package) and `$SPACE_SLUG` for later reference — they will be used together in Step 9 to ensure items are added to the correct package in the correct space (as `@$SPACE_SLUG/$PACKAGE_SLUG`). diff --git a/.cursor/skills/packmind-onboard/steps/step-3-announce.md b/.cursor/skills/packmind-onboard/steps/step-3-announce.md deleted file mode 100644 index 9cd862107..000000000 --- a/.cursor/skills/packmind-onboard/steps/step-3-announce.md +++ /dev/null @@ -1,8 +0,0 @@ -## Step 3 — Announce - -Print exactly: - -``` -packmind-onboard: analyzing codebase (read-only) -Target package: [package-name] -``` diff --git a/.cursor/skills/packmind-onboard/steps/step-4-detect-existing-config.md b/.cursor/skills/packmind-onboard/steps/step-4-detect-existing-config.md deleted file mode 100644 index 0bef746db..000000000 --- a/.cursor/skills/packmind-onboard/steps/step-4-detect-existing-config.md +++ /dev/null @@ -1,31 +0,0 @@ -## Step 4 — Detect Existing Packmind and Agent Configuration - -Before analyzing, detect and preserve any existing Packmind/agent configuration. - -### Glob (broad, future-proof) -Glob for markdown in these roots (recursive): -- `.packmind/**/*.md` -- `.claude/**/*.md` -- `.agents/**/*.md` -- `**/skills/**/*.md` -- `**/rules/**/*.md` - -### Classify -Classify found files into counts: -- **standards**: `.packmind/standards/**/*.md` -- **commands**: `.packmind/commands/**/*.md` -- **other_docs**: any markdown under `.claude/`, `.agents/`, or any `skills/` or `rules/` directory outside `.packmind` - -If any exist, print exactly: - -``` -Existing Packmind/agent docs detected: - - Standards: [N] - - Commands: [M] - - Other docs: [P] -``` - -No overwrites. New files (if you Export) will be added next to the existing ones. diff --git a/.cursor/skills/packmind-onboard/steps/step-5-detect-project-stack.md b/.cursor/skills/packmind-onboard/steps/step-5-detect-project-stack.md deleted file mode 100644 index 79f301519..000000000 --- a/.cursor/skills/packmind-onboard/steps/step-5-detect-project-stack.md +++ /dev/null @@ -1,28 +0,0 @@ -## Step 5 — Detect Project Stack (Minimal, Evidence-Based) - -### Language markers (check presence) -- JS/TS: `package.json`, `pnpm-lock.yaml`, `yarn.lock`, `tsconfig.json` -- Python: `pyproject.toml`, `requirements.txt`, `setup.py` -- Go: `go.mod` -- Rust: `Cargo.toml` -- Ruby: `Gemfile` -- JVM: `pom.xml`, `build.gradle`, `build.gradle.kts` -- .NET: `*.csproj`, `*.sln` -- PHP: `composer.json` - -### Architecture markers (check directories) -- Hexagonal/DDD: `src/application/`, `src/domain/`, `src/infra/` -- Layered/MVC: `src/controllers/`, `src/services/` -- Monorepo: `packages/`, `apps/` - -Print exactly: - -``` -Stack detected (heuristic): - - Languages: [..] - - Repo shape: [monorepo|single] - - Architecture markers: [..|none] -``` diff --git a/.cursor/skills/packmind-onboard/steps/step-6-run-analyses.md b/.cursor/skills/packmind-onboard/steps/step-6-run-analyses.md deleted file mode 100644 index 0b1ec0d6a..000000000 --- a/.cursor/skills/packmind-onboard/steps/step-6-run-analyses.md +++ /dev/null @@ -1,24 +0,0 @@ -## Step 6 — Run Analyses - -Read each reference file for detailed search patterns, thresholds, and insight templates. - -| Analysis | Reference File | Output focus | -|----------|----------------|--------------| -| File Template Consistency | `references/file-template-consistency.md` | Commands | -| CI/Local Workflow Parity | `references/ci-local-workflow-parity.md` | Commands | -| Role Taxonomy Drift | `references/role-taxonomy-drift.md` | Standards | -| Test Data Construction | `references/test-data-construction.md` | Standards | - -### Output schema (internal; do not print as-is to user) -For every finding, keep an internal record: - -``` -INSIGHT: -title: ... -why_it_matters: ... -confidence: [high|medium|low] -evidence: -- path[:line-line] -where_it_doesnt_apply: -- path[:line-line] -``` diff --git a/.cursor/skills/packmind-onboard/steps/step-7-generate-drafts.md b/.cursor/skills/packmind-onboard/steps/step-7-generate-drafts.md deleted file mode 100644 index e109f0e44..000000000 --- a/.cursor/skills/packmind-onboard/steps/step-7-generate-drafts.md +++ /dev/null @@ -1,12 +0,0 @@ -## Step 7 — Generate All Drafts - -Generate all draft files in one batch, using the format defined for your CLI version. - -Read the **Draft Format** section in [`packmind-versions/$PACKMIND_CLI_VERSION/create-items.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-items.md) and create draft files accordingly. - -### Generation Rules (all versions) - -- Generate drafts **only from discovered insights** (no invention) -- Use evidence from analysis to populate rules/steps -- Cap output: max **5 Standards** + **5 Commands** -- Never overwrite existing files; append `-2`, `-3`, etc. if slug exists diff --git a/.cursor/skills/packmind-onboard/steps/step-8-present-summary.md b/.cursor/skills/packmind-onboard/steps/step-8-present-summary.md deleted file mode 100644 index bb9c27cd2..000000000 --- a/.cursor/skills/packmind-onboard/steps/step-8-present-summary.md +++ /dev/null @@ -1,32 +0,0 @@ -## Step 8 — Present Summary & Confirm - -Present the generated draft files and ask for confirmation: - -``` -============================================================ - ANALYSIS COMPLETE -============================================================ - -Target package: [package-name] -Stack detected: [languages], [monorepo?], [architecture markers] -Analyses run: [N] checks - -DRAFTS CREATED: - -Standards ([N]): - 1. [Name] → .packmind/standards/_drafts/[slug].draft.md - 2. ... - -Commands ([M]): - 1. [Name] → .packmind/commands/_drafts/[slug].draft.md - 2. ... - -Drafts are saved in .packmind/*/_drafts/ — you can review or edit them before creating. -============================================================ -``` - -Then ask via AskUserQuestion with three options: - -- **Create all now** — Proceed with creating all standards and commands -- **Let me review drafts first** — Pause to allow editing, re-run skill when ready -- **Cancel** — Exit without creating anything diff --git a/.cursor/skills/packmind-onboard/steps/step-9-create-items.md b/.cursor/skills/packmind-onboard/steps/step-9-create-items.md deleted file mode 100644 index b70d15612..000000000 --- a/.cursor/skills/packmind-onboard/steps/step-9-create-items.md +++ /dev/null @@ -1,3 +0,0 @@ -## Step 9 — Create Items - -Follow [`packmind-versions/$PACKMIND_CLI_VERSION/create-items.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-items.md). diff --git a/.cursor/skills/playbook-cli-audit/SKILL.md b/.cursor/skills/playbook-cli-audit/SKILL.md new file mode 100644 index 000000000..aa57be232 --- /dev/null +++ b/.cursor/skills/playbook-cli-audit/SKILL.md @@ -0,0 +1,203 @@ +--- +name: 'playbook-cli-audit' +description: 'Audit all packmind-* skills for CLI compatibility issues: deprecated commands, invalid options, wrong file formats, missing flags, and version mismatches against the locally installed Packmind CLI. Use when you want to check that skills referencing packmind-cli are up to date, after a CLI upgrade, before a release, or whenever you suspect skills may reference outdated CLI commands. Also triggers on phrases like "audit CLI usage", "check skills for CLI issues", "are my skills up to date with the CLI", or "CLI compatibility check".' +--- + +# Playbook CLI Audit + +Detect incompatibilities between the packmind-* skills deployed in `.claude/skills/` and the current Packmind CLI. Skills evolve independently from the CLI — when the CLI deprecates or removes commands, renames flags, or changes accepted file formats, skills can silently break. This audit catches those issues before users hit them at runtime. + +**This skill only detects issues — it does not fix them.** + +## Phase 1: Establish CLI Surface + +Build the authoritative map of what the CLI currently supports. + +### 1.1 Get CLI version + +```bash +node ./dist/apps/cli/main.cjs --version +``` + +Remember this as `$CLI_VERSION` (e.g., `0.26.0`). If the command fails, try `packmind-cli --version` as a fallback. If both fail, stop and tell the user the CLI is not available. + +### 1.2 Build the command map from CLI source code + +The CLI command definitions live in `apps/cli/src/infra/commands/`. Read the command registration files to build a complete map of: + +- **Available top-level commands** and their subcommands +- **Accepted options/flags** for each command (names, types, whether required) +- **Accepted argument types** (file paths, strings, etc.) and any format constraints +- **Deprecated commands** — look for `[Deprecated]` in descriptions and `has been removed` in handler code +- **Migration paths** — what the deprecated command's error message suggests instead + +Structure this internally as a reference table. Here is the expected shape — adapt if the source reveals more or fewer commands: + +| Command | Status | Accepted Inputs | Key Flags | Migration | +|---|---|---|---|---| +| `standards create` | REMOVED | JSON file/stdin | `--space`, `--from-skill` | `playbook add ` | +| `commands create` | REMOVED | JSON file/stdin | `--space`, `--from-skill` | `playbook add ` | +| `skills add` | REMOVED | directory path | `--space`, `--from-skill` | `playbook add ` | +| `install --list` | REMOVED | — | — | `packages list` | +| `install --show` | REMOVED | — | — | `packages show ` | +| `diff` | DEPRECATED | — | `--submit`, `-m` | `playbook diff`, `playbook submit` | +| `diff add` | DEPRECATED | file path | `-m` | `playbook add` | +| `lint --diff` | DEPRECATED | — | — | `--changed-files` / `--changed-lines` | +| `playbook add` | ACTIVE | `.md`, `.mdc` files | `--space` | — | +| `playbook submit` | ACTIVE | — | `-m` (needed in non-TTY) | — | +| `packages add` | ACTIVE | — | `--to`, `--standard`/`--command`/`--skill` (one type per call) | — | +| ... | ... | ... | ... | ... | + +Do not hardcode this table — **read the actual source** in `apps/cli/src/infra/commands/` to build it. The table above is a starting point to orient your search, not the source of truth. + +### 1.3 Print summary + +``` +CLI version: $CLI_VERSION +Commands mapped: N active, M deprecated/removed +``` + +## Phase 2: Discover and Scan Skills + +### 2.1 Find all packmind-* skills + +Glob `.claude/skills/packmind-*/` to find all skill directories. For each, collect: +- `SKILL.md` (the main instruction file) +- All files in subdirectories (`references/`, `steps/`, `scripts/`, `packmind-versions/`, etc.) + +### 2.2 Handle version-specific subdirectories + +Some skills contain `packmind-versions//` directories with version-specific instructions. The version resolution logic works like this: + +1. List all version directories (e.g., `0.21.0/`, `0.23.0/`) +2. Compare each against `$CLI_VERSION` +3. A version directory is **in scope** if it is the highest version that is `<=` `$CLI_VERSION` +4. Version directories that are **not in scope** (lower versions superseded by a higher one that is still `<= $CLI_VERSION`) should be flagged as INFO-level findings ("version `0.21.0` is superseded by `0.23.0` for CLI `$CLI_VERSION`") but their CLI invocations still need to be checked — they may still be reachable by users with older CLI versions + +**Example**: If `$CLI_VERSION` is `0.25.0` and the skill has `0.21.0/` and `0.23.0/`: +- `0.23.0/` is the **active version** (highest `<= 0.25.0`) +- `0.21.0/` is **superseded** but still scanned + +### 2.3 Extract CLI invocations + +For every file in scope, extract all lines that invoke the CLI. Look for these patterns: +- `packmind-cli ` — the primary pattern +- `node ./dist/apps/cli/main.cjs ` — local dev invocation +- Code blocks containing CLI commands (inside ` ``` ` fences) +- Inline code references (inside `` ` `` backticks) +- Plain-text references in prose (e.g., "Run `packmind-cli standards create`") + +For each invocation, parse: +- **Command**: the top-level command (e.g., `standards`) +- **Subcommand**: if any (e.g., `create`) +- **Arguments**: positional args (e.g., file paths) +- **Flags/options**: named options (e.g., `--space`, `-m`) +- **Source location**: file path and line number + +## Phase 3: Cross-Reference and Detect Issues + +For each extracted CLI invocation, check it against the command map from Phase 1. Detect these categories of issues: + +### CRITICAL — Will fail at runtime + +| Check | Description | Example | +|---|---|---| +| **Removed command** | Invocation uses a command the CLI has removed | `standards create` → removed, use `playbook add` | +| **Non-existent command** | Command or subcommand does not exist in the CLI | `packmind-cli list standards` (correct: `standards list`) | +| **Invalid file format** | File type passed to a command that doesn't accept it | `.json` file to `playbook add` (accepts only `.md`/`.mdc`) | +| **Mixed artifact types** | `packages add` called with multiple artifact type flags | `--standard X --command Y` in one call | + +### WARNING — May fail or produce unexpected behavior + +| Check | Description | Example | +|---|---|---| +| **Deprecated command** | Command works but shows deprecation warning | `diff add` → use `playbook add` | +| **Missing required flag in agent context** | Command lacks a flag needed in non-interactive/CI contexts | `playbook submit` without `-m` opens an editor | +| **Deprecated flag** | A specific flag is deprecated | `lint --diff` → use `--changed-files` | +| **Incorrect install syntax** | `install --list` or `install --show` used instead of `packages list`/`packages show` | `packmind-cli install --list` | + +### INFO — Worth noting + +| Check | Description | Example | +|---|---|---| +| **Superseded version directory** | A version-specific directory is no longer the active one | `0.21.0/` when `0.23.0/` exists and CLI is `>= 0.23.0` | +| **CLI version metadata mismatch** | Skill frontmatter declares `packmind-cli-version` that conflicts with installed version | `packmind-cli-version: "< 0.25.0"` when CLI is `0.26.0` | +| **Undocumented flag usage** | A flag is used that doesn't appear in the command definition | May indicate a removed or renamed flag | + +## Phase 4: Generate Report + +Write the report to `playbook-cli-audit-report.md` at the project root. + +### Report Structure + +```markdown +# Playbook CLI Audit Report + +**Generated**: {date} +**CLI version**: {$CLI_VERSION} +**Skills scanned**: {count} +**Files analyzed**: {count} + +## Summary + +| Severity | Count | +|---|---| +| CRITICAL | {n} | +| WARNING | {n} | +| INFO | {n} | + +## Findings + +### CRITICAL + +#### {n}. [{skill-name}] {short description} + +- **File**: `{path}:{line}` +- **Invocation**: `{the CLI command as written in the skill}` +- **Issue**: {what's wrong} +- **Fix**: {what the command should be replaced with} + +### WARNING + +...same structure... + +### INFO + +...same structure... + +## Skills Scanned + +| Skill | Files | Invocations | Issues | +|---|---|---|---| +| packmind-onboard | 19 | 12 | 5 CRITICAL, 1 WARNING | +| packmind-update-playbook | 8 | 15 | 0 | +| ... | ... | ... | ... | + +## CLI Command Surface Reference + +List of all active commands with their accepted inputs, for quick cross-reference. +``` + +### If no issues are found + +```markdown +# Playbook CLI Audit Report + +**Generated**: {date} +**CLI version**: {$CLI_VERSION} +**Skills scanned**: {count} + +All packmind-* skills are compatible with the installed CLI version. No issues found. +``` + +## Phase 5: Present to User + +After writing the report: + +1. Print a summary to the conversation: + - Total CRITICAL / WARNING / INFO counts + - Top 3 most affected skills + - The report file path +2. If there are CRITICAL findings, highlight them explicitly — these are commands that **will fail** when users invoke the skill + +Do not suggest fixes automatically. The user decides how to address each finding. \ No newline at end of file diff --git a/.cursor/skills/product-map/SKILL.md b/.cursor/skills/product-map/SKILL.md new file mode 100644 index 000000000..0a47422f8 --- /dev/null +++ b/.cursor/skills/product-map/SKILL.md @@ -0,0 +1,190 @@ +--- +name: 'product-map' +description: 'Produces a functional cartography of the Packmind application (domains × features × usage signals) as `product-map.md` at the project root. Manual-only skill — invoke ONLY when the user explicitly runs `/product-map` or asks to "map the application", "cartographier l''application", "list every feature", or "find decommission candidates". Do NOT auto-load on generic mentions of features, domains, spaces, or standards.' +--- + +# Product Map + +Build a point-in-time functional map of the Packmind application by scanning the backend (use cases + endpoints), the frontend (routes + pages), and the CLI (commands). Aggregate features by functional domain, compute a usage signal per feature, and write a single Markdown report at `product-map.md` (project root). The report supports decommission decisions: features with weak signals (no frontend entry, no recent activity, no tests) become candidates for removal. + +**Manual-only.** Run only when the user explicitly invokes `/product-map` or asks for a functional cartography. The description above intentionally avoids generic trigger keywords. + +**This skill only maps — it does not delete anything.** Decommission decisions stay with the user. + +## Phase 0: Confirm Scope + +Before scanning, confirm with the user: + +- **Target output path** — default `product-map.md` at project root; offer to override. +- **Scope override** — default scans `apps/api`, `apps/frontend`, `apps/cli`, and `packages/*`. Ask if any package should be excluded (e.g., infra-only packages like `migrations`, `logger`, `node-utils`). +- **Decommission tolerance** — confirm signal heuristics (see Phase 4); user may want stricter or looser flagging. + +If the user invoked the skill with explicit instructions, skip confirmation and proceed with stated parameters. + +## Phase 1: Seed Domain Taxonomy + +Before launching subagents, read `packages/` and `apps/` to discover the domain taxonomy. Use folder names as the seed list — do NOT invent domains. + +The seed domains in this codebase (verify by `ls packages/` at run time, this list may drift): + +- **Spaces** — `packages/spaces`, `packages/spaces-management` +- **Standards** — `packages/standards`, `packages/linter`, `packages/linter-ast`, `packages/linter-execution` +- **Skills** — `packages/skills` +- **Recipes / Playbooks** — `packages/recipes` +- **Change Proposals** — `packages/playbook-change-applier`, `packages/playbook-change-management` +- **Users / Auth / Organizations** — `packages/accounts` +- **AI Agents / Coding Agent** — `packages/coding-agent` +- **Git Integration** — `packages/git` +- **Analytics** — `packages/amplitude` +- **Support / Crisp** — `packages/crisp` +- **Deployments** — `packages/deployments` +- **LLM Infrastructure** — `packages/llm` +- **Legacy Import** — `packages/import-practices-legacy` + +Cross-cutting infra packages (`logger`, `node-utils`, `migrations`, `types`, `ui`, `frontend`, `editions`, `test-utils`, `integration-tests`, `plugins`) are NOT user-facing domains — exclude from the map unless they expose user-facing features (e.g., `editions` may expose subscription/edition selection). + +Pass this seed taxonomy to all subagents so they classify consistently. + +## Phase 2: Launch 3 Parallel Source Subagents + +Launch three `Agent(general-purpose)` subagents simultaneously. Each scans one source and returns a structured feature list classified by domain. + +| Subagent | Source | Scans | +|----------|--------|-------| +| 1. Backend | Use cases + API endpoints | `packages/*/src/application/useCases/**/*.ts`, NestJS controllers in `apps/api/src/**/*.controller.ts` | +| 2. Frontend | Routes + pages | `apps/frontend/src` router config + page components | +| 3. CLI | Commander commands | `apps/cli/src` command definitions | + +### Subagent Prompt Template + +Each subagent receives: +1. The seed domain taxonomy from Phase 1 +2. The source-specific instructions below +3. A request for structured output + +``` +You are mapping the Packmind application. Your scope: . + +# Domain taxonomy (use these labels — do not invent new domains unless you find a clear standalone area): + + +# Your task +Scan and produce a list of every user-facing feature exposed through this source. +Group features under their domain. A "feature" is a user-meaningful capability (e.g. +"create a space", "invite a member", "archive a standard"), not a technical implementation +detail (e.g. "TypeORM repository method"). + +For each feature, return: +- domain: +- feature: short imperative phrase (≤ 8 words) +- evidence: file path(s) + relevant export/route/command name +- visibility: "user-facing" or "admin-only" or "internal" + +Skip: +- Pure infra plumbing (logging, config loading, health checks) +- Cross-cutting middleware (auth guards, rate limiters) — list them once under "Auth" +- Test utilities + +# Output format +Markdown list grouped by domain. One bullet per feature. End with a 2-line summary +(total features, anything unclassifiable). +``` + +### Source-Specific Instructions + +**Backend subagent**: scan use cases first (they map 1:1 to features), then controllers to confirm the endpoint exists. A use case with no controller binding is an "orphan" — flag it as `visibility: internal`. + +**Frontend subagent**: scan the router config first to enumerate routes, then read each route's page component to determine what the user actually does there. A route that only renders a placeholder or 404 is a candidate for decommission — flag it. + +**CLI subagent**: enumerate every Commander `.command(...)` registration. Subcommands count as separate features (e.g. `playbook add` and `playbook submit` are two features under "Change Proposals"). + +### Sequential Fallback + +If the Agent tool is unavailable, perform the three scans sequentially in the current session using `Grep` + `Read`. Maintain the same output structure. + +## Phase 3: Merge Sources Per Feature + +After subagents return, merge their lists into a single feature table. Two source entries belong to the same feature when: +- Same domain, AND +- Feature phrases describe the same capability (e.g. "create a space" backend ↔ "Create Space" frontend route ↔ `space create` CLI command) + +For each unified feature row, record which sources cover it: + +| Domain | Feature | Backend | Frontend | CLI | Evidence | +|--------|---------|:-:|:-:|:-:|----------| +| Spaces | Create a space | ✓ | ✓ | ✓ | `packages/spaces/src/.../createSpace.ts`, `/spaces/new`, `space create` | + +If a feature exists in only one source, that is a signal — keep the row, leave the missing sources blank. + +## Phase 4: Compute Usage Signal + +For each feature row, compute additional signal columns. These power the decommission section. + +| Signal | How to compute | +|--------|----------------| +| **Tests** | Grep for the use case class or controller path in `**/*.spec.ts` and `**/*.test.ts`. Mark ✓ if at least one test references it. | +| **Amplitude** | Grep for the feature's domain prefix in `packages/amplitude/src/application/AmplitudeEventListener.ts`. Mark ✓ if any event for this domain is subscribed AND the event name plausibly matches the feature. | +| **Recent activity** | `git log --since="6 months ago" --pretty=format:%H -- `. Mark ✓ if at least one commit. | + +A feature is a **decommission candidate** when ANY of the following holds: +- No frontend coverage AND no CLI coverage (= backend-only, possibly orphaned) +- No tests AND no recent activity (= cold code) +- Frontend route exists but backend use case missing (= dead UI) +- Backend use case exists but no entry point in any client (= dead endpoint) + +Do NOT auto-delete — only flag. + +## Phase 5: Write Report + +Write `product-map.md` at the project root with this structure: + +```markdown +# Packmind Product Map — + +> Snapshot of every user-facing feature, grouped by functional domain. +> Generated by the `product-map` skill. Manual invocation only. + +## Scope + +- Sources scanned: backend use cases & endpoints, frontend routes & pages, CLI commands +- Packages excluded: +- Git revision: + +## Functional Map + +| Domain | Feature | Backend | Frontend | CLI | Tests | Amplitude | Recent | Notes | +|--------|---------|:-:|:-:|:-:|:-:|:-:|:-:|-------| +| Spaces | Create a space | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| Spaces | Archive a space | ✓ | – | – | – | – | – | backend-only orphan | +| ... + +## Decommission Candidates + +Features matching at least one decommission heuristic (see methodology). Sorted by +weakest signal first. + +| Feature | Reason | Evidence | +|---------|--------|----------| +| Archive a space | backend-only, no tests, no commits in 6 months | `packages/spaces/src/.../archiveSpace.ts` | +| ... + +## Methodology + +- A feature = a user-meaningful capability, not a technical implementation detail. +- Sources merged when same domain + same capability across backend/frontend/CLI. +- Signal heuristics: see SKILL.md Phase 4. +- This is a point-in-time snapshot — re-run to refresh. +``` + +End by printing to the user: +- Path of the generated report +- Total features mapped +- Total decommission candidates +- Top 3 domains by feature count + +## Operating Rules + +- **Never delete code based on the report.** Decommission decisions belong to the user. +- **Do not invent domains.** If a feature does not fit the seed taxonomy, place it under "Other" with a one-line justification — the user will refine the taxonomy on re-run. +- **Stay factual.** Every row must cite an evidence path. No inferred features. +- **Re-runnable.** The skill produces a fresh snapshot each run; overwrite the previous report after confirming with the user. \ No newline at end of file diff --git a/.cursor/skills/qa-review/SKILL.md b/.cursor/skills/qa-review/SKILL.md new file mode 100644 index 000000000..4d9fe6e6f --- /dev/null +++ b/.cursor/skills/qa-review/SKILL.md @@ -0,0 +1,193 @@ +--- +name: 'qa-review' +description: 'Review a user story implementation against its Example Mapping (EM) specification.' +disable-model-invocation: true +--- + +# QA Review + +Audit a user story implementation against its Example Mapping specification. Reads the EM +markdown, finds all implementing code in the codebase, then runs two parallel agents — one for +functional coverage, one for code review. Produces a single compact report. + +**This skill only detects issues — it does not fix them.** + +## Reference + +A ready-to-fill EM template is available at `em_template.md` (in this skill's directory). Share it with the user when they need to write a new spec from scratch. + +## 1. Parse the EM Spec + +Read the markdown file at the provided path. Extract a structured summary with these sections: + +### What to extract + +1. **User Story title** — the first line or heading describing the US +2. **Rules** — each `# Rule N: ` block. For each rule, extract: + - Rule number and title + - Each `## Example N` with its setup/action/outcome narrative (preserve the full text) +3. **Technical Rules** — bullet points under a `# Technical rules` heading (implementation-focused constraints) +4. **User Events** — content under `# User Events` heading: event names, properties, schemas +5. **Check Also items** — bullet points after "Check also" markers (additional rules/constraints, often separated by dashes) +6. **Code References** — all backtick-quoted terms across the entire spec (class names, field names, event names like `ConflictDetector`, `space_created`, `decision`) +7. **Domain Keywords** — key nouns and verbs from rule titles and examples (e.g., "space", "create", "slug", "rename", "conflict") + +### Handling ambiguity + +If a spec item is ambiguous or explicitly deferred (e.g., "TBD", "on verra plus tard", "later"), flag it in the Parsed Spec Summary but exclude it from coverage assessment. Note it in the report as "Deferred — not assessed." + +Compile everything into a **Parsed Spec Summary** formatted as below. The "Full Examples" section preserves the complete raw text of every example — sub-agents need this full context to accurately assess coverage. + +``` +## Parsed Spec Summary + +### User Story +{title} + +### Rules and Examples +Rule 1: {title} + Example 1: {one-line summary of scenario} + Example 2: {one-line summary of scenario} +Rule 2: {title} + Example 1: {one-line summary of scenario} +[...] + +### Full Examples (raw text) +{Copy the complete text of every example verbatim from the spec, preserving setup/action/outcome narratives. Do not summarize here — this section is passed to sub-agents so they can assess nuanced behaviors.} + +### Technical Rules +- {rule text} +[...] + +### Deferred Items +- {item text} — Deferred, not assessed +[...] + +### User Events +- {event_name}: {properties} +[...] + +### Check Also +- {constraint text} +[...] + +### Code References (from backticks) +{list of all backtick-quoted terms} + +### Domain Keywords +{list of distinctive nouns/verbs extracted from rules} +``` + +## 2. Select Target Domains + +Ask the user which domains this user story touches. Use **AskUserQuestion** with `multiSelect: true`: + +| Option | Directories | +|--------|-------------| +| CLI (apps/cli) | `apps/cli/src/**` | +| API (apps/api) | `apps/api/src/**` | +| Packages (packages/*) | `packages/*/src/**`, `packages/types/src/**` | +| Frontend (apps/frontend) | `apps/frontend/app/**`, `apps/frontend/src/**` | +| MCP (apps/mcp-server) | `apps/mcp-server/src/**` | + +**Packages is always included**, even if the user does not select it — it contains domain logic, contracts, events, and infra that all other layers depend on. + +Store the user's selection as `{target_domains}` — a list of domain labels and their directory patterns. All subsequent steps use this to scope their searches. + +## 3. Validate Implementation Exists + +Grep 2–3 of the most distinctive backtick-quoted terms from the spec, **scoped to the `{target_domains}` directories only**. If fewer than 3 implementing files are found, **stop and ask the user** to confirm that the US has been fully implemented. Do not proceed with a full review on a partially-implemented or not-yet-started US — the report would be misleading. + +## 4. Build Code Map + +Launch a **Code Map Agent** (`subagent_type: general-purpose`) using the prompt from `agents/code-map-agent.md`. Replace: +- `{parsed_spec_summary}` with the Parsed Spec Summary from step 1 +- `{target_domains}` with the selected domains and their directory patterns from step 2 + +The agent will search only within the target domain directories, then return a structured Code Map organized by architectural layer. Only layers matching the selected domains will appear in the output. + +Wait for the agent to complete before proceeding to step 5. + +## 5. Pre-filter Packmind Standards + +Before launching the review agents, collect the applicable Packmind coding standards: + +1. Glob for `**/.claude/rules/packmind/*.md` across the repository +2. Read the YAML frontmatter of each file to extract its `paths` glob patterns +3. Match the Code Map file paths against each standard's `paths` patterns +4. For each standard that matches at least one Code Map file, read its full content + +Compile the applicable standards into `{applicable_standards}` — the full text of each matching standard, prefixed with its name. If no standards match, set `{applicable_standards}` to "None". + +## 6. Launch Parallel Sub-Agents + +Launch **two sub-agents in parallel** (same turn), each receiving the Parsed Spec Summary (including the Full Examples section with raw text), Code Map, and target domains as context: + +1. **Functional Coverage Agent** (`subagent_type: general-purpose`) — prompt built from `agents/functional-coverage-agent.md`. Replace `{parsed_spec_summary}`, `{code_map}`, and `{target_domains}`. +2. **Code Review Agent** (`subagent_type: general-purpose`) — prompt built from `agents/code-review-agent.md`. Replace `{parsed_spec_summary}`, `{code_map}`, `{target_domains}`, and `{applicable_standards}`. + +Launch both agents simultaneously. The full raw example text is critical — sub-agents need the complete setup/action/outcome narratives to assess nuanced behaviors, not just one-line summaries. + +### Sequential Fallback + +If the Agent tool is unavailable, perform both reviews sequentially yourself, following the instructions from each agent prompt file. + +## 7. Combine & Write Report + +Once both agents complete, merge their outputs into a single report. + +### Output path + +Derive from the input path: if input is `path/to/my-spec.md`, output is `path/to/my-spec-report.md`. + +### Report template + +```markdown +# QA Review Report + +**Spec**: {filename} | **Date**: {date} | **Branch**: {branch} | **Commit**: {short-sha} +**Rules**: {N} | **Examples**: {N} | **Tech Rules**: {N} | **Events**: {N} + +## Summary + +| Metric | Count | +|--------|-------| +| Covered | N | +| Partially Covered | N | +| Not Covered | N | +| Code Findings | N (Critical: X, High: Y, Medium: Z) | +| Standards Violations | N | + +## Functional Coverage + +### Coverage Matrix + +{coverage matrix table from functional coverage agent} + +### Gaps + +{reproduction steps from functional coverage agent — omit this subsection if all items are Covered} + +## Code Review + +### Findings + +{findings from code review agent — omit this section if no issues found} + +## Deferred Items + +{list of items marked as deferred/TBD in the spec — not assessed in this review} + +--- +*Static analysis only. No code was executed during this review.* +``` + +**Omit any section that has zero content.** Only include sections with actual results. + +### Print Summary + +After writing the report, print a brief summary to the console: +- Total rules/examples in the spec +- Coverage stats (Covered / Partially / Not Covered counts) +- Code review findings count by severity +- The report file path \ No newline at end of file diff --git a/.cursor/skills/qa-review/agents/code-map-agent.md b/.cursor/skills/qa-review/agents/code-map-agent.md new file mode 100644 index 000000000..fb083cef1 --- /dev/null +++ b/.cursor/skills/qa-review/agents/code-map-agent.md @@ -0,0 +1,133 @@ +You are a codebase navigator building a Code Map for a user story implementation. Your job is to find all files involved in implementing the feature described in the Parsed Spec Summary below, **scoped to the target domains only**. + +## Target Domains + +{target_domains} + +**Only search within the directories listed above.** Do not search domains that are not listed. + +## Parsed Spec Summary + +{parsed_spec_summary} + +## Available Tools + +You have access to **Glob**, **Grep**, and **Read** (read-only). Use them to systematically search the codebase. + +## Search Strategy + +Using the code references (backtick-quoted terms) and domain keywords from the spec, follow this funnel (precise → broad). **All searches must be scoped to the target domain directories above.** + +### Step 1: Backtick terms first (highest signal) + +Grep each backtick-quoted term, but only within the target domain directories. These are author-curated pointers into the codebase and almost always resolve to exact matches. + +### Step 2: Domain keyword search + +Grep the 3–5 most distinctive domain keywords against the target domain directories. Here is the mapping of domains to their search patterns — **only use patterns for selected domains**: + +**Backend (packages/*):** +- `packages/*/src/application/useCases/**/*.ts` and `packages/*/src/application/usecases/**/*.ts` (use cases — both casing variants exist) +- `packages/*/src/infra/**/*.ts` (repository implementations, TypeORM schemas, BullMQ job factories) +- `packages/types/src/*/events/**/*.ts` (domain events — centralized in @packmind/types) +- `packages/types/src/*/contracts/**/*.ts` (use case contracts — Command, Response, IUseCase interfaces) + +**API (apps/api):** +- `apps/api/src/app/**/*.ts` (NestJS controllers, guards, modules) + +**Frontend (apps/frontend):** +- `apps/frontend/app/routes/**/*.tsx` (file-based route components with clientLoaders) +- `apps/frontend/src/domain/**/*.ts` (gateways extending PackmindGateway, TanStack Query hooks) + +**CLI (apps/cli):** +- `apps/cli/src/**/*.ts` (CLI commands and handlers) + +**MCP (apps/mcp-server):** +- `apps/mcp-server/src/**/*.ts` (MCP server tools and prompts) + +### Step 3: Package-level scan (Backend only) + +Skip if Backend is not in the target domains. Once a relevant package is identified (e.g., `packages/spaces/`), list its `application/useCases/` directory (or `application/usecases/` — both casing variants exist) to find all related use cases. + +### Step 4: Hexagonal registry check (Backend only) + +Skip if Backend is not in the target domains. Once a relevant package is identified, read its `{PackageName}Hexa.ts` file (e.g., `packages/standards/src/StandardsHexa.ts`) to understand which ports, adapters, and use cases are registered. This reveals the full wiring of the feature. + +### Step 5: Contract discovery (Backend only) + +Skip if Backend is not in the target domains. For each use case found, check `packages/types/src/{domain}/contracts/` for the corresponding contract file, which defines `{Name}Command`, `{Name}Response`, and `I{Name}UseCase`. + +### Step 6: Test file discovery + +For each source file found within target domain directories, check for a sibling `.spec.ts` equivalent. + +### Step 7: Event tracking (Backend only) + +Skip if Backend is not in the target domains. Grep event names from the "User Events" section. Look for event class definitions in `packages/types/src/{domain}/events/`, emission via `eventEmitterService.emit(new {EventName}Event(`, and consumption via `PackmindListener` classes with `this.subscribe({EventName}Event, ...)`. + +### Step 8: Cross-US dependencies + +When a rule depends on behavior from another feature (e.g., conflict detection logic that belongs to a different US), include those files in the Code Map but mark them as `[DEPENDENCY]`. Only follow dependencies within the target domain directories. The functional agent should verify the integration point works, not re-audit the dependency itself. + +## Output Format + +Compile results into a **Code Map** organized by layer: + +``` +## Code Map + +### Hexagonal Registry +- packages/{pkg}/src/{Pkg}Hexa.ts (port/adapter wiring) + +### Contracts (@packmind/types) +- packages/types/src/{domain}/contracts/I{UseCaseName}UseCase.ts + ({Name}Command, {Name}Response, I{Name}UseCase) + +### Backend Domain +- packages/{pkg}/src/application/useCases/{useCaseName}/{UseCaseName}UseCase.ts + Test: {path}.spec.ts [EXISTS | NOT FOUND] +- packages/{pkg}/src/domain/{Entity}.ts + Test: [EXISTS | NOT FOUND] + +### Infra (repositories, schemas, jobs) +- packages/{pkg}/src/infra/repositories/{Repository}.ts + Test: [EXISTS | NOT FOUND] +- packages/{pkg}/src/infra/schemas/{Schema}.ts +- packages/{pkg}/src/infra/jobs/{JobFactory}.ts (BullMQ background jobs) + +### API Layer (NestJS) +- apps/api/src/app/{...}/{controller}.ts + Guards: [OrganizationAccessGuard | SpaceAccessGuard | None] + Adapter injection: [@Inject{Pkg}Adapter()] + Test: [EXISTS | NOT FOUND] + +### Frontend +- apps/frontend/app/routes/{...}.tsx (route component + clientLoader) +- apps/frontend/src/domain/{entity}/api/gateways/{Entity}GatewayApi.ts (extends PackmindGateway) +- apps/frontend/src/domain/{entity}/api/queries/{Entity}Queries.ts (TanStack Query v5 hooks) + Test: [EXISTS | NOT FOUND] + +### CLI +- apps/cli/src/{...}/{command|handler}.ts + Test: [EXISTS | NOT FOUND] + +### MCP Server +- apps/mcp-server/src/app/tools/{toolName}/{toolName}.tool.ts + Test: [EXISTS | NOT FOUND] + +### Domain Events (@packmind/types) +- packages/types/src/{domain}/events/{EventName}Event.ts (extends UserEvent/SystemEvent) +- {files containing eventEmitterService.emit()} +- {files containing PackmindListener / this.subscribe()} + +### Background Jobs (BullMQ) +- packages/{pkg}/src/infra/jobs/{JobFactory}.ts (WorkerQueue registration) + +### Dependencies (from other USs — verify integration only) +- {files that implement behavior from another feature but are used by this US} [DEPENDENCY] + +### Other Relevant Files +- {any other files found via keyword search} +``` + +Only include sections that have actual files **and** match the target domains. Omit empty sections and sections for domains not in the target list. diff --git a/.cursor/skills/qa-review/agents/code-review-agent.md b/.cursor/skills/qa-review/agents/code-review-agent.md new file mode 100644 index 000000000..4d4563ddd --- /dev/null +++ b/.cursor/skills/qa-review/agents/code-review-agent.md @@ -0,0 +1,183 @@ +You are a senior engineer performing a technical code review on a user story implementation. Your focus is exclusively on actual bugs, edge cases, and inconsistencies — not style, naming, formatting, or minor improvements. Report findings at **Medium** severity or above. If no Critical, High, or Medium issues are found, include **Low** severity findings instead. + +## Target Domains + +{target_domains} + +**Only review code within the target domains listed above.** Skip layers not in scope. + +## Spec Context + +{parsed_spec_summary} + +Understanding the spec helps you know what the code is *supposed* to do, which makes it easier to spot where it does something wrong or incomplete. + +## Code Map + +{code_map} + +## Available Tools + +You have access to **Glob**, **Grep**, and **Read** (read-only). Use them to read every file in the Code Map and trace the logic across target layers only. + +## Your Process + +### Step 1: Read All Implementing Files (within target domains) + +Read every file listed in the Code Map that belongs to the target domains. Build a mental model of the relevant flows — **only for selected domains**: + +**Backend (packages/*):** +- The hexagonal data flow: use case → domain entity → infra repository → TypeORM schema → database +- How contracts in `@packmind/types` define the shared interface (`{Name}Command`/`{Name}Response`/`I{Name}UseCase`) +- How domain events are emitted (`eventEmitterService.emit()`) and consumed (`PackmindListener` with `this.subscribe()`) +- How background jobs are registered and processed (BullMQ `WorkerQueue`, `JobsService`) +- How the Hexa registry (`{Pkg}Hexa.ts`) wires ports to adapters + +**API (apps/api):** +- NestJS controller → adapter (via `@Inject{Pkg}Adapter()`) → use case +- Where validations and guards are applied (NestJS guards like `OrganizationAccessGuard`) + +**Frontend (apps/frontend):** +- Route `clientLoader` → `queryClient.ensureQueryData()` → TanStack Query hook → gateway (extends `PackmindGateway`) → API call + +**CLI (apps/cli):** +- CLI command structure, output formatting, error handling + +**MCP (apps/mcp-server):** +- MCP tool implementation, use case invocation, result formatting + +### Step 2: Check for These Issue Categories + +#### A. Actual Bugs (Critical / High) + +- **Logic errors**: wrong conditions, inverted checks, off-by-one, missing null checks on required paths +- **Data integrity**: unique constraints that can be bypassed (e.g., race conditions on concurrent create), missing database constraints that the spec requires +- **Missing error handling**: operations that can throw but have no try/catch or return no meaningful error to the caller +- **Incorrect queries**: TypeORM queries that don't match the intended behavior (wrong where clause, missing relations, incorrect join) +- **Security gaps**: missing authorization checks the spec explicitly requires (e.g., "only admins can...") + +#### B. Edge Cases (Medium / High) + +- **Boundary values**: empty strings, max length violations, special characters (accents, unicode), whitespace handling +- **Concurrent operations**: two users performing the same action simultaneously (e.g., creating a resource with the same name at the same time) +- **State transitions**: operations that can leave data inconsistent if they fail partway through (e.g., entity created but event not emitted) + +#### C. Cross-File Inconsistencies (Medium / High) + +Look specifically for mismatches **between** files within the target domains. Only check mismatches between layers that are both in scope: +- **Gateway-to-contract mismatch** (Frontend + Backend): Frontend gateway method sends a field name or shape that differs from the `@packmind/types` contract (`{Name}Command`/`{Name}Response`) +- **Contract-to-API mismatch** (API + Backend): NestJS controller params or response types don't match the `@packmind/types` contract +- **Validation layer inconsistency** (any two selected layers): API validates a constraint that the domain use case does not enforce (or vice versa — validation only in one layer) +- **Event payload drift** (Backend): Event class in `packages/types/src/{domain}/events/` defines one payload shape, but `eventEmitterService.emit()` passes different properties, or `PackmindListener` handler expects different fields +- **Entity-to-schema drift** (Backend): Domain entity field names don't match TypeORM schema column names in `infra/schemas/` +- **Duplicate logic** (any two selected layers): Same logic implemented differently across layers (e.g., slug generation in both frontend gateway and backend use case with different rules) +- **Hexa wiring gap** (Backend): Adapter registered in `{Pkg}Hexa.ts` but the corresponding use case not wired, or a new use case not added to the Hexa registry + +#### D. Missing Validations (Medium) + +Constraints explicitly mentioned in the spec but not enforced in code: +- Max length for a field (spec says 64, no validation in DTO or entity) +- Authorization checks (spec says "only admins", no guard or role check) +- Uniqueness constraints (spec says "cannot have the same name", no unique check before insert) +- Required fields that accept null/undefined + +#### E. Technical Rules Violations (Medium / High) + +Systematically verify **every** technical rule from the "Technical Rules" section of the Parsed Spec Summary. For each technical rule: + +1. Identify which files in the Code Map are relevant — technical rules can apply to **any layer** (backend domain, infra, API, frontend, CLI, MCP server, background jobs, etc.) +2. Read those files and check whether the constraint is enforced +3. Report a finding if a technical rule is not implemented or is implemented incorrectly + +Common technical rule patterns to look for — **only check layers within the target domains**: +- **Backend**: missing domain validations, incorrect repository queries, missing event emissions, wrong error types, missing guards or authorization checks +- **Frontend**: missing form validations, incorrect API call parameters, missing loading/error states, wrong route guards, missing optimistic updates or cache invalidation +- **Cross-cutting**: naming conventions not followed, missing logging, incorrect feature flag checks, missing analytics events, wrong data transformations + +If a technical rule is ambiguous about which layer should enforce it, check only within the target domains. + +#### F. Hexagonal Architecture & NestJS Issues (Medium / High) + +- **Missing Hexa registration**: A new use case or port exists but is not registered in the package's `{Pkg}Hexa.ts` registry +- **Missing adapter injection**: NestJS controller uses a port but doesn't inject it via `@Inject{Pkg}Adapter()` decorator +- **Missing guard**: Controller endpoint modifies data but doesn't use `@UseGuards(OrganizationAccessGuard)` or appropriate guard +- **Event listener not registered**: A `PackmindListener` class exists but isn't wired to receive the expected events via `this.subscribe()` +- **Background job not registered**: A `WorkerQueue` is defined but `JobsService` doesn't register or submit it +- **Contract not exported**: A new use case contract exists in `@packmind/types` but isn't exported from the package's barrel file + +### Step 3: Check Test Quality + +If test files exist for the implementing code: +- Are the tests testing the **right behavior** or just mocking everything away? A test that mocks the repository and only checks the mock was called does not catch real bugs. +- Are there assertions that would catch regressions on the spec's key behaviors? +- Are error paths and edge cases tested, or only the happy path? + +Only flag test quality issues if they represent a **Medium+ risk** — e.g., a critical validation has no test at all, or tests mock away the exact layer where a bug exists. + +### Step 4: Check Pre-loaded Packmind Standards + +The following Packmind standards have been pre-filtered by the orchestrator and apply to files in the Code Map: + +{applicable_standards} + +If "None" is shown above, skip this step entirely. + +**Verification:** +- Read each applicable standard's rules carefully +- Check every file in the Code Map that matches the standard's scope +- Report violations at the same severity levels as other findings (Critical/High/Medium depending on impact) +- In the **Spec Reference** field, reference the standard name (e.g., "Standard: Testing Good Practices") + +## Severity Definitions + +- **Critical**: Data loss, security vulnerability, or crash in a production code path. Would block a release. +- **High**: Incorrect behavior that users will encounter in normal usage. A bug that produces wrong results or breaks a flow. +- **Medium**: Edge case that could cause issues under specific conditions. Missing validation that the spec explicitly requires. Cross-file inconsistency that could cause subtle bugs. +- **Low**: Minor issues — small inconsistencies, non-critical missing validations, minor edge cases unlikely to affect normal usage, slight contract mismatches that don't break functionality. + +**Do NOT report**: Informational, cosmetic, style preferences, missing comments, naming suggestions, or "nice to have" improvements. If you find fewer than 2 issues, that is perfectly fine — do not manufacture findings to fill the report. + +## Output Format + +Return findings in this exact format: + +``` +### Findings + +#### [CRITICAL] {Short descriptive title} +- **Category**: Bug | Edge Case | Inconsistency | Missing Validation | Technical Rule Violation +- **File**: `{file-path}:{line}` +- **Description**: {What is wrong, why it matters, and what could happen} +- **Spec Reference**: {Which rule/example/technical rule this relates to, e.g., "Rule 2 / Example 1", "Check Also: max length 64", or "Technical Rule: ..."} +- **Suggested Fix**: {Brief direction — not full code, just the approach} + +--- + +#### [HIGH] {Short descriptive title} +- **Category**: ... +[same format] + +--- + +#### [MEDIUM] {Short descriptive title} +- **Category**: ... +[same format] +``` + +**Order findings by severity**: Critical first, then High, then Medium. + +**Low-severity fallback**: If you found **no** Critical, High, or Medium issues, include any Low findings using this format: + +``` +#### [LOW] {Short descriptive title} +- **Category**: ... +[same format] +``` + +If no issues are found at any severity, return exactly: + +``` +### Findings + +No significant issues found. The implementation appears sound for the behaviors described in the spec. +``` diff --git a/.cursor/skills/qa-review/agents/functional-coverage-agent.md b/.cursor/skills/qa-review/agents/functional-coverage-agent.md new file mode 100644 index 000000000..d82534b83 --- /dev/null +++ b/.cursor/skills/qa-review/agents/functional-coverage-agent.md @@ -0,0 +1,116 @@ +You are a QA analyst reviewing whether a user story implementation fully covers its Example Mapping specification. Your job is evidence-based: every assessment must cite specific files and code patterns you found (or did not find) in the codebase. + +## Target Domains + +{target_domains} + +**Only trace rules through the layers listed above.** Skip layers not in the target domains — they are out of scope for this review. + +## Spec to Review + +{parsed_spec_summary} + +## Code Map + +{code_map} + +## Available Tools + +You have access to **Glob**, **Grep**, and **Read** (read-only). Use them to: +- Read source files identified in the Code Map +- Search for specific behavior implementations (Grep for method names, conditions, error messages) +- Check test file existence and content (Glob for `*.spec.ts` patterns) + +The Code Map is a starting index — always verify by reading the actual source files. + +## Your Process + +### Step 1: Trace Each Rule and Example Across Target Layers + +For every Rule + Example pair in the spec, trace the behavior through **only the target domain layers listed above**. A rule is only fully covered when all target layers that should implement it actually do. + +1. **Identify the expected behavior** from the example's setup/action/outcome +2. **Search for the implementation across target layers only**. Below are the checks for each domain — **only perform checks for domains in the target list**: + + **Backend (packages/*):** + - **Contract layer** (`@packmind/types`): Read the use case contract (`I{UseCaseName}UseCase.ts` in `packages/types/src/{domain}/contracts/`). Does it define the correct `{Name}Command` input shape and `{Name}Response` output shape for this behavior? + - **Backend domain**: Trace the hexagonal flow: NestJS controller → adapter (via `@Inject{Pkg}Adapter()`) → use case → domain entity. Does the use case handle the precondition, perform the action, and produce the expected outcome? Is the Hexa registry (`{Pkg}Hexa.ts`) wiring the correct adapter? + - **Infra layer**: Do the repository implementations in `infra/` correctly persist or query the data? Are TypeORM schemas aligned with domain entities? Are BullMQ jobs (`WorkerQueue`) registered for async operations? + + **API (apps/api):** + - **API layer** (NestJS): Does the controller expose this behavior at the correct route under `/organizations/:orgId`? Is `@UseGuards(OrganizationAccessGuard)` (or appropriate guard) present? Are the request/response types aligned with the `@packmind/types` contract? + + **Frontend (apps/frontend):** + - **Frontend**: Trace the data flow: route `clientLoader` → `queryClient.ensureQueryData()` → TanStack Query hook → gateway (extends `PackmindGateway`) → API call. Does each layer handle this scenario? Does the gateway method signature match the API endpoint and `@packmind/types` contract? + + **CLI (apps/cli):** + - **CLI**: Does the CLI command implement the expected behavior for this rule? Correct output, error handling, flags? + + **MCP (apps/mcp-server):** + - **MCP Server**: Does the tool in `apps/mcp-server/src/app/tools/` correctly invoke the use case and return the expected result? + + **Cross-layer consistency** (check only between selected domains): Do field names match between `@packmind/types` contracts, NestJS controller params, frontend gateway methods, and CLI handlers? Are validations applied consistently across selected layers (not just in one)? +3. **Check for test coverage** — look for test cases that exercise this specific scenario. Not just that test files exist, but that a test covers this particular case (look for describe/it blocks, test data, assertions matching the example). +4. **Assess coverage level**: + - **Covered**: Implementation code exists AND handles this specific case across all target layers. Cite the file:line where the behavior is implemented. + - **Partially Covered**: Implementation exists but is incomplete — e.g., backend handles it but frontend doesn't (when both are in scope), happy path only, missing error case, missing edge case from the example. Cite what exists and what is missing. + - **Not Covered**: No implementation found for this behavior in any target layer. Describe what you searched for and where. + +### Step 2: Check Technical Rules + +For each bullet under "Technical rules": +- Search for the implementation of the described technical constraint +- Verify the code matches what the spec says (e.g., if the spec says "ConflictDetector uses the `decision` field if available, payload otherwise", read the ConflictDetector and verify this logic) + +### Step 3: Check User Events + +For each event described: +- Check the event class definition in `packages/types/src/{domain}/events/{EventName}Event.ts`. Verify it extends `UserEvent<TPayload>` or `SystemEvent<TPayload>` with the correct payload type. +- Grep for `eventEmitterService.emit(new {EventName}Event(` to find where it is emitted. Verify it is emitted in the correct use case after the action succeeds. +- Grep for `PackmindListener` classes that `this.subscribe({EventName}Event, ...)` to verify the event is consumed where expected. +- Check the event payload properties match the spec (property names, types, optional/required flags). + +### Step 4: Check "Check Also" Items + +For each bullet under "Check also": +- Search for the constraint or validation in the code +- Assess coverage the same way as rules (Covered / Partially Covered / Not Covered) + +## Output Format + +Return **two sections**: + +### Coverage Matrix + +Use this exact table format, one row per rule+example, technical rule, user event, and check-also item. The **Layer** column indicates where the behavior is implemented (Contract, Backend Domain, Infra, API, Frontend (Route/Gateway/Query), CLI, MCP Server, Event, Background Job, or Cross-layer) — this helps route fixes to the right team/area: + +``` +| ID | Rule / Item | Layer | Status | Evidence | Test Coverage | +|----|-------------|-------|--------|----------|---------------| +| R1-E1 | Rule 1: {title} / Example 1: {summary} | Backend Domain | Covered | `file.ts:45` - {what the code does} | `file.spec.ts:23` - {test description} | +| R1-E2 | Rule 1: {title} / Example 2: {summary} | Frontend | Not Covered | No uniqueness check found in {file} | None | +| R2-E1 | Rule 2: {title} / Example 1: {summary} | Backend Domain | Partially Covered | `file.ts:80` - slug generated but not immutable | None | +| T1 | Tech: {description} | Cross-layer | Covered | `ConflictDetector.ts:12` - uses decision field | `ConflictDetector.spec.ts:5` | +| EV1 | Event: {event_name} | Event | Not Covered | No emit found for this event | None | +| C1 | Check: {description} | API | Covered | `guard.ts:15` - admin role check | None | +``` + +**ID format**: `R{rule}-E{example}` for rules, `T{n}` for technical rules, `EV{n}` for events, `C{n}` for check-also items. + +### Reproduction Steps for Gaps + +For each item marked **Not Covered** or **Partially Covered**, provide: + +``` +#### [{ID}] {Rule title} / {Example summary} +**Status**: Not Covered | Partially Covered +**What is missing**: {specific behavior not implemented} +**Where to look**: {file paths where this should be implemented} +**How to reproduce**: +1. {step-by-step reproduction of the scenario from the example} +2. {what happens vs what should happen} +``` + +Keep reproduction steps concise — focus on the minimum steps to demonstrate the gap. + +If everything is fully covered, state: "All rules and examples are fully covered." diff --git a/.cursor/skills/qa-review/em_template.md b/.cursor/skills/qa-review/em_template.md new file mode 100644 index 000000000..a2a483b55 --- /dev/null +++ b/.cursor/skills/qa-review/em_template.md @@ -0,0 +1,50 @@ +User Story Review: {Title of the user story} + +# Rule 1: {Short rule title describing the expected behavior} + +## Example 1 + +{Setup: describe the initial state} + +{Action: describe what the user does} + +{Outcome: describe the expected result} + +## Example 2 + +{Setup} + +{Action} + +{Outcome} + +# Rule 2: {Short rule title} + +## Example 1 + +{Setup} + +{Action} + +{Outcome} + +# Technical rules + +- {Implementation constraint that applies across frontend and backend, e.g., "Slug generation must be deterministic and locale-independent"} +- {Performance or infrastructure constraint, e.g., "The operation must be idempotent"} +- {Security or authorization constraint, e.g., "Only organization admins can perform this action"} +- {Data constraint, e.g., "Max length for the name field: 64 characters"} +- {Integration constraint, e.g., "Must work on both Linux and Windows (normalize paths)"} + +# User Events + +- `{event_name}`: emitted when {trigger description}. Properties: `{property1}`, `{property2}` +- `{event_name}`: emitted when {trigger description}. Properties: `{property1}` + +--- + +Check also the following rules are applied: + +- {Additional business rule or constraint not covered by the rules above} +- {Edge case to verify, e.g., "Two items in the same scope cannot have the same name"} +- {Default behavior, e.g., "By default, the user lands on the default space"} diff --git a/.cursor/skills/release-proprietary/SKILL.md b/.cursor/skills/release-proprietary/SKILL.md new file mode 100644 index 000000000..e20ce1386 --- /dev/null +++ b/.cursor/skills/release-proprietary/SKILL.md @@ -0,0 +1,207 @@ +--- +name: 'release-proprietary' +description: 'Orchestrate a full Packmind proprietary release: verify Main CI/CD Pipeline is green on both OSS and proprietary main, drive the release on the OSS sibling repo (version bumps, CHANGELOG, tag, push), wait for the oss-sync workflow to land the release commit on the proprietary fork, then tag and push `release/{{version}}` on the proprietary repo. Invoke from the proprietary repo.' +--- + +Create a full Packmind proprietary release with version `{{version}}`. + +This skill orchestrates a cross-repository release that spans the OSS sibling (`../packmind`, cloned next to the proprietary repo) and the proprietary repo itself (the current working directory). All real release content (version bumps, CHANGELOG) lives on OSS; the proprietary side receives the OSS commit through the `oss-sync` workflow and gets its own `release/{{version}}` tag pointing at a **different SHA** than the OSS tag — see below. + +**Invocation requirement:** run this skill from the root of the proprietary repo, with the OSS sibling checked out at `../packmind`. All commands below assume that layout. + +**Dual-SHA model — important:** + +* On **OSS**, the `release/{{version}}` tag points at the release commit itself (subject `chore: release {{version}}`). That commit's tree contains OSS code, which is what OSS deployments need. +* On **proprietary**, the same tag points at the **sync-merge commit** that brought the OSS release into proprietary `main`. The OSS release commit has no proprietary-only files in its tree, so tagging it on proprietary would break deployments (the build can't find `@packmind/proprietary/*` modules). The sync-merge commit's tree combines OSS-at-release with the proprietary-only files, which is what proprietary deployments need. + +The wait/tag scripts handle this for you — Phase 2 emits the proprietary merge SHA, and Phase 3 tags that SHA. + +Repository layout assumed by every command in this file: + +* OSS repo: `../packmind` (remote `PackmindHub/packmind`) +* Proprietary repo: `.` — the current working directory (remote `PackmindHub/packmind-proprietary`) + +Scripts live next to this file under `./scripts/`. From the proprietary repo root, paths are `.claude/skills/release-proprietary/scripts/<name>`. + +## Phase 0 — Pre-flight (proprietary repo) + +### 0.1 Feature flags audit gate (MANDATORY) + +Before doing anything, stop and ask the user: + +> Has the `feature-flags-audit` skill been run on the **proprietary** repo for this release? (yes / no) + +If the answer is anything other than an explicit `yes`, abort and tell the user to run `feature-flags-audit` on the proprietary repo first. + +If the audit surfaced any loose / stale flags that should have been removed, instruct the user to: + +1. Check whether each loose flag also exists in the OSS sibling at `../packmind`. Most code flows from OSS → proprietary via `oss-sync`, so flags should generally be cleaned up on the OSS side. +2. Remove them in OSS first, let the oss-sync workflow land the cleanup on proprietary, then re-invoke this skill. + +### 0.2 No open `oss-sync` PR + +```bash +./.claude/skills/release-proprietary/scripts/check-oss-sync-pr.sh PackmindHub/packmind-proprietary +``` + +When the auto-sync hits a merge conflict it opens a PR on branch `oss-sync` instead of fast-forwarding. If such a PR is open, Phase 2 polling cannot succeed. Abort and ask the user to resolve and merge the PR first. + +### 0.3 CI green on both repos + +Run the check script for both repositories: + +```bash +./.claude/skills/release-proprietary/scripts/check-ci.sh PackmindHub/packmind +./.claude/skills/release-proprietary/scripts/check-ci.sh PackmindHub/packmind-proprietary +``` + +Exit codes: + +* `0` — green, proceed. +* `1` — the run failed (or no run found, or auth missing). Abort, surface the URL. +* `2` — the run is still in progress / queued. Not a failure: ask the user whether to wait and retry, or abort. + +### 0.4 Clean working tree on proprietary and no local divergence + +```bash +git status --porcelain # must be empty +git fetch origin main +git merge-base --is-ancestor HEAD origin/main \ + || (echo "local main has diverged from origin/main — reconcile before releasing" && exit 1) +``` + +If the working tree is dirty, or local `HEAD` is not an ancestor of `origin/main`, abort and ask the user to reconcile. + +## Phase 1 — Drive the OSS release (`../packmind`) + +All commands in this phase target the OSS repo via `git -C` or `cd`. + +### 1.1 Verify clean OSS state and update + +```bash +git -C ../packmind status --porcelain # must be empty +git -C ../packmind checkout main +git -C ../packmind pull --ff-only origin main +``` + +If the working tree is dirty or the pull is not fast-forward, abort and ask the user to reconcile. + +### 1.2 Bump versions + +```bash +node ./.claude/skills/release-proprietary/scripts/bump-versions.mjs {{version}} ../packmind +( cd ../packmind && npm install ) +``` + +`npm install` regenerates `package-lock.json` with the new version. + +### 1.3 Rewrite CHANGELOG for release + +Resolve today's date in ISO 8601 (`YYYY-MM-DD`) — call it `{{today}}`. + +```bash +node ./.claude/skills/release-proprietary/scripts/changelog-release.mjs {{version}} {{today}} ../packmind +``` + +### 1.4 Commit the release and push main + +```bash +./.claude/skills/release-proprietary/scripts/commit-release.sh ../packmind {{version}} +``` + +This stages `package.json`, `apps/api/docker-package.json`, `package-lock.json`, and `CHANGELOG.MD`, commits with the exact subject `chore: release {{version}}` (Phase 2 polls for this fixed string), and pushes main. The script never uses `--no-verify`. + +### 1.5 Tag the release and push the tag + +```bash +./.claude/skills/release-proprietary/scripts/tag-release.sh ../packmind {{version}} +``` + +Creates `release/{{version}}` at HEAD (the release commit just pushed) and pushes the tag. If the tag already exists, the script verifies it points at HEAD before re-pushing. + +### 1.6 Prepare next dev cycle on OSS + +```bash +node ./.claude/skills/release-proprietary/scripts/changelog-next.mjs {{version}} ../packmind +./.claude/skills/release-proprietary/scripts/commit-next-cycle.sh ../packmind +``` + +## Phase 2 — Wait for oss-sync to land on proprietary + +The `sync-from-oss-repository.yml` workflow on the proprietary repo merges OSS commits into proprietary `main` automatically (usually under a minute). It will land BOTH the release commit AND the subsequent "prepare next development cycle" commit; each goes through its own sync-merge commit on proprietary `main`. + +```bash +PROP_TAG_SHA=$(./.claude/skills/release-proprietary/scripts/wait-for-oss-sync.sh \ + . {{version}}) +``` + +The script writes the **proprietary sync-merge SHA** to stdout (captured into `PROP_TAG_SHA`) and progress to stderr. Internally it: + +1. Polls `origin/main` every 5 seconds for the OSS release commit (subject exactly `chore: release {{version}}`), with a 10-minute timeout. +2. Once found, locates the proprietary merge commit whose **2nd parent** is that OSS commit — that's the upstream side of the sync merge. Requiring the OSS release to be the direct 2nd parent (not a grandparent) ensures the merge's tree is exactly "OSS at release + proprietary state at that moment" — i.e. version `{{version}}`, not `{{next}}-next`. + +This is the SHA you tag on proprietary. It is NOT the OSS release SHA — that one's tree contains only OSS code and would break proprietary deployments. See the "Dual-SHA model" note at the top. + +If it times out, abort and instruct the user to: + +* Check the `sync-from-oss-repository` workflow on `PackmindHub/packmind-proprietary`. +* Check whether an open `oss-sync` PR appeared after Phase 0 ran. + +If the script reports `OSS release commit … is on proprietary origin/main, but no merge commit has it as its direct upstream (2nd) parent`, the auto-sync either fast-forwarded or batched release + next-cycle into one merge. The operator must resolve manually before continuing — there is no merge commit with the right tree to tag. + +Once `PROP_TAG_SHA` is set, fast-forward the local proprietary checkout (purely to keep the working tree in sync — we do NOT rely on HEAD for the tag): + +```bash +git checkout main +git pull --ff-only origin main +``` + +## Phase 3 — Tag proprietary + +### 3.1 Re-check proprietary CI green + +The sync just pushed new commits to proprietary main; the Phase 0 CI gate is now stale. Re-run it (allowing exit 2 = still running) and either wait or abort: + +```bash +./.claude/skills/release-proprietary/scripts/check-ci.sh PackmindHub/packmind-proprietary +``` + +### 3.2 Tag the proprietary release commit + +```bash +./.claude/skills/release-proprietary/scripts/tag-release.sh \ + . {{version}} "${PROP_TAG_SHA}" +``` + +Passing the SHA explicitly is essential — tagging `HEAD` would tag a later sync-merge or the next-cycle commit. The script re-verifies that the target is either a direct release commit OR a merge commit whose 2nd parent is the release commit, and refuses to tag otherwise. It will not allow retagging an existing tag at a different commit. + +### 3.3 Done + +Report to the user: + +* OSS release commit URL: `https://github.com/PackmindHub/packmind/releases/tag/release/{{version}}` (or the compare link) +* Proprietary tag URL: `https://github.com/PackmindHub/packmind-proprietary/releases/tag/release/{{version}}` +* Reminder: any deployment workflow that watches `release/*` tags will trigger. + +## Important notes + +* Do NOT use `--no-verify` on any commit. If a hook fails, fix the root cause and create a new commit. +* The release commit subject MUST be exactly `chore: release {{version}}` — Phase 2 matches subjects by exact equality and `tag-release.sh` verifies subject equality before tagging. Any prefix/suffix (e.g. gitmoji) will break the flow. +* Date format MUST be ISO 8601 (`YYYY-MM-DD`). +* Verify each commit and push succeeded before proceeding to the next step. +* If anything fails mid-way after the OSS tag is pushed, do NOT delete the OSS tag — coordinate a follow-up patch release instead. + +## Recovery cheatsheet + +If the flow fails at a known phase, recover as follows: + +* **After 1.4, before 1.5** (release commit pushed, no OSS tag yet): re-run `tag-release.sh <oss-dir> {{version}}` from Phase 1.5. The script tags HEAD only if HEAD's subject is `chore: release {{version}}`. +* **After 1.5, before 1.6** (OSS is tagged, no next-cycle commit): re-run `changelog-next.mjs` + `commit-next-cycle.sh`. Then proceed to Phase 2. +* **After 1.6, Phase 2 timed out**: investigate the sync workflow / oss-sync PR. Once unstuck, re-run only Phase 2 + Phase 3. +* **After Phase 3 failure** (OSS tagged, proprietary not tagged): once any blocker is cleared, re-run only Phase 3, passing the same `PROP_TAG_SHA`. Recover it by re-running `wait-for-oss-sync.sh` (idempotent — it finds and emits the same merge SHA as before) or manually with: + ```bash + OSS_SHA=$(git log origin/main \ + --format='%H%x09%s' -500 | awk -F '\t' -v subj="chore: release {{version}}" '$2 == subj { print $1; exit }') + git log origin/main --merges \ + --format='%H %P' -500 | awk -v oss="${OSS_SHA}" '$3 == oss { print $1; exit }' + ``` \ No newline at end of file diff --git a/.cursor/skills/release-proprietary/scripts/bump-versions.mjs b/.cursor/skills/release-proprietary/scripts/bump-versions.mjs new file mode 100755 index 000000000..86f17a5ac --- /dev/null +++ b/.cursor/skills/release-proprietary/scripts/bump-versions.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// Bump version field in package.json and apps/api/docker-package.json to the +// provided version. Preserves 2-space indentation and trailing newline. +// +// Usage: node bump-versions.mjs <version> <repo-dir> + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const [, , version, repoDirArg] = process.argv; + +if (!version || !repoDirArg) { + console.error('usage: bump-versions.mjs <version> <repo-dir>'); + process.exit(1); +} + +const repoDir = resolve(repoDirArg); +const targets = ['package.json', 'apps/api/docker-package.json']; + +for (const rel of targets) { + const path = join(repoDir, rel); + const raw = readFileSync(path, 'utf8'); + const json = JSON.parse(raw); + const previous = json.version; + json.version = version; + const endsWithNewline = raw.endsWith('\n'); + writeFileSync(path, JSON.stringify(json, null, 2) + (endsWithNewline ? '\n' : '')); + console.log(`bumped ${rel}: ${previous} → ${version}`); +} diff --git a/.cursor/skills/release-proprietary/scripts/changelog-next.mjs b/.cursor/skills/release-proprietary/scripts/changelog-next.mjs new file mode 100755 index 000000000..1d74c49e5 --- /dev/null +++ b/.cursor/skills/release-proprietary/scripts/changelog-next.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// Mutate CHANGELOG.MD for the next development cycle: +// - Prepend a fresh `# [Unreleased]` section with empty Added/Changed/Fixed/Removed +// - Insert `[Unreleased]: ...compare/release/{version}...HEAD` link just above +// the `[{version}]: ...` link in the bottom link section +// +// Usage: node changelog-next.mjs <version> <repo-dir> + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const [, , version, repoDirArg] = process.argv; + +if (!version || !repoDirArg) { + console.error('usage: changelog-next.mjs <version> <repo-dir>'); + process.exit(1); +} + +const path = join(resolve(repoDirArg), 'CHANGELOG.MD'); +const original = readFileSync(path, 'utf8'); + +const firstHeadingMatch = original.match(/^# \[/m); +if (!firstHeadingMatch) { + console.error('CHANGELOG.MD: could not find any `# [` heading'); + process.exit(1); +} +const insertAt = firstHeadingMatch.index; + +const unreleasedBlock = + '# [Unreleased]\n\n## Added\n\n## Changed\n\n## Fixed\n\n## Removed\n\n'; + +let result = original.slice(0, insertAt) + unreleasedBlock + original.slice(insertAt); + +const versionLinkRegex = new RegExp( + `^\\[${version.replace(/\./g, '\\.')}\\]: (https://github\\.com/[^/]+/[^/]+)/compare/release/[0-9A-Za-z._-]+\\.\\.\\.release/${version.replace(/\./g, '\\.')}\\s*$`, + 'm', +); +const versionLinkMatch = result.match(versionLinkRegex); +if (!versionLinkMatch) { + console.error(`CHANGELOG.MD: could not find \`[${version}]: ...\` compare link`); + process.exit(1); +} +const repoUrl = versionLinkMatch[1]; +const unreleasedLink = `[Unreleased]: ${repoUrl}/compare/release/${version}...HEAD`; + +result = result.replace(versionLinkRegex, `${unreleasedLink}\n${versionLinkMatch[0]}`); + +writeFileSync(path, result); +console.log(`changelog: prepared next cycle after ${version}`); diff --git a/.cursor/skills/release-proprietary/scripts/changelog-release.mjs b/.cursor/skills/release-proprietary/scripts/changelog-release.mjs new file mode 100755 index 000000000..db97d5aea --- /dev/null +++ b/.cursor/skills/release-proprietary/scripts/changelog-release.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// Mutate CHANGELOG.MD for a release: +// - Rename `# [Unreleased]` heading to `# [{version}] - {date}` +// - Drop empty `## Added|Changed|Fixed|Removed` subsections inside that block +// - Swap the bottom compare link +// `[Unreleased]: ...compare/release/{prev}...HEAD` +// for +// `[{version}]: ...compare/release/{prev}...release/{version}` +// +// Usage: node changelog-release.mjs <version> <date> <repo-dir> + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const [, , version, date, repoDirArg] = process.argv; + +if (!version || !date || !repoDirArg) { + console.error('usage: changelog-release.mjs <version> <date> <repo-dir>'); + process.exit(1); +} + +const path = join(resolve(repoDirArg), 'CHANGELOG.MD'); +const original = readFileSync(path, 'utf8'); + +const unreleasedHeading = original.match(/^# \[Unreleased\][^\n]*$/m); +if (!unreleasedHeading) { + console.error('CHANGELOG.MD: could not find `# [Unreleased]` heading'); + process.exit(1); +} + +const start = unreleasedHeading.index; +const after = original.slice(start + unreleasedHeading[0].length); +const nextHeadingRel = after.match(/^# \[/m); +if (!nextHeadingRel) { + console.error('CHANGELOG.MD: could not find next `# [` heading after Unreleased'); + process.exit(1); +} +const blockEnd = start + unreleasedHeading[0].length + nextHeadingRel.index; +const block = original.slice(start, blockEnd); + +const lines = block.split('\n'); +const out = [`# [${version}] - ${date}`]; +let i = 1; +while (i < lines.length) { + const line = lines[i]; + if (line.startsWith('## ')) { + let j = i + 1; + while (j < lines.length && !lines[j].startsWith('## ')) j++; + const body = lines.slice(i + 1, j).join('\n').trim(); + if (body.length > 0) { + out.push(line); + for (let k = i + 1; k < j; k++) out.push(lines[k]); + } + i = j; + } else { + out.push(line); + i++; + } +} + +let newBlock = out.join('\n'); +if (!newBlock.endsWith('\n\n')) { + newBlock = newBlock.replace(/\n*$/, '\n\n'); +} + +let result = original.slice(0, start) + newBlock + original.slice(blockEnd); + +const linkRegex = /^\[Unreleased\]: (https:\/\/github\.com\/[^/]+\/[^/]+)\/compare\/release\/([0-9A-Za-z._-]+)\.\.\.HEAD\s*$/m; +const linkMatch = result.match(linkRegex); +if (!linkMatch) { + console.error('CHANGELOG.MD: could not find `[Unreleased]: .../compare/release/<prev>...HEAD` link'); + process.exit(1); +} +const repoUrl = linkMatch[1]; +const prev = linkMatch[2]; +const newLink = `[${version}]: ${repoUrl}/compare/release/${prev}...release/${version}`; +result = result.replace(linkRegex, newLink); + +writeFileSync(path, result); +console.log(`changelog: released ${version} (date ${date}, previous ${prev})`); diff --git a/.cursor/skills/release-proprietary/scripts/check-ci.sh b/.cursor/skills/release-proprietary/scripts/check-ci.sh new file mode 100755 index 000000000..f9cf727cc --- /dev/null +++ b/.cursor/skills/release-proprietary/scripts/check-ci.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Verify that the "Main CI/CD Pipeline" workflow on the latest commit of the +# main branch of a given GitHub repository is green. +# +# Usage: check-ci.sh <owner/repo> +# Exit codes: +# 0 - latest run on main concluded successfully (green) +# 1 - latest run failed / cancelled / no run found / gh auth missing +# 2 - latest run is still in progress or queued (not red, just not done) + +set -euo pipefail + +REPO="${1:?usage: check-ci.sh <owner/repo>}" + +if ! command -v gh >/dev/null 2>&1; then + echo "❌ gh CLI not found in PATH" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "❌ jq not found in PATH" >&2 + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "❌ gh is not authenticated — run \`gh auth login\` first" >&2 + exit 1 +fi + +HEAD_SHA=$(gh api "repos/${REPO}/commits/main" --jq .sha 2>/dev/null || true) +if [ -z "${HEAD_SHA}" ]; then + echo "❌ ${REPO}: could not resolve main HEAD SHA (does the repo exist and do you have access?)" >&2 + exit 1 +fi +SHORT_SHA="${HEAD_SHA:0:7}" + +RUN_JSON=$(gh run list \ + --repo "${REPO}" \ + --workflow=main.yml \ + --branch=main \ + --limit=20 \ + --json databaseId,status,conclusion,headSha,url,createdAt) + +MATCH=$(echo "${RUN_JSON}" | jq --arg sha "${HEAD_SHA}" ' + [ .[] | select(.headSha == $sha) ] | sort_by(.createdAt) | reverse | .[0] // empty +') + +if [ -z "${MATCH}" ] || [ "${MATCH}" = "null" ]; then + echo "❌ ${REPO}: no Main CI/CD Pipeline run found for current main HEAD (${SHORT_SHA})" >&2 + echo " Most recent runs on main:" >&2 + echo "${RUN_JSON}" | jq -r '.[] | " - sha=\(.headSha[0:7]) status=\(.status) conclusion=\(.conclusion) \(.url)"' >&2 + exit 1 +fi + +STATUS=$(echo "${MATCH}" | jq -r '.status') +CONCL=$(echo "${MATCH}" | jq -r '.conclusion') +URL=$(echo "${MATCH}" | jq -r '.url') + +case "${STATUS}" in + completed) + case "${CONCL}" in + success) + echo "✅ ${REPO}: Main CI/CD Pipeline green on main (${SHORT_SHA})" + echo " ${URL}" + exit 0 + ;; + *) + echo "❌ ${REPO}: Main CI/CD Pipeline concluded '${CONCL}' on main (${SHORT_SHA})" >&2 + echo " ${URL}" >&2 + exit 1 + ;; + esac + ;; + queued|in_progress|waiting|requested|pending) + echo "⏳ ${REPO}: Main CI/CD Pipeline still '${STATUS}' on main (${SHORT_SHA})" >&2 + echo " ${URL}" >&2 + echo " This is not a failure — re-run check-ci.sh after the run completes." >&2 + exit 2 + ;; + *) + echo "❌ ${REPO}: Main CI/CD Pipeline status '${STATUS}' on main (${SHORT_SHA}) — unexpected state" >&2 + echo " ${URL}" >&2 + exit 1 + ;; +esac diff --git a/.cursor/skills/release-proprietary/scripts/check-oss-sync-pr.sh b/.cursor/skills/release-proprietary/scripts/check-oss-sync-pr.sh new file mode 100755 index 000000000..20cac1bae --- /dev/null +++ b/.cursor/skills/release-proprietary/scripts/check-oss-sync-pr.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Verify there is no open `oss-sync` PR on the proprietary repository. +# +# Background: the sync-from-oss-repository workflow fast-forwards OSS commits +# onto proprietary main when there are no conflicts. When conflicts occur, it +# pushes branch `oss-sync` and opens a PR for manual review instead. If such +# a PR is open when we run a release, Phase 2 polling will never find the +# release commit on origin/main and will time out. +# +# Usage: check-oss-sync-pr.sh <owner/repo> +# Exit codes: +# 0 - no open oss-sync PR +# 1 - open oss-sync PR found (PR details printed to stderr) +# 2 - gh CLI / auth issue + +set -euo pipefail + +REPO="${1:?usage: check-oss-sync-pr.sh <owner/repo>}" + +if ! command -v gh >/dev/null 2>&1; then + echo "❌ gh CLI not found in PATH" >&2 + exit 2 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "❌ gh is not authenticated — run \`gh auth login\` first" >&2 + exit 2 +fi + +PR_JSON=$(gh pr list \ + --repo "${REPO}" \ + --head oss-sync \ + --state open \ + --json number,url,title) + +COUNT=$(echo "${PR_JSON}" | jq 'length') + +if [ "${COUNT}" -gt 0 ]; then + echo "❌ ${REPO}: an open oss-sync PR is blocking the auto-sync — resolve it before releasing." >&2 + echo "${PR_JSON}" | jq -r '.[] | " - PR #\(.number) \(.title) — \(.url)"' >&2 + exit 1 +fi + +echo "✅ ${REPO}: no open oss-sync PR" diff --git a/.cursor/skills/release-proprietary/scripts/commit-next-cycle.sh b/.cursor/skills/release-proprietary/scripts/commit-next-cycle.sh new file mode 100755 index 000000000..d03bd627c --- /dev/null +++ b/.cursor/skills/release-proprietary/scripts/commit-next-cycle.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Stage CHANGELOG.MD in <repo-dir>, commit with the exact subject +# `chore: prepare next development cycle`, and push main to origin. +# +# Usage: commit-next-cycle.sh <repo-dir> + +set -euo pipefail + +REPO_DIR="${1:?usage: commit-next-cycle.sh <repo-dir>}" + +cd "${REPO_DIR}" + +git add -- CHANGELOG.MD + +if git diff --cached --quiet; then + echo "❌ ${REPO_DIR}: nothing staged for next-cycle commit — did changelog-next.mjs run?" >&2 + exit 1 +fi + +git commit -m "chore: prepare next development cycle" +git push origin main + +echo "✅ ${REPO_DIR}: pushed \"chore: prepare next development cycle\"" diff --git a/.cursor/skills/release-proprietary/scripts/commit-release.sh b/.cursor/skills/release-proprietary/scripts/commit-release.sh new file mode 100755 index 000000000..166a11d3d --- /dev/null +++ b/.cursor/skills/release-proprietary/scripts/commit-release.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Stage the 4 release files in <repo-dir>, commit with the exact subject +# `chore: release <version>`, and push main to origin. +# +# Usage: commit-release.sh <repo-dir> <version> +# +# Never uses --no-verify. If a hook fails, fix the root cause and re-run. + +set -euo pipefail + +REPO_DIR="${1:?usage: commit-release.sh <repo-dir> <version>}" +VERSION="${2:?usage: commit-release.sh <repo-dir> <version>}" + +FILES=( + "package.json" + "apps/api/docker-package.json" + "package-lock.json" + "CHANGELOG.MD" +) + +cd "${REPO_DIR}" + +for f in "${FILES[@]}"; do + if [ ! -f "${f}" ]; then + echo "❌ ${REPO_DIR}: expected release file is missing: ${f}" >&2 + exit 1 + fi +done + +git add -- "${FILES[@]}" + +if git diff --cached --quiet; then + echo "❌ ${REPO_DIR}: nothing staged for release commit — did the bump/changelog scripts run?" >&2 + exit 1 +fi + +git commit -m "chore: release ${VERSION}" +git push origin main + +echo "✅ ${REPO_DIR}: pushed release commit \"chore: release ${VERSION}\"" diff --git a/.cursor/skills/release-proprietary/scripts/tag-release.sh b/.cursor/skills/release-proprietary/scripts/tag-release.sh new file mode 100755 index 000000000..56ffae1ff --- /dev/null +++ b/.cursor/skills/release-proprietary/scripts/tag-release.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Create the `release/<version>` tag in <repo-dir> and push it to origin. +# +# Usage: tag-release.sh <repo-dir> <version> [sha] +# +# If <sha> is provided, the tag is created at that exact commit. The +# commit's subject is verified before tagging — but for proprietary, the +# SHA we tag is a merge commit (subject "Merge remote-tracking branch +# 'upstream/main'") whose 2nd parent is the OSS release commit. So the +# check accepts EITHER: +# - a commit whose subject is `chore: release <version>` (OSS side, or +# a direct release commit), OR +# - a merge commit whose 2nd parent's subject is `chore: release <version>` +# (proprietary sync-merge of the OSS release). +# This safety net ensures we don't tag the wrong commit when the SHA is +# passed in by hand or by a partially recovered flow. +# +# If <sha> is omitted, the tag is created at HEAD (use on the OSS repo +# immediately after pushing the release commit, before any next-cycle commit). +# HEAD's subject must be exactly `chore: release <version>` in that case. + +set -euo pipefail + +REPO_DIR="${1:?usage: tag-release.sh <repo-dir> <version> [sha]}" +VERSION="${2:?usage: tag-release.sh <repo-dir> <version> [sha]}" +SHA_ARG="${3:-}" +TAG="release/${VERSION}" +EXPECTED_SUBJECT="chore: release ${VERSION}" + +cd "${REPO_DIR}" + +# Verify that <sha> is either the release commit itself OR a merge commit +# whose 2nd parent is the release commit. Returns 0 if OK, 1 otherwise, +# and prints a human-readable description of the match on success. +verify_release_target() { + local sha=$1 + local actual_subject + actual_subject=$(git log -1 --format='%s' "${sha}") + if [ "${actual_subject}" = "${EXPECTED_SUBJECT}" ]; then + echo "direct release commit" + return 0 + fi + # Maybe it's a merge commit whose 2nd parent is the release. + local second_parent + second_parent=$(git log -1 --format='%P' "${sha}" | awk '{print $2}') + if [ -n "${second_parent}" ]; then + local parent_subject + parent_subject=$(git log -1 --format='%s' "${second_parent}") + if [ "${parent_subject}" = "${EXPECTED_SUBJECT}" ]; then + echo "merge commit (2nd parent ${second_parent:0:7} is the release)" + return 0 + fi + fi + echo "neither subject \"${actual_subject}\" nor any merged parent matches \"${EXPECTED_SUBJECT}\"" + return 1 +} + +if [ -n "${SHA_ARG}" ]; then + TARGET_SHA=$(git rev-parse --verify "${SHA_ARG}" 2>/dev/null || true) + if [ -z "${TARGET_SHA}" ]; then + echo "❌ ${REPO_DIR}: provided SHA '${SHA_ARG}' is not a valid object" >&2 + exit 1 + fi + if ! MATCH_DESC=$(verify_release_target "${TARGET_SHA}"); then + echo "❌ ${REPO_DIR}: ${TARGET_SHA:0:7} ${MATCH_DESC}" >&2 + exit 1 + fi + echo "ℹ️ ${REPO_DIR}: ${TARGET_SHA:0:7} accepted — ${MATCH_DESC}" +else + TARGET_SHA=$(git rev-parse HEAD) + ACTUAL_SUBJECT=$(git log -1 --format='%s' HEAD) + if [ "${ACTUAL_SUBJECT}" != "${EXPECTED_SUBJECT}" ]; then + echo "❌ ${REPO_DIR}: HEAD subject is \"${ACTUAL_SUBJECT}\", expected \"${EXPECTED_SUBJECT}\"" >&2 + echo " Refusing to tag a commit that isn't the release commit." >&2 + exit 1 + fi +fi + +if git rev-parse --verify --quiet "refs/tags/${TAG}" >/dev/null; then + EXISTING_SHA=$(git rev-parse "refs/tags/${TAG}") + if [ "${EXISTING_SHA}" != "${TARGET_SHA}" ]; then + echo "❌ ${REPO_DIR}: tag ${TAG} already exists at ${EXISTING_SHA:0:7}, refusing to retag at ${TARGET_SHA:0:7}" >&2 + exit 1 + fi + echo "ℹ️ ${REPO_DIR}: tag ${TAG} already exists at target — skipping creation" +else + git tag "${TAG}" "${TARGET_SHA}" +fi + +git push origin "${TAG}" + +echo "✅ ${REPO_DIR}: pushed tag ${TAG} at ${TARGET_SHA:0:7}" diff --git a/.cursor/skills/release-proprietary/scripts/wait-for-oss-sync.sh b/.cursor/skills/release-proprietary/scripts/wait-for-oss-sync.sh new file mode 100755 index 000000000..f234dee23 --- /dev/null +++ b/.cursor/skills/release-proprietary/scripts/wait-for-oss-sync.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Poll the proprietary repo's origin/main until the OSS release commit has +# been merged in by the auto-sync workflow, then emit the SHA of the +# **proprietary merge commit** (NOT the OSS release commit itself). +# +# Why the merge commit and not the release commit: +# The OSS release commit (e.g. `chore: release 1.15.0`) is created on OSS, +# so its tree contains OSS-only files. When the proprietary auto-sync +# brings it into proprietary main, the resulting merge commit's tree +# combines the OSS release with the proprietary-only files. Tagging the +# merge commit is what we want — tagging the raw OSS commit would point +# `release/<version>` at an OSS-only tree, breaking proprietary +# deployments (the build can't find `@packmind/proprietary/*` modules). +# +# The script: +# 1. Polls origin/main for the OSS release commit (by exact subject). +# 2. Once found, locates the proprietary merge commit whose **2nd parent** +# is that OSS commit. The 2nd parent is the upstream side of the sync +# merge. Requiring the OSS release commit to be the *direct* upstream +# parent (not a grandparent) ensures the merge's tree is exactly +# "OSS at release + proprietary state at that moment" — i.e. version +# X.Y.Z, not X.Y.(Z+1)-next. +# 3. Emits the merge SHA on stdout. Progress messages go to stderr. +# +# Usage: wait-for-oss-sync.sh <prop-repo-dir> <version> [timeout-seconds] +# Defaults: timeout-seconds=600 (10 minutes) +# Exit codes: +# 0 - proprietary merge commit detected (SHA on stdout) +# 1 - timed out, or sync arrived but no suitable merge commit exists +# (e.g. fast-forward, or auto-sync batched release + next-cycle into +# one merge — in that case the operator must resolve manually). + +set -euo pipefail + +PROP_DIR="${1:?usage: wait-for-oss-sync.sh <prop-repo-dir> <version> [timeout-seconds]}" +VERSION="${2:?usage: wait-for-oss-sync.sh <prop-repo-dir> <version> [timeout-seconds]}" +TIMEOUT="${3:-600}" + +SUBJECT="chore: release ${VERSION}" +INTERVAL=5 +ELAPSED=0 + +echo "⏳ Polling ${PROP_DIR} origin/main for OSS commit \"${SUBJECT}\" and its proprietary merge (timeout ${TIMEOUT}s)…" >&2 + +while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do + git -C "${PROP_DIR}" fetch --quiet origin main + + # Step 1: find the OSS release commit on proprietary main (by exact subject) + OSS_SHA=$(git -C "${PROP_DIR}" log origin/main --format='%H%x09%s' -500 \ + | awk -F '\t' -v subj="${SUBJECT}" '$2 == subj { print $1; exit }') + + if [ -n "${OSS_SHA}" ]; then + # Step 2: find the proprietary merge commit whose 2nd parent is the OSS release. + # %P emits parents space-separated; on a merge commit, $2 is parent1 + # (prior proprietary tip) and $3 is parent2 (the upstream side). + MERGE_SHA=$(git -C "${PROP_DIR}" log origin/main --merges --format='%H %P' -500 \ + | awk -v oss="${OSS_SHA}" '$3 == oss { print $1; exit }') + + if [ -n "${MERGE_SHA}" ]; then + echo "✅ Found proprietary merge commit: ${MERGE_SHA:0:7} (merges OSS release ${OSS_SHA:0:7})" >&2 + echo "${MERGE_SHA}" + exit 0 + fi + + echo "⚠️ OSS release commit ${OSS_SHA:0:7} is on proprietary origin/main, but no merge commit has it as its direct upstream (2nd) parent." >&2 + echo " This can happen if the auto-sync fast-forwarded (unlikely if proprietary has local commits) or batched the release with later OSS commits into one merge." >&2 + echo " Proprietary cannot be tagged automatically — investigate the sync history before proceeding." >&2 + exit 1 + fi + + sleep "${INTERVAL}" + ELAPSED=$((ELAPSED + INTERVAL)) +done + +echo "❌ Timed out after ${TIMEOUT}s waiting for OSS release commit \"${SUBJECT}\" to land on proprietary origin/main" >&2 +echo " Check the sync-from-oss-repository workflow status and any pending oss-sync PR." >&2 +exit 1 diff --git a/.cursor/skills/upgrade-runtime-stack/SKILL.md b/.cursor/skills/upgrade-runtime-stack/SKILL.md new file mode 100644 index 000000000..04da3a3de --- /dev/null +++ b/.cursor/skills/upgrade-runtime-stack/SKILL.md @@ -0,0 +1,137 @@ +--- +name: 'upgrade-runtime-stack' +description: 'Check whether newer stable versions of Node.js (24.x line), Nx, or Vite are available and, if so, generate a detailed upgrade plan markdown file at the repo root. Use this skill whenever the user asks to "check for runtime upgrades", "upgrade Node/NX/Vite", "is our Node version current", "plan a Node 24 upgrade", "refresh our runtime stack", "monthly stack check", or anything along those lines — even if they don''t name a specific tool. Also use it when the user wants a recurring/cadence check of build-toolchain currency. Output is a plan only — does NOT mutate package.json, Dockerfiles, lockfiles, or any other repo file. CI/CD wrappers can invoke this skill to keep the runtime stack fresh.' +--- + +# Upgrade Runtime Stack + +Goal: detect available stable upgrades of **Node.js (24.x line only)**, **Nx**, and **Vite**, then emit an actionable, ready-to-execute upgrade plan as a markdown file at the repo root. Stop at the plan — never edit application files. + +## Why this skill exists + +Manual runtime upgrades drift months between attempts and produce avoidable risk. This skill captures the canonical file map (mined from the prior Node 22 → 24 migration on `emdash/migration-node24-cc0s9`) so each upgrade run reuses the same checklist instead of rediscovering it. The plan is the deliverable. A human or a CI bot decides whether to act on it. + +## Execution mode + +This skill **must run fully non-interactive**. It is invoked from CI/CD in headless mode where no human can answer prompts. + +- Never call `AskUserQuestion` or any other clarification tool. +- Never ask the user to confirm a choice. The skill makes deterministic decisions from the inputs (baked URLs + repo state) and produces the plan. +- Never wait on long-running interactive commands. Read-only file inspection, `WebFetch`, and `rg` scans are the only actions needed. +- All output goes to a single deterministic artifact: `upgrade-plan.md` at the repo root. The terminal print at the end (Phase 7) is informational only — CI can ignore stdout. + +If any input is missing or ambiguous, the skill records the gap **inside the plan** (e.g. "Vite changelog unreachable this run") and continues. It never blocks on input. + +## Inputs + +The skill takes **no arguments**. Version sources are baked in: + +| Tool | Source URL | What to extract | +|------|------------|-----------------| +| Node.js 24.x | `https://raw.githubusercontent.com/nodejs/node/refs/heads/main/doc/changelogs/CHANGELOG_V24.md` | Latest stable 24.x release | +| Nx | `https://nx.dev/changelog` | Latest stable Nx major.minor.patch | +| Vite | `https://raw.githubusercontent.com/vitejs/vite/refs/heads/main/packages/vite/CHANGELOG.md` | Latest stable Vite release | + +See `references/fetch-versions.md` for the exact parsing rules per source. + +## Workflow + +Execute phases in order. Each phase has a single clear deliverable. Do not skip steps; the value of this skill comes from the consistency of the output. + +### Phase 1 — Read current versions from the repo + +Read these exact locations and record what is currently pinned: + +- `.nvmrc` → exact Node version (e.g. `24.15.0`) +- `package.json` (root) → `engines.node`, `engines.npm`, `devDependencies.nx`, `devDependencies["@nx/*"]`, `devDependencies.vite` +- `apps/api/docker-package.json` → `engines.node`, `engines.npm` +- `dockerfile/Dockerfile.api` and `dockerfile/Dockerfile.mcp` → `FROM node:<version>-alpine<alpine-version>@sha256:<digest>` +- `docker-compose.yml` and `docker-compose.production.yml` → every `image: node:<version>-alpine<alpine-version>` occurrence +- `.github/workflows/*.yml` → default `node-version` inputs + +Record in a single in-memory table; this becomes the "Current versions" section of the plan. + +### Phase 2 — Fetch latest stable versions + +Use `WebFetch` against each source URL listed in **Inputs**. Follow the per-source parsing rules in `references/fetch-versions.md`. + +Filtering rules (apply to every source): + +- **Skip** anything tagged `next`, `beta`, `alpha`, `rc`, `canary`, `preview`, `dev`, or `pre`. +- **Node.js**: only consider `v24.x.y` entries. Ignore 22.x, 26.x, etc. — even if newer. +- **Nx**: take the highest `X.Y.Z` published as a stable release. +- **Vite**: take the highest `X.Y.Z` published as a stable release. + +For each tool, also extract the **published date** if visible and a short summary of headline changes / breaking changes from the changelog body covering the range *(current version, latest]*. This summary feeds the risk section of the plan. + +### Phase 3 — Compute delta and decide + +For each tool, compare current vs. latest stable: + +- `latest == current` → no upgrade needed for that tool. Record as "up to date". +- `latest > current` → upgrade candidate. Classify the bump as `patch`, `minor`, or `major` using semver rules. Note any breaking-change headlines from Phase 2. +- `latest < current` → unusual. Record but do not recommend a downgrade. + +If **all three** tools are up to date, still produce `upgrade-plan.md` but with a single "No upgrades available" section plus the timestamp. This keeps the CI integration deterministic. + +### Phase 4 — Resolve Docker image dependencies + +When Node is being upgraded, the Docker image pin (`node:<version>-alpine<X>@sha256:<digest>`) must change in lockstep. The plan must include: + +1. The exact new tag string to use (`node:<new-version>-alpine<X>`). +2. The current Alpine major (read from existing Dockerfiles) — keep it unless a Node breaking change requires a different base. +3. An explicit instruction line: *"Look up the sha256 digest for `node:<new-version>-alpine<X>` on Docker Hub before pinning."* Do not fabricate a digest. + +If Node is not being upgraded, the Docker section of the plan only lists which files **would** change in a future Node bump, for reference. + +### Phase 5 — Map files to modify + +Use `references/file-map.md` as the authoritative list of files that any Node / Nx / Vite upgrade must touch in this repo. The map is grouped per tool. Include in the plan only the file groups whose tool has a pending upgrade. + +After listing the bake-in files, run a quick scan to surface drift — files that match the relevant version pattern but are not yet in the map: + +- Node version drift: `rg -n "node:[0-9]+\.[0-9]+\.[0-9]+|node-version: ['\"]?[0-9]+" --hidden -g '!node_modules' -g '!dist'` +- Nx version drift: `rg -n '"nx": "[0-9]+\.[0-9]+\.[0-9]+"|"@nx/[a-z-]+": "[0-9]+\.[0-9]+\.[0-9]+"' --hidden -g '!node_modules' -g '!dist'` +- Vite version drift: `rg -n '"vite": "(\^|~)?[0-9]+\.[0-9]+\.[0-9]+"' --hidden -g '!node_modules' -g '!dist'` + +Add any hits that are not already covered to a "Drift detected" subsection of the plan so a human can decide whether to extend the file map. + +### Phase 6 — Validation harness + +Copy the validation steps from `references/validation.md` into the plan. The harness is the contract for "this upgrade did not break the repo" and must be runnable end-to-end after applying the plan. + +### Phase 7 — Write `upgrade-plan.md` + +Use the exact structure defined in `references/plan-template.md`. Write to the repo root as `upgrade-plan.md`. Overwrite any existing file at that path — the plan is always the latest snapshot. + +The first line of the plan **must** be a machine-readable status comment so CI can branch on it without parsing the body: + +``` +<!-- upgrade-status: available | none | partial-fetch-failure --> +``` + +- `available` — at least one tool has a stable upgrade. +- `none` — all three tools up to date. +- `partial-fetch-failure` — one or more source URLs could not be fetched this run. + +After writing, print to stdout (informational only — CI may discard): + +- One-line summary per tool (e.g. `Node 24.15.0 → 24.17.0 (patch)`). +- The absolute path of the generated `upgrade-plan.md`. +- Whether any drift was detected (so the file map may need updating). + +Do **not** apply edits. Stop here. + +## Hard rules + +- Never edit `package.json`, `package-lock.json`, `.nvmrc`, Dockerfiles, docker-compose files, CI workflows, or any other source file. The skill produces a plan only. +- Never invent version numbers, dates, or sha256 digests. If a source URL cannot be fetched, mark the tool as "unknown — fetch failed" in the plan and continue with the other tools. +- Never recommend Node majors other than 24. The team is on the 24.x line and a major bump is a separate, deliberate project. +- Never include `rc`, `beta`, `alpha`, `canary`, `next`, `preview`, `dev`, or `pre` versions in the recommendation, even if newer than current. + +## Reference files + +- `references/fetch-versions.md` — per-source parsing rules for the three changelog URLs. +- `references/file-map.md` — canonical list of files an upgrade of each tool touches in this repo. +- `references/plan-template.md` — exact markdown layout of `upgrade-plan.md`. +- `references/validation.md` — lint / test / build commands that act as the safety harness. \ No newline at end of file diff --git a/.cursor/skills/upgrade-runtime-stack/references/fetch-versions.md b/.cursor/skills/upgrade-runtime-stack/references/fetch-versions.md new file mode 100644 index 000000000..8ca96d4e3 --- /dev/null +++ b/.cursor/skills/upgrade-runtime-stack/references/fetch-versions.md @@ -0,0 +1,52 @@ +# Fetching latest stable versions + +Per-source parsing rules. All sources are fetched with `WebFetch`. Always strip pre-release tags (`rc`, `beta`, `alpha`, `canary`, `next`, `preview`, `dev`, `pre`). + +## Node.js (24.x line only) + +- URL: `https://raw.githubusercontent.com/nodejs/node/refs/heads/main/doc/changelogs/CHANGELOG_V24.md` +- The file is the changelog for the entire Node 24 line. Section headers look like: + ``` + <a id="24.15.0"></a> + ## 2025-XX-YY, Version 24.15.0 (Current), @<release-manager> + ``` +- Parse the topmost `## YYYY-MM-DD, Version 24.X.Y` heading whose tag is **not** marked `(Pre-release)`, `(RC)`, etc. +- Output: + - `latest`: e.g. `24.17.0` + - `released`: e.g. `2025-09-12` + - `highlights`: bullet headings between this version's heading and the next version heading. Limit to ~10 short bullets. Skip commit-only bullets. +- The "Current" / "LTS" annotation may be present — record it but do not gate the recommendation on it; the 24.x line moves from Current to LTS during its life. + +## Nx + +- URL: `https://nx.dev/changelog` +- The page lists release entries newest-first. Each entry looks like a section with a version (e.g. `Nx 22.7.2`) and a date. +- Parse the topmost entry whose version is **not** suffixed with `-rc.N`, `-beta.N`, `-alpha.N`, `-canary.N`, `-next.N`. +- Output: + - `latest`: e.g. `22.7.2` + - `released`: e.g. `2025-09-30` + - `highlights`: bullet section titles from that entry (e.g. "Breaking changes", "Features", "Bug Fixes"). Capture any text explicitly under "Breaking changes" verbatim — it drives the risk section. +- If the major changes between current and latest, also fetch the matching `https://nx.dev/changelog#vNN-migration` anchor or the linked migration guide and include the URL in the plan. + +## Vite + +- URL: `https://raw.githubusercontent.com/vitejs/vite/refs/heads/main/packages/vite/CHANGELOG.md` +- Standard `keep-a-changelog` style. Headings look like: + ``` + ## 8.0.3 (YYYY-MM-DD) + ``` +- Parse the topmost `## X.Y.Z` heading without a pre-release tag. +- Output: + - `latest`: e.g. `8.1.0` + - `released`: e.g. `2025-10-04` + - `highlights`: subsection titles (`### Features`, `### Bug Fixes`, `### BREAKING CHANGES`). Capture any `BREAKING CHANGES` section verbatim. + +## Fallback when a source cannot be fetched + +If `WebFetch` returns an error or an empty body, do not block the run. In the plan, record: + +``` +- <tool>: unable to fetch <URL> — skipped this run. +``` + +The two remaining tools are still processed normally. diff --git a/.cursor/skills/upgrade-runtime-stack/references/file-map.md b/.cursor/skills/upgrade-runtime-stack/references/file-map.md new file mode 100644 index 000000000..db47a227b --- /dev/null +++ b/.cursor/skills/upgrade-runtime-stack/references/file-map.md @@ -0,0 +1,62 @@ +# Canonical file map per tool + +These lists were mined from the Node 22 → 24 migration branch `emdash/migration-node24-cc0s9`. They represent the *minimum* set of files that an upgrade of each tool must touch. The skill's Phase 5 also scans for drift in case the repo gained a new file matching the relevant pattern. + +A file may appear under more than one tool when an upgrade of any of those tools requires editing it. + +## Node.js (24.x) + +When the Node line shifts to a new patch (or minor within 24.x), update every concrete pin so all environments agree. + +**Engine / runtime pins:** +- `.nvmrc` — full `X.Y.Z` Node version, no `v` prefix. +- `package.json` (root) — `engines.node` and `engines.npm`. +- `apps/api/docker-package.json` — `engines.node` and `engines.npm` (this file is shipped inside the API Docker image as `package.json`). + +**Docker images:** +- `dockerfile/Dockerfile.api` — `FROM node:<version>-alpine<X>@sha256:<digest>`. The sha256 digest must be looked up on Docker Hub for the new tag; do not invent it. +- `dockerfile/Dockerfile.mcp` — same pattern. +- `docker-compose.yml` — every `image: node:<version>-alpine<X>` line. There are several services. +- `docker-compose.production.yml` — every `image: node:<version>-alpine<X>` line. + +**CI workflows:** +- `.github/workflows/build.yml` — `node-version` workflow input default and every matrix entry. +- `.github/workflows/main.yml` — same. +- `.github/workflows/docker.yml` — same. +- `.github/workflows/publish-cli-release.yml` — same. +- `.github/workflows/tmp-cli-lint-windows.yml` — same. + +**Helper scripts (kept around from the prior migration; usually paired):** +- `migrate_node24.sh` — invoked by humans to switch local env after a bump. +- `downgrade_node22.sh` — escape hatch back to the previous major; only relevant on a *major* Node bump. + +When the Node bump is patch- or minor-only, the two helper scripts can be left as-is. On a major bump (e.g. 24.x → 26.x), the plan must explicitly call out rewriting/removing them. + +## Nx + +When Nx is bumped, every `@nx/*` package and `nx` itself must move to the same version. The Nx CLI's own `nx migrate` workflow handles most edits, but the plan must list: + +- `package.json` (root) — `devDependencies.nx` and every `devDependencies["@nx/*"]`. Currently used scopes: `@nx/devkit`, `@nx/esbuild`, `@nx/eslint`, `@nx/eslint-plugin`, `@nx/jest`, `@nx/js`, `@nx/nest`, `@nx/node`, `@nx/playwright`, `@nx/plugin`, `@nx/react`, `@nx/storybook`, `@nx/vite`, `@nx/vitest`, `@nx/web`, `@nx/webpack`. +- `tools/packmind-plugin/package.json` — `@nx/*` deps referenced by the local Nx plugin. +- `nx.json` — schema / plugin configuration sometimes shifts between minors. +- `package-lock.json` — regenerated by `npm install` after the version bumps. +- `migrations.json` — created by `nx migrate <version>` at the repo root. The plan must include the explicit command `npx nx migrate <new-version>` and a follow-up `npx nx migrate --run-migrations`. + +**ESLint coupling:** the Nx ESLint plugin sometimes lands new flat-config requirements that affect `eslint.config.mjs` and `packages/ui/eslint.config.mjs`. List them as "review only" in the plan unless the changelog explicitly calls them out. + +## Vite + +When Vite is bumped, the consumers of Vite in this repo are: + +- `package.json` (root) — `devDependencies.vite`. +- `apps/frontend/vite.config.ts` — config surface; major bumps often change plugin or build options. +- `packages/ui/vite.config.ts` — same. +- `apps/frontend/package.json` — peer / dev deps may need realignment if the major changes. +- `packages/ui/package.json` — same. +- `package-lock.json` — regenerated. + +**Coupling with Nx:** `@nx/vite` and `@nx/vitest` carry their own Vite peer-dep ranges. On a Vite major bump, the plan must check the Nx changelog for compatibility before recommending the move; if Nx doesn't yet support the new Vite major, the plan recommends *waiting* and notes the blocking constraint. + +## Drift scan + +Phase 5 of the skill also runs three ripgrep scans (see `SKILL.md`) and reports any additional matches. Anything reported there is a candidate for adding to this file map after the upgrade lands. diff --git a/.cursor/skills/upgrade-runtime-stack/references/plan-template.md b/.cursor/skills/upgrade-runtime-stack/references/plan-template.md new file mode 100644 index 000000000..2ec5bebd9 --- /dev/null +++ b/.cursor/skills/upgrade-runtime-stack/references/plan-template.md @@ -0,0 +1,103 @@ +# `upgrade-plan.md` layout + +Write the plan to the **repo root** as `upgrade-plan.md`. Overwrite any existing file at that path. The structure below is mandatory — downstream tooling (and the human reader's mental model) depends on it. + +When a tool has no pending upgrade, still emit its section but populate it with "Up to date — no action required" so the file's shape stays stable. + +```markdown +<!-- upgrade-status: available | none | partial-fetch-failure --> +# Runtime stack upgrade plan + +_Generated: <YYYY-MM-DD HH:MM TZ> by the `upgrade-runtime-stack` skill._ + +## Summary + +| Tool | Current | Latest stable | Bump | Action | +|------|---------|---------------|------|--------| +| Node.js 24.x | <X.Y.Z> | <X.Y.Z> | patch / minor / major / none | Upgrade / Skip | +| Nx | <X.Y.Z> | <X.Y.Z> | patch / minor / major / none | Upgrade / Skip | +| Vite | <X.Y.Z> | <X.Y.Z> | patch / minor / major / none | Upgrade / Skip | + +<one-paragraph recommendation: e.g. "All three tools have stable updates available. Node and Nx are patch-only and safe; Vite is a minor with one deprecation worth reviewing."> + +## Node.js + +- **Current**: <X.Y.Z> (from `.nvmrc`) +- **Latest stable**: <X.Y.Z>, released <YYYY-MM-DD> +- **Bump type**: patch / minor / major +- **Changelog highlights**: + - <bullet> + - <bullet> +- **Breaking changes**: <verbatim section from changelog, or "none documented"> + +### Files to modify + +<file map — group by category from `references/file-map.md`, with exact line patterns to change> + +### Docker image pin + +- New tag: `node:<new-version>-alpine<X>` +- Action: look up the sha256 digest for that tag on Docker Hub before editing `dockerfile/Dockerfile.api` and `dockerfile/Dockerfile.mcp`. Do **not** reuse the previous digest. +- Sample lookup: `docker manifest inspect node:<new-version>-alpine<X> | jq -r '.manifests[0].digest'` or use the Docker Hub UI. + +## Nx + +- **Current**: <X.Y.Z> +- **Latest stable**: <X.Y.Z>, released <YYYY-MM-DD> +- **Bump type**: patch / minor / major +- **Changelog highlights**: + - <bullet> +- **Breaking changes**: <verbatim> +- **Migration command**: + ``` + npx nx migrate <new-version> + npm install + npx nx migrate --run-migrations + ``` + +### Files to modify + +<from `references/file-map.md` — Nx section> + +## Vite + +- **Current**: <X.Y.Z> +- **Latest stable**: <X.Y.Z>, released <YYYY-MM-DD> +- **Bump type**: patch / minor / major +- **Changelog highlights**: + - <bullet> +- **Breaking changes**: <verbatim> +- **Nx / Vite compatibility note**: <only if Vite is a major bump — confirm `@nx/vite` and `@nx/vitest` support the new Vite major> + +### Files to modify + +<from `references/file-map.md` — Vite section> + +## Drift detected + +<empty list, or any extra hits the Phase 5 scans found that are not in the file map> + +## Validation harness + +<paste the steps from `references/validation.md` verbatim> + +## Risks + +- <pre-flagged risk: e.g. "Node 24.X.Y removes deprecated experimental flag `--foo`; the repo does not use it (verified via rg)"> +- <every breaking-change bullet from the changelogs goes here, paired with a quick repo check> + +## Rollback + +- Revert the upgrade commit and run `npm install` to regenerate the lockfile. +- For a Node major rollback, `downgrade_node22.sh` exists for the 22 ↔ 24 transition; on later majors a similar helper must be created before applying. +- Docker images are pinned by `@sha256:...` so previous deploys are reproducible. + +## Suggested commit split + +A single PR is fine for patch/minor bumps of all three tools combined. Split into separate PRs when: + +- Any tool has a **major** bump. +- The Nx and Vite bumps would interact (e.g. Vite major + `@nx/vite` major). + +Suggested split for this run: <one-paragraph proposal> +``` diff --git a/.cursor/skills/upgrade-runtime-stack/references/validation.md b/.cursor/skills/upgrade-runtime-stack/references/validation.md new file mode 100644 index 000000000..c26764221 --- /dev/null +++ b/.cursor/skills/upgrade-runtime-stack/references/validation.md @@ -0,0 +1,73 @@ +# Validation harness + +After the upgrade plan is applied, these steps must all succeed before merging. Copy this section verbatim into `upgrade-plan.md`. + +The order matters — fail fast on cheap checks before paying for full builds. + +## Local + +1. **Node + npm match the pins** + ``` + node --version # expect: vNEW_NODE + npm --version # expect: NEW_NPM + ``` + If `nvm` is in use: `nvm use` should read the new `.nvmrc` automatically. + +2. **Clean install** + ``` + rm -rf node_modules package-lock.json # only on major bumps; skip for patch/minor + npm install + ``` + For patch/minor bumps prefer `npm install` against the existing lockfile so the diff stays auditable. + +3. **Lint affected** + ``` + npm run lint:staged + ``` + +4. **Test affected** + ``` + npm run test:staged + ``` + +5. **Build the heaviest targets** + ``` + ./node_modules/.bin/nx build api + ./node_modules/.bin/nx build frontend + ./node_modules/.bin/nx build cli + ./node_modules/.bin/nx build mcp-server + ``` + +6. **Docker build smoke test** (only when Node is bumped) + ``` + docker build -f dockerfile/Dockerfile.api -t packmind-api:upgrade-check . + docker build -f dockerfile/Dockerfile.mcp -t packmind-mcp:upgrade-check . + docker compose -f docker-compose.yml up -d api frontend + docker compose -f docker-compose.yml ps + docker compose -f docker-compose.yml down + ``` + +## CI + +The GitHub Actions workflows already test against the `node-version` matrix entries. After the plan is applied, the **Main CI/CD Pipeline** must be green on the branch before merging: + +- `.github/workflows/build.yml` +- `.github/workflows/main.yml` +- `.github/workflows/docker.yml` + +If any workflow runs `nx affected`, it picks up the changed files automatically and runs the relevant projects. + +## Manual smoke (post-merge) + +- Spin up the local stack: `docker compose up -d`. +- Open the frontend on its dev URL and confirm the app loads. +- Hit the API health endpoint. +- Run one MCP server interaction end-to-end. + +## Failure handling + +If any harness step fails: + +1. Capture the exact error in the upgrade PR description. +2. Revert with `git revert <upgrade-commit>` rather than amending — keeps history auditable. +3. Re-run the skill on the reverted branch to regenerate a fresh plan once the upstream fix lands. diff --git a/.github/instructions/packmind-amplitude-analytics-usage.instructions.md b/.github/instructions/packmind-amplitude-analytics-usage.instructions.md new file mode 100644 index 000000000..3e79508d0 --- /dev/null +++ b/.github/instructions/packmind-amplitude-analytics-usage.instructions.md @@ -0,0 +1,11 @@ +--- +applyTo: '**' +--- +# Standard: Amplitude analytics usage + +* We use Amplitude to get insights about users' behavior when using our product with the different UI (CLI / MCP / web app). : +* Event name ends with the verb (e.g 'standard_created', 'user_signed_up') +* Property name should be in lower camel case +* Tracked event name should be snake cased + +Full standard is available here for further request: [Amplitude analytics usage](../../.packmind/standards/amplitude-analytics-usage.md) \ No newline at end of file diff --git a/.github/instructions/packmind-back-end-repositories-sql-queries-using-typeorm.instructions.md b/.github/instructions/packmind-back-end-repositories-sql-queries-using-typeorm.instructions.md new file mode 100644 index 000000000..813b48ead --- /dev/null +++ b/.github/instructions/packmind-back-end-repositories-sql-queries-using-typeorm.instructions.md @@ -0,0 +1,11 @@ +--- +applyTo: '**/infra/repositories/*.ts' +--- +## Standard: Back-end repositories SQL queries using TypeORM + +Implement SQL query guidelines using TypeORM's QueryBuilder in back-end repositories under /infra/repositories/*Repository.ts to enhance type safety, prevent SQL injection, and improve code maintainability when writing database queries, including lookups, joins, and handling soft-deleted entities. : +* Handle soft-deleted entities properly using withDeleted() or includeDeleted options. Always respect the QueryOption parameter when provided, and only include deleted entities when explicitly requested. +* Use IN clause with array parameterization for filtering by multiple values. Always pass arrays as spread parameters using :...paramName syntax to ensure proper parameterization. +* Use TypeORM's QueryBuilder with parameterized queries instead of raw SQL strings. Always pass parameters as objects to where(), andWhere(), and other query methods to prevent SQL injection and ensure type safety. + +Full standard is available here for further request: [Back-end repositories SQL queries using TypeORM](../../.packmind/standards/back-end-repositories-sql-queries-using-typeorm.md) \ No newline at end of file diff --git a/.github/instructions/packmind-backend-tests-redaction.instructions.md b/.github/instructions/packmind-backend-tests-redaction.instructions.md index 2bc1e6d8d..90b5bf112 100644 --- a/.github/instructions/packmind-backend-tests-redaction.instructions.md +++ b/.github/instructions/packmind-backend-tests-redaction.instructions.md @@ -3,7 +3,7 @@ applyTo: '**/*.spec.ts' --- # Standard: Backend Tests Redaction -Enforce Jest backend test conventions in Packmind **/*.spec.ts (verb-first names, behavioral assertions, nested `describe('when...')`, one `expect`, `afterEach` cleanup with `datasource.destroy()` and `jest.clearAllMocks()`, `toEqual` for arrays, and `stubLogger()` for typed `PackmindLogger` stubs) to improve readability, consistency, and debuggability while preventing inter-test pollution. : +This standard establishes best practices for writing backend tests using Jest in the Packmind monorepo. It focuses on clarity, maintainability, and consistency across test suites by emphasizing behavi... : * Avoid asserting on stubbed logger output like specific messages or call counts; instead verify observable behavior or return values * Avoid testing that a method is a function; instead invoke the method and assert its observable behavior * Avoid testing that registry components are defined; instead test the actual behavior and functionality of the registry methods like registration, retrieval, and error handling diff --git a/.github/instructions/packmind-changelog.instructions.md b/.github/instructions/packmind-changelog.instructions.md index 70cd42c5a..70d63b996 100644 --- a/.github/instructions/packmind-changelog.instructions.md +++ b/.github/instructions/packmind-changelog.instructions.md @@ -3,7 +3,7 @@ applyTo: 'CHANGELOG.MD' --- # Standard: Changelog -Maintain CHANGELOG.MD using Keep a Changelog format with a top [Unreleased] section linked to HEAD, ISO 8601 dates (YYYY-MM-DD), and per-release comparison links like [X.Y.Z]: https://github.com/PackmindHub/packmind/compare/release/<previous>...release/X.Y.Z to ensure accurate, consistent release documentation and version links. : +Maintain a consistent and well-structured CHANGELOG.MD file following the Keep a Changelog format to ensure all releases are properly documented with accurate version links and dates. This standard ap... : * Ensure all released versions have their corresponding comparison links defined at the bottom of the CHANGELOG.MD file in the format [X.Y.Z]: https://github.com/PackmindHub/packmind/compare/release/<previous>...release/X.Y.Z * Format all release dates using the ISO 8601 date format YYYY-MM-DD (e.g., 2025-11-21) to ensure consistent and internationally recognized date representation * Maintain an [Unreleased] section at the top of the changelog with its corresponding link at the bottom pointing to HEAD to track ongoing changes between releases diff --git a/.github/instructions/packmind-compliance-logging-personal-information.instructions.md b/.github/instructions/packmind-compliance-logging-personal-information.instructions.md index 7585cab0e..bc3c5e9b9 100644 --- a/.github/instructions/packmind-compliance-logging-personal-information.instructions.md +++ b/.github/instructions/packmind-compliance-logging-personal-information.instructions.md @@ -3,7 +3,7 @@ applyTo: '**/*.ts' --- # Standard: Compliance - Logging Personal Information -Enforce masking of personal information in TypeScript logs, using a standard first-6-characters-plus-* format for emails and similar patterns for other identifiers, to protect user privacy, comply with data protection regulations, and reduce security risks when handling user-related log entries. : +This standard ensures personal information is not exposed in application logs across all environments (development, staging, and production). Logs are often forwarded to external processors such as Da... : * Never log personal information in clear text across all log levels. Always mask sensitive data such as emails, phone numbers, IP addresses, and other personally identifiable information before logging. * Use the standard masking format of first 6 characters followed by "*" for logging user emails. This ensures consistency across the codebase and makes it easier to audit logs for compliance. diff --git a/.github/instructions/packmind-front-end-ui-and-design-systems.instructions.md b/.github/instructions/packmind-front-end-ui-and-design-systems.instructions.md new file mode 100644 index 000000000..bff90cdee --- /dev/null +++ b/.github/instructions/packmind-front-end-ui-and-design-systems.instructions.md @@ -0,0 +1,12 @@ +--- +applyTo: 'apps/frontend/**/*.tsx' +--- +## Standard: Front-end UI and Design Systems + +Adopt guidelines for using Chakra UI v3 through the @packmind/ui design system in React applications to ensure consistent UI implementation and visual consistency, applying this standard when building or modifying any frontend components. : +* Never use vanilla HTML tags (div, span, button, input, etc.) in frontend component code; always use corresponding @packmind/ui components (PMBox, PMText, PMButton, PMInput, etc.) to ensure consistent styling and theming. +* Prefer using the design token 'full' instead of the literal value '100%' for width or height properties in UI components to maintain consistency with the design system. +* Use components imported from '@packmind/ui' instead of '@chakra-ui' packages to maintain a consistent UI abstraction layer, e.g., import { PMButton } from '@packmind/ui'; not import { Button } from '@chakra-ui/react'; +* Use only semantic tokens to customize @packmind/ui components, such as colorPalette for color schemes, background.primary/secondary/tertiary for backgrounds, text.primary/secondary/tertiary for text colors, and border.primary/secondary/tertiary for borders, rather than hardcoded color values. + +Full standard is available here for further request: [Front-end UI and Design Systems](../../.packmind/standards/front-end-ui-and-design-systems.md) \ No newline at end of file diff --git a/.github/instructions/packmind-packmind-proprietary.instructions.md b/.github/instructions/packmind-packmind-proprietary.instructions.md new file mode 100644 index 000000000..206f479f3 --- /dev/null +++ b/.github/instructions/packmind-packmind-proprietary.instructions.md @@ -0,0 +1,9 @@ +--- +applyTo: '**' +--- +# Standard: Packmind Proprietary + +. : +* Never import something from '@packmind/editions', this is for OSS only + +Full standard is available here for further request: [Packmind Proprietary](../../.packmind/standards/packmind-proprietary.md) \ No newline at end of file diff --git a/.github/instructions/packmind-typescript-good-practices.instructions.md b/.github/instructions/packmind-typescript-good-practices.instructions.md index ab4ca3be6..617245ca2 100644 --- a/.github/instructions/packmind-typescript-good-practices.instructions.md +++ b/.github/instructions/packmind-typescript-good-practices.instructions.md @@ -3,7 +3,7 @@ applyTo: '**/*.ts' --- # Standard: Typescript good practices -Enforce TypeScript error and DTO conventions by prohibiting Object.setPrototypeOf in custom errors and requiring intersection types (DomainType & { extraField: T }) for presentation DTO enrichment to improve reliability and catch domain-field drift at compile time. : +Generic practices that can be applied for all TS code in our app : * Do not use `Object.setPrototypeOf` when defining errors. * When defining a presentation DTO that enriches a domain type, use an intersection type (`DomainType & { extraField: T }`) instead of manually re-declaring the domain type's fields, so that structural drift is caught at compile time. diff --git a/.github/skills/agent-skill-frontmatter-audit/SKILL.md b/.github/skills/agent-skill-frontmatter-audit/SKILL.md new file mode 100644 index 000000000..d1ad40de0 --- /dev/null +++ b/.github/skills/agent-skill-frontmatter-audit/SKILL.md @@ -0,0 +1,203 @@ +--- +name: 'agent-skill-frontmatter-audit' +description: 'Audit per-agent SKILL.md frontmatter support in Packmind against the current Agent Skills baseline specification and the latest official docs for each AI coding agent.' +--- + +# Agent Skill Frontmatter Audit + +Packmind renders skills (one of the three artefact types along with standards and commands) as a `SKILL.md` file with YAML frontmatter deployed into each AI coding agent's expected folder (`.claude/skills/`, `.cursor/skills/`, `.github/skills/`, `.agents/skills/`, `.opencode/skills/`, etc.). + +There is a shared **Agent Skills baseline specification** that all supporting agents commit to (the "core" fields every agent understands), and then each agent may publish its own documentation extending that baseline with agent-specific "additional properties". Both layers evolve over time: the baseline spec ships new versions, and vendors add, rename, or deprecate extensions. + +This skill audits Packmind's rendering against both layers and produces a dated report at the project root so drift can be tracked release over release. + +## Context + +The audit is a **four-way comparison** per agent: + +1. **Baseline spec** — the current Agent Skills specification at <https://agentskills.io/specification.md>. Defines the set of fields every compliant agent is expected to accept (typically `name`, `description`, and similar core keys). +2. **Upstream agent spec** — the agent's own public documentation page. May extend the baseline with agent-specific properties, or may be baseline-only. +3. **Packmind declared support** — the per-agent constants in `packages/types/src/skills/skillAdditionalProperties.ts`. +4. **Packmind actual rendering** — what each agent's deployer in `packages/coding-agent/src/infra/repositories/{agent}/{Agent}Deployer.ts` actually writes into the YAML frontmatter. + +The four pairs can disagree silently: + +- **Baseline vs. upstream** — a vendor page that omits a field the baseline requires is a vendor bug to note, not a Packmind action item. +- **Upstream vs. constants** — Packmind is behind (or ahead of) the vendor on agent-specific fields. +- **Constants vs. deployer** — internal drift; the "supported" promise in the codebase is a lie. +- **A dead doc URL** — the audit can no longer be trusted automatically for that agent. + +None of these produce runtime errors. A deprecated field is silently ignored by the agent; a missing field is a silent feature loss for users. The only way to catch either is an audit against the upstream spec — which is what this skill automates. + +### Core principle: baseline-only is fine + +Some agents choose to support **only** the baseline spec — no additional properties on top. That is a legitimate design choice and **must not be reported as a warning, gap, or drift**. Concretely: if an agent has no `_ADDITIONAL_FIELDS` constant in Packmind, no `filterAdditionalProperties` call in its deployer, and no agent-specific properties documented upstream, that agent is "baseline-only" and the report should mark it in sync with a short note like *"no additional properties — baseline spec only"*. + +Only flag a missing constant / missing filter **when the agent's own upstream documentation lists properties beyond the baseline**. Otherwise the absence is correct. + +## Sources in scope + +The baseline spec is fetched once. Each of the five agents is fetched individually. Keep this list verbatim — adding, removing, or re-pointing an entry is a skill update, not an ad-hoc decision. + +| Source | URL | Codebase locations | +| --- | --- | --- | +| **Baseline spec** | `https://agentskills.io/specification.md` | *(no single file — the baseline names map to core frontmatter emitted by every deployer)* | +| Claude Code | `https://code.claude.com/docs/en/skills` | `CLAUDE_CODE_ADDITIONAL_FIELDS` + `packages/coding-agent/src/infra/repositories/claude/ClaudeDeployer.ts` | +| GitHub Copilot | `https://code.visualstudio.com/docs/copilot/customization/agent-skills` | `COPILOT_ADDITIONAL_FIELDS` + `packages/coding-agent/src/infra/repositories/copilot/CopilotDeployer.ts` | +| Cursor | `https://cursor.com/docs/skills#frontmatter-fields` | `CURSOR_ADDITIONAL_FIELDS` + `packages/coding-agent/src/infra/repositories/cursor/CursorDeployer.ts` | +| OpenAI Codex | `https://developers.openai.com/codex/skills` | *(constant optional — declare one only if upstream lists additional properties)* + `packages/coding-agent/src/infra/repositories/codex/CodexDeployer.ts` | +| OpenCode | `https://opencode.ai/docs/skills/` | *(constant optional — declare one only if upstream lists additional properties)* + `packages/coding-agent/src/infra/repositories/opencode/OpenCodeDeployer.ts` | + +The canonical constants file is `packages/types/src/skills/skillAdditionalProperties.ts`. + +## Workflow + +Run the steps in order. Parallelise fetches and codebase reads whenever one step doesn't depend on another — the audit is network-heavy and the user is waiting for a report. + +### Step 1 — Fetch the baseline spec and all upstream docs + +Issue `WebFetch` calls in parallel: one for the baseline spec, plus one per agent URL. + +**For the baseline spec**, extract: +- The version identifier of the spec (if stated on the page), so the report can cite which baseline was used. +- The complete list of baseline frontmatter keys with whether each is required or optional, and a one-line description quoted from the spec. + +**For each agent doc**, extract: +- The complete list of YAML frontmatter keys the agent documents on `SKILL.md`. Separate baseline fields (those already in the baseline spec) from agent-specific additions. +- Any explicit deprecation markers (words like "deprecated", "removed", "no longer supported", "legacy", or strikethrough styling the page renders). +- A short one-line description of each agent-specific key, quoted from the vendor wording rather than invented. +- Whether the agent's docs explicitly declare "this agent supports only the baseline" or equivalent. If so, note it — it's the cleanest signal for the baseline-only classification. + +Classify the fetch result of every URL as one of: +- ✅ **Reachable and relevant** — the page loads and documents skills/frontmatter. +- ⚠️ **Redirected** — the URL resolves but lands on a different, still-relevant page. Record both the requested and resolved URLs. +- ❌ **Dead** — 404, 403, empty body, or the landing page no longer mentions skills/frontmatter. Record the exact failure signal. + +Do **not** fall back to training-data knowledge for a dead URL. If the baseline spec itself is dead, note that the entire audit cannot separate baseline from agent-specific fields and mark every per-agent classification as `(uncertain — baseline source unreachable)`. If an agent doc is dead, only that agent's findings are marked uncertain. + +### Step 2 — Read Packmind's declared support + +Read `packages/types/src/skills/skillAdditionalProperties.ts`. Extract: + +1. Each per-agent constant that exists (today: `CLAUDE_CODE_ADDITIONAL_FIELDS`, `COPILOT_ADDITIONAL_FIELDS`, `CURSOR_ADDITIONAL_FIELDS`). For each, capture the YAML key ↔ camelCase storage key mapping. Claude Code declares its fields as a `Record<string, string>` (YAML→camel); Cursor and Copilot declare a flat `string[]` of camelCase keys. Do not assume the shape is uniform. +2. `CLAUDE_CODE_ADDITIONAL_FIELDS_ORDER` — the canonical rendering order for Claude. A key that's in `CLAUDE_CODE_ADDITIONAL_FIELDS` but not in the order array (or vice-versa) is a **low-severity internal drift** worth mentioning. +3. Any new constant that may have been introduced for Codex or OpenCode since this skill was last updated. + +**The absence of a constant for Codex or OpenCode is not automatically a finding.** Whether it's a gap depends entirely on Step 1: if the agent's upstream doc lists no properties beyond the baseline, the absence is correct. If it does list extensions, then the absence is a missing-support finding. + +### Step 3 — Read the deployer for each agent + +For each agent, open its deployer file listed in the scope table. In the file, locate: + +- The method that produces the frontmatter block (typically `generateSkillMdContent` or similar). Read which keys it writes and in what order. +- Any call to `filterAdditionalProperties(...)`: which constant does it pass in? +- Any call to `sortAdditionalPropertiesKeys(...)`: missing here means non-deterministic YAML output for that agent's frontmatter. + +For Codex and OpenCode, the current pattern is to delegate multi-file skill deployment to `SingleFileDeployer`/base-class logic (see `packages/coding-agent/src/infra/repositories/utils/` and `defaultSkillsDeployer/`). If the deployer has no skill-specific frontmatter method at all, record `(inherits base deployer; baseline fields only)` as the deployer behaviour. + +Treat a missing `filterAdditionalProperties` call as a concern **only when** the agent's upstream doc lists additional properties — in that case, the deployer either bypasses the declared list (leaking everything) or emits nothing agent-specific (leaking nothing, failing to expose supported fields). Figure out which from the emitted-keys list and classify accordingly. If the agent is baseline-only upstream, no filter is needed and no finding should be raised. + +### Step 4 — Classify every property + +For each agent, build a single combined set of keys = (baseline keys) ∪ (agent-upstream keys) ∪ (constant keys) ∪ (deployer-emitted keys). For each key, assign exactly one classification: + +- ✅ **In sync (baseline)** — a baseline key, supported by the agent upstream and emitted by the deployer. Expected state; keep it concise in the table. +- ✅ **In sync (agent-specific)** — an agent-specific key that upstream lists, the constant declares, and the deployer emits. +- ➕ **Missing in Packmind** — upstream lists it (baseline or agent-specific) but Packmind doesn't support it. This is the primary "we're behind the spec" bucket. +- ➖ **Deprecated / removed upstream** — the constant or deployer supports it, but upstream no longer lists it (or explicitly deprecates it). Packmind is still shipping a field the agent will ignore. +- 🔀 **Internal drift** — constant and deployer disagree with each other, independent of upstream. Examples: the constant declares a key the deployer never emits; the deployer emits a key the constant never declared; the `_ORDER` array and the `_FIELDS` map disagree. +- ❓ **Uncertain** — the relevant upstream source is unreachable (from Step 1). Do not guess. + +**Do not classify baseline-only as a gap.** If the agent's upstream lists no extensions and Packmind declares none, there is nothing to report for additional properties beyond a single one-line note per agent saying *"baseline spec only — no additional properties"*. + +Prefer concrete, linkable evidence for every classification. "Missing" must cite the exact vendor section that lists the property; "Deprecated" must cite the exact file:line that still supports it. + +### Step 5 — Structural gaps (conditional) + +Beyond per-property drift, surface structural gaps at the agent level **only when the evidence warrants it**. Do not raise any of the following for an agent that is legitimately baseline-only: + +- Raise *"no constant declared"* **only if** upstream lists additional properties for that agent and Packmind has no way to emit them. An agent with no upstream extensions and no Packmind constant is in sync, not gapped. +- Raise *"deployer bypasses `filterAdditionalProperties`"* **only if** upstream lists additional properties and the deployer writes the additional-props blob without filtering (so unrelated fields could leak through). +- Raise *"deployer renders non-deterministic frontmatter order"* **only if** the deployer actually emits additional properties without a sort call. If there are no additional properties to sort, the absence of `sortAdditionalPropertiesKeys` is fine. +- Raise *"upstream URL redirected or 404d"* whenever Step 1 classified the URL as ⚠️ or ❌ — this is always a finding independent of property state. + +If all structural checks pass for every agent, write *"None — all agents structurally aligned with their upstream commitments."* The empty section is still worth keeping so the reader knows the check happened. + +### Step 6 — Write the report + +Produce a single markdown file at the project root, named using the current date from the system (today is e.g. `2026-04-21` → `skills_properties_2026_04_21.md`). The filename uses underscores between year, month, and day to match the user's convention. Inside the file, the heading date can use dashes (`2026-04-21`) for readability. + +Use this exact structure: + +```markdown +# Agent Skill Frontmatter Audit — YYYY-MM-DD + +## Summary + +- Baseline spec version: <version or "unversioned — fetched YYYY-MM-DD"> +- Agents audited: N +- Upstream URL health: X/(N+1) reachable and relevant (including baseline) +- Baseline-only agents: … +- Properties in sync: … +- Missing in Packmind: … +- Deprecated / removed upstream: … +- Internal drift findings: … +- Structural gaps: … + +## Upstream URL health + +| Source | Requested URL | Status | Resolved URL / Notes | +| --- | --- | --- | --- | +| Baseline spec | … | ✅/⚠️/❌ | … | +| <Agent> | … | ✅/⚠️/❌ | … | + +## Baseline spec + +- **Source:** <url> (<status>) +- **Version / fetched at:** … +- **Core fields:** list the baseline keys with required/optional and a one-line description each. + +## Per-agent findings + +### <Agent name> +- **Upstream docs:** <url> (<status>) +- **Baseline-only?** Yes / No +- **Declared constant:** `<CONSTANT_NAME>` — `packages/types/src/skills/skillAdditionalProperties.ts:<line>` *(or "none declared — expected for baseline-only agents")* +- **Deployer:** `packages/coding-agent/src/infra/repositories/<agent>/<Agent>Deployer.ts:<line>` — filters via `<constant>` / inherits base / no filter + +If the agent is baseline-only and in sync, a single sentence is enough: *"Baseline spec only — no additional properties. Packmind emits the baseline fields via the <deployer/base-class> path; no further action needed."* + +Otherwise, include the property table: + +| Property | Classification | Baseline | Upstream | Constant | Deployer | Notes | +| --- | --- | --- | --- | --- | --- | --- | +| … | ✅/➕/➖/🔀/❓ | ✓/✗ | ✓/✗ | ✓/✗ | ✓/✗ | short rationale / vendor quote | + +**Action items** (omit section if empty) +- ➕ Add `<prop>` to `<CONSTANT>` and emit in `<Deployer>` — upstream lists it under "<section>". +- ➖ Remove `<prop>` from `<CONSTANT>` (and deployer if applicable) — upstream no longer documents it. +- 🔀 `<prop>` is declared in `<CONSTANT>` but never emitted by `<Deployer>`; pick one source of truth. + +*(Repeat for each of the five agents, even if findings are empty — an empty section still documents that the agent was checked.)* + +## Structural gaps + +(List only the conditional gaps from Step 5. If none, write "None — all agents structurally aligned with their upstream commitments.") + +## Recommendations + +Prioritised, plain-language next steps. For each, name the file(s) to touch and why. When a property is newly deprecated upstream, prefer a deprecation path (leave supported for one release, emit a warning, remove next release) rather than immediate removal — users may have existing skill artefacts that rely on the field. +``` + +Write only the report — no preamble, no "here is the report" sentence. When the skill is invoked the user wants the report itself at the project root, plus a one-line confirmation of where the file was written. + +## Notes and edge cases + +- **Baseline-only is a valid state.** The skill must never treat the absence of additional properties as a problem. Codex and OpenCode today are typical examples — if their vendor docs don't list extensions beyond the baseline, they're in sync. +- **Dates use underscores in the filename.** `skills_properties_2026_04_21.md`, not `-2026-04-21-` and not without a date. If the user asks for the audit twice the same day, overwrite the file — the day's audit is the authoritative snapshot. +- **Do not invent URLs.** If the user supplies a replacement URL for a dead one, use it and note the substitution in the URL-health table. Otherwise, mark the source uncertain. +- **Do not scan deployed example SKILL.md files** (e.g. what's under `.claude/skills/` in the user's projects) to infer support. The contract is the deployer code and the constants, not the artefacts they happen to have produced so far — an unused supported field would be invisible in that sample. +- **Claude Code is the reference implementation.** Its constants map (`CLAUDE_CODE_ADDITIONAL_FIELDS`) and order array (`CLAUDE_CODE_ADDITIONAL_FIELDS_ORDER`) are the richest and most mature. When in doubt about how a new Codex/OpenCode constant should be shaped (in the event they ever need one), mirror the Claude Code pattern rather than inventing a third shape. +- **Core / baseline fields are tracked by the baseline source, not per-agent.** Put baseline-level observations in the "Baseline spec" section, not duplicated under every agent. Per-agent sections only need to confirm baseline support and then focus on additional properties. +- **Changes in scope (new agents, removed agents, URL updates) are skill edits.** Don't silently drop an agent because its URL 404s this week — that's a finding, not a scope change. +- **Run sequencing.** Fetches (Step 1, including the baseline) and codebase reads (Steps 2–3) don't depend on each other; kick them off in parallel. Steps 4–6 depend on both, so they run after. \ No newline at end of file diff --git a/.github/skills/amplitude-listener-events-adoption/SKILL.md b/.github/skills/amplitude-listener-events-adoption/SKILL.md new file mode 100644 index 000000000..950bbcf1e --- /dev/null +++ b/.github/skills/amplitude-listener-events-adoption/SKILL.md @@ -0,0 +1,173 @@ +--- +name: 'amplitude-listener-events-adoption' +description: 'Report organization adoption in Amplitude for the most recently added domain events in the AmplitudeEventListener. Walks git history on `packages/amplitude/src/application/AmplitudeEventListener.ts` to find the last 15 still-subscribed events, queries the Packmind V3 [CLOUD] Amplitude project via the MCP, and produces a markdown report grouped by domain prefix (text before the first underscore). Triggers when the user asks to audit recent amplitude listener events, check which orgs use new domain events, measure adoption of recently added events, or requests an amplitude report on space/playbook/standard/etc. events.' +--- + +# Amplitude Listener Events Adoption + +Produce a markdown adoption report for the **last 15 domain events** subscribed in the Amplitude listener, grouped by domain, listing organizations that triggered each event and how many times. + +## Prerequisites + +- The Amplitude MCP must be connected (tools prefixed `mcp__amplitude__`). If not, prompt the user to run `/mcp`. +- Target project: **Packmind V3 [CLOUD]**, `appId = 100032310`. Never query the DEV or legacy projects unless the user explicitly asks. + +## Inputs + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `N` | 15 | Number of most-recently-added events to report on | +| `range` | `Last 30 Days` | Amplitude date range | +| Excluded orgs | see below | Defaults applied automatically; user may extend the list | +| Excluded events | see below | Defaults applied automatically; user may extend the list | + +## Organizations exclusion + +Always exclude these internal/test organizations from the report (apply **after** querying Amplitude, before aggregation): + +- `AI Configuration orga` +- `test_package_standards` +- `packmind` +- `Joan's orga` +- `demo-orga` +- `VIncent test` +- `cedric.teyton's organization` +- `cedric-test` +- `test-skills` + +Match on the exact organization `grp:name` value returned by Amplitude. Extend this list if the user provides additional orgs to skip. + +## Events exclusion + +Always exclude these Amplitude event names from the report even if they are among the last N subscribed in the listener: + +- `user_signed_in` +- `new_organization_created` +- `user_signed_up` + +Rationale: these three events fire once per user/organization creation and produce extreme long tails (hundreds of orgs with a single event each), drowning out the actual feature-adoption signal the report is built for. + +Drop excluded events from the event-name list **before** building the Amplitude query (do not count them toward `N` — select the next most recent event to fill the quota). Extend this list if the user provides additional events to skip. + +## Workflow + +### Step 1 — Identify the last N still-subscribed events + +Source file: `packages/amplitude/src/application/AmplitudeEventListener.ts`. + +1. Read the current file and collect the set of event **class names** that appear in `this.subscribe(<Event>, ...)` inside `registerHandlers()`. This is the "still-subscribed" set. +2. For each class, also extract the corresponding **Amplitude event name** (the string literal passed as the 2nd argument to `this.emitAmplitudeEvent(event, '<name>', ...)` in the handler). This is what Amplitude stores. +3. Walk the file's git history newest→oldest: + + ```bash + git log --all --follow --format="%h|%ai|%s" -- packages/amplitude/src/application/AmplitudeEventListener.ts + ``` + +4. For each commit, extract added `this.subscribe(` lines: + + ```bash + git show --format="" <commit> -- packages/amplitude/src/application/AmplitudeEventListener.ts \ + | grep -E "^\+ *this\.subscribe\(" + ``` + +5. Walk commits in order; for every added class name that is also in the still-subscribed set, record `(amplitude_event_name, class_name, commit_date, commit_sha)` once (deduplicate by class — only keep the **most recent** addition). Stop once `N` unique events are collected. +6. Discard entries for events that were later removed or renamed out of the current file (e.g. `SpaceRenamedEvent` → filtered because no longer in the current `registerHandlers`). + +Multi-line `this.subscribe(\n EventName,\n ...)` must also be captured — widen the grep context (`grep -A2`) when a line ends just after `this.subscribe(`. + +**Efficient commit walk**: rather than running `git show` once per commit interactively, batch the first ~40 commits returned by `git log` and loop in a single `bash` call: + +```bash +for c in $(git log --all --follow --format="%h" -- packages/amplitude/src/application/AmplitudeEventListener.ts | head -40); do + echo "=== $c ===" + git show --format="" $c -- packages/amplitude/src/application/AmplitudeEventListener.ts \ + | grep -E "^\+ *this\.subscribe\(" -A1 +done +``` + +This surfaces all added `this.subscribe(` lines with commit boundaries in one tool call, which keeps token cost low and preserves the newest→oldest ordering needed for dedupe. + +### Step 2 — Query Amplitude + +Use `mcp__amplitude__query_dataset` with: + +- `projectId`: `"100032310"` +- `definition`: + ```json + { + "type": "eventsSegmentation", + "app": "100032310", + "params": { + "range": "Last 30 Days", + "events": [ /* one entry per amplitude event name */ + { "event_type": "<name>", "filters": [], "group_by": [] } + ], + "metric": "totals", + "countGroup": "organization", + "groupBy": [{ "type": "group", "group_type": "organization", "value": "grp:name" }], + "interval": 30, + "segments": [{ "conditions": [] }] + } + } + ``` +- `groupByLimit`: `200` +- `timeSeriesLimit`: `0` + +**Critical gotcha**: the CSV returned by `query_dataset` *omits* the organization name column when `groupBy` is set on a group property. Re-query using `mcp__amplitude__query_chart` with the `chartEditId` returned by `query_dataset`; that response includes the real org-name column. Always do this second call to get usable data. + +If the response payload is large, rely on `timeSeriesLimit: 0` (totals-only) to keep it compact. + +### Step 3 — Aggregate and format + +Parse the CSV rows from `query_chart`. The response contains header rows (chart name, list of events), an empty separator row, and then a data section starting with a `["Event", "name", "<interval-label-1>", "<interval-label-2>", ...]` row followed by data rows. + +- **Row shape with `timeSeriesLimit: 0`**: each data row is `[event_name, org_name, <interval_1_count>, <interval_2_count>, ...]`. There is **no trailing grand-total column** in that mode — sum the interval columns yourself to get the per-org total for the window. A 30-day range typically yields two interval columns (one per calendar month touched). +- **Ignore blank or placeholder org rows**: rows with `org_name == ""` or `org_name == "(none)"` represent events fired outside any organization context and must be skipped. + +Aggregation is mechanical and repetitive across 10+ events — use a small inline Python script via `Bash` with `python3 -` or a heredoc to load the parsed rows, apply the exclusion list, sum intervals, sort, and slice top-10 + `+K`. Doing this by hand for long tails (e.g. `standard_sample_selected` with 60+ orgs) is error-prone and wastes tokens. + +- Group events by **domain prefix** = substring before the first underscore in the Amplitude event name (`space_created` → `space`, `playbook_artefact_moved` → `playbook`, `change_proposal_submitted` → `change`, etc.). +- Inside each domain, list events in the order they appear in the source file (stable for the reader). +- Apply the **org exclusion list** before totaling. If no orgs remain for an event, treat it as zero-orgs. +- For each event: + 1. Start the line with the total number of **distinct organizations** that triggered it (after exclusions): `- <event> (N orgs): ...`. + 2. Sort organizations by their event count, descending; list at most the **top 10** with their counts: `orgA (42), orgB (18), ...`. + 3. If more than 10 orgs remain, append a trailing `+K` token where `K` = remaining org count. Example: `- space_created (23 orgs): orgA (9), orgB (7), ... orgJ (2) +13`. Never enumerate those remaining orgs by name; the `+K` is a trend signal only. +- **Collapse zero-adoption events into a single summary line** at the end of each domain (or at the end of the full report if the user prefers global grouping): `- No adoption (0 orgs): event_a, event_b, event_c`. + +### Step 4 — Emit the report + +Write the report to `amplitude-listener-events-adoption.md` at the project root (overwrite if it already exists). Also display the highlights inline in the chat reply so the user can scan them without opening the file. + +Skeleton: + +```markdown +# Amplitude adoption — last N listener events (Packmind V3 [CLOUD], <range>) + +Excluded orgs: <list>. Excluded events: <list>. + +## Domain: space +- space_created (23 orgs): orgA (9), orgB (7), orgC (6), orgD (5), orgE (4), orgF (3), orgG (3), orgH (2), orgI (2), orgJ (2) +13 +- space_members_added (4 orgs): orgA (18), orgB (12), orgC (5), orgD (1) +- ... +- No adoption (0 orgs): space_pinned, space_unpinned + +## Domain: playbook +- playbook_artefact_moved (2 orgs): orgA (40), orgC (4) + +... + +Source chart: [Open in Amplitude](<chartEditUrl>) +``` + +Keep the report concise — no per-month breakdown, no narrative paragraphs. The user wants a scan-friendly list. + +## Edge cases + +- **Fewer than N events in history**: report whatever is available and note the shortfall in one line. +- **Event renamed in listener but kept in Amplitude** (e.g. renaming `user_signup` → `user_signed_up`): trust the current file; query only the current Amplitude event name. +- **Event subscribed in listener but never emitted in prod**: it will appear as a zero-adoption event — that is the correct signal. +- **Duplicate commits touching the same subscribe line** (merges, reverts): keep the most recent non-reverted addition. +- **Group property is empty or placeholder** (`""` or `"(none)"` org name row): ignore that row — the event fired outside any org context. +- **Event also tracked with an identify call** (e.g. `OrganizationCreatedEvent` triggers `identifyOrganizationGroup` in addition to the tracked event): this does not affect the adoption query but explains why some org names appear populated only after the event fires once. +- **All remaining orgs are in the exclusion list**: emit the event under the `No adoption (0 orgs): …` summary line — do not emit a misleading `(N orgs)` header when the real external count is zero. \ No newline at end of file diff --git a/.github/skills/datadog-analysis/SKILL.md b/.github/skills/datadog-analysis/SKILL.md new file mode 100644 index 000000000..54d6d9d0b --- /dev/null +++ b/.github/skills/datadog-analysis/SKILL.md @@ -0,0 +1,102 @@ +--- +name: 'datadog-analysis' +description: 'Analyze Datadog error logs for Packmind production services (api-proprietary, mcp-proprietary, frontend-proprietary), group them into patterns, root-cause against the codebase, and produce a structured bug report. Triggers on Datadog, production logs, prod errors, service health, or periodic error reviews.' +--- + +# Datadog Analysis + +Analyze production error logs from Packmind Datadog services, group them into patterns, cross-reference stack traces with the codebase, and produce a structured markdown report with root causes and Datadog search patterns. + +## Prerequisites + +- The Datadog MCP server must be connected. If not connected, prompt the user to run `/mcp` first. +- Read `references/datadog_mcp.md` before making any MCP tool calls for guidance on tool usage, gotchas, and known pitfalls. + +## Services + +The analysis covers three production services. Each maps to a Datadog service name, a codebase location, and a Dockerfile: + +| Datadog service | App | Codebase | Dockerfile | Runtime | +|----------------|-----|----------|------------|---------| +| `api-proprietary` | API | `apps/api/` + all `packages/` | `dockerfile/Dockerfile.api` | Node.js (NestJS, TypeORM, Redis/ioredis, BullMQ) | +| `mcp-proprietary` | MCP Server | `apps/mcp-server/` + all `packages/` | `dockerfile/Dockerfile.mcp` | Node.js (tree-sitter, SSE) | +| `frontend-proprietary` | Frontend | `apps/frontend/` | `dockerfile/Dockerfile.frontend` | Nginx (static SPA serving) | + +Root cause analysis should trace errors back to source files in the monorepo. For Nginx (frontend), also check the Nginx configs in `dockerfile/nginx.*.conf` and the entrypoint `dockerfile/nginx-entrypoint.sh`. + +## Parameters + +| Parameter | Default | Description | +|-----------|---------|-------------| +| Days to analyze | 7 | Number of past days to look at. Override by user request (e.g., "last 3 days") | + +## Exclusions + +The following log patterns should be **discarded** and not included in the report. Skip them during pattern discovery and do not count them as errors: + +- `(node:1) [DEP0060] DeprecationWarning: The util._extend API is deprecated. Please use Object.assign() instead.` -- Known Node.js deprecation from a transitive dependency. Noise, not actionable. Filter with `-DEP0060`. + +- Nginx stale asset 404s (`open() "/usr/share/nginx/html/assets/..." failed (2: No such file or directory)`) -- Expected SPA behavior after deployments. Browsers with a cached `index.html` request old hashed JS chunks that no longer exist. Not a bug. Filter with `-"No such file or directory" -"/assets/"` on `frontend-proprietary`. + +When filtering in Phase 1, exclude these patterns from the analysis by appending the exclusion terms to Datadog queries, or remove them during report consolidation. + +## Workflow + +### Phase 1: Discover Error Patterns (all services in parallel) + +For **each** of the three services, launch two parallel MCP calls (6 calls total, all in parallel). If rate-limited by the MCP server, fall back to batching 2 calls per service sequentially. + +Every Datadog MCP call requires a `telemetry` object with an `intent` string describing the call's purpose (e.g., `{"intent": "Discover error patterns for api-proprietary over last 7 days"}`). Keep intents concise and avoid including PII or secrets. + +1. **Pattern discovery** -- Use `mcp__datadog-mcp__search_datadog_logs` with: + - `query`: `service:{service_name} status:(error OR critical OR emergency)` + - `from`: `now-{N}d` (where N = number of days, default 7) + - `use_log_patterns`: `true` + - `max_tokens`: `10000` + +2. **Error message counts** -- Use `mcp__datadog-mcp__analyze_datadog_logs` with: + - `filter`: `service:{service_name} status:(error OR critical OR emergency)` + - `sql_query`: `SELECT message, count(*) as cnt FROM logs GROUP BY message ORDER BY cnt DESC LIMIT 50` + - `from`: `now-{N}d` + - `max_tokens`: `10000` + +From these results, identify the distinct error groups per service. If a service has zero errors in the period, mention "No issues found" in the report and skip Phases 2-3 for that service. + +### Phase 2: Deep Dive Each Error Group + +For each distinct error group identified in Phase 1: + +1. **Fetch raw logs** -- Use `search_datadog_logs` with a targeted query to get full stack traces and context. Use `extra_fields: ["*"]` for tag metadata when useful. + +2. **Get daily distribution** -- Use `analyze_datadog_logs` with: + - `sql_query`: `SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt FROM logs WHERE message LIKE '%<pattern>%' GROUP BY DATE_TRUNC('day', timestamp) ORDER BY DATE_TRUNC('day', timestamp)` + +3. **Count occurrences** -- Use `analyze_datadog_logs` to get total unique occurrences grouped by message. + +Parallelize independent MCP calls wherever possible to save time. + +#### Frontend-Specific Notes + +For `frontend-proprietary`, Nginx writes all `error_log` output (including `[notice]`) to stderr. Datadog classifies stderr as `status:error`. Filter out Nginx lifecycle noise: +- Ignore patterns containing `[notice]` (worker start/stop, SIGQUIT, SIGCHLD, SIGIO) -- these are normal Nginx operations misclassified as errors +- Focus on `[error]` (404s for missing files) and `[alert]` (permission issues, config errors) + +### Phase 3: Codebase Root Cause Analysis + +For each application-level error (not infra/external): + +Before grepping, consult `references/known_patterns.md` — if the error matches a catalogued pattern, jump straight to its entry point and skip to step 2. + +1. **Grep for the error class or message** in the codebase using the Grep tool (e.g., `SpaceMembershipRequiredError`, `Recipe.*not found`) +2. **Read the source files** where the error is thrown +3. **Trace the call chain**: error class -> service/use case -> controller/adapter +4. **Identify the root cause**: missing error handling, wrong HTTP status, race condition, missing validation, Dockerfile misconfiguration, etc. + +For frontend Nginx errors, check: +- `dockerfile/Dockerfile.frontend` for permission/ownership issues +- `dockerfile/nginx.k8s.conf`, `dockerfile/nginx.k8s.no-ingress.conf`, `dockerfile/nginx.compose.conf` for config issues +- `dockerfile/nginx-entrypoint.sh` for entrypoint issues + +### Phase 4: Generate Report + +Read `references/report-template.md` before writing the report for the output path, scaffold, severity ordering, occurrence labels, and final summary table. \ No newline at end of file diff --git a/.github/skills/datadog-analysis/references/datadog_mcp.md b/.github/skills/datadog-analysis/references/datadog_mcp.md new file mode 100644 index 000000000..c69e11aae --- /dev/null +++ b/.github/skills/datadog-analysis/references/datadog_mcp.md @@ -0,0 +1,158 @@ +# Datadog MCP Server Reference + +Reference guide for using the Datadog MCP server tools. Read this before making any Datadog MCP calls. + +## Available Tools + +### `mcp__datadog-mcp__search_datadog_logs` + +**Purpose**: Search and retrieve raw log entries or log patterns. Best for viewing raw logs, discovering patterns, and discovering custom attributes. + +**Key parameters**: +- `query` (required): Datadog search query using `key:value` syntax +- `from` / `to`: Time range. Use relative format `now-Xh`, `now-Xd`. Default: last 1 hour +- `use_log_patterns` (boolean): When `true`, clusters similar log messages into patterns with counts instead of returning raw logs. Essential for the initial discovery phase. +- `extra_fields` (array): Include extra attributes/tags. Use `["*"]` to get all metadata (tags, pod names, container info, etc.) +- `max_tokens`: Cap response size. Default 5000, use 10000 for pattern discovery +- `start_at`: Pagination offset for large result sets + +**When to use**: +- Initial pattern discovery (`use_log_patterns: true`) +- Fetching full stack traces for specific errors +- Getting tag metadata with `extra_fields: ["*"]` + +### `mcp__datadog-mcp__analyze_datadog_logs` + +**Purpose**: Run SQL queries against logs. Best for aggregations, counts, group-bys, and time-series analysis. + +**Key parameters**: +- `sql_query` (required): SQL against a virtual `logs` table +- `filter`: Datadog search query to pre-filter logs before SQL +- `from` / `to`: Time range (same format as search) +- `extra_columns`: Extend the `logs` table with typed columns from log attributes +- `max_tokens`: Default 10000 + +**Default columns**: `timestamp`, `host`, `service`, `env`, `version`, `status`, `message` + +**When to use**: +- Counting errors by message, day, or host +- Time-series aggregations (`DATE_TRUNC`) +- Top-N queries + +## Query Syntax + +### Datadog Search Query (for `query` / `filter` parameters) + +``` +service:api-proprietary status:error # Basic tag filtering +service:api-proprietary status:(error OR critical) # OR within a tag +"exact phrase match" # Quoted exact match +service:api-proprietary "Recipe" "not found" # Multiple terms (AND) +@http.status_code:[400 TO 499] # Range on raw attribute +-version:beta # Exclusion +service:web* # Wildcard +``` + +### DDSQL (for `sql_query` parameter) + +DDSQL is a PostgreSQL subset with restrictions: +- Every non-aggregated `SELECT` column must appear in `GROUP BY` +- `SELECT` aliases **cannot** be reused in `WHERE`, `GROUP BY`, or `HAVING` -- repeat the full expression instead +- Only declared table columns may be referenced +- Use `->` or `json_extract_path_text` for JSON access (cast as needed) +- Column names with special characters like `@` must be quoted: `SELECT "@foo" FROM logs` + +**Unsupported**: `ANY()`, `->>`, `information_schema`, `current_timestamp` + +**Common patterns**: + +```sql +-- Count by message +SELECT message, count(*) as cnt FROM logs GROUP BY message ORDER BY cnt DESC LIMIT 50 + +-- Daily distribution +SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt +FROM logs +GROUP BY DATE_TRUNC('day', timestamp) +ORDER BY DATE_TRUNC('day', timestamp) + +-- Filter with LIKE +SELECT message, count(*) as cnt +FROM logs +WHERE message LIKE '%SpaceMembershipRequiredError%' +GROUP BY message ORDER BY cnt DESC LIMIT 10 + +-- Hourly breakdown +SELECT DATE_TRUNC('hour', timestamp) as hour, count(*) as cnt +FROM logs +GROUP BY DATE_TRUNC('hour', timestamp) +ORDER BY DATE_TRUNC('hour', timestamp) +``` + +## Gotchas and Lessons Learned + +### 1. Pattern search vs raw search return different formats + +- `use_log_patterns: true` returns **TSV_DATA** with columns: `first_seen`, `last_seen`, `count`, `status`, `pattern` +- Raw search returns **TSV_DATA** or **YAML_DATA** with individual log entries +- Pattern search is much more useful for initial discovery -- always start with it + +### 2. Datadog search does NOT support regex -- use LIKE in SQL as fallback + +Queries like `"Recipe * not found"` with wildcards in quoted strings will return 0 results. Instead: +- Use multiple quoted terms: `"Recipe" "not found"` (matches logs containing both) +- Or use unquoted keywords: `Recipe not found` (matches as AND) +- Wildcards only work on tag values: `service:web*` + +When `search_datadog_logs` still returns 0 results for a complex pattern, fall back to `analyze_datadog_logs` with `WHERE message LIKE '%pattern%'`. The SQL LIKE operator works on the raw message text and is more flexible than the search query syntax. + +### 3. Multi-line stack traces are separate log entries + +In Datadog, each line of a stack trace is typically a separate log entry. A single exception with a 10-line stack trace produces 10 log entries. This means: +- Raw log counts are inflated (one error = many log lines) +- Pattern discovery helps group these related lines +- To find the actual error message, filter for the line containing the exception class name (e.g., `ExceptionsHandler`) + +### 4. ANSI escape codes appear in log messages + +Production NestJS logs contain ANSI color codes like `[31m`, `[39m`, `[38;5;3m`. These appear in raw log output. When searching, ignore these codes -- they won't affect keyword matching but make messages harder to read visually. + +### 5. The `extra_fields: ["*"]` option returns extensive tag metadata + +Using `["*"]` returns all Kubernetes/cloud metadata (pod name, node, container, cluster, etc.). This is useful for: +- Identifying which pods are affected +- Determining if errors are node-specific +- Cross-referencing with infrastructure incidents + +But it significantly increases response size -- only use when needed. + +### 6. Time range format + +- Relative: Must start with `now-` (e.g., `now-5d`, `now-24h`, `now-30m`) +- Absolute: ISO 8601 (e.g., `2026-04-10T00:00:00Z`) +- Default is `now-1h` which is too short for most analyses -- always set explicitly + +### 7. Token limits and pagination + +- Default `max_tokens` is 5000 for search and 10000 for analyze +- If results are truncated, the response includes `is_truncated: true` and a `truncation_message` with the next `start_at` offset +- For pattern discovery, 10000 tokens is usually sufficient +- For raw log fetching with `extra_fields`, reduce the number of results or increase max_tokens + +### 8. DDSQL GROUP BY alias trap + +This fails: +```sql +SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt +FROM logs GROUP BY day -- ERROR: 'day' alias not recognized +``` + +This works: +```sql +SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt +FROM logs GROUP BY DATE_TRUNC('day', timestamp) -- Repeat full expression +``` + +### 9. Storage tier for older logs + +For logs older than the default retention period, use `storage_tier: "flex_and_indexes"` to also search Flex storage. This is only needed for the analyze tool; the search tool supports both `flex_and_indexes` and `online_archives_and_indexes`. diff --git a/.github/skills/datadog-analysis/references/known_patterns.md b/.github/skills/datadog-analysis/references/known_patterns.md new file mode 100644 index 000000000..18d908a7d --- /dev/null +++ b/.github/skills/datadog-analysis/references/known_patterns.md @@ -0,0 +1,23 @@ +# Known Error Patterns + +Pre-catalogued recurring error patterns and their codebase entry points. Consult during Phase 3 when an error matches a known signature — jump straight to the listed entry point and skip ad-hoc grepping. + +## API (`api-proprietary`) + +| Pattern | Datadog query | Codebase entry point | +|---------|--------------|---------------------| +| Redis connection failure | `service:api-proprietary status:error ETIMEDOUT OR ECONNREFUSED` | `ioredis` client, infra-level | +| Space membership check | `service:api-proprietary status:error SpaceMembershipRequiredError` | `packages/node-utils/src/application/AbstractSpaceMemberUseCase.ts` | +| Recipe not found | `service:api-proprietary status:error "Recipe" "not found"` | `packages/recipes/src/application/services/RecipeService.ts` | +| Artefact not found | `service:api-proprietary status:error "Artefact" "not found"` | `packages/playbook-change-management/src/application/services/validateArtefactInSpace.ts` | +| Sign-in failure | `service:api-proprietary status:error "Failed to sign in"` | `apps/api/src/app/auth/auth.controller.ts` | +| Onboarding status failure | `service:api-proprietary status:error "onboarding status"` | `apps/api/src/app/auth/auth.service.ts` | +| Amplitude tracking failure | `service:api-proprietary status:error Amplitude` | Amplitude Node.js SDK (external) | +| PG concurrent query | `service:api-proprietary status:error "client.query() when the client is already executing"` | `pg` driver through TypeORM | + +## Frontend (`frontend-proprietary`) + +| Pattern | Datadog query | Codebase entry point | +|---------|--------------|---------------------| +| Nginx PID unlink permission denied | `service:frontend-proprietary "unlink" "nginx.pid"` | `dockerfile/Dockerfile.frontend:17` + `dockerfile/nginx.*.conf:3` | +| Nginx notice logs misclassified | `service:frontend-proprietary status:error "[notice]"` | Datadog log pipeline config (not code) | diff --git a/.github/skills/datadog-analysis/references/report-template.md b/.github/skills/datadog-analysis/references/report-template.md new file mode 100644 index 000000000..2e18b42f6 --- /dev/null +++ b/.github/skills/datadog-analysis/references/report-template.md @@ -0,0 +1,101 @@ +# Datadog Report Template + +Output path, scaffold, ordering, and labels for the Phase 4 report. + +## Output path + +Write the report to `datadog_{YYYY_MM_DD}.md` at the project root, where the date is today's date. + +## Report structure + +The report is organized with **one top-level section per application**, each containing its own issues: + +````markdown +# Datadog Error Report + +**Period**: {start_date} to {end_date} ({N} days) + +--- + +# API (`api-proprietary`) + +**Total error log lines**: ~{count} + +| Day | Error count | +|-----------|------------| +| {date} | {count} | + +## 1. {Issue Title} + +**Occurrences**: {frequency description} + +**Datadog search pattern**: +``` +service:api-proprietary status:error {specific pattern keywords} +``` + +**Description**: {What the error is and how it manifests} + +**Root cause**: {Analysis with source file paths and line numbers from the codebase.} + +**Source**: `{file_path}:{line_number}` + +--- + +## 2. {Next Issue} +... + +--- + +# MCP Server (`mcp-proprietary`) + +**Total error log lines**: ~{count} + +| Day | Error count | +|-----------|------------| +| {date} | {count} | + +## 1. {Issue Title} +... + +--- + +# Frontend (`frontend-proprietary`) + +**Total error log lines**: ~{count} (excluding Nginx [notice] noise) + +| Day | Error count | +|-----------|------------| +| {date} | {count} | + +## 1. {Issue Title} +... +```` + +## Ordering (within each section) + +Sort issues by severity: +1. Infrastructure errors (Redis, database connectivity, Nginx permission errors) +2. Application bugs (unhandled exceptions returning 500, missing static assets) +3. External service failures (Amplitude, third-party APIs) +4. Deprecation warnings (Node.js, library deprecations) +5. Expected user errors logged at wrong level (failed logins) + +## Occurrence labels + +Use these labels based on the pattern: +- **ONCE** -- single occurrence in the period +- **{N} TIMES** -- N occurrences total, no clear daily pattern +- **{N} DAYS THIS WEEK** -- recurring across N distinct days +- **ALL {N} DAYS** -- present every day in the period +- **{N} lines, {M} day(s)** -- for high-volume infra errors, specify raw log line count and day spread + +## Summary table + +After writing the report, print a summary table covering all services: + +| Service | # | Issue | Occurrences | Severity | +|---------|---|-------|-------------|----------| +| API | 1 | ... | ... | High/Medium/Low | +| MCP | 1 | ... | ... | High/Medium/Low | +| Frontend | 1 | ... | ... | High/Medium/Low | diff --git a/.github/skills/doc-audit/SKILL.md b/.github/skills/doc-audit/SKILL.md new file mode 100644 index 000000000..c5ffcc215 --- /dev/null +++ b/.github/skills/doc-audit/SKILL.md @@ -0,0 +1,140 @@ +--- +name: 'doc-audit' +description: 'Audit Packmind end-user documentation (apps/doc/) for broken links, outdated CLI references, non-existent concepts, misleading information, and missing coverage. Produces a structured markdown report at project root. Use when docs may have drifted from the codebase, before a release, or on a regular cadence.' +--- + +# Documentation Audit + +Detect outdated, broken, or misleading documentation by cross-referencing MDX pages against the actual codebase. Produces a structured `doc-audit-report.md` at the project root. + +**This skill only detects issues — it does not fix them.** + +## Phase 1: Build Ground Truth + +Before launching any sub-agents, build a concise ground truth summary by gathering these four data sources: + +1. **Navigation structure** — Read `apps/doc/docs.json` and extract all navigation groups with their page lists +2. **CLI commands** — List files in `apps/cli/src/infra/commands/` to get current command files +3. **Domain packages** — List directories in `packages/` to get current package names +4. **Doc MDX files** — Glob `apps/doc/**/*.mdx` to get all actual pages on disk + +Compile these into a **ground truth summary** string formatted as: + +``` +## Ground Truth + +### Navigation Groups (from docs.json) +- Getting Started: index, getting-started/gs-install-cloud, ... +- Concepts: concepts/standards-management, ... +[list all groups] + +### CLI Commands (from apps/cli/src/infra/commands/) +[list all *Command.ts and *Handler.ts files] + +### Domain Packages (from packages/) +[list all package directory names] + +### MDX Files on Disk (from apps/doc/**/*.mdx) +[list all .mdx file paths relative to apps/doc/] + +### Current Date +{today's date} +``` + +## Phase 2: Launch Parallel Sub-Agents + +Launch **5 Explore sub-agents** in parallel (`subagent_type: Explore`), one per section group. Each agent receives: +- The ground truth summary from Phase 1 +- The full contents of `references/section-audit-instructions.md` (read this file and include its contents in each prompt) +- Its assigned section and list of MDX files to audit + +### Agent Assignments + +| Agent | Sections | Pages to Audit | +|-------|----------|----------------| +| 1 | Getting Started + root pages | `index.mdx`, `home.mdx` + all `getting-started/*.mdx` | +| 2 | Concepts | All `concepts/*.mdx` + `tools/import-from-knowledge-base.mdx` | +| 3 | Tools & Integrations | `tools/cli.mdx` | +| 4 | Governance + Playbook Maintenance + Linter | All `governance/*.mdx` + `playbook-maintenance/*.mdx` + `linter/*.mdx` | +| 5 | Administration + Security | All `administration/*.mdx` + `security/*.mdx` | + +### Agent Prompt Template + +Each agent's prompt should follow this structure: + +``` +You are auditing the {section_name} section of the Packmind documentation. + +## Your Assigned Pages +{list of MDX file paths to read and audit} + +## Ground Truth +{ground truth summary from Phase 1} + +## Audit Instructions +{full contents of references/section-audit-instructions.md} + +Read each assigned MDX page completely and apply all detection categories. Return your findings in the exact format specified in the instructions. +``` + +### Sequential Fallback + +If the Agent tool is unavailable, perform the audit sequentially: read each section's pages one by one and apply the same checks from `references/section-audit-instructions.md` directly. + +## Phase 3: Consolidate Report + +After all sub-agents complete: + +1. **Collect** all findings from the 5 agents +2. **Deduplicate** — remove exact duplicates (same page, same line, same issue) +3. **Sort** by severity: ERROR first, then WARNING, then INFO +4. **Group** by category within each severity level +5. **Write** the report to `doc-audit-report.md` at the project root + +### Report Format + +```markdown +# Documentation Audit Report +Generated: {date} | Pages audited: {count} + +## Summary +| Severity | Count | +|----------|-------| +| ERROR | N | +| WARNING | N | +| INFO | N | + +## Errors + +### [A] Broken Internal Links +- **{page}** (line ~{N}): Link to `{target}` — no matching MDX file exists +[... more findings] + +### [B] Outdated CLI Commands +- **{page}** (line ~{N}): References `packmind-cli {cmd}` — command not found in CLI source +[... more findings] + +### [C] Non-Existent Concepts +- **{page}** (line ~{N}): References `{concept}` — not found in codebase +[... more findings] + +## Warnings + +### [D] Misleading Information +- **{page}** (line ~{N}): "{quoted text}" — {reason} +[... more findings] + +## Info + +### [E] Missing Documentation Coverage +- CLI command `{cmd}` has no documentation +- Package `{pkg}` has no documentation page +[... more findings] +``` + +**Omit any category section that has zero findings.** Only include sections with actual results. + +After writing the report, print a brief summary: +- Total issues found per severity +- Top 3 most problematic pages (by issue count) +- The report file path \ No newline at end of file diff --git a/.github/skills/doc-audit/references/section-audit-instructions.md b/.github/skills/doc-audit/references/section-audit-instructions.md new file mode 100644 index 000000000..61ee255f0 --- /dev/null +++ b/.github/skills/doc-audit/references/section-audit-instructions.md @@ -0,0 +1,122 @@ +# Section Audit Instructions + +You are auditing a section of the Packmind end-user documentation. Your job is to cross-reference claims in the MDX pages against the actual codebase and ground truth data to find **concrete, verifiable issues only**. + +## Golden Rule + +**Only flag issues where you can point to a specific codebase artifact that contradicts the documentation.** Do not flag stylistic issues, subjective concerns, or things that "might" be wrong. Every finding must be backed by evidence. + +## Detection Categories + +### Category A: Broken Internal Links (ERROR) + +**What to check:** Links in MDX files that reference other doc pages (e.g., `/concepts/standards-management`, `getting-started/gs-cli-setup`). + +**How to verify:** Check if the link target resolves to an actual MDX file in the ground truth MDX file list. Mintlify resolves links relative to the `apps/doc/` root — a link to `/concepts/foo` should map to `apps/doc/concepts/foo.mdx`. + +**Valid finding:** +- Page links to `/concepts/workflow-management` but no `apps/doc/concepts/workflow-management.mdx` exists + +**Not a finding (false positive):** +- Links to external URLs (https://...) — do not check these +- Links to anchors within the same page (#section) +- Links listed in the `redirects` section of `docs.json` — these are handled by Mintlify + +### Category B: Outdated CLI Commands (ERROR) + +**What to check:** References to `packmind-cli <command>` or CLI command names in the documentation. + +**How to verify:** Check if the referenced command exists in the CLI commands ground truth (file list from `apps/cli/src/infra/commands/`). Command files follow the pattern `*Command.ts` or `*Handler.ts`. + +**Valid finding:** +- Doc references `packmind-cli migrate` but no `MigrateCommand.ts` or `migrateHandler.ts` exists in CLI source + +**Not a finding (false positive):** +- CLI flags or options (e.g., `--verbose`) — only check command names +- Generic references to "the CLI" without a specific command name + +### Category C: Non-Existent Concepts (ERROR) + +**What to check:** References to specific Packmind features, domain packages, or integrations that should exist in the codebase. + +**How to verify:** Check against the ground truth package list and codebase. Look for references to packages (e.g., `@packmind/some-package`), specific integration names, or feature names that imply codebase support. + +**Valid finding:** +- Doc describes a "Bitbucket integration" but no bitbucket-related package or code exists +- Doc references `@packmind/analytics` but that package doesn't exist in `packages/` + +**Not a finding (false positive):** +- High-level conceptual descriptions (e.g., "Packmind helps teams share knowledge") +- References to third-party tools that Packmind integrates with via configuration (not code) +- UI features that exist in the frontend but not as standalone packages +- Do not infer feature availability per edition (OSS vs. Enterprise vs. Cloud) from package paths. Edition gating is controlled by TypeScript path aliases in `tsconfig.paths.oss.json` vs `tsconfig.paths.proprietary.json`, not by directory structure — see Category D for the full explanation + +### Category D: Misleading Information (WARNING) + +**What to check:** +- Dates in the past presented as future events (e.g., "coming in Q2 2024" when it's 2026) +- Direct contradictions between two doc pages about the same feature +- References to deprecated or removed features still presented as current + +**How to verify:** Compare dates against the current date (provided in your context). Compare claims across pages for consistency. + +**Valid finding:** +- "This feature will be available in Q3 2024" — that date has passed +- Page A says "standards support Markdown only" while page B says "standards support Markdown and YAML" + +**Not a finding (false positive):** +- Vague future references ("we plan to add...") +- Minor wording differences between pages about the same concept +- Claims about feature availability per edition (e.g., "Enterprise only", "OSS only", "available in the Cloud version") cannot be verified by inspecting package directories. Edition gating in this repo is done at build time via TypeScript path aliases — `tsconfig.paths.oss.json` redirects `@packmind/<feature>` imports to `packages/editions/src/index.ts` (a **stubs aggregator** for the OSS build), while `tsconfig.paths.proprietary.json` redirects the same imports to the real top-level package such as `packages/linter/src/index.ts`. Consequences: (a) the presence of `packages/editions/src/oss/<feature>/` only means an OSS-build stub exists — it is **not evidence** the feature works in OSS; (b) the existence of a top-level `packages/<feature>/` package does **not** mean the feature ships in OSS, because the OSS tsconfig points elsewhere. Do not flag edition-availability claims based on either of these paths — verify against the two `tsconfig.paths.*.json` files if a check is unavoidable, otherwise skip. + +### Category E: Missing Documentation Coverage (INFO) + +**What to check:** CLI commands or domain packages that exist in the codebase but have no corresponding documentation page or section. + +**How to verify:** Compare the ground truth CLI commands and packages against what the documentation covers. A command is "covered" if it's mentioned on any doc page (typically `tools/cli.mdx`). A package is "covered" if its functionality is described somewhere in the docs. + +**Valid finding:** +- CLI has `SyncCommand.ts` but no doc page mentions the `sync` command +- Package `packages/linter` exists but no linter documentation page exists + +**Not a finding (false positive):** +- Internal/infrastructure packages (e.g., `node-utils`, `test-helpers`) — these are not user-facing +- Commands that are clearly internal or development-only + +## Expected Output Format + +Return your findings as a structured list, one per line, in this exact format: + +``` +[SEVERITY] [CATEGORY] **{page-path}** (line ~{N}): {description} +``` + +Where: +- `SEVERITY` is one of: `ERROR`, `WARNING`, `INFO` +- `CATEGORY` is one of: `A`, `B`, `C`, `D`, `E` +- `page-path` is the relative path from `apps/doc/` (e.g., `tools/cli.mdx`) +- `line ~{N}` is the approximate line number (use `~` since MDX rendering may shift lines) +- `description` is a concise explanation of the issue with evidence + +**Example output:** + +``` +[ERROR] [A] **getting-started/gs-onboarding.mdx** (line ~45): Link to `/concepts/workflow-management` — no matching MDX file exists in apps/doc/concepts/ +[ERROR] [B] **tools/cli.mdx** (line ~120): References `packmind-cli migrate` — no MigrateCommand.ts found in CLI source +[WARNING] [D] **concepts/standards-management.mdx** (line ~30): "Coming in Q2 2024" — date has passed (current date: 2026-03-12) +[INFO] [E] **N/A**: CLI command `SyncCommand.ts` has no documentation coverage +``` + +If you find **no issues** in your assigned section, return: + +``` +NO_ISSUES_FOUND +``` + +## Important Reminders + +- Read each MDX file **completely** — don't skip content +- Be thorough but precise — false positives waste time +- Include approximate line numbers to help locate issues +- For Category E, you only need to check commands/packages relevant to your assigned section +- Do NOT suggest improvements or rewrites — this is detection only diff --git a/.github/skills/feature-spec/SKILL.md b/.github/skills/feature-spec/SKILL.md new file mode 100644 index 000000000..7ee6ce9eb --- /dev/null +++ b/.github/skills/feature-spec/SKILL.md @@ -0,0 +1,135 @@ +--- +name: 'feature-spec' +description: 'Generate a Packmind feature specification from a GitHub issue, file, URL, or direct description. Breaks Phase 3 into 4 fine-grained approval gates (domain data structures, ports/use cases/routes, frontend components, implementation plan) to catch problems early. Routes work between the OSS sibling (../packmind) and the proprietary repo (cwd). Outputs spec artifacts under tmp/feature-specs/{slug}/ for use with /feature-sprint.' +--- + +# Feature Spec Skill + +Generate a comprehensive feature specification grounded in Packmind's hexagonal architecture, frontend gateway/PM-UI conventions, and the OSS/proprietary fork boundary. + +## When to Use + +Use this skill when the user wants to: +- Plan a new feature, fix, or refactor that spans Packmind's stack +- Convert a GitHub issue, design doc, or rough idea into a structured spec +- Get fine-grained validation of data structures, routes, and components before committing to a plan + +**Do NOT use** for trivial one-line fixes or pure dependency bumps. + +## Packmind Repo Layout (essential context) + +- Closed-source fork (`packmind-proprietary`): the current working directory when this skill runs. +- Open-source repo (`packmind`): the sibling directory at `../packmind` (cloned next to the proprietary repo). Resolve to an absolute path at runtime with `realpath ../packmind`. +- **Most feature work happens on the OSS repo and auto-merges into the proprietary fork.** The proprietary fork only contains paid/closed features (e.g. `packages/editions`, certain `packages/deployments` extensions). After an OSS merge, you typically pull on the proprietary side. +- Architecture: hexagonal (`packages/{domain}/{domain,application,infra}`), NestJS API at `apps/api`, React+`@packmind/ui` frontend at `apps/frontend`, TypeORM, Jest+@swc/jest. + +## Input Sources + +| Source | Examples | Detection | +|--------|----------|-----------| +| GitHub | `#123`, `owner/repo#123`, full issue URL | Pattern `#\d+` or `github.com/.../issues/` | +| File | `file:spec.md`, `path/to/spec.md` | Prefix `file:` or readable file | +| URL | `https://...` | HTTP(S) URL (non-GitHub) | +| Prompt | Any other text | Default | + +## Workflow Phases + +Execute phases **sequentially**. Phases 1 and 4 run seamlessly (no approval gate). Phases 2 and 3 require user approval before proceeding. + +| Phase | File | Purpose | Gate | +|-------|------|---------|------| +| 1 | `phase-1-resolve-source.md` | Detect source type, fetch content, decide OSS-vs-proprietary routing, create state | seamless | +| 2 | `phase-2-discovery-and-spec.md` | Research Packmind patterns (reference domain, hex layers, frontend conventions), draft acceptance criteria, generate functional spec | approval | +| 3 | `phase-3-implementation.md` | Technical approach + implementation plan (4 subtasks below) | 4 approvals | +| 3A | ↳ Subtask 3A | Validate domain data structures (entities, value objects, events, migrations) | approval | +| 3B | ↳ Subtask 3B | Validate ports, contracts, use cases, services, adapters, NestJS routes | approval | +| 3C | ↳ Subtask 3C | Validate frontend components, gateways, query hooks, PM-UI usage | approval | +| 3D | ↳ Subtask 3D | Generate full implementation plan (task breakdown, grouping, coverage) | approval | +| 4 | `phase-4-finalize.md` | Generate `context.md`, mark spec complete, report (no commit — artifacts live in `tmp/`) | seamless | + +## State Management + +State is persisted to markdown files under a git-ignored `tmp/` directory for cross-session continuity. Phase progress is inferred from which output files exist — no separate state file needed. + +``` +tmp/feature-specs/{slug}/ +├── {slug}.md # Main spec (status: DRAFT → COMPLETE in frontmatter) +├── discovery.md # Phase 2 output (YAML frontmatter + markdown) +├── functional-spec.md # Phase 2 output (informed by discovery) +├── implementation-plan.md # Phase 3 output (includes parallel groups section) +└── context.md # Phase 4 output (YAML frontmatter + markdown) +``` + +### Phase Detection (Resume Logic) + +| Files Present | Completed Phase | Resume At | +|---------------|----------------|-----------| +| `{slug}.md` only | Phase 1 | Phase 2 | +| + `discovery.md`, `functional-spec.md` | Phase 2 | Phase 3 | +| + `implementation-plan.md` | Phase 3 | Phase 4 | +| + `context.md` (status: COMPLETE) | Phase 4 | Done | + +## Invocation + +### Auto-Detection (Resuming) + +Before starting, check for an existing task directory at `tmp/feature-specs/{slug}/`: + +1. Check which output files exist to determine current phase (see Phase Detection table above) +2. Read the main spec file's YAML frontmatter for `status: DRAFT|COMPLETE` +3. If resuming, display: + ``` + **Resuming Feature Spec**: {slug} + **Current Phase**: {detected_phase} + **Status**: {status from frontmatter} + + Continue from Phase {detected_phase}? (Y/n) + ``` +4. If user confirms, proceed to the appropriate phase file +5. If no task directory found, start a new feature spec + +### Starting New + +To start a new feature spec: Read `phase-1-resolve-source.md` and follow its instructions. + +<feature-spec-rules> + <rule>Execute phases SEQUENTIALLY — never skip phases</rule> + <rule>Phases 1 and 4 proceed automatically — no approval gate</rule> + <rule>Phases 2 and 3 require USER APPROVAL via AskUserQuestion before proceeding</rule> + <rule>Save all artifacts under tmp/feature-specs/{slug}/ — never under tracked folders</rule> + <rule>Use Task tool with Explore subagent for codebase research; never read 50 files yourself</rule> + <rule>Agent NEVER implements code in this skill — output ONLY specification documents</rule> + <rule>Main spec file has status DRAFT until Phase 4 completes</rule> + <rule>Always record target_repo (oss | proprietary | both) decided in Phase 1; consumed by /feature-sprint</rule> + <rule>Never invent OSS/proprietary boundaries — if unsure, ask the user</rule> +</feature-spec-rules> + +## Output + +Final deliverable: `tmp/feature-specs/{slug}/{slug}.md` + sibling artifacts. + +Contains: +- Functional specification with acceptance criteria +- Technical approach grounded in Packmind reference patterns +- Implementation plan with phased tasks (each task has reference `file:line` patterns) +- Parallel Groups section (for parallel execution via `/feature-sprint`) +- `target_repo`: oss | proprietary | both + +## Parallel Execution Support + +Phase 3 includes a grouping step that analyzes task dependencies and creates parallel execution groups: + +**How grouping works:** +1. After generating the implementation plan, a Plan subagent analyzes tasks +2. Tasks are grouped by file dependencies (tasks sharing files go together) +3. Groups are non-overlapping (no file belongs to multiple groups) +4. Results are stored in `implementation-plan.md` (Parallel Groups section) and `context.md` (YAML frontmatter) + +**When grouping is skipped:** +- Tasks have no clear file dependencies +- Single-file or trivial implementations +- User opts out during Phase 3 review + +## Handoff + +When the spec is complete (Phase 4), the next step is `/feature-sprint {slug}` — the companion skill that executes the implementation plan. \ No newline at end of file diff --git a/.github/skills/feature-spec/phase-1-resolve-source.md b/.github/skills/feature-spec/phase-1-resolve-source.md new file mode 100644 index 000000000..6f21a1240 --- /dev/null +++ b/.github/skills/feature-spec/phase-1-resolve-source.md @@ -0,0 +1,171 @@ +# Phase 1: Resolve Source & Decide Fork Routing + +Detect the source type, fetch content, decide where the work will land (OSS fork or proprietary), and create the initial state directory. + +## Input + +User provides `task_input` — one of: +- GitHub issue: `#123`, `owner/repo#123`, or `https://github.com/.../issues/123` +- File path: `file:spec.md`, `path/to/spec.md` +- URL: `https://example.com/...` +- Direct prompt: any other text + +## Steps + +### 1.1 Detect Source Type + +| Pattern | Source Type | +|---------|-------------| +| `#\d+` or `owner/repo#\d+` or `github.com/.../issues/` | github | +| `file:` prefix or readable file path with `.md`/`.txt`/`.json` extension | file | +| `https?://` (non-GitHub) | url | +| Anything else | prompt | + +### 1.2 Generate Task Slug + +Create `task_slug` from the user-provided title or first meaningful phrase: +- Convert to lowercase +- Replace spaces and special characters with hyphens +- Strip leading/trailing hyphens, collapse consecutive hyphens +- Keep it under 60 characters + +### 1.3 Create State Directory + +```bash +mkdir -p tmp/feature-specs/{task_slug}/ +``` + +`tmp/` is git-ignored — these files never get committed. + +### 1.4 Create Initial Spec File (DRAFT) + +Write `tmp/feature-specs/{task_slug}/{task_slug}.md`: + +```markdown +--- +status: DRAFT +task_slug: {task_slug} +created: {ISO-8601 timestamp} +finished: null +--- + +# {task_name} + +_Specification in progress. See state directory for current phase._ +``` + +### 1.5 Fetch Content + +Use the appropriate tool based on source type: + +**GitHub:** +- If `gh` is available, use `gh issue view {issue_ref} --json title,body,labels,url,author` +- Otherwise use WebFetch on the issue URL +- Extract: `task_id` (e.g. `GH-{number}`), `task_name`, `task_description`, `task_url`, `labels` + +**File:** +- Read the file with the Read tool +- `task_name` = first heading or filename; `task_description` = full body + +**URL:** +- Use WebFetch +- Extract: `task_name`, `task_description`, `task_url` + +**Prompt:** +- `task_description` = the raw input +- `task_name` = first line (truncated to ~80 chars) +- `task_id` = `PROMPT-{timestamp}` + +### 1.6 Decide Fork Routing (Required) + +Packmind ships from two repos: +- **OSS** (`../packmind`): public packages and features. **Most code changes go here.** After merge, the proprietary fork pulls from upstream automatically. +- **Proprietary** (this repo): closed-source extensions. Examples: `packages/editions` (forbidden import elsewhere), some `packages/deployments` proprietary flows, billing, enterprise auth. + +Before continuing, classify the work using these heuristics: + +| Signal | Likely target | +|--------|---------------| +| Touches `packages/editions/`, billing, license, enterprise auth, paid deploy flows | proprietary | +| Touches `apps/api`, `apps/frontend`, `apps/cli`, `apps/mcp-server`, or any package present in BOTH repos | oss | +| Mentions enterprise-only features or paid customers in the issue | likely proprietary | +| Public bug, generic feature, OSS-visible UI | oss | +| Unsure | ask the user | + +Use `AskUserQuestion` to confirm: + +```json +{ + "questions": [{ + "question": "Where should this feature be implemented?", + "header": "Fork routing", + "multiSelect": false, + "options": [ + {"label": "OSS (../packmind) (Recommended)", "description": "Default for most work. Auto-merges into proprietary; you pull afterward."}, + {"label": "Proprietary (this repo)", "description": "Closed-source only: editions, paid deployments, enterprise features."}, + {"label": "Both (split)", "description": "Some tasks land on OSS, others on proprietary. Spec will split them in Phase 3."} + ] + }] +} +``` + +Record the answer as `target_repo` in `oss | proprietary | both`. + +**If `target_repo == "oss"`**: verify the OSS repo exists at `../packmind`. If missing, warn the user and ask whether to proceed targeting proprietary instead. + +### 1.7 Check for Existing Documentation + +Look for prior work on the same topic: +- Glob: `tmp/feature-specs/*{task_slug_keyword}*/` +- Glob: `.claude/specs/*{task_slug_keyword}*.md` (existing design specs) +- Glob: `.claude/plans/*{task_slug_keyword}*.md` (existing plans) + +If matches are found, read them and surface them in the summary so the user can decide whether to extend or supersede. + +### 1.8 Update Spec Frontmatter + +Update `tmp/feature-specs/{task_slug}/{task_slug}.md` with full metadata: + +```markdown +--- +status: DRAFT +task_slug: {task_slug} +task_id: {task_id} +source_type: {github | file | url | prompt} +source_url: {url or null} +task_name: {task_name} +target_repo: {oss | proprietary | both} +created: {ISO-8601 timestamp} +finished: null +--- + +# {task_name} + +{task_description} + +## Related Prior Work + +{Optional: list of existing specs/plans found in step 1.7, or "None found."} +``` + +This frontmatter is the source of truth for task metadata. Phase progress is inferred from which output files exist in the directory — no separate state file needed. + +## Task Summary + +Display before proceeding: + +``` +**Phase 1 complete.** Source resolved. + +**Task:** {task_name} +**ID:** {task_id} +**Source:** {source_type} +**Target repo:** {target_repo} +**Prior work:** {none | list of matched files} + +{task_description (first 500 chars)} +``` + +## Next Phase + +Proceed automatically to `phase-2-discovery-and-spec.md`. diff --git a/.github/skills/feature-spec/phase-2-discovery-and-spec.md b/.github/skills/feature-spec/phase-2-discovery-and-spec.md new file mode 100644 index 000000000..ec9a0dd34 --- /dev/null +++ b/.github/skills/feature-spec/phase-2-discovery-and-spec.md @@ -0,0 +1,443 @@ +# Phase 2: Discovery & Functional Spec + +Discover how Packmind actually does this kind of thing, then draft requirements grounded in that reality. + +## Purpose + +Two goals in one phase: +1. Find **the closest existing implementation** in Packmind — which domain package, which use case, which frontend component — and trace its full hex stack +2. Draft **functional requirements** informed by that discovery, so acceptance criteria match what Packmind's architecture can actually support + +## Prerequisites + +- Phase 1 completed (source resolved, `target_repo` decided) +- `tmp/feature-specs/{task_slug}/{task_slug}.md` exists with `status: DRAFT` + +## Steps + +### 2.1 Load Source Context + +Read `tmp/feature-specs/{task_slug}/{task_slug}.md`: +- YAML frontmatter: `task_name`, `source_type`, `target_repo` +- Body: `task_description` + +Extract key terms from `task_description`: +- **Domain entities** mentioned (e.g., "standard", "recipe", "space", "deployment", "user") +- **Actions/verbs** (e.g., "create", "import", "deploy", "rename", "preview") +- **Cross-cutting concerns** (e.g., "event", "migration", "permission", "rate limit") + +### 2.2 Pick the Discovery Root + +Based on `target_repo`: +- `oss` → run discovery in `../packmind` (most patterns live there) +- `proprietary` → run discovery in `.` (this repo) +- `both` → discovery in `../packmind` first; if a needed reference doesn't exist there, also search `.` + +Subagents should be told explicitly which root(s) to search. + +### 2.3 Launch Research Subagents (PARALLEL) + +Launch **TWO** subagents in parallel using the Task tool. Both should run concurrently in the same message. + +#### Subagent A: Packmind Pattern Finder + +Use `Agent` tool with `subagent_type="Explore"`, `description="Find Packmind reference patterns"`. Run "very thorough" search: + +``` +Find Packmind reference patterns for: {task_name} + +Search root: {discovery_root absolute path} +Key terms: {extracted_terms} +Domain entities mentioned: {entities} +Task description: {task_description} + +## What to find + +Trace ACTUAL code paths for the closest similar feature in Packmind. Don't just list files — explain HOW the feature flows through the hex layers. + +### 1. Reference domain package +Find the closest existing `packages/{domain}/` whose problem matches. Example domains: accounts, deployments, spaces, standards, recipes, skills, coding-agent, playbook-change-management. + +For the chosen reference domain, document: +- **Domain layer** (`packages/{domain}/src/domain/`): + - Entities used: file path + key fields + - Repository interfaces: `IFooRepository` file path + - Events: any domain events emitted + - Errors: domain error classes +- **Application layer** (`packages/{domain}/src/application/`): + - Closest use case: `useCases/{name}/{name}.usecase.ts` with the abstract base it extends (AbstractMemberUseCase, AbstractSpaceMemberUseCase, AbstractAdminUseCase) + - Services involved + - Adapter: `adapter/{Domain}Adapter.ts` and which ports it exposes +- **Infrastructure layer** (`packages/{domain}/src/infra/`): + - Repository impl + TypeORM Schema + - Migration pattern reference (under `packages/migrations/`) +- **Types package** (`packages/types/src/{domain}/`): + - Port interface + - Contract for the use case (request/response shapes) +- **Hexa facade** (`{Domain}Hexa.ts`) and `index.ts` exports + +Capture every `file:line` reference. + +### 2. Reference API route +Search `apps/api/src/` for the closest NestJS controller method. Document: +- Controller file + method name + decorators +- DTO file (if any) under `apps/api/src/` or types package +- How it resolves the use case (`HexaRegistry.getAdapter<IFooPort>(...)`) + +### 3. Reference frontend feature +Search `apps/frontend/` for the closest existing component that does this kind of thing. Document: +- **Gateway** (`apps/frontend/src/.../gateways/{Name}Gateway.ts`) — how it wraps the API call +- **Query/mutation hook** (TanStack Query or local equivalent) +- **Page / container component** that orchestrates queries + UI +- **UI components** built from `@packmind/ui` (PM-prefixed wrappers around Chakra) +- Form/validation library, if any (Zod, react-hook-form, etc.) + +### 4. Test patterns +For each layer touched, find the matching test file pattern (e.g. `*.usecase.spec.ts`, `*.repository.spec.ts`, `*.tsx` with React Testing Library). Note where integration tests live (`packages/integration-tests/`). + +### 5. Conventions +Cross-reference 2-3 similar features and note: +- Naming conventions for use cases, ports, contracts +- Authorization base class typically used +- Error-mapping approach (domain errors → HTTP) +- How events propagate cross-domain +- Frontend data-flow shape (gateway → query hook → component) + +### 6. Constraints +Note any: +- Files under `packages/editions/` (forbidden to import from elsewhere when on proprietary) +- Feature flags involved +- Existing migrations that overlap + +## Output Format + +Return JSON: +{ + "discovery_root": "absolute path searched", + "reference_domain": { + "package": "packages/standards", + "domain": [{"file": "", "line": 0, "role": ""}], + "application": [{"file": "", "line": 0, "role": ""}], + "infra": [{"file": "", "line": 0, "role": ""}], + "types": [{"file": "", "line": 0, "role": ""}], + "hexa_facade": {"file": "", "line": 0} + }, + "reference_route": {"controller": "", "method": "", "file": "", "line": 0, "uses_port": ""}, + "reference_frontend": { + "gateway": {"file": "", "line": 0}, + "queries": [{"file": "", "line": 0}], + "page": {"file": "", "line": 0}, + "ui_components": [{"name": "", "file": "", "line": 0}] + }, + "test_patterns": {"usecase_spec": "", "repository_spec": "", "frontend_component_spec": "", "integration_tests_dir": ""}, + "conventions": { + "naming": "", + "authorization": "", + "error_mapping": "", + "events": "", + "frontend_data_flow": "" + }, + "constraints": ["..."] +} +``` + +#### Subagent B: Documentation Researcher + +Use `Agent` tool with `subagent_type="Explore"`, `description="Search Packmind docs for context"`, "medium" thoroughness: + +``` +Search documentation for context on: {task_name} + +Search roots: {discovery_root}, and also the proprietary repo at {proprietary_root} if different + +## Where to look + +1. `apps/doc/` — public Mintlify end-user docs +2. `AGENTS.md`, `CLAUDE.md`, root `README.md` +3. `.claude/specs/` — prior design specs in this repo +4. `.claude/plans/` — prior implementation plans in this repo +5. `.claude/rules/packmind/*.md` — coding standards (frontmatter `paths` shows which paths each governs) +6. `tmp/feature-specs/` — earlier feature specs (any directory matching the task keywords) +7. Package-level `*.md` files (e.g., `packages/standards/README.md`) + +## Find + +- Related specifications, RFCs, or design docs +- Coding standards whose `paths` glob will match the files this feature touches +- Planned work that might overlap or conflict +- Historical context or decisions + +## Output Format + +Return JSON: +{ + "related_docs": [{"path": "", "relevance": "high|medium|low", "summary": ""}], + "applicable_standards": [{"path": ".claude/rules/packmind/foo.md", "paths_glob": "", "summary": ""}], + "planned_work": [{"path": "", "description": "", "potential_overlap": ""}], + "historical_context": [""] +} +``` + +Wait for BOTH subagents to complete before proceeding. + +### 2.4 Synthesize & Present Discovery + +Combine subagent results and present to the user (informational — no approval gate here): + +``` +## Discovery Summary + +### Target repo +{target_repo} (discovery root: {discovery_root}) + +### Reference Domain Package: `packages/{name}/` +Full hex stack trace: + +**Domain layer** +- `{file}:{line}` — {role} + +**Application layer** +- `{file}:{line}` — {role} + +**Infrastructure layer** +- `{file}:{line}` — {role} + +**Types package** (`packages/types/src/{domain}/`) +- `{file}:{line}` — {role} + +**Hexa facade** +- `{file}:{line}` + +### Reference API Route +- `{controller_file}:{line}` — `{METHOD} {path}` → uses port `{port_name}` + +### Reference Frontend Feature +- Gateway: `{file}:{line}` +- Queries: `{file}:{line}` +- Page: `{file}:{line}` +- UI components: {list of PM-* components used} + +### Test Patterns +- Use case spec: `{file_pattern}` +- Repository spec: `{file_pattern}` +- Frontend component spec: `{file_pattern}` +- Integration tests: `{dir}` + +### Conventions Found +- Naming: {conventions.naming} +- Authorization: {conventions.authorization} +- Error mapping: {conventions.error_mapping} +- Events: {conventions.events} +- Frontend data flow: {conventions.frontend_data_flow} + +### Applicable Coding Standards +{For each from documentation researcher:} +- `{path}` (governs: `{paths_glob}`) — {summary} + +### Related Documentation +{For each:} +- `{path}` ({relevance}) — {summary} + +### Constraints +{constraints list, including OSS/proprietary boundary callouts if relevant} +``` + +### 2.5 Draft Acceptance Criteria + +Using the discovery context, analyze `task_description` for: +- Existing acceptance criteria (checkboxes, numbered lists) +- User stories or personas mentioned +- Technical constraints or requirements +- Scope boundaries (what's included/excluded) + +Validate patterns against the task: + +1. **Relevance check** — Does the reference domain cover the layers this task needs? +2. **Consistency check** — Do similar features follow the same conventions? +3. **Gap check** — Does the task require something no existing feature does (e.g., a new port type, a new event)? + +Then draft acceptance criteria and present everything for approval: + +``` +### Pattern Validation + +**Reference template:** `packages/{name}` — {applicable | partially applicable | not applicable} +**Convention consistency:** {consistent across N features | divergences noted: ...} +**Gaps:** {none | list of things no existing feature covers} + +**Derived approach:** Follow `packages/{name}` pattern across {layers}. {Gap handling, if any.} + +## Draft Acceptance Criteria + +Based on the source description and discovery analysis: + +- [ ] {Criterion 1 — extracted from source} +- [ ] {Criterion 2 — extracted from source} +- [ ] {Criterion N — inferred from discovery patterns, e.g., "Soft delete supported following standard repository pattern"} + +### Potential Gaps +{List anything the source doesn't cover but the reference feature handles, e.g., authorization, events, error mapping} + +### Potential Over-scope +{List anything in the source that may be too broad or vague} +``` + +### 2.6 Approval Gate + +Use the `AskUserQuestion` tool: + +```json +{ + "questions": [{ + "question": "Do the discovered patterns and draft acceptance criteria look correct?", + "header": "Phase 2", + "multiSelect": false, + "options": [ + {"label": "Yes, proceed", "description": "Patterns and criteria are accurate, continue to generate the full functional spec"}, + {"label": "Needs adjustment", "description": "I'll clarify what's wrong or missing"} + ] + }] +} +``` + +If the user needs adjustment, incorporate their feedback and re-present step 2.5. + +### 2.7 Generate Functional Specification + +Create `tmp/feature-specs/{task_slug}/functional-spec.md`: + +```markdown +## Functional Specification + +### Overview +{1-2 paragraphs: what the feature does and why} +{Reference the derived approach from pattern validation} + +### Target Repo +{target_repo} — most changes land in {discovery_root}. Note any tasks that must live in the proprietary fork (e.g., editions, paid deployments). + +### Business Requirements +1. {Requirement 1} +2. {Requirement 2} +3. {Requirement 3} + +### User Stories +- As a {role}, I want to {action}, so that {benefit} +- As a {role}, I want to {action}, so that {benefit} + +### Acceptance Criteria +- [ ] {Criterion 1 — specific, measurable} +- [ ] {Criterion 2 — specific, measurable} +- [ ] {Criterion 3 — specific, measurable} + +### Constraints +- {Technical constraint, e.g., "Must follow AbstractSpaceMemberUseCase authorization pattern"} +- {Performance requirement} +- {Compatibility requirement} +- {OSS/proprietary boundary — e.g., "No imports from @packmind/editions in OSS tasks"} +{Include constraints from discovery if relevant} + +### Out of Scope +- {Explicitly excluded item} + +### Dependencies +{List known dependencies — other packages, ports, planned work that must precede this} +``` + +### 2.8 Save Discovery + +Write `tmp/feature-specs/{task_slug}/discovery.md` with YAML frontmatter capturing everything the implementation plan subagent will need: + +```markdown +--- +target_repo: {oss | proprietary | both} +discovery_root: "{absolute path}" + +reference_domain: + package: "packages/{name}" + domain_files: + - file: "packages/{name}/src/domain/entities/{Entity}.ts" + line: 1 + role: "Main entity" + application_files: + - file: "packages/{name}/src/application/useCases/{name}/{name}.usecase.ts" + line: 1 + role: "Closest existing use case" + infra_files: + - file: "packages/{name}/src/infra/repositories/{Entity}Repository.ts" + line: 1 + role: "Repository implementation" + types_files: + - file: "packages/types/src/{name}/ports/I{Name}Port.ts" + line: 1 + role: "Port interface" + hexa_facade: + file: "packages/{name}/src/{Name}Hexa.ts" + line: 1 + +reference_route: + controller: "apps/api/src/controllers/{Name}Controller.ts" + method: "create" + line: 42 + http: "POST /api/{path}" + uses_port: "I{Name}Port" + +reference_frontend: + gateway: + file: "apps/frontend/src/.../{Name}Gateway.ts" + line: 1 + queries: + - file: "apps/frontend/src/.../use{Name}.ts" + line: 1 + page: + file: "apps/frontend/src/.../{Name}Page.tsx" + line: 1 + ui_components: + - name: "PMButton" + file: "packages/ui/src/PMButton.tsx" + +test_patterns: + usecase_spec: "packages/{name}/src/application/useCases/{name}/{name}.usecase.spec.ts" + repository_spec: "packages/{name}/src/infra/repositories/{Entity}Repository.spec.ts" + frontend_component_spec: "apps/frontend/src/.../{Name}Page.spec.tsx" + integration_tests_dir: "packages/integration-tests" + +conventions: + naming: "{from subagent}" + authorization: "AbstractSpaceMemberUseCase | AbstractMemberUseCase | AbstractAdminUseCase" + error_mapping: "{from subagent}" + events: "{from subagent}" + frontend_data_flow: "gateway → query hook → component, PM-prefixed UI from @packmind/ui" + +applicable_standards: + - path: ".claude/rules/packmind/packmind-proprietary.md" + paths_glob: "**/*" + summary: "Forbid imports from @packmind/editions in proprietary code" + +constraints: + - "{e.g., must use soft-delete-aware AbstractRepository}" + +pattern_validation: + reference_applicable: true + convention_consistency: "consistent across N features" + gaps: [] + derived_approach: "Follow packages/{name} pattern across {layers}" + +related_documentation: + - path: "{path}" + relevance: "high" + summary: "..." +--- + +## Discovery Summary + +{Mirror the human-readable view from step 2.4} + +## Pattern Validation + +{Mirror the pattern-validation block from step 2.5} +``` + +## Next Phase + +Proceed automatically to `phase-3-implementation.md`. diff --git a/.github/skills/feature-spec/phase-3-implementation.md b/.github/skills/feature-spec/phase-3-implementation.md new file mode 100644 index 000000000..290349702 --- /dev/null +++ b/.github/skills/feature-spec/phase-3-implementation.md @@ -0,0 +1,403 @@ +# Phase 3: Implementation Plan + +Generate the technical approach and detailed task breakdown, with Packmind-specific reference patterns and file targets. + +Phase 3 is split into **four subtasks**, each with its own approval gate. Foundational decisions are validated before generating the full plan, so problems get caught early. + +| Subtask | Purpose | Gate | +|---------|---------|------| +| 3A | Validate **domain data structures** (entities, value objects, events, repositories, schemas, migrations) | approval | +| 3B | Validate **ports, contracts, use cases, services, adapters, NestJS routes** | approval | +| 3C | Validate **frontend components** (page/container, gateway, query hooks, PM-UI usage, forms) | approval | +| 3D | Generate full **implementation plan** (task breakdown, grouping, coverage) | approval | + +## Prerequisites + +- Phase 2 completed +- If resuming a fresh session, read `tmp/feature-specs/{task_slug}/functional-spec.md` and `discovery.md` + +## Steps + +### 3.1 Present Technical Approach + +Using functional spec + discovery, present a brief technical approach to the user: + +``` +## Technical Approach + +### Target Repo +{target_repo} — implementation root: `{discovery_root}`. Tasks landing in this fork (proprietary) will be tagged; otherwise default to OSS. + +### Stack +- API: NestJS (`apps/api`) +- Domain packages: hexagonal layout under `packages/{domain}/src/{domain,application,infra}` +- Types/ports/contracts: `packages/types/src/{domain}` +- Frontend: React + `@packmind/ui` (PM-prefixed Chakra wrappers) (`apps/frontend`) +- Tests: Jest + `@swc/jest`; integration tests in `packages/integration-tests` +- DB: TypeORM + PostgreSQL; migrations under `packages/migrations` + +### Reference Patterns (from discovery) +- Reference domain: `{reference_domain.package}` +- Reference use case: `{reference_domain.application_files[0].file}:{line}` +- Reference route: `{reference_route.controller}:{line}` (`{reference_route.http}`) +- Reference frontend page: `{reference_frontend.page.file}:{line}` + +### Architecture Decisions +- {derived_approach from pattern_validation} +- Authorization: {convention chosen, e.g. AbstractSpaceMemberUseCase} +- Error mapping: {convention} +- Cross-domain: {via injected port or via domain event} + +### New Patterns Required +{If any:} +- {e.g., "New port `IFooPort` — no existing port covers this responsibility"} +- {e.g., "New domain event `FooDeletedEvent` — to notify deployments domain"} + +{Else: "None — fully covered by existing patterns."} +``` + +If new patterns are identified, use `AskUserQuestion` for each decision (one question per decision): + +```json +{ + "questions": [{ + "question": "{Describe the specific new pattern decision}", + "header": "New pattern", + "multiSelect": false, + "options": [ + {"label": "{Option A}", "description": "{What this means concretely}"}, + {"label": "{Option B}", "description": "{What this means concretely}"} + ] + }] +} +``` + +If no new patterns are needed, proceed directly. + +### 3.2 Append Technical Approach to discovery.md + +Append the finalized technical approach to `tmp/feature-specs/{task_slug}/discovery.md` after the YAML frontmatter: + +```markdown +## Technical Approach + +### Target Repo +... + +### Stack +... + +### Reference Patterns +... + +### Architecture Decisions +... +``` + +--- + +## Subtask 3A: Validate Domain Data Structures + +### 3A.1 Extract Data Structure Changes + +From functional spec + discovery, identify ALL data-structure impacts in the **domain** and **infra** layers: + +**New entities** (`packages/{domain}/src/domain/entities/`) — domain objects that don't exist yet: +- Name, purpose, key fields with TS types +- Relationships to existing entities (1:1, 1:N, N:M) +- Whether it carries an ID type (`{Entity}Id` branded type in `packages/types`) + +**Entity modifications** — changes to existing domain objects: +- New fields (name, type, nullable, default) +- Modified fields (what changes and why) +- Removed fields (what + migration impact) + +**Value objects / branded ID types** (`packages/types/src/{domain}/`): +- Name, shape + +**Domain events** (`packages/types/src/events/{EventName}.ts`): +- Event name, payload shape +- Which domain emits it, which listeners consume it +- Reference the `event.md` component guide + +**Repository interfaces** (`packages/{domain}/src/domain/repositories/I{Entity}Repository.ts`): +- Methods needed (findById, findByX, etc.) +- Whether `AbstractRepository<T>` (soft-delete) is appropriate + +**TypeORM schemas** (`packages/{domain}/src/infra/schemas/{Entity}Schema.ts`): +- Columns + types + indexes + foreign keys + +**Migrations** (`packages/migrations/src/migrations/`): +- New tables, altered columns, new indexes +- Reference the `how-to-write-typeorm-migrations-in-packmind` skill +- Down-migration plan + +**Domain errors** (`packages/{domain}/src/domain/errors/`): +- New error classes for invariant violations + +Use `discovery.md` reference paths to ground every naming and structure decision. + +### 3A.2 Present Data Structures for Validation + +Render the **Subtask 3A** presentation template from `references/spec-templates.md`, substituting the items extracted in 3A.1. Do not paraphrase the section headings — they keep 3A → 3D consistent. + +### 3A.3 Approval Gate — Data Structures + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Are the proposed domain data structures correct? (entities, events, repositories, schemas, migrations)", + "header": "Subtask 3A", + "multiSelect": false, + "options": [ + {"label": "Approved", "description": "Data structures are correct, proceed to ports/use cases/routes"}, + {"label": "Needs changes", "description": "I'll specify what to adjust"} + ] + }] +} +``` + +If "Needs changes", incorporate feedback and re-present 3A.2. + +If there are no data-structure changes for this task, state so briefly and skip the approval gate — proceed directly to Subtask 3B. + +--- + +## Subtask 3B: Validate Ports, Contracts, Use Cases, Services, Adapters, Routes + +### 3B.1 Extract Application + API Changes + +From functional spec + discovery, identify ALL impacts in the **application** layer and **API** layer: + +**New ports** (`packages/types/src/{domain}/ports/I{Domain}Port.ts`): +- Port name, methods exposed +- Which adapter implements it +- Justify: why a new port (vs. extending an existing one) + +**New use case contracts** (`packages/types/src/{domain}/contracts/{UseCaseName}.ts`): +- Request shape, Response shape +- Which port method returns these + +**New use cases** (`packages/{domain}/src/application/useCases/{name}/{name}.usecase.ts`): +- Name, purpose +- Which `Abstract*UseCase` base class (`AbstractMemberUseCase`, `AbstractSpaceMemberUseCase`, `AbstractAdminUseCase`) +- Authorization rules (who can call) +- Which services / repos it uses +- Which events it emits + +**Modified use cases** — changes to existing use cases: +- What changes, backward-compatibility notes + +**New services** (`packages/{domain}/src/application/services/{Name}Service.ts`): +- Name, methods, callers (use cases) + +**New adapter methods** (`packages/{domain}/src/application/adapter/{Domain}Adapter.ts`): +- Which port methods are now implemented + +**New listeners** (`packages/{domain}/src/application/listeners/{Domain}Listener.ts`): +- Which events listened to, what it does + +**New / modified NestJS routes** (`apps/api/src/`): +- Method + path (e.g., `POST /api/standards/{id}/preview`) +- Request/response DTOs (referencing contracts from above) +- Controller file + method +- How it resolves the port: `HexaRegistry.getAdapter<IFooPort>('foo')` + +**Removed routes**: +- Path + reason + +Use `discovery.md` references for path conventions, base classes, and adapter patterns. + +### 3B.2 Present for Validation + +Render the **Subtask 3B** presentation template from `references/spec-templates.md`, substituting the items extracted in 3B.1. + +### 3B.3 Approval Gate — Application + Routes + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Are the proposed ports, contracts, use cases, services, adapters, and routes correct?", + "header": "Subtask 3B", + "multiSelect": false, + "options": [ + {"label": "Approved", "description": "Application + API layer is correct, proceed to frontend"}, + {"label": "Needs changes", "description": "I'll specify what to adjust"} + ] + }] +} +``` + +If "Needs changes", incorporate feedback and re-present 3B.2. + +If nothing changes in this layer, state so and skip the gate — proceed to Subtask 3C. + +--- + +## Subtask 3C: Validate Frontend Components + +### 3C.1 Extract Frontend Changes + +From functional spec + discovery, identify ALL frontend impacts in `apps/frontend/`: + +**New page / route components** — top-level orchestrators: +- File path under `apps/frontend/src/` +- Route it mounts on (React Router or app router) +- Which gateway queries/mutations it owns +- Which container/domain components it renders + +**New domain / container components** — feature-scoped UI: +- File path, props interface (TypeScript) +- Pure-display vs. stateful (owns queries?) +- Form components: validation lib + schema, submit callback shape +- List components: item shape, empty state, loading skeleton +- Detail components: data shown, actions + +**Modified domain components**: +- File + line, what changes, props added/removed + +**New gateways** (`apps/frontend/src/.../gateways/{Name}Gateway.ts`): +- Method signatures wrapping API calls +- Reference the existing gateway pattern (see `gateway-pattern-implementation-in-packmind-frontend` command) +- Type mapping (contract → frontend type) + +**New query / mutation hooks**: +- Hook name, query key, return shape +- Cache invalidation rules + +**`@packmind/ui` usage** (PM-prefixed Chakra wrappers): +- Which existing PM-* components are used (PMButton, PMDialog, PMField, etc.) +- Whether any new wrapper is needed (see `wrapping-chakra-ui-with-slot-components` command and `working-with-pm-design-kit` skill) + +**Layout / navigation changes**: +- New nav links, sidebar items, page tabs + +**Microcopy**: +- New user-facing strings — flag for `ux-microcopy` skill if there are non-trivial messages (errors, empty states, dialogs) + +Use `discovery.md`'s reference frontend feature to ground naming and structure decisions. + +### 3C.2 Present for Validation + +Render the **Subtask 3C** presentation template from `references/spec-templates.md`, substituting the items extracted in 3C.1. + +### 3C.3 Approval Gate — Frontend Components + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Are the proposed frontend components correct? (pages, containers, gateways, queries, PM-UI usage)", + "header": "Subtask 3C", + "multiSelect": false, + "options": [ + {"label": "Approved", "description": "Frontend is correct, proceed to full implementation plan"}, + {"label": "Needs changes", "description": "I'll specify what to adjust"} + ] + }] +} +``` + +If "Needs changes", incorporate feedback and re-present 3C.2. + +If no frontend changes, state so and skip — proceed to Subtask 3D. + +--- + +## Subtask 3D: Generate Implementation Plan + +### 3D.1 Skill Loading (conditional) + +Load Packmind skills based on which layers the task touches: + +**Backend (domain / application / infra) is touched:** +1. Read `.claude/skills/hexagonal-architecture/SKILL.md` +2. Read `.claude/skills/hexagonal-architecture/components/usecase.md` +3. Read `.claude/skills/hexagonal-architecture/components/repository.md` +4. Read `.claude/skills/repository-implementation-and-testing-pattern/SKILL.md` (if it exists) + +**Migrations are touched:** +5. Read `.claude/skills/how-to-write-typeorm-migrations-in-packmind/SKILL.md` +6. Read `.claude/skills/create-or-update-model-and-typeorm-schemas/SKILL.md` + +**Frontend is touched:** +7. Read `.claude/skills/working-with-pm-design-kit/SKILL.md` +8. Read `.claude/commands/gateway-pattern-implementation-in-packmind-frontend.md` +9. Read `.claude/commands/wrapping-chakra-ui-with-slot-components.md` (only if new PM wrappers needed) + +**CLI tests are touched:** +10. Read `.claude/skills/cli-e2e-test-authoring/SKILL.md` + +Skip a category entirely if no task in that layer. + +### 3D.2 Launch Implementation Plan Subagent + +Read the bundled prompt template at `.claude/skills/feature-spec/references/implementation-plan-prompt.md`. Substitute its placeholders (`{task_name}`, `{task_slug}`, `{target_repo}`, and the three `{APPROVED_3*_BLOCK}` blocks pasted verbatim from the prior approval gates). Include or omit the Backend / Frontend / Migration constraint sections based on which layers the prior subtasks produced work for — guidance is at the top of the reference file. + +Invoke `Agent` with `subagent_type="general-purpose"` and the substituted prompt. Wait for the subagent to complete. + +Wait for the subagent to complete. + +### 3D.3 Validate Plan Output + +Read `tmp/feature-specs/{task_slug}/implementation-plan.md` and verify: +- Every acceptance criterion is covered in the Coverage Matrix +- Every task has a reference pattern (`file:line`) +- Every task has target files and a `repo` tag +- Plan is consistent with 3A, 3B, 3C decisions (no extra entities, no missing routes) +- Parallel Groups section exists with at least 1 group +- All tasks belong to exactly one group; no file appears in multiple groups + +If validation fails: tell the user what's missing and ask whether to refine manually or relaunch the subagent. + +### 3D.4 Approval Gate — Implementation Plan + +Display the summary: + +``` +**Subtask 3D complete.** Implementation plan generated. + +**Phases:** {count} +**Total tasks:** {count} +**Parallel groups:** {group_count} +**Repos used:** {oss only | proprietary only | both, with count} + +Review the plan in `tmp/feature-specs/{task_slug}/implementation-plan.md` + +Questions to consider: +- Does each task follow the identified patterns? +- Are file references specific enough? +- Are inter-group dependencies clear? +- Are OSS-vs-proprietary tags correct? +``` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Is the implementation plan approved? Proceed to finalize?", + "header": "Subtask 3D", + "multiSelect": false, + "options": [ + {"label": "Yes, proceed", "description": "Approve plan and continue to Phase 4: Finalize"}, + {"label": "Need changes", "description": "I need to modify the implementation plan first"} + ] + }] +} +``` + +Do not continue until user confirms via AskUserQuestion response. + +### 3.7 Phase Complete + +The existence of `implementation-plan.md` marks Phase 3 as complete. No separate state update needed. + +## Next Phase + +After approval, proceed automatically to `phase-4-finalize.md`. diff --git a/.github/skills/feature-spec/phase-4-finalize.md b/.github/skills/feature-spec/phase-4-finalize.md new file mode 100644 index 000000000..842d1d6da --- /dev/null +++ b/.github/skills/feature-spec/phase-4-finalize.md @@ -0,0 +1,89 @@ +# Phase 4: Finalize + +Generate the implementation context, mark the spec as complete, and report. This phase runs seamlessly — no approval gate. + +Artifacts live in `tmp/feature-specs/{task_slug}/` which is git-ignored, so **no commit step** here. The user commits implementation code via `/feature-sprint`. + +## Prerequisites + +- Phases 1-3 completed +- If resuming a fresh session, read `discovery.md`, `functional-spec.md`, and `implementation-plan.md` from `tmp/feature-specs/{task_slug}/` + +## Steps + +### 4.1 Generate Implementation Context + +**Purpose**: produce a structured `context.md` that `/feature-sprint` will read to set up parallel execution. + +Read `.claude/skills/feature-spec/references/context-schema.md` — it contains the full YAML template, the body template, and the ordered extraction process (sources: `discovery.md` frontmatter, main spec frontmatter, `functional-spec.md` body, `implementation-plan.md` Parallel Groups table). + +Write the substituted result to `tmp/feature-specs/{task_slug}/context.md`. + +### 4.2 Mark Spec Complete + +Update the main spec file's YAML frontmatter: + +```markdown +--- +status: COMPLETE +... +finished: {ISO-8601 timestamp} +--- +``` + +The body of the main spec should now include a brief summary (one paragraph) and links to the sibling artifacts: + +```markdown +# {task_name} + +{1-paragraph summary of the feature} + +## Artifacts + +- [Discovery](./discovery.md) — reference patterns and conventions +- [Functional Spec](./functional-spec.md) — acceptance criteria and scope +- [Implementation Plan](./implementation-plan.md) — task breakdown and parallel groups +- [Context](./context.md) — machine-readable handoff for `/feature-sprint` + +## Next Step + +``` +/feature-sprint {task_slug} +``` +``` + +### 4.3 Completion Report + +Output to the user: + +``` +## ✅ FEATURE SPEC COMPLETE + +**Task**: {task_name} +**Slug**: {task_slug} +**Target repo**: {target_repo} + +### Summary +- Acceptance criteria: {count} items +- Implementation phases: {phase_count} +- Total tasks: {task_count} +- Parallel groups: {group_count} + +### Artifacts +- `tmp/feature-specs/{task_slug}/{task_slug}.md` +- `tmp/feature-specs/{task_slug}/discovery.md` +- `tmp/feature-specs/{task_slug}/functional-spec.md` +- `tmp/feature-specs/{task_slug}/implementation-plan.md` +- `tmp/feature-specs/{task_slug}/context.md` + +### Reminder +Artifacts live in `tmp/` (git-ignored). They will NOT be committed automatically — they're scratch state for `/feature-sprint`. + +### Next Steps +1. Review the artifacts above +2. Execute: `/feature-sprint {task_slug}` +``` + +## End of Workflow + +The feature spec is ready. The companion skill `/feature-sprint` will pick it up and execute the implementation plan. diff --git a/.github/skills/feature-spec/references/context-schema.md b/.github/skills/feature-spec/references/context-schema.md new file mode 100644 index 000000000..a3175418c --- /dev/null +++ b/.github/skills/feature-spec/references/context-schema.md @@ -0,0 +1,141 @@ +# context.md Schema + +Loaded by `phase-4-finalize.md` step 4.1. Defines the structured artifact +that `/feature-sprint` consumes to set up parallel execution. + +## Extraction process (in order) + +1. Read `discovery.md` YAML frontmatter → `target_repo`, `discovery_root`, + `reference_domain.*`, `reference_route.*`, `reference_frontend.*`, + `applicable_standards`. +2. Read `{task_slug}.md` YAML frontmatter → `task_id`, `task_name`, + `source_type`, `source_url`. +3. Parse `functional-spec.md` body → scope (`in_scope`, `out_of_scope`, + `constraints`). These need light prose-to-list conversion; keep items + short and verbatim where possible. +4. Parse `implementation-plan.md` "Parallel Groups" table → populate + `parallel_groups[]`. Each row maps to one entry; `task_ids` is the + comma-split task IDs column. +5. Set `version: "1.1"` if `parallel_groups[]` is non-empty, else `"1.0"`. + +## Template + +Write to `tmp/feature-specs/{task_slug}/context.md`: + +```markdown +--- +version: "1.0" # bump to "1.1" if parallel_groups non-empty +task_slug: "{task_slug}" +task_id: "{from main spec frontmatter}" +task_name: "{task_name}" + +source: + type: "{source_type from main spec}" + url: "{source_url from main spec}" + +target_repo: "{oss | proprietary | both}" +discovery_root: "{absolute path from discovery.md}" +proprietary_root: "{absolute path of the proprietary repo — i.e. `realpath .` when running this skill from the proprietary repo root}" +oss_root: "{absolute path of the OSS sibling — i.e. `realpath ../packmind`}" + +stack: + api: "NestJS (apps/api)" + domain_packages: "packages/{domain}/src/{domain,application,infra}" + types: "packages/types" + frontend: "React + @packmind/ui (apps/frontend)" + tests: "Jest + @swc/jest" + integration_tests: "packages/integration-tests" + db: "TypeORM + PostgreSQL" + migrations: "packages/migrations" + +reference: + domain_package: "{from discovery.reference_domain.package}" + usecase_file: "{from discovery}" + api_controller: "{from discovery}" + frontend_page: "{from discovery}" + +discovery_summary: + patterns: "{1-2 sentence summary of the derived approach}" + applicable_standards: + - path: ".claude/rules/packmind/packmind-proprietary.md" + summary: "Forbid imports from @packmind/editions" + +scope_definition: + in_scope: + - "{extracted from functional-spec.md}" + out_of_scope: + - "{extracted from functional-spec.md}" + constraints: + - "{extracted from functional-spec.md}" + +quality_gates: + # Nx targets to run for the projects this feature touches. + # /feature-sprint will resolve which projects need each target. + lint: true + test: true + build: true + extra: [] + +parallel_groups: # Populate from implementation-plan.md "Parallel Groups" section + - group_id: "A" + group_name: "backend-{domain}" + task_ids: ["1.1", "1.2", "1.3"] + target_files: ["packages/{domain}/..."] + repo: "oss" + blocked_by: [] + - group_id: "B" + group_name: "frontend-{domain}" + task_ids: ["2.1", "2.2"] + target_files: ["apps/frontend/..."] + repo: "oss" + blocked_by: [] + - group_id: "C" + group_name: "tests" + task_ids: ["3.1", "3.2"] + target_files: ["packages/integration-tests/..."] + repo: "oss" + blocked_by: ["A", "B"] +--- + +## Implementation Context + +**Task:** {task_name} +**Slug:** {task_slug} +**Target repo:** {target_repo} + +### Discovery summary +- Reference domain: `{reference.domain_package}` +- Reference use case: `{reference.usecase_file}` +- Reference API: `{reference.api_controller}` +- Reference frontend: `{reference.frontend_page}` + +### Scope +**In scope:** +- {items} + +**Out of scope:** +- {items} + +**Constraints:** +- {items} + +### Quality gates +- Lint: nx lint on affected projects +- Test: nx test on affected projects +- Build: nx build on affected projects + +### Parallel groups +| Group | Name | Tasks | Repo | Blocked by | +|-------|------|-------|------|------------| +| A | {name} | 1.1, 1.2, 1.3 | oss | — | +| B | {name} | 2.1, 2.2 | oss | — | +| C | {name} | 3.1, 3.2 | oss | A, B | +``` + +## Notes + +- `parallel_groups[*].repo` is independent of the top-level `target_repo`: + one feature can have OSS and proprietary groups in `both` mode. +- `blocked_by` may be empty `[]`. Don't omit the key. +- If a group has no obvious file area (e.g., cross-cutting tests), name it + by purpose (`tests`, `wiring`) rather than by file path. diff --git a/.github/skills/feature-spec/references/implementation-plan-prompt.md b/.github/skills/feature-spec/references/implementation-plan-prompt.md new file mode 100644 index 000000000..96254d2fd --- /dev/null +++ b/.github/skills/feature-spec/references/implementation-plan-prompt.md @@ -0,0 +1,138 @@ +# Implementation Plan Subagent Prompt Template + +Loaded by `phase-3-implementation.md` step 3D.2 to generate +`tmp/feature-specs/{task_slug}/implementation-plan.md`. The orchestrator +substitutes every `{placeholder}` and then passes the result as the `prompt` +to `Agent` with `subagent_type="general-purpose"`. + +## Placeholders + +| Placeholder | Source | +|-------------|--------| +| `{task_name}` | main spec frontmatter | +| `{task_slug}` | spec directory name | +| `{target_repo}` | discovery.md frontmatter (`oss \| proprietary \| both`) | +| `{APPROVED_3A_BLOCK}` | the approved 3A.2 summary, pasted verbatim | +| `{APPROVED_3B_BLOCK}` | the approved 3B.2 summary, pasted verbatim | +| `{APPROVED_3C_BLOCK}` | the approved 3C.2 summary, pasted verbatim | + +## Conditional sections + +The template includes layer-specific constraint sections. Include or skip each +based on whether any task in that layer exists: + +- Backend section → include if 3A or 3B produced any work +- Frontend section → include if 3C produced any work +- Migration section → include if 3A produced any migration entries + +**Do not** paste the contents of referenced skills inline. The subagent has +access to the project's `.claude/skills/` directory and should `Read` them +directly. Inline pastes would force the orchestrator to grow with every +upstream skill change. + +## Template + +``` +Design implementation tasks for: {task_name} + +IMPORTANT: Do NOT use EnterPlanMode. Write the plan directly to the file path in the Output section below. + +## Context files (Read these first) +- tmp/feature-specs/{task_slug}/functional-spec.md (requirements, acceptance criteria) +- tmp/feature-specs/{task_slug}/discovery.md (patterns, reference paths in YAML frontmatter + Technical Approach) + +## Validated decisions (from earlier subtasks — treat as LOCKED) + +The following have been reviewed and approved by the user. The implementation plan MUST be consistent — do not add, remove, or rename anything listed here. + +### Domain Data Structures (Subtask 3A) +{APPROVED_3A_BLOCK} + +### Ports, Contracts, Use Cases, Routes (Subtask 3B) +{APPROVED_3B_BLOCK} + +### Frontend Components (Subtask 3C) +{APPROVED_3C_BLOCK} + +## Architecture Constraints — Backend (include only if backend touched) + +Read these skills before producing backend tasks. Do NOT paste their contents into this prompt — they are loaded conditionally and may evolve: + +- `.claude/skills/hexagonal-architecture/SKILL.md` +- `.claude/skills/hexagonal-architecture/components/usecase.md` +- `.claude/skills/hexagonal-architecture/components/repository.md` +- `.claude/skills/repository-implementation-and-testing-pattern/SKILL.md` (if it exists) + +For each backend task ensure: +- Layer is explicit (domain, application, infra) +- Cross-domain communication goes through a port in `packages/types/` or a domain event — NEVER direct cross-package imports +- The right `Abstract*UseCase` base class is used for authorization +- Domain errors are mapped at the API layer, not raised as HTTP exceptions inside the domain +- New repositories use `AbstractRepository<T>` with soft-delete support unless explicitly justified otherwise + +## Testing Constraints + +For each backend task involving logic, plan a sibling `*.spec.ts` task using Jest + `@swc/jest`. For repositories, follow the test pattern from `repository-implementation-and-testing-pattern` skill (factory-driven tests). For end-to-end behavior, add an `integration-tests` task in `packages/integration-tests`. + +## Frontend Constraints (include only if frontend touched) + +Read these skills before producing frontend tasks: + +- `.claude/skills/working-with-pm-design-kit/SKILL.md` +- `.claude/commands/gateway-pattern-implementation-in-packmind-frontend.md` + +For each frontend task ensure: +- UI is built from `@packmind/ui` PM-prefixed components, NOT raw Chakra primitives (unless wrapping a new slot component — then plan a slot-wrapping task referencing `.claude/commands/wrapping-chakra-ui-with-slot-components.md`) +- API calls go through a Gateway, not directly from components or hooks +- TanStack Query (or the project's equivalent) is used via the existing hook pattern from discovery +- Tests use React Testing Library at the component layer; integration scenarios go to e2e + +## Migration Constraints (include only if migrations touched) + +Read: `.claude/skills/how-to-write-typeorm-migrations-in-packmind/SKILL.md` + +Each migration task must: +- Create both `up` and `down` methods +- Use the standard logger +- Land under `packages/migrations/src/migrations/{timestamp}-{name}.ts` + +## OSS / Proprietary Constraints + +`target_repo` for this spec: `{target_repo}`. + +- If `target_repo == "oss"`, all tasks must be implementable in `../packmind` (the OSS sibling next to the proprietary repo). Verify no task references `packages/editions/` or paid-only paths. +- If `target_repo == "proprietary"`, mark each task with its repo. Never import from `@packmind/editions` outside files that already live in the editions package (see `.claude/rules/packmind/packmind-proprietary.md`). +- If `target_repo == "both"`, tag every task with `repo: oss | proprietary`. Place OSS tasks first (they unblock proprietary ones after auto-merge). + +## Requirements + +Generate a phased implementation plan that: +1. Maps each acceptance criterion to specific tasks (Coverage Matrix at end) +2. Groups tasks logically: domain entities/events → repositories/migrations → use cases → API routes → frontend gateway/queries → frontend components → tests +3. Uses reference patterns from discovery.md for every task (`file:line`) +4. Specifies target files (existing or new) for every task +5. Includes test tasks for every behavioral change +6. Groups tasks into parallel execution groups by file dependencies + +## Task format + +See `.claude/skills/feature-spec/references/spec-templates.md` for the canonical task block shape and section structure. The format must match exactly so `/feature-sprint`'s parse_implementation_plan.py can read it. + +## Parallel grouping rules + +After defining all tasks, append a "Parallel Groups" section. + +- Tasks sharing the SAME target files MUST be in the same group +- Tasks where A writes a file B reads MUST be in the same group +- Backend domain/application tasks usually share `packages/{domain}/`, so they often go in one group +- Frontend tasks under `apps/frontend/` often share the gateway file and form a second group +- Migration tasks usually stand alone +- Name groups by dominant file area (e.g. `backend-{domain}`, `frontend-{domain}`, `migrations`) +- Maximize parallelism while respecting file conflicts + +## Output + +Write to: `tmp/feature-specs/{task_slug}/implementation-plan.md` + +Use the section structure documented in `.claude/skills/feature-spec/references/spec-templates.md` (Implementation Plan section). Then return a summary: `{phase_count}` phases, `{task_count}` tasks, `{group_count}` parallel groups, coverage complete/incomplete. +``` diff --git a/.github/skills/feature-spec/references/spec-templates.md b/.github/skills/feature-spec/references/spec-templates.md new file mode 100644 index 000000000..77b7530a9 --- /dev/null +++ b/.github/skills/feature-spec/references/spec-templates.md @@ -0,0 +1,253 @@ +# Spec Presentation & Plan Templates + +Reusable markdown skeletons for Phase 3 (Subtasks 3A/3B/3C) and Phase 3D +(implementation plan + task format). Keeps `phase-3-implementation.md` lean +and procedural; this file holds the bulky presentation structure. + +The templates are fill-in-the-blank — substitute the `{placeholder}` tokens +from `discovery.md` + the prior subtask's approved decisions. Always preserve +the exact section ordering: `/feature-sprint` parses the resulting +`implementation-plan.md` and the order matters for it. + +## Subtask 3A: Domain Data Structures (presentation template) + +``` +## Subtask 3A: Domain Data Structures + +### Target Repo +{target_repo} — these changes live in {oss | proprietary}. + +### New Entities +{For each:} +- **`{EntityName}`** — {purpose} + - File: `packages/{domain}/src/domain/entities/{EntityName}.ts` + - Fields: + - `id: {EntityName}Id` (branded type in `packages/types/src/{domain}/`) + - `{field}: {type}` {constraints} + - Relationships: {description} + - **Reference**: `{discovery.reference_domain.domain_files[*].file}:{line}` — follows pattern + +### Entity Modifications +{For each:} +- **`{EntityName}`** (`{existing_file}:{line}`) + - Add: `{field}: {type}` — {reason} + - Modify: `{field}: {old}` → `{new}` — {reason + migration impact} + - Remove: `{field}` — {reason + migration note} + +### New Value Objects / Branded IDs +{For each:} +- **`{Name}`** (`packages/types/src/{domain}/{Name}.ts`) + +### New Domain Events +{For each:} +- **`{EventName}`** (`packages/types/src/events/{EventName}.ts`) + - Payload: `{shape}` + - Emitted by: `{Domain}` (use case `{name}`) + - Listened by: `{ListenerDomain}` — `application/listeners/{Name}Listener.ts` + +### New Repository Interfaces +{For each:} +- **`I{Entity}Repository`** (`packages/{domain}/src/domain/repositories/`) + - Methods: `{methods}` + - Extends `AbstractRepository<{Entity}>`: yes/no + +### TypeORM Schemas +{For each:} +- **`{Entity}Schema`** (`packages/{domain}/src/infra/schemas/`) + - Table: `{table_name}` + - Columns: {list} + - Indexes: {list} + - FKs: {list} + +### Migrations +{For each:} +- **`{TimestampMigrationName}`** under `packages/migrations/src/migrations/` + - Up: {description} + - Down: {description} + - Reference pattern: `{existing_migration_file}` + +### New Domain Errors +{For each:} +- **`{ErrorClass}`** — thrown when {invariant} + +{If no data-structure changes: "No domain data-structure changes — this task only touches application/UI behavior."} +``` + +## Subtask 3B: Ports, Contracts, Use Cases, Routes (presentation template) + +``` +## Subtask 3B: Ports, Contracts, Use Cases, Routes + +### Target Repo +{target_repo} + +### New Ports +{For each:} +- **`I{Name}Port`** (`packages/types/src/{domain}/ports/`) + - Methods: `{signatures}` + - Implemented by: `{Domain}Adapter` + - Why new: {justification} + +### New Contracts +{For each:} +- **`{UseCaseName}`** (`packages/types/src/{domain}/contracts/`) + - Request: `{shape}` + - Response: `{shape}` + +### New Use Cases +{For each:} +- **`{name}.usecase.ts`** (`packages/{domain}/src/application/useCases/{name}/`) — {purpose} + - Base class: `AbstractSpaceMemberUseCase` (etc.) + - Authorization: {who can call} + - Uses repos: {list} + - Uses services: {list} + - Emits events: {list, referencing 3A} + - **Reference**: `{discovery.reference_domain.application_files[*].file}:{line}` + +### Modified Use Cases +{For each:} +- **`{file}:{line}`** — {change description} + +### New Services +{For each:} +- **`{Name}Service`** (`packages/{domain}/src/application/services/`) + - Methods: {signatures} + - Used by: {use cases} + +### New Adapter Methods +{For each:} +- **`{Domain}Adapter.{method}()`** (`packages/{domain}/src/application/adapter/`) + - Implements port: `I{Name}Port.{method}` + +### New Listeners +{For each:} +- **`{Domain}Listener.handle{Event}()`** — reacts to `{EventName}` + +### New API Routes +{For each:} +- **`{METHOD} {path}`** — {purpose} + - Controller: `apps/api/src/controllers/{Name}Controller.ts` (new or extends existing) + - Request DTO: `{shape or file}` + - Response DTO: `{shape or file}` + - Resolves port: `IFooPort.{method}` via `HexaRegistry` + - **Reference**: `{discovery.reference_route.controller}:{line}` + +### Modified API Routes +{For each:} +- **`{METHOD} {path}`** (`{file}:{line}`) + - Change: {description} + - Backward compatible: yes/no + detail + +### Removed Routes +{For each:} +- **`{METHOD} {path}`** — {reason + deprecation plan} + +{If no application/API changes: "No application or API changes — this task only modifies data or UI."} +``` + +## Subtask 3C: Frontend Components (presentation template) + +``` +## Subtask 3C: Frontend Components + +### Target Repo +{target_repo} + +### New Page / Route Components +{For each:} +- **`{ComponentName}`** (`apps/frontend/src/.../{ComponentName}.tsx`) — {purpose} + - Route: `{path}` + - Queries/mutations owned: {list} + - Renders: {list of child components} + - **Reference**: `{discovery.reference_frontend.page.file}:{line}` + +### New Domain / Container Components +{For each:} +- **`{ComponentName}`** (`apps/frontend/src/.../{ComponentName}.tsx`) — {purpose} + - Props: `{prop}: {type}` + - Behavior: pure display | form (validation: {schema}) | stateful with queries + - **Reference**: `{similar_component_file}:{line}` + +### Modified Domain Components +{For each:} +- **`{ComponentName}`** (`{file}:{line}`) — {change} + +### New Gateways +{For each:} +- **`{Name}Gateway`** (`apps/frontend/src/.../gateways/{Name}Gateway.ts`) + - Methods: `{signatures}` wrapping `{contract from 3B}` + - **Reference**: `{discovery.reference_frontend.gateway.file}:{line}` + +### New Query / Mutation Hooks +{For each:} +- **`use{Name}`** (`apps/frontend/src/.../{name}.ts`) + - Query key: `{key}` + - Returns: `{shape}` + - Invalidates: `{keys}` + +### `@packmind/ui` Usage +- Reused PM components: {list} +- New PM wrapper components needed: {list or "none"} +- **If new wrappers**: see `wrapping-chakra-ui-with-slot-components` command before implementing + +### Layout / Navigation +{For each change:} +- **`{file}`** — {description} + +### Microcopy +- {Flag any non-trivial user-facing strings that should be reviewed with the `ux-microcopy` skill} + +{If no frontend changes: "No frontend changes — this task only touches backend/data."} +``` + +## 3D: Task & Implementation Plan Format + +The implementation-plan.md generated by 3D must use this exact shape so that +`/feature-sprint`'s `parse_implementation_plan.py` can read it. + +### Task block + +``` +- [ ] **{PHASE}.{TASK}: {short description}** + - Repo: `oss | proprietary` + - Layer: `domain | application | infra | api | frontend | tests | migrations` + - Files: `{paths}` + - Reference: `{file}:{line}` — {pattern_role} + - Notes: {short implementation notes} +``` + +Phases are numbered (1, 2, 3...). Tasks within a phase use decimals (1.1, +1.2, 2.1). Indentation is exactly two spaces for the metadata lines — the +parser depends on it. + +### Plan-level structure + +``` +# Implementation Plan: {task_name} + +## Phase 1: {short label} +- [ ] **1.1: ...** + - Repo, Layer, Files, Reference, Notes +- [ ] **1.2: ...** + ... + +## Phase 2: ... + +## Coverage Matrix + +| Acceptance Criterion | Task IDs | +|----------------------|----------| +| {criterion} | 1.1, 2.3 | + +## Parallel Groups + +| group_id | group_name | task_ids | target_files | rationale | +|----------|-----------|----------|--------------|-----------| +| A | backend-standards | 1.1, 1.2, 1.3 | packages/standards/... | shared domain package | +| B | frontend-standards | 2.1, 2.2 | apps/frontend/src/standards/... | shared gateway + page | +| C | tests | 3.1, 3.2 | varies | after A and B | + +## Dependency Notes +- Group C blocked by Groups A and B +- ... +``` diff --git a/.github/skills/feature-sprint/SKILL.md b/.github/skills/feature-sprint/SKILL.md new file mode 100644 index 000000000..06e1aa8a6 --- /dev/null +++ b/.github/skills/feature-sprint/SKILL.md @@ -0,0 +1,165 @@ +--- +name: 'feature-sprint' +description: 'Execute the implementation plan produced by /feature-spec. Loads tasks from tmp/feature-specs/{slug}/implementation-plan.md, hydrates them as Claude Tasks, runs parallel groups via subagents in the correct repo (OSS sibling at ../packmind or the proprietary repo in cwd), syncs progress back to checkboxes, commits per group, and runs Nx quality gates. Resumable across sessions via checkbox state.' +--- + +# Feature Sprint Skill + +Execute a Packmind feature implementation plan via subagents with automatic parallel execution and sync-back. Companion to `/feature-spec`. + +## When to Use + +| Scenario | Use feature-sprint | Use plan + architect-executor | +|----------|--------------------|-----------------------------------| +| Fast execution of an already-specified feature | ✅ | ❌ | +| Parallel group execution | ✅ | partial | +| Multi-repo (OSS + proprietary) execution | ✅ | ❌ | +| Heavy TDD enforcement, per-task escalation | ❌ | ✅ | +| Specs in `tmp/feature-specs/` (this flow) | ✅ | ❌ | +| Specs in `.claude/specs/` and `.claude/plans/` | ❌ | ✅ | + +**Rule of thumb**: `/feature-sprint` is the executor for `/feature-spec`. For more careful, single-task-at-a-time execution with the global `plan` skill, use `architect-executor` instead. + +## Prerequisites + +A completed feature spec at `tmp/feature-specs/{slug}/`: +- `{slug}.md` with `status: COMPLETE` in frontmatter +- `context.md` with YAML frontmatter (version 1.0+) +- `implementation-plan.md` with task checkboxes + +If missing or DRAFT: tell the user to run `/feature-spec {source}` first. + +## Architecture: Orchestration + Subagents + Sync-Back + +**Core Principle**: The main session **orchestrates only**. All implementation happens in Task subagents — that keeps the main context clean and lets agents do heavy reading without poisoning your conversation. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Persistent Layer (tmp/, git-ignored) │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ implementation-plan.md │ │ +│ │ Source of truth: task checkboxes [ ] / [x] │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ ▲ + │ Hydrate (session start) │ Sync-back (per group) + ▼ │ +┌─────────────────────────────────────────────────────────────────┐ +│ Main Session (ORCHESTRATION ONLY) │ +│ • Load spec & state • Spawn subagents (one per group) │ +│ • Parse agent output • Sync checkboxes │ +│ • Run Nx quality gates • Commit per group │ +│ • NEVER implement tasks directly │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ Spawn (parallel or sequential) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Subagent Layer (IMPLEMENTATION) │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────┐ │ +│ │ Group A agent │ │ Group B agent │ │ Group C agent │ │ +│ │ Backend tasks │ │ Frontend tasks │ │ Tests / integration│ │ +│ │ cwd: OSS repo │ │ cwd: OSS repo │ │ cwd: depends │ │ +│ └────────────────┘ └────────────────┘ └────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Hydration (session start) +1. Read `implementation-plan.md` → extract tasks with checkbox state (`[ ]` pending, `[x]` done) +2. Read `context.md` → extract `parallel_groups`, `target_repo`, `quality_gates` +3. Use `TaskCreate` for each pending task (visual tracking) +4. Wire `addBlockedBy` from `parallel_groups[*].blocked_by` + +### Subagent delegation +Every group runs in a Task subagent — parallel or sequential. The main session never touches code, never reads source files, never runs Nx commands itself. + +### Sync-back (automatic) +After each group's subagent completes: +1. Parse output for `TASK_COMPLETE: {id}` markers +2. Update `implementation-plan.md` checkboxes: `[ ]` → `[x]` +3. Update Claude Tasks status + +Progress is durable: kill the session, restart `/feature-sprint {slug}`, and it resumes from where checkboxes left off. + +## Workflow Phases + +| Phase | File | Purpose | +|-------|------|---------| +| 1 | `phase-1-init.md` | Load spec, hydrate tasks, get approval | +| 2 | `phase-2-execute.md` | Parallel execution via subagents with auto sync-back + per-group commits | +| 3 | `phase-3-finalize.md` | Run Nx quality gates, final report, optional archive | + +## State Management + +``` +tmp/feature-specs/{slug}/ +├── {slug}.md # status: COMPLETE +├── context.md # parallel_groups, target_repo, quality_gates +├── implementation-plan.md # tasks with checkboxes — SOURCE OF TRUTH for progress +├── discovery.md # reference patterns +└── functional-spec.md # acceptance criteria +``` + +Progress is tracked entirely through `implementation-plan.md` checkboxes — no separate state file. + +## OSS / Proprietary Routing + +`context.md` declares `target_repo` and each parallel group declares its `repo` field. The main session sets `cwd` for each subagent accordingly: + +- `repo: oss` → `cwd: ../packmind` (the OSS sibling cloned next to the proprietary repo) +- `repo: proprietary` → `cwd: .` (this repo) + +After all OSS work is committed and merged upstream, remind the user to `git pull` here so the proprietary fork picks up the auto-merge before any proprietary tasks run. + +## Commit Strategy + +**Each parallel group gets its own commit** when its subagent reports success (one commit per group, not per task). This matches the Packmind CLAUDE.md rule "Each sub-task should have its own commit" — we treat a parallel group as a coherent sub-task unit. + +Commits follow the `git-commit-guidelines` skill format (gitmoji + Conventional Commits, never auto-closing issues). The main session always shows the proposed message and asks for approval before running `git commit`. + +## Invocation + +### New sprint + +``` +/feature-sprint {task_slug} +``` + +If `{task_slug}` is omitted, the skill lists available specs in `tmp/feature-specs/` and asks the user to pick one. + +### Resume + +Same command. The skill detects existing checkbox state and offers to continue. + +## Status + +Show progress across active sprints: + +``` +/feature-sprint status +``` + +(Implemented by listing `tmp/feature-specs/*/implementation-plan.md` and counting checkbox state in each.) + +## Error Handling + +| Situation | Action | +|-----------|--------| +| Spec not found | Error: "Run /feature-spec first" | +| Spec DRAFT | Error: "Complete the spec first — `status` is DRAFT" | +| No parallel groups | Fall back to single-group sequential execution | +| `repo: oss` but `../packmind` missing | Ask user — switch to proprietary or abort | +| Agent fails | Sync completed tasks, pause sprint, report error | +| Quality gate fails | Sync progress, report failures, prompt for fix-and-retry | + +## Integration with /feature-spec + +``` +/feature-spec #123 # produces tmp/feature-specs/{slug}/ +/feature-sprint {slug} # executes the plan +``` + +Sprint reads: +- `context.md` → `parallel_groups`, `target_repo`, `quality_gates` +- `implementation-plan.md` → task list with checkboxes (source of truth) +- `{slug}.md` → verify `status: COMPLETE` \ No newline at end of file diff --git a/.github/skills/feature-sprint/phase-1-init.md b/.github/skills/feature-sprint/phase-1-init.md new file mode 100644 index 000000000..4cdd6225c --- /dev/null +++ b/.github/skills/feature-sprint/phase-1-init.md @@ -0,0 +1,267 @@ +# Phase 1: Initialize Sprint + +Load the feature spec, hydrate Claude Tasks, detect parallel groups, decide OSS-vs-proprietary execution roots, get user approval. + +## Prerequisites + +- Feature spec exists at `tmp/feature-specs/{task_slug}/` +- Spec `status: COMPLETE` + +## Steps + +### 1.1 Resolve Task Slug + +Extract `{task_slug}` from user input. If not provided: + +```bash +ls tmp/feature-specs/ +``` + +For each candidate directory, read `{slug}.md` YAML frontmatter: +- Skip directories without `status: COMPLETE` +- Capture `task_name`, `source_type`, `target_repo` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Which feature spec do you want to sprint?", + "header": "Task", + "multiSelect": false, + "options": [ + {"label": "{slug-1}", "description": "[{target_repo}] {task_name}"}, + {"label": "{slug-2}", "description": "[{target_repo}] {task_name}"} + ] + }] +} +``` + +If `tmp/feature-specs/` has no COMPLETE specs, tell the user to run `/feature-spec` first. + +### 1.2 Load Specification Files + +Read from `tmp/feature-specs/{task_slug}/`: + +1. **`{task_slug}.md`** — Read YAML frontmatter, verify `status: COMPLETE`. If DRAFT or missing, error and stop. + +2. **`context.md`** — Extract YAML frontmatter fields: + - `version` (≥ 1.0) + - `target_repo` (`oss | proprietary | both`) + - `oss_root`, `proprietary_root`, `discovery_root` + - `parallel_groups[]` (may be empty for trivial features) + - `quality_gates` (`lint`, `test`, `build`, `extra`) + +3. **`implementation-plan.md`** — Parse via the bundled script (do NOT hand-roll the regex; it must handle the `_(BLOCKED: …)_` annotation and indented metadata blocks): + + ```bash + python3 .claude/skills/feature-sprint/scripts/parse_implementation_plan.py \ + tmp/feature-specs/{task_slug}/implementation-plan.md + ``` + + Output is JSON: `{tasks: [{id, completed, description, metadata, blocked_reason, raw_block}], summary}`. Keep each task's `raw_block` — Phase 2 pastes it verbatim into the subagent prompt. + +### 1.3 Verify Repos Exist + +For each parallel group, check the repo it targets exists: + +- `repo: oss` → confirm `oss_root` directory exists (the OSS sibling, typically `realpath ../packmind` from the proprietary repo) +- `repo: proprietary` → confirm `proprietary_root` exists (the current working directory when /feature-spec was run) + +If a required repo is missing, ask the user: + +```json +{ + "questions": [{ + "question": "OSS repo at `{oss_root}` is missing. How to proceed?", + "header": "Missing repo", + "multiSelect": false, + "options": [ + {"label": "Abort", "description": "Cancel sprint; user will set up the repo"}, + {"label": "Switch to proprietary", "description": "Run all groups in this repo regardless of repo tag"}, + {"label": "Skip OSS groups", "description": "Only execute groups tagged proprietary"} + ] + }] +} +``` + +### 1.4 Map Tasks to Groups + +If `context.md` has non-empty `parallel_groups`: + +```javascript +const groupedTasks = {}; +for (const group of parallel_groups) { + groupedTasks[group.group_id] = { + name: group.group_name, + tasks: group.task_ids, + target_files: group.target_files, + repo: group.repo, + blocked_by: group.blocked_by ?? [], + status: 'pending', + }; +} +``` + +If `parallel_groups` is empty, create a single sequential group covering all tasks, using `target_repo` from context for `repo`. + +### 1.5 Detect Resume State + +Use the plan-wide totals (`completed`, `pending`, `total`, `percent`) from the `summary` field already returned by `parse_implementation_plan.py`. + +For the **per-group** breakdown table below, also run the readiness resolver and use each group's `completed_ids`/`task_ids` lengths: + +```bash +python3 .claude/skills/feature-sprint/scripts/resolve_ready_groups.py \ + tmp/feature-specs/{task_slug}/context.md \ + tmp/feature-specs/{task_slug}/implementation-plan.md +``` + +Map each `groups[*]` entry to a row — `Tasks = len(task_ids)`, `Completed = len(completed_ids)`, `Status` from `status` (`ready` → ✅ Ready / 🔄 Partial / ⏳ Blocked / ✅ Done). + +If any tasks are already completed: + +``` +**Resuming Sprint: {task_slug}** + +Previous progress: +- Completed: {n}/{total} ({percent}%) +- Pending: {pending} + +| Group | Repo | Tasks | Completed | Status | +|-------|------|-------|-----------|--------| +| A: {name} | oss | 3 | 3 | ✅ Done | +| B: {name} | oss | 2 | 1 | 🔄 Partial | +| C: {name} | oss | 2 | 0 | ⏳ Blocked (after A, B) | +``` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Resume sprint from previous progress?", + "header": "Resume", + "multiSelect": false, + "options": [ + {"label": "Continue (Recommended)", "description": "Resume from {completed}/{total} done"}, + {"label": "Restart", "description": "Reset all checkboxes to [ ] and start over"} + ] + }] +} +``` + +If "Restart": rewrite `implementation-plan.md` replacing every `- [x]` with `- [ ]` and stripping any `_(BLOCKED: …)_` annotations (a single `sed` pass is fine since this is destructive on purpose). + +### 1.6 Pull-Before-Sprint (proprietary only) + +If `target_repo` is `proprietary` or `both`: + +Remind the user (informational, no gate): +``` +Heads up: most OSS work auto-merges into this proprietary fork. +Before starting, you may want to run `git pull` here so the fork is up to date. +``` + +If `target_repo` is `oss` only, skip this reminder. + +### 1.7 Hydrate Claude Tasks + +For visual feedback, use `TaskCreate` for each pending task: + +``` +TaskCreate({ + subject: `${task.id}: ${task.description}`, + description: `Repo: ${task.repo} | Layer: ${task.layer} | Files: ${task.files}`, + activeForm: `Implementing ${task.id}...`, +}) +``` + +Capture each returned task ID into a `sessionTasks` map keyed by `task.id`. + +### 1.8 Set Up Task Dependencies + +For each group with `blocked_by`: + +``` +for blockerGroupId in group.blocked_by: + for taskId in group.tasks: + TaskUpdate( + taskId: sessionTasks[taskId], + addBlockedBy: blockerGroup.tasks.map(t => sessionTasks[t]) + ) +``` + +This is purely visual — the actual execution gating is enforced in Phase 2 by ready-group selection. + +### 1.9 Display Execution Plan + +``` +**Sprint Ready: {task_slug}** + +**Target repo:** {target_repo} +**Tasks:** {pending}/{total} pending ({completed} already done) + +**Parallel Execution Plan:** +| Group | Repo | Tasks | Status | Dependencies | +|-------|------|-------|--------|--------------| +| A: {name} | oss | {ids} | ✅ Ready | None | +| B: {name} | oss | {ids} | ✅ Ready | None | +| C: {name} | oss | {ids} | ⏳ Blocked | After A, B | + +**Quality gates (final phase):** Nx lint / test / build on affected projects +**Commit strategy:** one commit per group via git-commit-guidelines +``` + +### 1.10 Authorize Subagent File Edits + +Phase 2 spawns Agent subagents that call Edit / Write / Bash on files in the target repo. **Subagents inherit the parent session's permission mode** — in default mode every tool call pauses for approval, which serializes parallel execution and stalls the sprint. + +**Before continuing, the user must enable auto-accept mode in the Claude Code TUI** (`Shift+Tab` cycles through modes; pick "accept edits" or "bypass permissions"). The orchestrator cannot toggle this on the user's behalf. + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Confirm auto-accept (Shift+Tab) is enabled so subagents can edit files without prompts?", + "header": "Authorize", + "multiSelect": false, + "options": [ + {"label": "Yes, ready to sprint", "description": "Auto-accept is on; subagents will edit files without per-call prompts"}, + {"label": "Not yet — cancel", "description": "Stop the sprint; I'll toggle auto-accept and re-run /feature-sprint"} + ] + }] +} +``` + +If "Not yet — cancel": stop and leave state untouched. Otherwise continue to §1.11. + +### 1.11 Get Approval + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Start sprint execution?", + "header": "Sprint", + "multiSelect": false, + "options": [ + {"label": "Start parallel (Recommended)", "description": "Execute all ready groups in parallel via subagents"}, + {"label": "Sequential only", "description": "One group at a time — slower but easier to debug"}, + {"label": "Cancel", "description": "Don't start"} + ] + }] +} +``` + +Do not continue until the user confirms. + +- "Start parallel" → set `execution_mode = "parallel"` +- "Sequential only" → set `execution_mode = "sequential"` +- "Cancel" → stop, leave state untouched + +### 1.12 Proceed + +Read `phase-2-execute.md` and continue. diff --git a/.github/skills/feature-sprint/phase-2-execute.md b/.github/skills/feature-sprint/phase-2-execute.md new file mode 100644 index 000000000..5507d67c5 --- /dev/null +++ b/.github/skills/feature-sprint/phase-2-execute.md @@ -0,0 +1,222 @@ +# Phase 2: Execute Sprint + +Execute tasks via subagents with automatic sync-back and per-group commits. Runs autonomously after Phase 1 approval. + +## Critical Rule: Subagent-Only Execution + +**The main session MUST NOT implement tasks directly.** All implementation lives in Task subagents. + +- Main session = orchestration (spawn agents, parse output, sync state, run commits) +- Subagents = implementation (read specs, write code, run small validation commands) + +This keeps the main context clean and lets agents read heavily without poisoning your conversation. + +## Prerequisites + +- Phase 1 completed +- `execution_mode` chosen (parallel or sequential) +- `sessionTasks` map populated +- `groupedTasks` populated from `parallel_groups` + +## Execution Modes + +### Parallel Mode (Default) + +Spawn Task subagents for ALL ready groups **simultaneously in a single message**. Agents run concurrently. + +### Sequential Mode + +Spawn Task subagents **one at a time**. Wait for each to complete before spawning the next. Used when: +- No `parallel_groups` defined (single sequential group) +- User chose "Sequential only" in Phase 1 +- Only one ready group exists + +## Steps + +### 2.1 Identify Ready Groups + +Call the bundled script — it reads `context.md` + `implementation-plan.md` and classifies every group as `ready | completed | partial | blocked`: + +```bash +python3 .claude/skills/feature-sprint/scripts/resolve_ready_groups.py \ + tmp/feature-specs/{task_slug}/context.md \ + tmp/feature-specs/{task_slug}/implementation-plan.md +``` + +Output JSON contains `ready_group_ids[]` and per-group `status`/`completed_ids`/`blocked_ids`. Use this every iteration of the loop in step 2.10 — never hand-compute the readiness check. + +### 2.2 Pick Working Directory per Group + +For each ready group: + +| group.repo | cwd | +|------------|-----| +| `oss` | `oss_root` from context (the OSS sibling at `../packmind`, resolved to absolute path at spec time) | +| `proprietary` | `proprietary_root` from context (the proprietary repo, the cwd when /feature-spec ran) | + +This `cwd` is passed to the subagent's prompt as the *implementation root*. The subagent must Bash with absolute paths so it works regardless of the agent's own cwd. + +### 2.3 Build Subagent Prompt + +Read the bundled template once: `.claude/skills/feature-sprint/references/group-prompt-template.md`. Substitute the placeholders documented at the top of that file. For `{TASKS_BLOCK}`, concatenate the `raw_block` of each task in `group.task_ids` (from the Phase 1 `parse_implementation_plan.py` output) separated by blank lines. + +Do not paraphrase the template — it is the authoritative contract subagents respond to (`TASK_COMPLETE`, `GROUP_COMPLETE`, `BLOCKED`, `FILES_MODIFIED` markers are parsed in step 2.5). + +### 2.4 Spawn Subagents + +**Parallel mode**: send a single message containing one `Agent` tool call per ready group. They run concurrently. + +**Sequential mode**: send one `Agent` call, wait for it to return, then move to the next. + +In both cases, set `subagent_type="general-purpose"`. + +### 2.5 Parse Subagent Output + +For each agent that returns, parse: + +- `TASK_COMPLETE: {id}` markers — collect into `completed[]` +- `GROUP_COMPLETE: {id}` marker — group finished cleanly +- `BLOCKED: {id} - {reason}` markers — collect into `blocked[]` +- `FILES_MODIFIED: ...` — capture for the commit step + +### 2.6 Sync Checkboxes to implementation-plan.md + +Apply both completed and blocked updates in one pass via the bundled script: + +```bash +python3 .claude/skills/feature-sprint/scripts/sync_checkboxes.py \ + tmp/feature-specs/{task_slug}/implementation-plan.md \ + --complete {comma-separated ids from completed[]} \ + --blocked "{id1}:{reason1}" "{id2}:{reason2}" +``` + +`--blocked` takes one `id:reason` per argument (so reasons may contain commas freely). `--complete` is a single comma-joined ID list. + +**Omit a flag entirely when its list is empty** — do not pass an unquoted empty value (`--complete `) since argparse will treat the next flag as the value. If `completed[]` and `blocked[]` are both empty, skip this step (nothing to sync). + +The script returns JSON with `applied_complete`, `applied_blocked`, and `missing` (any task ID that didn't match — surface these as a warning; don't retry the agent). Exit code is `1` if anything was missing, `0` otherwise. + +### 2.7 Update Claude Tasks + +For every task in `completed[]`: + +``` +TaskUpdate({ taskId: sessionTasks[task_id], status: "completed" }) +``` + +For tasks currently being executed but not yet complete (shouldn't normally happen here since groups are atomic), mark them `in_progress`. + +### 2.8 Commit the Group + +After the agent finishes (whether all tasks completed or some blocked), commit the changes if anything was modified: + +1. Switch to the group's working repo (use absolute path): + ``` + git -C {repo_root} status --short + ``` + If nothing changed, skip the commit step for this group. + +2. Stage only the files listed in `FILES_MODIFIED` (don't `git add -A`): + ``` + git -C {repo_root} add {files...} + ``` + +3. Build the commit message using the `git-commit-guidelines` skill conventions: + - Gitmoji prefix (✨ for new features, 🐛 for fixes, ♻️ for refactor, ✅ for tests, etc.) + - Conventional Commits format: `<type>(<scope>): <subject>` + - NEVER use `Close`/`Fix #` keywords (no auto-closing of issues) + - Body lists completed task IDs and references the spec slug + - Include `Co-Authored-By: Claude <noreply@anthropic.com>` + + Template: + ``` + {emoji} {type}({scope}): {one-line subject derived from group_name} + + Sprint group {group_id} ({group_name}) for feature `{task_slug}`. + + Completed tasks: + - {1.1}: {description} + - {1.2}: {description} + + Refs: tmp/feature-specs/{task_slug}/ + + Co-Authored-By: Claude <noreply@anthropic.com> + ``` + +4. Show the message to the user and ask for approval: + + ```json + { + "questions": [{ + "question": "Commit group {group_id} ({group_name})?", + "header": "Commit", + "multiSelect": false, + "options": [ + {"label": "Commit (Recommended)", "description": "Apply the proposed message"}, + {"label": "Edit message", "description": "Provide a new commit message"}, + {"label": "Skip commit", "description": "Leave changes staged for manual commit later"} + ] + }] + } + ``` + +5. On approval, run: + ``` + git -C {repo_root} commit -m "$(cat <<'EOF' + {message} + EOF + )" + ``` + + If a pre-commit hook fails, do NOT use `--no-verify`. Fix the underlying issue or pass control back to the user. + +### 2.9 Refresh Group Status + +Group status is derived from checkbox state on the next call to `resolve_ready_groups.py` — no in-memory bookkeeping required. The sync step in 2.6 is the only state mutation. + +### 2.10 Loop Until No Ready Groups Remain + +After each round, re-run `resolve_ready_groups.py` (it reads from disk, so it picks up the sync from 2.6): + +1. If `ready_group_ids[]` is non-empty → repeat steps 2.3–2.8 for the new ready groups +2. If `ready_group_ids[]` is empty AND `all_completed` is false → every remaining group is blocked (status `blocked` or `partial`). Report blockers and exit to Phase 3. +3. If `all_completed` is true → proceed to Phase 3. + +### 2.11 Final Sync Summary + +Before handing off to Phase 3, print a summary: + +``` +**Execution Phase Complete** + +| Group | Repo | Tasks | Status | Commit | +|-------|------|-------|--------|--------| +| A: backend-{domain} | oss | 3/3 | ✅ Complete | abc123 | +| B: frontend-{domain} | oss | 2/2 | ✅ Complete | def456 | +| C: tests | oss | 1/2 | 🔄 Partial | ghi789 | + +Total: {completed}/{total} tasks done +Blocked: {list of blocked task IDs with reasons} +``` + +## Error Recovery + +| Scenario | Action | +|----------|--------| +| Agent times out | Sync completed tasks, mark group `partial`, continue | +| Agent crashes | Sync completed tasks, log the error, pause sprint and ask user | +| Lint/test fails inside subagent | Subagent should self-correct; if it can't, it emits `BLOCKED` | +| File conflict (group A writes while group B is reading) | Shouldn't happen if Phase 3D grouping was correct. If it does: pause, report, ask user to resolve | +| All ready groups are blocked | Pause sprint, report blockers, suggest fixes | +| `git commit` fails | Surface the error verbatim; do NOT retry with `--no-verify` | + +## Handling Interruption + +If the session is killed mid-sprint: +- Completed tasks already have `[x]` in `implementation-plan.md` +- Blocked tasks have the `_(BLOCKED: ...)_` annotation +- Re-running `/feature-sprint {task_slug}` resumes from those checkboxes + +## Next Phase + +After all reachable groups are completed or blocked, read `phase-3-finalize.md`. diff --git a/.github/skills/feature-sprint/phase-3-finalize.md b/.github/skills/feature-sprint/phase-3-finalize.md new file mode 100644 index 000000000..ac7f3f426 --- /dev/null +++ b/.github/skills/feature-sprint/phase-3-finalize.md @@ -0,0 +1,227 @@ +# Phase 3: Finalize Sprint + +Run Nx quality gates on affected projects, produce the final report, optionally archive the spec. + +## Prerequisites + +- Phase 2 completed (all reachable groups completed or blocked) +- Per-group commits already created (or skipped) in each working repo + +## Steps + +### 3.1 Load Final State + +Read `tmp/feature-specs/{task_slug}/implementation-plan.md`: +- Count `[x]` vs `[ ]` +- Find any `_(BLOCKED: ...)_` annotations + +Read `tmp/feature-specs/{task_slug}/context.md`: +- `quality_gates` (lint/test/build flags + extras) +- `parallel_groups[]` to derive which Nx projects were touched + +### 3.2 Resolve Affected Nx Projects + +For each completed group, look at `target_files`: +- A path under `packages/{name}/` → Nx project `{name}` +- A path under `apps/{name}/` → Nx project `{name}` + +Build a deduplicated set of `affected_projects[]`. Also note which **repo** each project belongs to (`oss` or `proprietary`) — gates run in the right repo. + +If unsure or you want a broader gate, the Nx convention is: +``` +./node_modules/.bin/nx affected -t lint +./node_modules/.bin/nx affected -t test +./node_modules/.bin/nx affected -t build +``` +But narrowly targeting the specific projects is faster and clearer when you know them. + +### 3.3 Run Quality Gates + +For each `affected_project` × each gate flag in `quality_gates`: + +```bash +# Always cd to the repo via -C; use absolute paths. +{ensure node version per .nvmrc, e.g. via nvm if available} + +# Lint +./node_modules/.bin/nx lint {project} + +# Test +./node_modules/.bin/nx test {project} + +# Build (only if quality_gates.build is true) +./node_modules/.bin/nx build {project} +``` + +Set `PACKMIND_EDITION=proprietary` in the environment when running gates against the proprietary repo (per `CLAUDE.md`). + +Capture for each: `passed: bool`, `duration`, truncated output. + +If any extras are listed in `quality_gates.extra`, run them too (e.g., a project-specific e2e command). + +### 3.4 Handle Gate Failures + +If any gate fails, display the failure: + +``` +**Quality Gate Failed: {project} / {gate}** + +Repo: {repo} +Command: {command} +Exit: {code} + +{Last 30 lines of output} +``` + +Then use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Quality gate '{project}/{gate}' failed. How to proceed?", + "header": "Gate Failed", + "multiSelect": false, + "options": [ + {"label": "Fix and retry (Recommended)", "description": "I'll fix the issue, then retry the gate"}, + {"label": "Spawn fixer subagent", "description": "Delegate the fix to a fresh general-purpose subagent in the right repo"}, + {"label": "Skip gate", "description": "Continue without this gate passing (risky)"}, + {"label": "Pause sprint", "description": "Stop here, investigate manually"} + ] + }] +} +``` + +- "Fix and retry" → wait for the user to make changes, then re-run the gate +- "Spawn fixer subagent" → launch an `Agent` with `subagent_type="general-purpose"`. Prompt: working repo, the failed command and output, the relevant files. Instruct it to make the smallest fix and re-run the gate. On success, commit the fix as a follow-up commit (with user approval, per the per-group commit policy). +- "Skip gate" → record skipped, continue +- "Pause sprint" → exit; progress is preserved in checkboxes + commits + +### 3.5 Verify Final Checkbox State + +Re-read `implementation-plan.md`: +- Count completed (`[x]`) +- Count remaining (`[ ]`) — these should all be annotated `_(BLOCKED: ...)_` or the sprint is incomplete + +If any unblocked `[ ]` tasks remain, the sprint is not finished. Tell the user and offer to resume Phase 2. + +### 3.6 Offer to Archive + +``` +**Sprint Complete: {task_slug}** + +All reachable tasks executed, gates run, commits created. + +Move spec artifacts to an archive folder? (Still in tmp/ — git-ignored.) +- From: tmp/feature-specs/{task_slug}/ +- To: tmp/feature-specs/done/{task_slug}/ +``` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Move completed spec to tmp/feature-specs/done/?", + "header": "Archive", + "multiSelect": false, + "options": [ + {"label": "Yes, archive", "description": "Move under tmp/feature-specs/done/ so /feature-spec sees a clean pending list"}, + {"label": "Keep in place (Recommended)", "description": "Leave it; you may want to revisit it"} + ] + }] +} +``` + +If "Yes, archive": +```bash +mkdir -p tmp/feature-specs/done/ +mv tmp/feature-specs/{task_slug} tmp/feature-specs/done/{task_slug} +``` + +(No commit needed — `tmp/` is git-ignored.) + +### 3.7 Pull Reminder (OSS → proprietary) + +If any commits landed in the OSS repo (`oss_root`), remind the user: + +``` +✅ OSS commits created in {oss_root}. + +When upstream auto-merges into the proprietary fork, run: + git -C {proprietary_root} pull +to pick up the changes here. + +If this feature has proprietary-only tasks pending (e.g., editions wiring), do that pull before resuming /feature-sprint {task_slug}. +``` + +Skip this section if `target_repo` was `proprietary`. + +### 3.8 Final Report + +``` +## ✅ SPRINT COMPLETE + +**Task**: {task_name} +**Slug**: {task_slug} +**Target repo**: {target_repo} + +### Execution Summary + +| Metric | Value | +|--------|-------| +| Tasks completed | {n}/{total} | +| Parallel groups | {completed_groups}/{total_groups} | +| Commits created | {commit_count} | +| Files modified | {file_count} | +| Quality gates | {passed}/{total} passed{, X skipped if any} | + +### Commits + +| Repo | Group | SHA | Message | +|------|-------|-----|---------| +| oss | A: backend-... | abc123 | ✨ feat(...): ... | +| oss | B: frontend-... | def456 | ✨ feat(...): ... | + +### Quality Gates + +| Project | Gate | Status | Duration | +|---------|------|--------|----------| +| standards | lint | ✅ | 4s | +| standards | test | ✅ | 22s | +| frontend | lint | ✅ | 8s | +| frontend | test | ✅ | 31s | + +### Blocked Tasks + +{if any:} +| ID | Reason | +|----|--------| +| 3.2 | {reason} | + +{else: "None."} + +### Files Modified + +{abbreviated git status -s output per repo} + +--- + +**Spec**: `tmp/feature-specs/{pending|done}/{task_slug}/` +**Plan**: `tmp/feature-specs/{pending|done}/{task_slug}/implementation-plan.md` + +Sprint complete. Changes committed locally (not pushed). +``` + +### 3.9 Cleanup Session Tasks + +``` +for (taskId, sessionTaskId) in sessionTasks: + TaskUpdate({ taskId: sessionTaskId, status: "completed" }) +``` + +`sessionTasks` is ephemeral — nothing else to clean up. + +## End of Sprint + +To run another: `/feature-sprint {another_task_slug}` +To check overall progress: `/feature-sprint status` diff --git a/.github/skills/feature-sprint/references/group-prompt-template.md b/.github/skills/feature-sprint/references/group-prompt-template.md new file mode 100644 index 000000000..d928f20ba --- /dev/null +++ b/.github/skills/feature-sprint/references/group-prompt-template.md @@ -0,0 +1,84 @@ +# Per-Group Subagent Prompt Template + +Loaded by `phase-2-execute.md` step 2.3. The orchestrator substitutes every +`{placeholder}` (and the `{TASKS_BLOCK}` section) with concrete values, then +passes the result as the `prompt` argument to `Agent` with +`subagent_type="general-purpose"`. + +## Placeholders + +| Placeholder | Source | +|-------------|--------| +| `{group_id}` | `context.md` → `parallel_groups[*].group_id` | +| `{group_name}` | `context.md` → `parallel_groups[*].group_name` | +| `{task_slug}` | spec directory name (`tmp/feature-specs/{slug}/`) | +| `{repo}` | `parallel_groups[*].repo` (`oss` or `proprietary`) | +| `{repo_root}` | absolute path: `oss_root` or `proprietary_root` from `context.md` | +| `{proprietary_root}` | always `context.md` → `proprietary_root` (spec artifacts live here) | +| `{TASKS_BLOCK}` | concatenation of each task's `raw_block` from `parse_implementation_plan.py` output | + +## Template + +``` +Execute sprint group {group_id}: {group_name} for feature {task_slug}. + +## Working repo +- Repo: {repo} +- Root (absolute path): {repo_root} +- All file paths in tasks are relative to this root unless otherwise noted. + +## Context files (read in this exact order) +1. {proprietary_root}/tmp/feature-specs/{task_slug}/context.md +2. {proprietary_root}/tmp/feature-specs/{task_slug}/functional-spec.md +3. {proprietary_root}/tmp/feature-specs/{task_slug}/discovery.md +4. {proprietary_root}/tmp/feature-specs/{task_slug}/implementation-plan.md + +Note: spec artifacts live in the PROPRIETARY repo's tmp/ even when implementation +targets OSS. Always read from `{proprietary_root}/tmp/feature-specs/{task_slug}/`. + +## Your tasks (in this exact order) + +{TASKS_BLOCK} + +## Rules + +1. For each task in order: + a. Re-read the task block in implementation-plan.md for full detail + b. Open the Reference file:line — that is your pattern template + c. Implement the task following the Packmind conventions established in discovery.md + d. If the task is a backend domain/application/infra task, respect the hexagonal-architecture skill at `{proprietary_root}/.claude/skills/hexagonal-architecture/` + e. If the task is a frontend task, respect `working-with-pm-design-kit` and `gateway-pattern-implementation-in-packmind-frontend` + f. If the task is a migration, respect `how-to-write-typeorm-migrations-in-packmind` + g. After finishing the task, output exactly: `TASK_COMPLETE: {task_id}` + +2. NEVER import from `@packmind/editions` unless the file you're editing already lives inside `packages/editions/` (this is enforced by `.claude/rules/packmind/packmind-proprietary.md`). + +3. Run `./node_modules/.bin/nx lint <project>` and `./node_modules/.bin/nx test <project>` after finishing each Nx project's tasks within the group. If anything fails, fix it before moving on. + +4. After ALL tasks in this group are complete, output exactly: + ``` + GROUP_COMPLETE: {group_id} + COMPLETED_TASKS: <comma-separated task_ids> + FILES_MODIFIED: <list of absolute file paths you changed> + ``` + +5. If you encounter a blocker: + - Output: `BLOCKED: <task_id> - <short reason>` + - Continue with later tasks in this group only if they don't depend on the blocked task + - At the end, list blocked tasks in your group summary + +## Hard constraints + +- Do NOT commit changes — the main session handles commits per group +- Do NOT modify `implementation-plan.md` checkboxes — the main session handles sync-back +- Do NOT modify any file in `{proprietary_root}/tmp/feature-specs/{task_slug}/` +- Focus only on implementing the listed task IDs; do not "improve" unrelated files +- Use absolute paths in your Bash calls so they work regardless of your cwd + +## Tools you should rely on + +- Read, Edit, Write for code changes +- Bash with absolute paths for `nx lint`, `nx test`, `nx build` in the working repo +- Glob/Grep within the working repo for navigation +- Avoid spawning further subagents (`Agent`) — flatten everything in this one +``` diff --git a/.github/skills/feature-sprint/scripts/parse_implementation_plan.py b/.github/skills/feature-sprint/scripts/parse_implementation_plan.py new file mode 100644 index 000000000..b8e6068c2 --- /dev/null +++ b/.github/skills/feature-sprint/scripts/parse_implementation_plan.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Parse a feature-sprint implementation-plan.md into structured JSON. + +Reads checkbox tasks of the form: + + - [ ] **1.2: short description** + - Repo: oss + - Layer: application + - Files: packages/foo/... + - Reference: packages/bar/baz.ts:42 + - Notes: free text + +and emits JSON to stdout: + + { + "tasks": [ + { + "id": "1.2", + "completed": false, + "description": "short description", + "metadata": { + "repo": "oss", + "layer": "application", + "files": "packages/foo/...", + "reference": "packages/bar/baz.ts:42", + "notes": "free text" + }, + "blocked_reason": null, + "raw_block": "..." + } + ], + "summary": {"total": 1, "completed": 0, "pending": 1, "blocked": 0} + } + +Tasks annotated with `_(BLOCKED: reason)_` after the description carry that +reason on `blocked_reason`. The full markdown block (including indented +metadata lines) is preserved on `raw_block` so the orchestrator can paste it +verbatim into subagent prompts without re-reading the file. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +TASK_LINE = re.compile( + r"""^-\s\[(?P<state>[ x])\]\s\*\* + (?P<id>\d+(?:\.\d+)+):\s + (?P<desc>.+?)\*\* + (?:\s+_\(BLOCKED:\s(?P<blocked>.+?)\)_)? + \s*$""", + re.VERBOSE, +) +META_LINE = re.compile(r"^\s+-\s(?P<key>[A-Za-z]+):\s*(?P<value>.*)$") + + +def parse(content: str) -> dict: + tasks: list[dict] = [] + current: dict | None = None + raw_lines: list[str] = [] + + def flush() -> None: + if current is None: + return + current["raw_block"] = "\n".join(raw_lines).rstrip() + tasks.append(current) + + for line in content.splitlines(): + task_match = TASK_LINE.match(line) + if task_match: + flush() + raw_lines = [line] + current = { + "id": task_match.group("id"), + "completed": task_match.group("state") == "x", + "description": task_match.group("desc").strip(), + "metadata": {}, + "blocked_reason": task_match.group("blocked"), + "raw_block": "", + } + continue + + if current is None: + continue + + meta_match = META_LINE.match(line) + if meta_match: + raw_lines.append(line) + key = meta_match.group("key").lower() + value = meta_match.group("value").strip() + current["metadata"][key] = value + continue + + if line.startswith("- [") or (line.strip() == "" and len(raw_lines) > 1): + flush() + current = None + raw_lines = [] + continue + + if line.strip(): + raw_lines.append(line) + + flush() + + completed = sum(1 for t in tasks if t["completed"]) + blocked = sum(1 for t in tasks if t["blocked_reason"]) + return { + "tasks": tasks, + "summary": { + "total": len(tasks), + "completed": completed, + "pending": len(tasks) - completed, + "blocked": blocked, + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("plan", help="Path to implementation-plan.md") + parser.add_argument( + "--ids-only", + action="store_true", + help="Print only the task IDs (one per line) instead of JSON", + ) + parser.add_argument( + "--pending-only", + action="store_true", + help="Filter to non-completed tasks before printing", + ) + args = parser.parse_args() + + path = Path(args.plan) + if not path.exists(): + print(f"Error: plan file not found: {path}", file=sys.stderr) + return 1 + + result = parse(path.read_text(encoding="utf-8")) + + if args.pending_only: + result["tasks"] = [t for t in result["tasks"] if not t["completed"]] + + if args.ids_only: + for task in result["tasks"]: + print(task["id"]) + return 0 + + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/skills/feature-sprint/scripts/resolve_ready_groups.py b/.github/skills/feature-sprint/scripts/resolve_ready_groups.py new file mode 100644 index 000000000..b6da2306a --- /dev/null +++ b/.github/skills/feature-sprint/scripts/resolve_ready_groups.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Compute which parallel groups are ready to execute next. + +Reads: +- context.md (YAML frontmatter with `parallel_groups[]`) +- implementation-plan.md (checkbox state per task) + +A group is **completed** when every task in `task_ids` is checked `[x]` AND +not annotated `_(BLOCKED: ...)_`. A group is **partial** when some tasks are +done and some are blocked. A group is **ready** when its status is not yet +`completed` and every group it depends on (`blocked_by`) has status +`completed`. + +Output (stdout, JSON): + + { + "groups": [ + { + "group_id": "A", + "group_name": "backend-foo", + "repo": "oss", + "status": "ready" | "completed" | "partial" | "blocked", + "task_ids": ["1.1", "1.2"], + "completed_ids": ["1.1"], + "blocked_ids": [], + "blocked_by": [] + } + ], + "ready_group_ids": ["A"], + "all_completed": false + } + +Requires PyYAML. Exit 1 on missing files or malformed YAML. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +try: + import yaml +except ImportError: + print( + "Error: PyYAML is required (pip install pyyaml)", + file=sys.stderr, + ) + sys.exit(1) + + +FRONTMATTER = re.compile(r"^---\n(.*?)\n---", re.DOTALL) +TASK_STATE = re.compile( + r"^-\s\[(?P<state>[ x])\]\s\*\*(?P<id>\d+(?:\.\d+)+):\s.+?\*\*" + r"(?P<blocked>\s+_\(BLOCKED:.+?\)_)?\s*$", + re.MULTILINE, +) + + +def load_frontmatter(path: Path) -> dict: + text = path.read_text(encoding="utf-8") + match = FRONTMATTER.match(text) + if not match: + print(f"Error: no YAML frontmatter in {path}", file=sys.stderr) + sys.exit(1) + try: + data = yaml.safe_load(match.group(1)) + except yaml.YAMLError as exc: + print(f"Error: invalid YAML in {path}: {exc}", file=sys.stderr) + sys.exit(1) + if not isinstance(data, dict): + print(f"Error: frontmatter must be a mapping in {path}", file=sys.stderr) + sys.exit(1) + return data + + +def extract_task_state(plan_path: Path) -> dict[str, dict]: + states: dict[str, dict] = {} + for match in TASK_STATE.finditer(plan_path.read_text(encoding="utf-8")): + states[match.group("id")] = { + "completed": match.group("state") == "x", + "blocked": bool(match.group("blocked")), + } + return states + + +def classify_group(group: dict, task_state: dict[str, dict]) -> dict: + task_ids = group.get("task_ids", []) or [] + completed: list[str] = [] + blocked: list[str] = [] + for task_id in task_ids: + info = task_state.get(task_id) + if info is None: + continue + if info["completed"]: + completed.append(task_id) + elif info["blocked"]: + blocked.append(task_id) + + if task_ids and len(completed) == len(task_ids): + status = "completed" + elif blocked and (len(completed) + len(blocked)) == len(task_ids): + status = "partial" + else: + status = "pending" # may become "ready" after dep check + + return { + "group_id": group.get("group_id"), + "group_name": group.get("group_name"), + "repo": group.get("repo"), + "task_ids": task_ids, + "blocked_by": group.get("blocked_by", []) or [], + "target_files": group.get("target_files", []) or [], + "status": status, + "completed_ids": completed, + "blocked_ids": blocked, + } + + +def resolve_readiness(groups: list[dict]) -> None: + by_id = {g["group_id"]: g for g in groups} + for group in groups: + if group["status"] in ("completed", "partial"): + continue + deps_done = all( + by_id.get(dep, {}).get("status") == "completed" + for dep in group["blocked_by"] + ) + group["status"] = "ready" if deps_done else "blocked" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("context", help="Path to context.md") + parser.add_argument("plan", help="Path to implementation-plan.md") + args = parser.parse_args() + + context_path = Path(args.context) + plan_path = Path(args.plan) + for path, label in [(context_path, "context"), (plan_path, "plan")]: + if not path.exists(): + print(f"Error: {label} file not found: {path}", file=sys.stderr) + return 1 + + frontmatter = load_frontmatter(context_path) + parallel_groups = frontmatter.get("parallel_groups") or [] + if not isinstance(parallel_groups, list): + print("Error: parallel_groups must be a list", file=sys.stderr) + return 1 + + task_state = extract_task_state(plan_path) + groups = [classify_group(g, task_state) for g in parallel_groups] + resolve_readiness(groups) + + ready_ids = [g["group_id"] for g in groups if g["status"] == "ready"] + all_completed = bool(groups) and all(g["status"] == "completed" for g in groups) + + result = { + "groups": groups, + "ready_group_ids": ready_ids, + "all_completed": all_completed, + } + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/skills/feature-sprint/scripts/sync_checkboxes.py b/.github/skills/feature-sprint/scripts/sync_checkboxes.py new file mode 100644 index 000000000..0253477fa --- /dev/null +++ b/.github/skills/feature-sprint/scripts/sync_checkboxes.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Batch-update task checkboxes in a feature-sprint implementation-plan.md. + +Supports two operations in a single pass: + + --complete 1.1,1.2,2.3 Flip `- [ ] **{id}:` to `- [x] **{id}:` + --blocked '3.1:reason' '3.2:r2' Annotate `- [ ] **{id}: desc**` with + `_(BLOCKED: reason)_` (keeps the checkbox + unchecked so resume picks it up). + +`--blocked` accepts one pair per argument so reasons can contain commas +freely (e.g. `'3.1:waiting on API, see #123'`). Pass either flag without +values, or omit it entirely, when the corresponding list is empty. + +Already-completed tasks (`[x]`) are left alone. Tasks already annotated +BLOCKED have their annotation replaced if a new reason is supplied. Any task +ID passed but not found in the file is reported on stderr; exit code is 1 if +any IDs were not applied, 0 otherwise. + +Output (stdout, JSON): + + { + "applied_complete": ["1.1", "1.2"], + "applied_blocked": [{"id": "3.1", "reason": "..."}], + "missing": ["9.9"] + } +""" + +import argparse +import json +import re +import sys +from pathlib import Path + + +def parse_id_list(raw: str | None) -> list[str]: + if not raw: + return [] + return [item.strip() for item in raw.split(",") if item.strip()] + + +def parse_blocked_list(items: list[str] | None) -> list[tuple[str, str]]: + if not items: + return [] + pairs = [] + for item in items: + item = item.strip() + if not item: + continue + if ":" not in item: + print( + f"Error: --blocked entry '{item}' missing ':reason'", + file=sys.stderr, + ) + sys.exit(2) + task_id, reason = item.split(":", 1) + pairs.append((task_id.strip(), reason.strip())) + return pairs + + +def task_line_pattern(task_id: str) -> re.Pattern[str]: + # Match the exact task line, preserving the description and any trailing + # markup. Captures: 1=state ([ ] or [x]), 2=description, 3=optional + # BLOCKED annotation already present. + return re.compile( + rf"^-\s\[(?P<state>[ x])\]\s\*\*{re.escape(task_id)}:\s(?P<desc>.+?)\*\*" + r"(?P<blocked>\s+_\(BLOCKED:.+?\)_)?\s*$", + re.MULTILINE, + ) + + +def apply_complete(content: str, ids: list[str]) -> tuple[str, list[str], list[str]]: + applied: list[str] = [] + missing: list[str] = [] + for task_id in ids: + pattern = task_line_pattern(task_id) + match = pattern.search(content) + if not match: + missing.append(task_id) + continue + if match.group("state") == "x": + applied.append(task_id) # idempotent — already complete + continue + # Replace the matched line: flip state, strip any BLOCKED annotation + new_line = f"- [x] **{task_id}: {match.group('desc')}**" + content = content[: match.start()] + new_line + content[match.end():] + applied.append(task_id) + return content, applied, missing + + +def apply_blocked( + content: str, pairs: list[tuple[str, str]] +) -> tuple[str, list[dict], list[str]]: + applied: list[dict] = [] + missing: list[str] = [] + for task_id, reason in pairs: + pattern = task_line_pattern(task_id) + match = pattern.search(content) + if not match: + missing.append(task_id) + continue + if match.group("state") == "x": + # Already complete — don't re-annotate + continue + new_line = ( + f"- [ ] **{task_id}: {match.group('desc')}** " + f"_(BLOCKED: {reason})_" + ) + content = content[: match.start()] + new_line + content[match.end():] + applied.append({"id": task_id, "reason": reason}) + return content, applied, missing + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("plan", help="Path to implementation-plan.md") + parser.add_argument( + "--complete", + help="Comma-separated task IDs to mark complete (e.g. 1.1,1.2)", + ) + parser.add_argument( + "--blocked", + nargs="*", + default=[], + help=( + "One or more 'id:reason' pairs, each as a single argument so " + "reasons may contain commas " + "(e.g. --blocked '3.1:waiting on API, see #123' '3.2:other')" + ), + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would change without writing the file", + ) + args = parser.parse_args() + + path = Path(args.plan) + if not path.exists(): + print(f"Error: plan file not found: {path}", file=sys.stderr) + return 1 + + completes = parse_id_list(args.complete) + blocks = parse_blocked_list(args.blocked) + + if not completes and not blocks: + print( + "Error: at least one of --complete or --blocked is required", + file=sys.stderr, + ) + return 2 + + content = path.read_text(encoding="utf-8") + content, applied_complete, missing_complete = apply_complete(content, completes) + content, applied_blocked, missing_blocked = apply_blocked(content, blocks) + + if not args.dry_run: + path.write_text(content, encoding="utf-8") + + result = { + "applied_complete": applied_complete, + "applied_blocked": applied_blocked, + "missing": sorted(set(missing_complete + missing_blocked)), + "dry_run": args.dry_run, + } + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + + return 1 if result["missing"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/skills/packmind-onboard/steps/edge-cases.md b/.github/skills/packmind-onboard/steps/edge-cases.md deleted file mode 100644 index d50f21737..000000000 --- a/.github/skills/packmind-onboard/steps/edge-cases.md +++ /dev/null @@ -1,37 +0,0 @@ -## Edge Cases - -### Package creation fails - -If `packmind-cli packages create` fails: - -``` -❌ Failed to create package: [error message] - -Please check: - - You are logged in: `packmind-cli login` - - Your network connection is working - - The package name is valid - -Cannot proceed with onboarding until package is created. -``` - -Exit the skill. Do not proceed to analysis. - -### Not logged in - -If CLI commands fail with authentication errors: - -``` -❌ Not logged in to Packmind - -Please run: - packmind-cli login - -Then re-run this skill. -``` - -### No packages available - -If the package listing command returns no packages: - -Auto-create a package using the repository name. diff --git a/.github/skills/packmind-onboard/steps/step-0-introduction.md b/.github/skills/packmind-onboard/steps/step-0-introduction.md deleted file mode 100644 index 093426137..000000000 --- a/.github/skills/packmind-onboard/steps/step-0-introduction.md +++ /dev/null @@ -1,7 +0,0 @@ -## Step 0 — Introduction - -Print exactly: - -``` -I'll start the Packmind onboarding process. I'll create your first standards and commands and send them to your Packmind organization. This usually takes ~3 minutes. -``` diff --git a/.github/skills/packmind-onboard/steps/step-1-get-repository-name.md b/.github/skills/packmind-onboard/steps/step-1-get-repository-name.md deleted file mode 100644 index 3377064ab..000000000 --- a/.github/skills/packmind-onboard/steps/step-1-get-repository-name.md +++ /dev/null @@ -1,11 +0,0 @@ -## Step 1 — Get Repository Name - -Get the repository name for package naming: - -```bash -basename "$(git rev-parse --show-toplevel)" -``` - -Remember this as the repository name for package creation in Step 2. - -Also run `packmind-cli whoami` and extract the `Host:` value from the output. Remember this URL for the completion summary. diff --git a/.github/skills/packmind-onboard/steps/step-10-completion-summary.md b/.github/skills/packmind-onboard/steps/step-10-completion-summary.md deleted file mode 100644 index 966eb70a8..000000000 --- a/.github/skills/packmind-onboard/steps/step-10-completion-summary.md +++ /dev/null @@ -1,3 +0,0 @@ -## Step 10 — Completion Summary - -Follow [`packmind-versions/$PACKMIND_CLI_VERSION/completion-summary.md`](packmind-versions/$PACKMIND_CLI_VERSION/completion-summary.md). diff --git a/.github/skills/packmind-onboard/steps/step-2-package-handling.md b/.github/skills/packmind-onboard/steps/step-2-package-handling.md deleted file mode 100644 index ba7b1e4a6..000000000 --- a/.github/skills/packmind-onboard/steps/step-2-package-handling.md +++ /dev/null @@ -1,46 +0,0 @@ -## Step 2 — Package Handling - -Handle package creation or selection. - -### Check existing packages - -List available packages by following [`packmind-versions/$PACKMIND_CLI_VERSION/list-packages.md`](packmind-versions/$PACKMIND_CLI_VERSION/list-packages.md). - -Parse the output to get package names and slugs. - -### No packages exist - -Auto-create a package using the repository name. Follow [`packmind-versions/$PACKMIND_CLI_VERSION/create-package.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-package.md) using `${REPO_NAME}-standards` as the package name. - -The create-package step will determine the space. Capture the chosen space slug as `$SPACE_SLUG` and the new package slug as `$PACKAGE_SLUG`. - -Print: -``` -No existing packages found — created a new one: ${REPO_NAME}-standards -``` - -### One package exists - -Ask via AskUserQuestion: -- "Add to `{package-name}`?" -- "Create new package instead" - -### Multiple packages exist - -Ask via AskUserQuestion: -- List each existing package as an option -- Include "Create new package" option - -### If "Create new package" is selected - -- Ask for package name (suggest `${REPO_NAME}-standards` as default) -- Follow [`packmind-versions/$PACKMIND_CLI_VERSION/create-package.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-package.md) using the chosen name. -- The create-package step will determine the space. Capture the chosen space slug as `$SPACE_SLUG` and the new package slug as `$PACKAGE_SLUG`. - -### If an existing package is selected - -Follow [`packmind-versions/$PACKMIND_CLI_VERSION/select-package.md`](packmind-versions/$PACKMIND_CLI_VERSION/select-package.md). - -### After this step - -Remember `$PACKAGE_SLUG` (the slug of the selected/created package) and `$SPACE_SLUG` for later reference — they will be used together in Step 9 to ensure items are added to the correct package in the correct space (as `@$SPACE_SLUG/$PACKAGE_SLUG`). diff --git a/.github/skills/packmind-onboard/steps/step-3-announce.md b/.github/skills/packmind-onboard/steps/step-3-announce.md deleted file mode 100644 index 9cd862107..000000000 --- a/.github/skills/packmind-onboard/steps/step-3-announce.md +++ /dev/null @@ -1,8 +0,0 @@ -## Step 3 — Announce - -Print exactly: - -``` -packmind-onboard: analyzing codebase (read-only) -Target package: [package-name] -``` diff --git a/.github/skills/packmind-onboard/steps/step-4-detect-existing-config.md b/.github/skills/packmind-onboard/steps/step-4-detect-existing-config.md deleted file mode 100644 index 0bef746db..000000000 --- a/.github/skills/packmind-onboard/steps/step-4-detect-existing-config.md +++ /dev/null @@ -1,31 +0,0 @@ -## Step 4 — Detect Existing Packmind and Agent Configuration - -Before analyzing, detect and preserve any existing Packmind/agent configuration. - -### Glob (broad, future-proof) -Glob for markdown in these roots (recursive): -- `.packmind/**/*.md` -- `.claude/**/*.md` -- `.agents/**/*.md` -- `**/skills/**/*.md` -- `**/rules/**/*.md` - -### Classify -Classify found files into counts: -- **standards**: `.packmind/standards/**/*.md` -- **commands**: `.packmind/commands/**/*.md` -- **other_docs**: any markdown under `.claude/`, `.agents/`, or any `skills/` or `rules/` directory outside `.packmind` - -If any exist, print exactly: - -``` -Existing Packmind/agent docs detected: - - Standards: [N] - - Commands: [M] - - Other docs: [P] -``` - -No overwrites. New files (if you Export) will be added next to the existing ones. diff --git a/.github/skills/packmind-onboard/steps/step-5-detect-project-stack.md b/.github/skills/packmind-onboard/steps/step-5-detect-project-stack.md deleted file mode 100644 index 79f301519..000000000 --- a/.github/skills/packmind-onboard/steps/step-5-detect-project-stack.md +++ /dev/null @@ -1,28 +0,0 @@ -## Step 5 — Detect Project Stack (Minimal, Evidence-Based) - -### Language markers (check presence) -- JS/TS: `package.json`, `pnpm-lock.yaml`, `yarn.lock`, `tsconfig.json` -- Python: `pyproject.toml`, `requirements.txt`, `setup.py` -- Go: `go.mod` -- Rust: `Cargo.toml` -- Ruby: `Gemfile` -- JVM: `pom.xml`, `build.gradle`, `build.gradle.kts` -- .NET: `*.csproj`, `*.sln` -- PHP: `composer.json` - -### Architecture markers (check directories) -- Hexagonal/DDD: `src/application/`, `src/domain/`, `src/infra/` -- Layered/MVC: `src/controllers/`, `src/services/` -- Monorepo: `packages/`, `apps/` - -Print exactly: - -``` -Stack detected (heuristic): - - Languages: [..] - - Repo shape: [monorepo|single] - - Architecture markers: [..|none] -``` diff --git a/.github/skills/packmind-onboard/steps/step-6-run-analyses.md b/.github/skills/packmind-onboard/steps/step-6-run-analyses.md deleted file mode 100644 index 0b1ec0d6a..000000000 --- a/.github/skills/packmind-onboard/steps/step-6-run-analyses.md +++ /dev/null @@ -1,24 +0,0 @@ -## Step 6 — Run Analyses - -Read each reference file for detailed search patterns, thresholds, and insight templates. - -| Analysis | Reference File | Output focus | -|----------|----------------|--------------| -| File Template Consistency | `references/file-template-consistency.md` | Commands | -| CI/Local Workflow Parity | `references/ci-local-workflow-parity.md` | Commands | -| Role Taxonomy Drift | `references/role-taxonomy-drift.md` | Standards | -| Test Data Construction | `references/test-data-construction.md` | Standards | - -### Output schema (internal; do not print as-is to user) -For every finding, keep an internal record: - -``` -INSIGHT: -title: ... -why_it_matters: ... -confidence: [high|medium|low] -evidence: -- path[:line-line] -where_it_doesnt_apply: -- path[:line-line] -``` diff --git a/.github/skills/packmind-onboard/steps/step-7-generate-drafts.md b/.github/skills/packmind-onboard/steps/step-7-generate-drafts.md deleted file mode 100644 index e109f0e44..000000000 --- a/.github/skills/packmind-onboard/steps/step-7-generate-drafts.md +++ /dev/null @@ -1,12 +0,0 @@ -## Step 7 — Generate All Drafts - -Generate all draft files in one batch, using the format defined for your CLI version. - -Read the **Draft Format** section in [`packmind-versions/$PACKMIND_CLI_VERSION/create-items.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-items.md) and create draft files accordingly. - -### Generation Rules (all versions) - -- Generate drafts **only from discovered insights** (no invention) -- Use evidence from analysis to populate rules/steps -- Cap output: max **5 Standards** + **5 Commands** -- Never overwrite existing files; append `-2`, `-3`, etc. if slug exists diff --git a/.github/skills/packmind-onboard/steps/step-8-present-summary.md b/.github/skills/packmind-onboard/steps/step-8-present-summary.md deleted file mode 100644 index bb9c27cd2..000000000 --- a/.github/skills/packmind-onboard/steps/step-8-present-summary.md +++ /dev/null @@ -1,32 +0,0 @@ -## Step 8 — Present Summary & Confirm - -Present the generated draft files and ask for confirmation: - -``` -============================================================ - ANALYSIS COMPLETE -============================================================ - -Target package: [package-name] -Stack detected: [languages], [monorepo?], [architecture markers] -Analyses run: [N] checks - -DRAFTS CREATED: - -Standards ([N]): - 1. [Name] → .packmind/standards/_drafts/[slug].draft.md - 2. ... - -Commands ([M]): - 1. [Name] → .packmind/commands/_drafts/[slug].draft.md - 2. ... - -Drafts are saved in .packmind/*/_drafts/ — you can review or edit them before creating. -============================================================ -``` - -Then ask via AskUserQuestion with three options: - -- **Create all now** — Proceed with creating all standards and commands -- **Let me review drafts first** — Pause to allow editing, re-run skill when ready -- **Cancel** — Exit without creating anything diff --git a/.github/skills/packmind-onboard/steps/step-9-create-items.md b/.github/skills/packmind-onboard/steps/step-9-create-items.md deleted file mode 100644 index b70d15612..000000000 --- a/.github/skills/packmind-onboard/steps/step-9-create-items.md +++ /dev/null @@ -1,3 +0,0 @@ -## Step 9 — Create Items - -Follow [`packmind-versions/$PACKMIND_CLI_VERSION/create-items.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-items.md). diff --git a/.github/skills/playbook-cli-audit/SKILL.md b/.github/skills/playbook-cli-audit/SKILL.md new file mode 100644 index 000000000..aa57be232 --- /dev/null +++ b/.github/skills/playbook-cli-audit/SKILL.md @@ -0,0 +1,203 @@ +--- +name: 'playbook-cli-audit' +description: 'Audit all packmind-* skills for CLI compatibility issues: deprecated commands, invalid options, wrong file formats, missing flags, and version mismatches against the locally installed Packmind CLI. Use when you want to check that skills referencing packmind-cli are up to date, after a CLI upgrade, before a release, or whenever you suspect skills may reference outdated CLI commands. Also triggers on phrases like "audit CLI usage", "check skills for CLI issues", "are my skills up to date with the CLI", or "CLI compatibility check".' +--- + +# Playbook CLI Audit + +Detect incompatibilities between the packmind-* skills deployed in `.claude/skills/` and the current Packmind CLI. Skills evolve independently from the CLI — when the CLI deprecates or removes commands, renames flags, or changes accepted file formats, skills can silently break. This audit catches those issues before users hit them at runtime. + +**This skill only detects issues — it does not fix them.** + +## Phase 1: Establish CLI Surface + +Build the authoritative map of what the CLI currently supports. + +### 1.1 Get CLI version + +```bash +node ./dist/apps/cli/main.cjs --version +``` + +Remember this as `$CLI_VERSION` (e.g., `0.26.0`). If the command fails, try `packmind-cli --version` as a fallback. If both fail, stop and tell the user the CLI is not available. + +### 1.2 Build the command map from CLI source code + +The CLI command definitions live in `apps/cli/src/infra/commands/`. Read the command registration files to build a complete map of: + +- **Available top-level commands** and their subcommands +- **Accepted options/flags** for each command (names, types, whether required) +- **Accepted argument types** (file paths, strings, etc.) and any format constraints +- **Deprecated commands** — look for `[Deprecated]` in descriptions and `has been removed` in handler code +- **Migration paths** — what the deprecated command's error message suggests instead + +Structure this internally as a reference table. Here is the expected shape — adapt if the source reveals more or fewer commands: + +| Command | Status | Accepted Inputs | Key Flags | Migration | +|---|---|---|---|---| +| `standards create` | REMOVED | JSON file/stdin | `--space`, `--from-skill` | `playbook add <path>` | +| `commands create` | REMOVED | JSON file/stdin | `--space`, `--from-skill` | `playbook add <path>` | +| `skills add` | REMOVED | directory path | `--space`, `--from-skill` | `playbook add <path>` | +| `install --list` | REMOVED | — | — | `packages list` | +| `install --show` | REMOVED | — | — | `packages show <slug>` | +| `diff` | DEPRECATED | — | `--submit`, `-m` | `playbook diff`, `playbook submit` | +| `diff add` | DEPRECATED | file path | `-m` | `playbook add` | +| `lint --diff` | DEPRECATED | — | — | `--changed-files` / `--changed-lines` | +| `playbook add` | ACTIVE | `.md`, `.mdc` files | `--space` | — | +| `playbook submit` | ACTIVE | — | `-m` (needed in non-TTY) | — | +| `packages add` | ACTIVE | — | `--to`, `--standard`/`--command`/`--skill` (one type per call) | — | +| ... | ... | ... | ... | ... | + +Do not hardcode this table — **read the actual source** in `apps/cli/src/infra/commands/` to build it. The table above is a starting point to orient your search, not the source of truth. + +### 1.3 Print summary + +``` +CLI version: $CLI_VERSION +Commands mapped: N active, M deprecated/removed +``` + +## Phase 2: Discover and Scan Skills + +### 2.1 Find all packmind-* skills + +Glob `.claude/skills/packmind-*/` to find all skill directories. For each, collect: +- `SKILL.md` (the main instruction file) +- All files in subdirectories (`references/`, `steps/`, `scripts/`, `packmind-versions/`, etc.) + +### 2.2 Handle version-specific subdirectories + +Some skills contain `packmind-versions/<semver>/` directories with version-specific instructions. The version resolution logic works like this: + +1. List all version directories (e.g., `0.21.0/`, `0.23.0/`) +2. Compare each against `$CLI_VERSION` +3. A version directory is **in scope** if it is the highest version that is `<=` `$CLI_VERSION` +4. Version directories that are **not in scope** (lower versions superseded by a higher one that is still `<= $CLI_VERSION`) should be flagged as INFO-level findings ("version `0.21.0` is superseded by `0.23.0` for CLI `$CLI_VERSION`") but their CLI invocations still need to be checked — they may still be reachable by users with older CLI versions + +**Example**: If `$CLI_VERSION` is `0.25.0` and the skill has `0.21.0/` and `0.23.0/`: +- `0.23.0/` is the **active version** (highest `<= 0.25.0`) +- `0.21.0/` is **superseded** but still scanned + +### 2.3 Extract CLI invocations + +For every file in scope, extract all lines that invoke the CLI. Look for these patterns: +- `packmind-cli <anything>` — the primary pattern +- `node ./dist/apps/cli/main.cjs <anything>` — local dev invocation +- Code blocks containing CLI commands (inside ` ``` ` fences) +- Inline code references (inside `` ` `` backticks) +- Plain-text references in prose (e.g., "Run `packmind-cli standards create`") + +For each invocation, parse: +- **Command**: the top-level command (e.g., `standards`) +- **Subcommand**: if any (e.g., `create`) +- **Arguments**: positional args (e.g., file paths) +- **Flags/options**: named options (e.g., `--space`, `-m`) +- **Source location**: file path and line number + +## Phase 3: Cross-Reference and Detect Issues + +For each extracted CLI invocation, check it against the command map from Phase 1. Detect these categories of issues: + +### CRITICAL — Will fail at runtime + +| Check | Description | Example | +|---|---|---| +| **Removed command** | Invocation uses a command the CLI has removed | `standards create` → removed, use `playbook add` | +| **Non-existent command** | Command or subcommand does not exist in the CLI | `packmind-cli list standards` (correct: `standards list`) | +| **Invalid file format** | File type passed to a command that doesn't accept it | `.json` file to `playbook add` (accepts only `.md`/`.mdc`) | +| **Mixed artifact types** | `packages add` called with multiple artifact type flags | `--standard X --command Y` in one call | + +### WARNING — May fail or produce unexpected behavior + +| Check | Description | Example | +|---|---|---| +| **Deprecated command** | Command works but shows deprecation warning | `diff add` → use `playbook add` | +| **Missing required flag in agent context** | Command lacks a flag needed in non-interactive/CI contexts | `playbook submit` without `-m` opens an editor | +| **Deprecated flag** | A specific flag is deprecated | `lint --diff` → use `--changed-files` | +| **Incorrect install syntax** | `install --list` or `install --show` used instead of `packages list`/`packages show` | `packmind-cli install --list` | + +### INFO — Worth noting + +| Check | Description | Example | +|---|---|---| +| **Superseded version directory** | A version-specific directory is no longer the active one | `0.21.0/` when `0.23.0/` exists and CLI is `>= 0.23.0` | +| **CLI version metadata mismatch** | Skill frontmatter declares `packmind-cli-version` that conflicts with installed version | `packmind-cli-version: "< 0.25.0"` when CLI is `0.26.0` | +| **Undocumented flag usage** | A flag is used that doesn't appear in the command definition | May indicate a removed or renamed flag | + +## Phase 4: Generate Report + +Write the report to `playbook-cli-audit-report.md` at the project root. + +### Report Structure + +```markdown +# Playbook CLI Audit Report + +**Generated**: {date} +**CLI version**: {$CLI_VERSION} +**Skills scanned**: {count} +**Files analyzed**: {count} + +## Summary + +| Severity | Count | +|---|---| +| CRITICAL | {n} | +| WARNING | {n} | +| INFO | {n} | + +## Findings + +### CRITICAL + +#### {n}. [{skill-name}] {short description} + +- **File**: `{path}:{line}` +- **Invocation**: `{the CLI command as written in the skill}` +- **Issue**: {what's wrong} +- **Fix**: {what the command should be replaced with} + +### WARNING + +...same structure... + +### INFO + +...same structure... + +## Skills Scanned + +| Skill | Files | Invocations | Issues | +|---|---|---|---| +| packmind-onboard | 19 | 12 | 5 CRITICAL, 1 WARNING | +| packmind-update-playbook | 8 | 15 | 0 | +| ... | ... | ... | ... | + +## CLI Command Surface Reference + +List of all active commands with their accepted inputs, for quick cross-reference. +``` + +### If no issues are found + +```markdown +# Playbook CLI Audit Report + +**Generated**: {date} +**CLI version**: {$CLI_VERSION} +**Skills scanned**: {count} + +All packmind-* skills are compatible with the installed CLI version. No issues found. +``` + +## Phase 5: Present to User + +After writing the report: + +1. Print a summary to the conversation: + - Total CRITICAL / WARNING / INFO counts + - Top 3 most affected skills + - The report file path +2. If there are CRITICAL findings, highlight them explicitly — these are commands that **will fail** when users invoke the skill + +Do not suggest fixes automatically. The user decides how to address each finding. \ No newline at end of file diff --git a/.github/skills/product-map/SKILL.md b/.github/skills/product-map/SKILL.md new file mode 100644 index 000000000..0a47422f8 --- /dev/null +++ b/.github/skills/product-map/SKILL.md @@ -0,0 +1,190 @@ +--- +name: 'product-map' +description: 'Produces a functional cartography of the Packmind application (domains × features × usage signals) as `product-map.md` at the project root. Manual-only skill — invoke ONLY when the user explicitly runs `/product-map` or asks to "map the application", "cartographier l''application", "list every feature", or "find decommission candidates". Do NOT auto-load on generic mentions of features, domains, spaces, or standards.' +--- + +# Product Map + +Build a point-in-time functional map of the Packmind application by scanning the backend (use cases + endpoints), the frontend (routes + pages), and the CLI (commands). Aggregate features by functional domain, compute a usage signal per feature, and write a single Markdown report at `product-map.md` (project root). The report supports decommission decisions: features with weak signals (no frontend entry, no recent activity, no tests) become candidates for removal. + +**Manual-only.** Run only when the user explicitly invokes `/product-map` or asks for a functional cartography. The description above intentionally avoids generic trigger keywords. + +**This skill only maps — it does not delete anything.** Decommission decisions stay with the user. + +## Phase 0: Confirm Scope + +Before scanning, confirm with the user: + +- **Target output path** — default `product-map.md` at project root; offer to override. +- **Scope override** — default scans `apps/api`, `apps/frontend`, `apps/cli`, and `packages/*`. Ask if any package should be excluded (e.g., infra-only packages like `migrations`, `logger`, `node-utils`). +- **Decommission tolerance** — confirm signal heuristics (see Phase 4); user may want stricter or looser flagging. + +If the user invoked the skill with explicit instructions, skip confirmation and proceed with stated parameters. + +## Phase 1: Seed Domain Taxonomy + +Before launching subagents, read `packages/` and `apps/` to discover the domain taxonomy. Use folder names as the seed list — do NOT invent domains. + +The seed domains in this codebase (verify by `ls packages/` at run time, this list may drift): + +- **Spaces** — `packages/spaces`, `packages/spaces-management` +- **Standards** — `packages/standards`, `packages/linter`, `packages/linter-ast`, `packages/linter-execution` +- **Skills** — `packages/skills` +- **Recipes / Playbooks** — `packages/recipes` +- **Change Proposals** — `packages/playbook-change-applier`, `packages/playbook-change-management` +- **Users / Auth / Organizations** — `packages/accounts` +- **AI Agents / Coding Agent** — `packages/coding-agent` +- **Git Integration** — `packages/git` +- **Analytics** — `packages/amplitude` +- **Support / Crisp** — `packages/crisp` +- **Deployments** — `packages/deployments` +- **LLM Infrastructure** — `packages/llm` +- **Legacy Import** — `packages/import-practices-legacy` + +Cross-cutting infra packages (`logger`, `node-utils`, `migrations`, `types`, `ui`, `frontend`, `editions`, `test-utils`, `integration-tests`, `plugins`) are NOT user-facing domains — exclude from the map unless they expose user-facing features (e.g., `editions` may expose subscription/edition selection). + +Pass this seed taxonomy to all subagents so they classify consistently. + +## Phase 2: Launch 3 Parallel Source Subagents + +Launch three `Agent(general-purpose)` subagents simultaneously. Each scans one source and returns a structured feature list classified by domain. + +| Subagent | Source | Scans | +|----------|--------|-------| +| 1. Backend | Use cases + API endpoints | `packages/*/src/application/useCases/**/*.ts`, NestJS controllers in `apps/api/src/**/*.controller.ts` | +| 2. Frontend | Routes + pages | `apps/frontend/src` router config + page components | +| 3. CLI | Commander commands | `apps/cli/src` command definitions | + +### Subagent Prompt Template + +Each subagent receives: +1. The seed domain taxonomy from Phase 1 +2. The source-specific instructions below +3. A request for structured output + +``` +You are mapping the Packmind application. Your scope: <SOURCE_NAME>. + +# Domain taxonomy (use these labels — do not invent new domains unless you find a clear standalone area): +<seed taxonomy> + +# Your task +Scan <PATHS> and produce a list of every user-facing feature exposed through this source. +Group features under their domain. A "feature" is a user-meaningful capability (e.g. +"create a space", "invite a member", "archive a standard"), not a technical implementation +detail (e.g. "TypeORM repository method"). + +For each feature, return: +- domain: <one of the seed labels, or "Other (justify)"> +- feature: short imperative phrase (≤ 8 words) +- evidence: file path(s) + relevant export/route/command name +- visibility: "user-facing" or "admin-only" or "internal" + +Skip: +- Pure infra plumbing (logging, config loading, health checks) +- Cross-cutting middleware (auth guards, rate limiters) — list them once under "Auth" +- Test utilities + +# Output format +Markdown list grouped by domain. One bullet per feature. End with a 2-line summary +(total features, anything unclassifiable). +``` + +### Source-Specific Instructions + +**Backend subagent**: scan use cases first (they map 1:1 to features), then controllers to confirm the endpoint exists. A use case with no controller binding is an "orphan" — flag it as `visibility: internal`. + +**Frontend subagent**: scan the router config first to enumerate routes, then read each route's page component to determine what the user actually does there. A route that only renders a placeholder or 404 is a candidate for decommission — flag it. + +**CLI subagent**: enumerate every Commander `.command(...)` registration. Subcommands count as separate features (e.g. `playbook add` and `playbook submit` are two features under "Change Proposals"). + +### Sequential Fallback + +If the Agent tool is unavailable, perform the three scans sequentially in the current session using `Grep` + `Read`. Maintain the same output structure. + +## Phase 3: Merge Sources Per Feature + +After subagents return, merge their lists into a single feature table. Two source entries belong to the same feature when: +- Same domain, AND +- Feature phrases describe the same capability (e.g. "create a space" backend ↔ "Create Space" frontend route ↔ `space create` CLI command) + +For each unified feature row, record which sources cover it: + +| Domain | Feature | Backend | Frontend | CLI | Evidence | +|--------|---------|:-:|:-:|:-:|----------| +| Spaces | Create a space | ✓ | ✓ | ✓ | `packages/spaces/src/.../createSpace.ts`, `/spaces/new`, `space create` | + +If a feature exists in only one source, that is a signal — keep the row, leave the missing sources blank. + +## Phase 4: Compute Usage Signal + +For each feature row, compute additional signal columns. These power the decommission section. + +| Signal | How to compute | +|--------|----------------| +| **Tests** | Grep for the use case class or controller path in `**/*.spec.ts` and `**/*.test.ts`. Mark ✓ if at least one test references it. | +| **Amplitude** | Grep for the feature's domain prefix in `packages/amplitude/src/application/AmplitudeEventListener.ts`. Mark ✓ if any event for this domain is subscribed AND the event name plausibly matches the feature. | +| **Recent activity** | `git log --since="6 months ago" --pretty=format:%H -- <evidence path>`. Mark ✓ if at least one commit. | + +A feature is a **decommission candidate** when ANY of the following holds: +- No frontend coverage AND no CLI coverage (= backend-only, possibly orphaned) +- No tests AND no recent activity (= cold code) +- Frontend route exists but backend use case missing (= dead UI) +- Backend use case exists but no entry point in any client (= dead endpoint) + +Do NOT auto-delete — only flag. + +## Phase 5: Write Report + +Write `product-map.md` at the project root with this structure: + +```markdown +# Packmind Product Map — <YYYY-MM-DD> + +> Snapshot of every user-facing feature, grouped by functional domain. +> Generated by the `product-map` skill. Manual invocation only. + +## Scope + +- Sources scanned: backend use cases & endpoints, frontend routes & pages, CLI commands +- Packages excluded: <list> +- Git revision: <short SHA> + +## Functional Map + +| Domain | Feature | Backend | Frontend | CLI | Tests | Amplitude | Recent | Notes | +|--------|---------|:-:|:-:|:-:|:-:|:-:|:-:|-------| +| Spaces | Create a space | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| Spaces | Archive a space | ✓ | – | – | – | – | – | backend-only orphan | +| ... + +## Decommission Candidates + +Features matching at least one decommission heuristic (see methodology). Sorted by +weakest signal first. + +| Feature | Reason | Evidence | +|---------|--------|----------| +| Archive a space | backend-only, no tests, no commits in 6 months | `packages/spaces/src/.../archiveSpace.ts` | +| ... + +## Methodology + +- A feature = a user-meaningful capability, not a technical implementation detail. +- Sources merged when same domain + same capability across backend/frontend/CLI. +- Signal heuristics: see SKILL.md Phase 4. +- This is a point-in-time snapshot — re-run to refresh. +``` + +End by printing to the user: +- Path of the generated report +- Total features mapped +- Total decommission candidates +- Top 3 domains by feature count + +## Operating Rules + +- **Never delete code based on the report.** Decommission decisions belong to the user. +- **Do not invent domains.** If a feature does not fit the seed taxonomy, place it under "Other" with a one-line justification — the user will refine the taxonomy on re-run. +- **Stay factual.** Every row must cite an evidence path. No inferred features. +- **Re-runnable.** The skill produces a fresh snapshot each run; overwrite the previous report after confirming with the user. \ No newline at end of file diff --git a/.github/skills/qa-review/SKILL.md b/.github/skills/qa-review/SKILL.md new file mode 100644 index 000000000..d990de2b4 --- /dev/null +++ b/.github/skills/qa-review/SKILL.md @@ -0,0 +1,194 @@ +--- +name: 'qa-review' +description: 'Review a user story implementation against its Example Mapping (EM) specification.' +argument-hint: '["em-file-path"]' +disable-model-invocation: true +--- + +# QA Review + +Audit a user story implementation against its Example Mapping specification. Reads the EM +markdown, finds all implementing code in the codebase, then runs two parallel agents — one for +functional coverage, one for code review. Produces a single compact report. + +**This skill only detects issues — it does not fix them.** + +## Reference + +A ready-to-fill EM template is available at `em_template.md` (in this skill's directory). Share it with the user when they need to write a new spec from scratch. + +## 1. Parse the EM Spec + +Read the markdown file at the provided path. Extract a structured summary with these sections: + +### What to extract + +1. **User Story title** — the first line or heading describing the US +2. **Rules** — each `# Rule N: <title>` block. For each rule, extract: + - Rule number and title + - Each `## Example N` with its setup/action/outcome narrative (preserve the full text) +3. **Technical Rules** — bullet points under a `# Technical rules` heading (implementation-focused constraints) +4. **User Events** — content under `# User Events` heading: event names, properties, schemas +5. **Check Also items** — bullet points after "Check also" markers (additional rules/constraints, often separated by dashes) +6. **Code References** — all backtick-quoted terms across the entire spec (class names, field names, event names like `ConflictDetector`, `space_created`, `decision`) +7. **Domain Keywords** — key nouns and verbs from rule titles and examples (e.g., "space", "create", "slug", "rename", "conflict") + +### Handling ambiguity + +If a spec item is ambiguous or explicitly deferred (e.g., "TBD", "on verra plus tard", "later"), flag it in the Parsed Spec Summary but exclude it from coverage assessment. Note it in the report as "Deferred — not assessed." + +Compile everything into a **Parsed Spec Summary** formatted as below. The "Full Examples" section preserves the complete raw text of every example — sub-agents need this full context to accurately assess coverage. + +``` +## Parsed Spec Summary + +### User Story +{title} + +### Rules and Examples +Rule 1: {title} + Example 1: {one-line summary of scenario} + Example 2: {one-line summary of scenario} +Rule 2: {title} + Example 1: {one-line summary of scenario} +[...] + +### Full Examples (raw text) +{Copy the complete text of every example verbatim from the spec, preserving setup/action/outcome narratives. Do not summarize here — this section is passed to sub-agents so they can assess nuanced behaviors.} + +### Technical Rules +- {rule text} +[...] + +### Deferred Items +- {item text} — Deferred, not assessed +[...] + +### User Events +- {event_name}: {properties} +[...] + +### Check Also +- {constraint text} +[...] + +### Code References (from backticks) +{list of all backtick-quoted terms} + +### Domain Keywords +{list of distinctive nouns/verbs extracted from rules} +``` + +## 2. Select Target Domains + +Ask the user which domains this user story touches. Use **AskUserQuestion** with `multiSelect: true`: + +| Option | Directories | +|--------|-------------| +| CLI (apps/cli) | `apps/cli/src/**` | +| API (apps/api) | `apps/api/src/**` | +| Packages (packages/*) | `packages/*/src/**`, `packages/types/src/**` | +| Frontend (apps/frontend) | `apps/frontend/app/**`, `apps/frontend/src/**` | +| MCP (apps/mcp-server) | `apps/mcp-server/src/**` | + +**Packages is always included**, even if the user does not select it — it contains domain logic, contracts, events, and infra that all other layers depend on. + +Store the user's selection as `{target_domains}` — a list of domain labels and their directory patterns. All subsequent steps use this to scope their searches. + +## 3. Validate Implementation Exists + +Grep 2–3 of the most distinctive backtick-quoted terms from the spec, **scoped to the `{target_domains}` directories only**. If fewer than 3 implementing files are found, **stop and ask the user** to confirm that the US has been fully implemented. Do not proceed with a full review on a partially-implemented or not-yet-started US — the report would be misleading. + +## 4. Build Code Map + +Launch a **Code Map Agent** (`subagent_type: general-purpose`) using the prompt from `agents/code-map-agent.md`. Replace: +- `{parsed_spec_summary}` with the Parsed Spec Summary from step 1 +- `{target_domains}` with the selected domains and their directory patterns from step 2 + +The agent will search only within the target domain directories, then return a structured Code Map organized by architectural layer. Only layers matching the selected domains will appear in the output. + +Wait for the agent to complete before proceeding to step 5. + +## 5. Pre-filter Packmind Standards + +Before launching the review agents, collect the applicable Packmind coding standards: + +1. Glob for `**/.claude/rules/packmind/*.md` across the repository +2. Read the YAML frontmatter of each file to extract its `paths` glob patterns +3. Match the Code Map file paths against each standard's `paths` patterns +4. For each standard that matches at least one Code Map file, read its full content + +Compile the applicable standards into `{applicable_standards}` — the full text of each matching standard, prefixed with its name. If no standards match, set `{applicable_standards}` to "None". + +## 6. Launch Parallel Sub-Agents + +Launch **two sub-agents in parallel** (same turn), each receiving the Parsed Spec Summary (including the Full Examples section with raw text), Code Map, and target domains as context: + +1. **Functional Coverage Agent** (`subagent_type: general-purpose`) — prompt built from `agents/functional-coverage-agent.md`. Replace `{parsed_spec_summary}`, `{code_map}`, and `{target_domains}`. +2. **Code Review Agent** (`subagent_type: general-purpose`) — prompt built from `agents/code-review-agent.md`. Replace `{parsed_spec_summary}`, `{code_map}`, `{target_domains}`, and `{applicable_standards}`. + +Launch both agents simultaneously. The full raw example text is critical — sub-agents need the complete setup/action/outcome narratives to assess nuanced behaviors, not just one-line summaries. + +### Sequential Fallback + +If the Agent tool is unavailable, perform both reviews sequentially yourself, following the instructions from each agent prompt file. + +## 7. Combine & Write Report + +Once both agents complete, merge their outputs into a single report. + +### Output path + +Derive from the input path: if input is `path/to/my-spec.md`, output is `path/to/my-spec-report.md`. + +### Report template + +```markdown +# QA Review Report + +**Spec**: {filename} | **Date**: {date} | **Branch**: {branch} | **Commit**: {short-sha} +**Rules**: {N} | **Examples**: {N} | **Tech Rules**: {N} | **Events**: {N} + +## Summary + +| Metric | Count | +|--------|-------| +| Covered | N | +| Partially Covered | N | +| Not Covered | N | +| Code Findings | N (Critical: X, High: Y, Medium: Z) | +| Standards Violations | N | + +## Functional Coverage + +### Coverage Matrix + +{coverage matrix table from functional coverage agent} + +### Gaps + +{reproduction steps from functional coverage agent — omit this subsection if all items are Covered} + +## Code Review + +### Findings + +{findings from code review agent — omit this section if no issues found} + +## Deferred Items + +{list of items marked as deferred/TBD in the spec — not assessed in this review} + +--- +*Static analysis only. No code was executed during this review.* +``` + +**Omit any section that has zero content.** Only include sections with actual results. + +### Print Summary + +After writing the report, print a brief summary to the console: +- Total rules/examples in the spec +- Coverage stats (Covered / Partially / Not Covered counts) +- Code review findings count by severity +- The report file path \ No newline at end of file diff --git a/.github/skills/qa-review/agents/code-map-agent.md b/.github/skills/qa-review/agents/code-map-agent.md new file mode 100644 index 000000000..fb083cef1 --- /dev/null +++ b/.github/skills/qa-review/agents/code-map-agent.md @@ -0,0 +1,133 @@ +You are a codebase navigator building a Code Map for a user story implementation. Your job is to find all files involved in implementing the feature described in the Parsed Spec Summary below, **scoped to the target domains only**. + +## Target Domains + +{target_domains} + +**Only search within the directories listed above.** Do not search domains that are not listed. + +## Parsed Spec Summary + +{parsed_spec_summary} + +## Available Tools + +You have access to **Glob**, **Grep**, and **Read** (read-only). Use them to systematically search the codebase. + +## Search Strategy + +Using the code references (backtick-quoted terms) and domain keywords from the spec, follow this funnel (precise → broad). **All searches must be scoped to the target domain directories above.** + +### Step 1: Backtick terms first (highest signal) + +Grep each backtick-quoted term, but only within the target domain directories. These are author-curated pointers into the codebase and almost always resolve to exact matches. + +### Step 2: Domain keyword search + +Grep the 3–5 most distinctive domain keywords against the target domain directories. Here is the mapping of domains to their search patterns — **only use patterns for selected domains**: + +**Backend (packages/*):** +- `packages/*/src/application/useCases/**/*.ts` and `packages/*/src/application/usecases/**/*.ts` (use cases — both casing variants exist) +- `packages/*/src/infra/**/*.ts` (repository implementations, TypeORM schemas, BullMQ job factories) +- `packages/types/src/*/events/**/*.ts` (domain events — centralized in @packmind/types) +- `packages/types/src/*/contracts/**/*.ts` (use case contracts — Command, Response, IUseCase interfaces) + +**API (apps/api):** +- `apps/api/src/app/**/*.ts` (NestJS controllers, guards, modules) + +**Frontend (apps/frontend):** +- `apps/frontend/app/routes/**/*.tsx` (file-based route components with clientLoaders) +- `apps/frontend/src/domain/**/*.ts` (gateways extending PackmindGateway, TanStack Query hooks) + +**CLI (apps/cli):** +- `apps/cli/src/**/*.ts` (CLI commands and handlers) + +**MCP (apps/mcp-server):** +- `apps/mcp-server/src/**/*.ts` (MCP server tools and prompts) + +### Step 3: Package-level scan (Backend only) + +Skip if Backend is not in the target domains. Once a relevant package is identified (e.g., `packages/spaces/`), list its `application/useCases/` directory (or `application/usecases/` — both casing variants exist) to find all related use cases. + +### Step 4: Hexagonal registry check (Backend only) + +Skip if Backend is not in the target domains. Once a relevant package is identified, read its `{PackageName}Hexa.ts` file (e.g., `packages/standards/src/StandardsHexa.ts`) to understand which ports, adapters, and use cases are registered. This reveals the full wiring of the feature. + +### Step 5: Contract discovery (Backend only) + +Skip if Backend is not in the target domains. For each use case found, check `packages/types/src/{domain}/contracts/` for the corresponding contract file, which defines `{Name}Command`, `{Name}Response`, and `I{Name}UseCase`. + +### Step 6: Test file discovery + +For each source file found within target domain directories, check for a sibling `.spec.ts` equivalent. + +### Step 7: Event tracking (Backend only) + +Skip if Backend is not in the target domains. Grep event names from the "User Events" section. Look for event class definitions in `packages/types/src/{domain}/events/`, emission via `eventEmitterService.emit(new {EventName}Event(`, and consumption via `PackmindListener` classes with `this.subscribe({EventName}Event, ...)`. + +### Step 8: Cross-US dependencies + +When a rule depends on behavior from another feature (e.g., conflict detection logic that belongs to a different US), include those files in the Code Map but mark them as `[DEPENDENCY]`. Only follow dependencies within the target domain directories. The functional agent should verify the integration point works, not re-audit the dependency itself. + +## Output Format + +Compile results into a **Code Map** organized by layer: + +``` +## Code Map + +### Hexagonal Registry +- packages/{pkg}/src/{Pkg}Hexa.ts (port/adapter wiring) + +### Contracts (@packmind/types) +- packages/types/src/{domain}/contracts/I{UseCaseName}UseCase.ts + ({Name}Command, {Name}Response, I{Name}UseCase) + +### Backend Domain +- packages/{pkg}/src/application/useCases/{useCaseName}/{UseCaseName}UseCase.ts + Test: {path}.spec.ts [EXISTS | NOT FOUND] +- packages/{pkg}/src/domain/{Entity}.ts + Test: [EXISTS | NOT FOUND] + +### Infra (repositories, schemas, jobs) +- packages/{pkg}/src/infra/repositories/{Repository}.ts + Test: [EXISTS | NOT FOUND] +- packages/{pkg}/src/infra/schemas/{Schema}.ts +- packages/{pkg}/src/infra/jobs/{JobFactory}.ts (BullMQ background jobs) + +### API Layer (NestJS) +- apps/api/src/app/{...}/{controller}.ts + Guards: [OrganizationAccessGuard | SpaceAccessGuard | None] + Adapter injection: [@Inject{Pkg}Adapter()] + Test: [EXISTS | NOT FOUND] + +### Frontend +- apps/frontend/app/routes/{...}.tsx (route component + clientLoader) +- apps/frontend/src/domain/{entity}/api/gateways/{Entity}GatewayApi.ts (extends PackmindGateway) +- apps/frontend/src/domain/{entity}/api/queries/{Entity}Queries.ts (TanStack Query v5 hooks) + Test: [EXISTS | NOT FOUND] + +### CLI +- apps/cli/src/{...}/{command|handler}.ts + Test: [EXISTS | NOT FOUND] + +### MCP Server +- apps/mcp-server/src/app/tools/{toolName}/{toolName}.tool.ts + Test: [EXISTS | NOT FOUND] + +### Domain Events (@packmind/types) +- packages/types/src/{domain}/events/{EventName}Event.ts (extends UserEvent/SystemEvent) +- {files containing eventEmitterService.emit()} +- {files containing PackmindListener / this.subscribe()} + +### Background Jobs (BullMQ) +- packages/{pkg}/src/infra/jobs/{JobFactory}.ts (WorkerQueue registration) + +### Dependencies (from other USs — verify integration only) +- {files that implement behavior from another feature but are used by this US} [DEPENDENCY] + +### Other Relevant Files +- {any other files found via keyword search} +``` + +Only include sections that have actual files **and** match the target domains. Omit empty sections and sections for domains not in the target list. diff --git a/.github/skills/qa-review/agents/code-review-agent.md b/.github/skills/qa-review/agents/code-review-agent.md new file mode 100644 index 000000000..4d4563ddd --- /dev/null +++ b/.github/skills/qa-review/agents/code-review-agent.md @@ -0,0 +1,183 @@ +You are a senior engineer performing a technical code review on a user story implementation. Your focus is exclusively on actual bugs, edge cases, and inconsistencies — not style, naming, formatting, or minor improvements. Report findings at **Medium** severity or above. If no Critical, High, or Medium issues are found, include **Low** severity findings instead. + +## Target Domains + +{target_domains} + +**Only review code within the target domains listed above.** Skip layers not in scope. + +## Spec Context + +{parsed_spec_summary} + +Understanding the spec helps you know what the code is *supposed* to do, which makes it easier to spot where it does something wrong or incomplete. + +## Code Map + +{code_map} + +## Available Tools + +You have access to **Glob**, **Grep**, and **Read** (read-only). Use them to read every file in the Code Map and trace the logic across target layers only. + +## Your Process + +### Step 1: Read All Implementing Files (within target domains) + +Read every file listed in the Code Map that belongs to the target domains. Build a mental model of the relevant flows — **only for selected domains**: + +**Backend (packages/*):** +- The hexagonal data flow: use case → domain entity → infra repository → TypeORM schema → database +- How contracts in `@packmind/types` define the shared interface (`{Name}Command`/`{Name}Response`/`I{Name}UseCase`) +- How domain events are emitted (`eventEmitterService.emit()`) and consumed (`PackmindListener` with `this.subscribe()`) +- How background jobs are registered and processed (BullMQ `WorkerQueue`, `JobsService`) +- How the Hexa registry (`{Pkg}Hexa.ts`) wires ports to adapters + +**API (apps/api):** +- NestJS controller → adapter (via `@Inject{Pkg}Adapter()`) → use case +- Where validations and guards are applied (NestJS guards like `OrganizationAccessGuard`) + +**Frontend (apps/frontend):** +- Route `clientLoader` → `queryClient.ensureQueryData()` → TanStack Query hook → gateway (extends `PackmindGateway`) → API call + +**CLI (apps/cli):** +- CLI command structure, output formatting, error handling + +**MCP (apps/mcp-server):** +- MCP tool implementation, use case invocation, result formatting + +### Step 2: Check for These Issue Categories + +#### A. Actual Bugs (Critical / High) + +- **Logic errors**: wrong conditions, inverted checks, off-by-one, missing null checks on required paths +- **Data integrity**: unique constraints that can be bypassed (e.g., race conditions on concurrent create), missing database constraints that the spec requires +- **Missing error handling**: operations that can throw but have no try/catch or return no meaningful error to the caller +- **Incorrect queries**: TypeORM queries that don't match the intended behavior (wrong where clause, missing relations, incorrect join) +- **Security gaps**: missing authorization checks the spec explicitly requires (e.g., "only admins can...") + +#### B. Edge Cases (Medium / High) + +- **Boundary values**: empty strings, max length violations, special characters (accents, unicode), whitespace handling +- **Concurrent operations**: two users performing the same action simultaneously (e.g., creating a resource with the same name at the same time) +- **State transitions**: operations that can leave data inconsistent if they fail partway through (e.g., entity created but event not emitted) + +#### C. Cross-File Inconsistencies (Medium / High) + +Look specifically for mismatches **between** files within the target domains. Only check mismatches between layers that are both in scope: +- **Gateway-to-contract mismatch** (Frontend + Backend): Frontend gateway method sends a field name or shape that differs from the `@packmind/types` contract (`{Name}Command`/`{Name}Response`) +- **Contract-to-API mismatch** (API + Backend): NestJS controller params or response types don't match the `@packmind/types` contract +- **Validation layer inconsistency** (any two selected layers): API validates a constraint that the domain use case does not enforce (or vice versa — validation only in one layer) +- **Event payload drift** (Backend): Event class in `packages/types/src/{domain}/events/` defines one payload shape, but `eventEmitterService.emit()` passes different properties, or `PackmindListener` handler expects different fields +- **Entity-to-schema drift** (Backend): Domain entity field names don't match TypeORM schema column names in `infra/schemas/` +- **Duplicate logic** (any two selected layers): Same logic implemented differently across layers (e.g., slug generation in both frontend gateway and backend use case with different rules) +- **Hexa wiring gap** (Backend): Adapter registered in `{Pkg}Hexa.ts` but the corresponding use case not wired, or a new use case not added to the Hexa registry + +#### D. Missing Validations (Medium) + +Constraints explicitly mentioned in the spec but not enforced in code: +- Max length for a field (spec says 64, no validation in DTO or entity) +- Authorization checks (spec says "only admins", no guard or role check) +- Uniqueness constraints (spec says "cannot have the same name", no unique check before insert) +- Required fields that accept null/undefined + +#### E. Technical Rules Violations (Medium / High) + +Systematically verify **every** technical rule from the "Technical Rules" section of the Parsed Spec Summary. For each technical rule: + +1. Identify which files in the Code Map are relevant — technical rules can apply to **any layer** (backend domain, infra, API, frontend, CLI, MCP server, background jobs, etc.) +2. Read those files and check whether the constraint is enforced +3. Report a finding if a technical rule is not implemented or is implemented incorrectly + +Common technical rule patterns to look for — **only check layers within the target domains**: +- **Backend**: missing domain validations, incorrect repository queries, missing event emissions, wrong error types, missing guards or authorization checks +- **Frontend**: missing form validations, incorrect API call parameters, missing loading/error states, wrong route guards, missing optimistic updates or cache invalidation +- **Cross-cutting**: naming conventions not followed, missing logging, incorrect feature flag checks, missing analytics events, wrong data transformations + +If a technical rule is ambiguous about which layer should enforce it, check only within the target domains. + +#### F. Hexagonal Architecture & NestJS Issues (Medium / High) + +- **Missing Hexa registration**: A new use case or port exists but is not registered in the package's `{Pkg}Hexa.ts` registry +- **Missing adapter injection**: NestJS controller uses a port but doesn't inject it via `@Inject{Pkg}Adapter()` decorator +- **Missing guard**: Controller endpoint modifies data but doesn't use `@UseGuards(OrganizationAccessGuard)` or appropriate guard +- **Event listener not registered**: A `PackmindListener` class exists but isn't wired to receive the expected events via `this.subscribe()` +- **Background job not registered**: A `WorkerQueue` is defined but `JobsService` doesn't register or submit it +- **Contract not exported**: A new use case contract exists in `@packmind/types` but isn't exported from the package's barrel file + +### Step 3: Check Test Quality + +If test files exist for the implementing code: +- Are the tests testing the **right behavior** or just mocking everything away? A test that mocks the repository and only checks the mock was called does not catch real bugs. +- Are there assertions that would catch regressions on the spec's key behaviors? +- Are error paths and edge cases tested, or only the happy path? + +Only flag test quality issues if they represent a **Medium+ risk** — e.g., a critical validation has no test at all, or tests mock away the exact layer where a bug exists. + +### Step 4: Check Pre-loaded Packmind Standards + +The following Packmind standards have been pre-filtered by the orchestrator and apply to files in the Code Map: + +{applicable_standards} + +If "None" is shown above, skip this step entirely. + +**Verification:** +- Read each applicable standard's rules carefully +- Check every file in the Code Map that matches the standard's scope +- Report violations at the same severity levels as other findings (Critical/High/Medium depending on impact) +- In the **Spec Reference** field, reference the standard name (e.g., "Standard: Testing Good Practices") + +## Severity Definitions + +- **Critical**: Data loss, security vulnerability, or crash in a production code path. Would block a release. +- **High**: Incorrect behavior that users will encounter in normal usage. A bug that produces wrong results or breaks a flow. +- **Medium**: Edge case that could cause issues under specific conditions. Missing validation that the spec explicitly requires. Cross-file inconsistency that could cause subtle bugs. +- **Low**: Minor issues — small inconsistencies, non-critical missing validations, minor edge cases unlikely to affect normal usage, slight contract mismatches that don't break functionality. + +**Do NOT report**: Informational, cosmetic, style preferences, missing comments, naming suggestions, or "nice to have" improvements. If you find fewer than 2 issues, that is perfectly fine — do not manufacture findings to fill the report. + +## Output Format + +Return findings in this exact format: + +``` +### Findings + +#### [CRITICAL] {Short descriptive title} +- **Category**: Bug | Edge Case | Inconsistency | Missing Validation | Technical Rule Violation +- **File**: `{file-path}:{line}` +- **Description**: {What is wrong, why it matters, and what could happen} +- **Spec Reference**: {Which rule/example/technical rule this relates to, e.g., "Rule 2 / Example 1", "Check Also: max length 64", or "Technical Rule: ..."} +- **Suggested Fix**: {Brief direction — not full code, just the approach} + +--- + +#### [HIGH] {Short descriptive title} +- **Category**: ... +[same format] + +--- + +#### [MEDIUM] {Short descriptive title} +- **Category**: ... +[same format] +``` + +**Order findings by severity**: Critical first, then High, then Medium. + +**Low-severity fallback**: If you found **no** Critical, High, or Medium issues, include any Low findings using this format: + +``` +#### [LOW] {Short descriptive title} +- **Category**: ... +[same format] +``` + +If no issues are found at any severity, return exactly: + +``` +### Findings + +No significant issues found. The implementation appears sound for the behaviors described in the spec. +``` diff --git a/.github/skills/qa-review/agents/functional-coverage-agent.md b/.github/skills/qa-review/agents/functional-coverage-agent.md new file mode 100644 index 000000000..d82534b83 --- /dev/null +++ b/.github/skills/qa-review/agents/functional-coverage-agent.md @@ -0,0 +1,116 @@ +You are a QA analyst reviewing whether a user story implementation fully covers its Example Mapping specification. Your job is evidence-based: every assessment must cite specific files and code patterns you found (or did not find) in the codebase. + +## Target Domains + +{target_domains} + +**Only trace rules through the layers listed above.** Skip layers not in the target domains — they are out of scope for this review. + +## Spec to Review + +{parsed_spec_summary} + +## Code Map + +{code_map} + +## Available Tools + +You have access to **Glob**, **Grep**, and **Read** (read-only). Use them to: +- Read source files identified in the Code Map +- Search for specific behavior implementations (Grep for method names, conditions, error messages) +- Check test file existence and content (Glob for `*.spec.ts` patterns) + +The Code Map is a starting index — always verify by reading the actual source files. + +## Your Process + +### Step 1: Trace Each Rule and Example Across Target Layers + +For every Rule + Example pair in the spec, trace the behavior through **only the target domain layers listed above**. A rule is only fully covered when all target layers that should implement it actually do. + +1. **Identify the expected behavior** from the example's setup/action/outcome +2. **Search for the implementation across target layers only**. Below are the checks for each domain — **only perform checks for domains in the target list**: + + **Backend (packages/*):** + - **Contract layer** (`@packmind/types`): Read the use case contract (`I{UseCaseName}UseCase.ts` in `packages/types/src/{domain}/contracts/`). Does it define the correct `{Name}Command` input shape and `{Name}Response` output shape for this behavior? + - **Backend domain**: Trace the hexagonal flow: NestJS controller → adapter (via `@Inject{Pkg}Adapter()`) → use case → domain entity. Does the use case handle the precondition, perform the action, and produce the expected outcome? Is the Hexa registry (`{Pkg}Hexa.ts`) wiring the correct adapter? + - **Infra layer**: Do the repository implementations in `infra/` correctly persist or query the data? Are TypeORM schemas aligned with domain entities? Are BullMQ jobs (`WorkerQueue`) registered for async operations? + + **API (apps/api):** + - **API layer** (NestJS): Does the controller expose this behavior at the correct route under `/organizations/:orgId`? Is `@UseGuards(OrganizationAccessGuard)` (or appropriate guard) present? Are the request/response types aligned with the `@packmind/types` contract? + + **Frontend (apps/frontend):** + - **Frontend**: Trace the data flow: route `clientLoader` → `queryClient.ensureQueryData()` → TanStack Query hook → gateway (extends `PackmindGateway`) → API call. Does each layer handle this scenario? Does the gateway method signature match the API endpoint and `@packmind/types` contract? + + **CLI (apps/cli):** + - **CLI**: Does the CLI command implement the expected behavior for this rule? Correct output, error handling, flags? + + **MCP (apps/mcp-server):** + - **MCP Server**: Does the tool in `apps/mcp-server/src/app/tools/` correctly invoke the use case and return the expected result? + + **Cross-layer consistency** (check only between selected domains): Do field names match between `@packmind/types` contracts, NestJS controller params, frontend gateway methods, and CLI handlers? Are validations applied consistently across selected layers (not just in one)? +3. **Check for test coverage** — look for test cases that exercise this specific scenario. Not just that test files exist, but that a test covers this particular case (look for describe/it blocks, test data, assertions matching the example). +4. **Assess coverage level**: + - **Covered**: Implementation code exists AND handles this specific case across all target layers. Cite the file:line where the behavior is implemented. + - **Partially Covered**: Implementation exists but is incomplete — e.g., backend handles it but frontend doesn't (when both are in scope), happy path only, missing error case, missing edge case from the example. Cite what exists and what is missing. + - **Not Covered**: No implementation found for this behavior in any target layer. Describe what you searched for and where. + +### Step 2: Check Technical Rules + +For each bullet under "Technical rules": +- Search for the implementation of the described technical constraint +- Verify the code matches what the spec says (e.g., if the spec says "ConflictDetector uses the `decision` field if available, payload otherwise", read the ConflictDetector and verify this logic) + +### Step 3: Check User Events + +For each event described: +- Check the event class definition in `packages/types/src/{domain}/events/{EventName}Event.ts`. Verify it extends `UserEvent<TPayload>` or `SystemEvent<TPayload>` with the correct payload type. +- Grep for `eventEmitterService.emit(new {EventName}Event(` to find where it is emitted. Verify it is emitted in the correct use case after the action succeeds. +- Grep for `PackmindListener` classes that `this.subscribe({EventName}Event, ...)` to verify the event is consumed where expected. +- Check the event payload properties match the spec (property names, types, optional/required flags). + +### Step 4: Check "Check Also" Items + +For each bullet under "Check also": +- Search for the constraint or validation in the code +- Assess coverage the same way as rules (Covered / Partially Covered / Not Covered) + +## Output Format + +Return **two sections**: + +### Coverage Matrix + +Use this exact table format, one row per rule+example, technical rule, user event, and check-also item. The **Layer** column indicates where the behavior is implemented (Contract, Backend Domain, Infra, API, Frontend (Route/Gateway/Query), CLI, MCP Server, Event, Background Job, or Cross-layer) — this helps route fixes to the right team/area: + +``` +| ID | Rule / Item | Layer | Status | Evidence | Test Coverage | +|----|-------------|-------|--------|----------|---------------| +| R1-E1 | Rule 1: {title} / Example 1: {summary} | Backend Domain | Covered | `file.ts:45` - {what the code does} | `file.spec.ts:23` - {test description} | +| R1-E2 | Rule 1: {title} / Example 2: {summary} | Frontend | Not Covered | No uniqueness check found in {file} | None | +| R2-E1 | Rule 2: {title} / Example 1: {summary} | Backend Domain | Partially Covered | `file.ts:80` - slug generated but not immutable | None | +| T1 | Tech: {description} | Cross-layer | Covered | `ConflictDetector.ts:12` - uses decision field | `ConflictDetector.spec.ts:5` | +| EV1 | Event: {event_name} | Event | Not Covered | No emit found for this event | None | +| C1 | Check: {description} | API | Covered | `guard.ts:15` - admin role check | None | +``` + +**ID format**: `R{rule}-E{example}` for rules, `T{n}` for technical rules, `EV{n}` for events, `C{n}` for check-also items. + +### Reproduction Steps for Gaps + +For each item marked **Not Covered** or **Partially Covered**, provide: + +``` +#### [{ID}] {Rule title} / {Example summary} +**Status**: Not Covered | Partially Covered +**What is missing**: {specific behavior not implemented} +**Where to look**: {file paths where this should be implemented} +**How to reproduce**: +1. {step-by-step reproduction of the scenario from the example} +2. {what happens vs what should happen} +``` + +Keep reproduction steps concise — focus on the minimum steps to demonstrate the gap. + +If everything is fully covered, state: "All rules and examples are fully covered." diff --git a/.github/skills/qa-review/em_template.md b/.github/skills/qa-review/em_template.md new file mode 100644 index 000000000..a2a483b55 --- /dev/null +++ b/.github/skills/qa-review/em_template.md @@ -0,0 +1,50 @@ +User Story Review: {Title of the user story} + +# Rule 1: {Short rule title describing the expected behavior} + +## Example 1 + +{Setup: describe the initial state} + +{Action: describe what the user does} + +{Outcome: describe the expected result} + +## Example 2 + +{Setup} + +{Action} + +{Outcome} + +# Rule 2: {Short rule title} + +## Example 1 + +{Setup} + +{Action} + +{Outcome} + +# Technical rules + +- {Implementation constraint that applies across frontend and backend, e.g., "Slug generation must be deterministic and locale-independent"} +- {Performance or infrastructure constraint, e.g., "The operation must be idempotent"} +- {Security or authorization constraint, e.g., "Only organization admins can perform this action"} +- {Data constraint, e.g., "Max length for the name field: 64 characters"} +- {Integration constraint, e.g., "Must work on both Linux and Windows (normalize paths)"} + +# User Events + +- `{event_name}`: emitted when {trigger description}. Properties: `{property1}`, `{property2}` +- `{event_name}`: emitted when {trigger description}. Properties: `{property1}` + +--- + +Check also the following rules are applied: + +- {Additional business rule or constraint not covered by the rules above} +- {Edge case to verify, e.g., "Two items in the same scope cannot have the same name"} +- {Default behavior, e.g., "By default, the user lands on the default space"} diff --git a/.github/skills/release-proprietary/SKILL.md b/.github/skills/release-proprietary/SKILL.md new file mode 100644 index 000000000..e20ce1386 --- /dev/null +++ b/.github/skills/release-proprietary/SKILL.md @@ -0,0 +1,207 @@ +--- +name: 'release-proprietary' +description: 'Orchestrate a full Packmind proprietary release: verify Main CI/CD Pipeline is green on both OSS and proprietary main, drive the release on the OSS sibling repo (version bumps, CHANGELOG, tag, push), wait for the oss-sync workflow to land the release commit on the proprietary fork, then tag and push `release/{{version}}` on the proprietary repo. Invoke from the proprietary repo.' +--- + +Create a full Packmind proprietary release with version `{{version}}`. + +This skill orchestrates a cross-repository release that spans the OSS sibling (`../packmind`, cloned next to the proprietary repo) and the proprietary repo itself (the current working directory). All real release content (version bumps, CHANGELOG) lives on OSS; the proprietary side receives the OSS commit through the `oss-sync` workflow and gets its own `release/{{version}}` tag pointing at a **different SHA** than the OSS tag — see below. + +**Invocation requirement:** run this skill from the root of the proprietary repo, with the OSS sibling checked out at `../packmind`. All commands below assume that layout. + +**Dual-SHA model — important:** + +* On **OSS**, the `release/{{version}}` tag points at the release commit itself (subject `chore: release {{version}}`). That commit's tree contains OSS code, which is what OSS deployments need. +* On **proprietary**, the same tag points at the **sync-merge commit** that brought the OSS release into proprietary `main`. The OSS release commit has no proprietary-only files in its tree, so tagging it on proprietary would break deployments (the build can't find `@packmind/proprietary/*` modules). The sync-merge commit's tree combines OSS-at-release with the proprietary-only files, which is what proprietary deployments need. + +The wait/tag scripts handle this for you — Phase 2 emits the proprietary merge SHA, and Phase 3 tags that SHA. + +Repository layout assumed by every command in this file: + +* OSS repo: `../packmind` (remote `PackmindHub/packmind`) +* Proprietary repo: `.` — the current working directory (remote `PackmindHub/packmind-proprietary`) + +Scripts live next to this file under `./scripts/`. From the proprietary repo root, paths are `.claude/skills/release-proprietary/scripts/<name>`. + +## Phase 0 — Pre-flight (proprietary repo) + +### 0.1 Feature flags audit gate (MANDATORY) + +Before doing anything, stop and ask the user: + +> Has the `feature-flags-audit` skill been run on the **proprietary** repo for this release? (yes / no) + +If the answer is anything other than an explicit `yes`, abort and tell the user to run `feature-flags-audit` on the proprietary repo first. + +If the audit surfaced any loose / stale flags that should have been removed, instruct the user to: + +1. Check whether each loose flag also exists in the OSS sibling at `../packmind`. Most code flows from OSS → proprietary via `oss-sync`, so flags should generally be cleaned up on the OSS side. +2. Remove them in OSS first, let the oss-sync workflow land the cleanup on proprietary, then re-invoke this skill. + +### 0.2 No open `oss-sync` PR + +```bash +./.claude/skills/release-proprietary/scripts/check-oss-sync-pr.sh PackmindHub/packmind-proprietary +``` + +When the auto-sync hits a merge conflict it opens a PR on branch `oss-sync` instead of fast-forwarding. If such a PR is open, Phase 2 polling cannot succeed. Abort and ask the user to resolve and merge the PR first. + +### 0.3 CI green on both repos + +Run the check script for both repositories: + +```bash +./.claude/skills/release-proprietary/scripts/check-ci.sh PackmindHub/packmind +./.claude/skills/release-proprietary/scripts/check-ci.sh PackmindHub/packmind-proprietary +``` + +Exit codes: + +* `0` — green, proceed. +* `1` — the run failed (or no run found, or auth missing). Abort, surface the URL. +* `2` — the run is still in progress / queued. Not a failure: ask the user whether to wait and retry, or abort. + +### 0.4 Clean working tree on proprietary and no local divergence + +```bash +git status --porcelain # must be empty +git fetch origin main +git merge-base --is-ancestor HEAD origin/main \ + || (echo "local main has diverged from origin/main — reconcile before releasing" && exit 1) +``` + +If the working tree is dirty, or local `HEAD` is not an ancestor of `origin/main`, abort and ask the user to reconcile. + +## Phase 1 — Drive the OSS release (`../packmind`) + +All commands in this phase target the OSS repo via `git -C` or `cd`. + +### 1.1 Verify clean OSS state and update + +```bash +git -C ../packmind status --porcelain # must be empty +git -C ../packmind checkout main +git -C ../packmind pull --ff-only origin main +``` + +If the working tree is dirty or the pull is not fast-forward, abort and ask the user to reconcile. + +### 1.2 Bump versions + +```bash +node ./.claude/skills/release-proprietary/scripts/bump-versions.mjs {{version}} ../packmind +( cd ../packmind && npm install ) +``` + +`npm install` regenerates `package-lock.json` with the new version. + +### 1.3 Rewrite CHANGELOG for release + +Resolve today's date in ISO 8601 (`YYYY-MM-DD`) — call it `{{today}}`. + +```bash +node ./.claude/skills/release-proprietary/scripts/changelog-release.mjs {{version}} {{today}} ../packmind +``` + +### 1.4 Commit the release and push main + +```bash +./.claude/skills/release-proprietary/scripts/commit-release.sh ../packmind {{version}} +``` + +This stages `package.json`, `apps/api/docker-package.json`, `package-lock.json`, and `CHANGELOG.MD`, commits with the exact subject `chore: release {{version}}` (Phase 2 polls for this fixed string), and pushes main. The script never uses `--no-verify`. + +### 1.5 Tag the release and push the tag + +```bash +./.claude/skills/release-proprietary/scripts/tag-release.sh ../packmind {{version}} +``` + +Creates `release/{{version}}` at HEAD (the release commit just pushed) and pushes the tag. If the tag already exists, the script verifies it points at HEAD before re-pushing. + +### 1.6 Prepare next dev cycle on OSS + +```bash +node ./.claude/skills/release-proprietary/scripts/changelog-next.mjs {{version}} ../packmind +./.claude/skills/release-proprietary/scripts/commit-next-cycle.sh ../packmind +``` + +## Phase 2 — Wait for oss-sync to land on proprietary + +The `sync-from-oss-repository.yml` workflow on the proprietary repo merges OSS commits into proprietary `main` automatically (usually under a minute). It will land BOTH the release commit AND the subsequent "prepare next development cycle" commit; each goes through its own sync-merge commit on proprietary `main`. + +```bash +PROP_TAG_SHA=$(./.claude/skills/release-proprietary/scripts/wait-for-oss-sync.sh \ + . {{version}}) +``` + +The script writes the **proprietary sync-merge SHA** to stdout (captured into `PROP_TAG_SHA`) and progress to stderr. Internally it: + +1. Polls `origin/main` every 5 seconds for the OSS release commit (subject exactly `chore: release {{version}}`), with a 10-minute timeout. +2. Once found, locates the proprietary merge commit whose **2nd parent** is that OSS commit — that's the upstream side of the sync merge. Requiring the OSS release to be the direct 2nd parent (not a grandparent) ensures the merge's tree is exactly "OSS at release + proprietary state at that moment" — i.e. version `{{version}}`, not `{{next}}-next`. + +This is the SHA you tag on proprietary. It is NOT the OSS release SHA — that one's tree contains only OSS code and would break proprietary deployments. See the "Dual-SHA model" note at the top. + +If it times out, abort and instruct the user to: + +* Check the `sync-from-oss-repository` workflow on `PackmindHub/packmind-proprietary`. +* Check whether an open `oss-sync` PR appeared after Phase 0 ran. + +If the script reports `OSS release commit … is on proprietary origin/main, but no merge commit has it as its direct upstream (2nd) parent`, the auto-sync either fast-forwarded or batched release + next-cycle into one merge. The operator must resolve manually before continuing — there is no merge commit with the right tree to tag. + +Once `PROP_TAG_SHA` is set, fast-forward the local proprietary checkout (purely to keep the working tree in sync — we do NOT rely on HEAD for the tag): + +```bash +git checkout main +git pull --ff-only origin main +``` + +## Phase 3 — Tag proprietary + +### 3.1 Re-check proprietary CI green + +The sync just pushed new commits to proprietary main; the Phase 0 CI gate is now stale. Re-run it (allowing exit 2 = still running) and either wait or abort: + +```bash +./.claude/skills/release-proprietary/scripts/check-ci.sh PackmindHub/packmind-proprietary +``` + +### 3.2 Tag the proprietary release commit + +```bash +./.claude/skills/release-proprietary/scripts/tag-release.sh \ + . {{version}} "${PROP_TAG_SHA}" +``` + +Passing the SHA explicitly is essential — tagging `HEAD` would tag a later sync-merge or the next-cycle commit. The script re-verifies that the target is either a direct release commit OR a merge commit whose 2nd parent is the release commit, and refuses to tag otherwise. It will not allow retagging an existing tag at a different commit. + +### 3.3 Done + +Report to the user: + +* OSS release commit URL: `https://github.com/PackmindHub/packmind/releases/tag/release/{{version}}` (or the compare link) +* Proprietary tag URL: `https://github.com/PackmindHub/packmind-proprietary/releases/tag/release/{{version}}` +* Reminder: any deployment workflow that watches `release/*` tags will trigger. + +## Important notes + +* Do NOT use `--no-verify` on any commit. If a hook fails, fix the root cause and create a new commit. +* The release commit subject MUST be exactly `chore: release {{version}}` — Phase 2 matches subjects by exact equality and `tag-release.sh` verifies subject equality before tagging. Any prefix/suffix (e.g. gitmoji) will break the flow. +* Date format MUST be ISO 8601 (`YYYY-MM-DD`). +* Verify each commit and push succeeded before proceeding to the next step. +* If anything fails mid-way after the OSS tag is pushed, do NOT delete the OSS tag — coordinate a follow-up patch release instead. + +## Recovery cheatsheet + +If the flow fails at a known phase, recover as follows: + +* **After 1.4, before 1.5** (release commit pushed, no OSS tag yet): re-run `tag-release.sh <oss-dir> {{version}}` from Phase 1.5. The script tags HEAD only if HEAD's subject is `chore: release {{version}}`. +* **After 1.5, before 1.6** (OSS is tagged, no next-cycle commit): re-run `changelog-next.mjs` + `commit-next-cycle.sh`. Then proceed to Phase 2. +* **After 1.6, Phase 2 timed out**: investigate the sync workflow / oss-sync PR. Once unstuck, re-run only Phase 2 + Phase 3. +* **After Phase 3 failure** (OSS tagged, proprietary not tagged): once any blocker is cleared, re-run only Phase 3, passing the same `PROP_TAG_SHA`. Recover it by re-running `wait-for-oss-sync.sh` (idempotent — it finds and emits the same merge SHA as before) or manually with: + ```bash + OSS_SHA=$(git log origin/main \ + --format='%H%x09%s' -500 | awk -F '\t' -v subj="chore: release {{version}}" '$2 == subj { print $1; exit }') + git log origin/main --merges \ + --format='%H %P' -500 | awk -v oss="${OSS_SHA}" '$3 == oss { print $1; exit }' + ``` \ No newline at end of file diff --git a/.github/skills/release-proprietary/scripts/bump-versions.mjs b/.github/skills/release-proprietary/scripts/bump-versions.mjs new file mode 100755 index 000000000..86f17a5ac --- /dev/null +++ b/.github/skills/release-proprietary/scripts/bump-versions.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// Bump version field in package.json and apps/api/docker-package.json to the +// provided version. Preserves 2-space indentation and trailing newline. +// +// Usage: node bump-versions.mjs <version> <repo-dir> + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const [, , version, repoDirArg] = process.argv; + +if (!version || !repoDirArg) { + console.error('usage: bump-versions.mjs <version> <repo-dir>'); + process.exit(1); +} + +const repoDir = resolve(repoDirArg); +const targets = ['package.json', 'apps/api/docker-package.json']; + +for (const rel of targets) { + const path = join(repoDir, rel); + const raw = readFileSync(path, 'utf8'); + const json = JSON.parse(raw); + const previous = json.version; + json.version = version; + const endsWithNewline = raw.endsWith('\n'); + writeFileSync(path, JSON.stringify(json, null, 2) + (endsWithNewline ? '\n' : '')); + console.log(`bumped ${rel}: ${previous} → ${version}`); +} diff --git a/.github/skills/release-proprietary/scripts/changelog-next.mjs b/.github/skills/release-proprietary/scripts/changelog-next.mjs new file mode 100755 index 000000000..1d74c49e5 --- /dev/null +++ b/.github/skills/release-proprietary/scripts/changelog-next.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// Mutate CHANGELOG.MD for the next development cycle: +// - Prepend a fresh `# [Unreleased]` section with empty Added/Changed/Fixed/Removed +// - Insert `[Unreleased]: ...compare/release/{version}...HEAD` link just above +// the `[{version}]: ...` link in the bottom link section +// +// Usage: node changelog-next.mjs <version> <repo-dir> + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const [, , version, repoDirArg] = process.argv; + +if (!version || !repoDirArg) { + console.error('usage: changelog-next.mjs <version> <repo-dir>'); + process.exit(1); +} + +const path = join(resolve(repoDirArg), 'CHANGELOG.MD'); +const original = readFileSync(path, 'utf8'); + +const firstHeadingMatch = original.match(/^# \[/m); +if (!firstHeadingMatch) { + console.error('CHANGELOG.MD: could not find any `# [` heading'); + process.exit(1); +} +const insertAt = firstHeadingMatch.index; + +const unreleasedBlock = + '# [Unreleased]\n\n## Added\n\n## Changed\n\n## Fixed\n\n## Removed\n\n'; + +let result = original.slice(0, insertAt) + unreleasedBlock + original.slice(insertAt); + +const versionLinkRegex = new RegExp( + `^\\[${version.replace(/\./g, '\\.')}\\]: (https://github\\.com/[^/]+/[^/]+)/compare/release/[0-9A-Za-z._-]+\\.\\.\\.release/${version.replace(/\./g, '\\.')}\\s*$`, + 'm', +); +const versionLinkMatch = result.match(versionLinkRegex); +if (!versionLinkMatch) { + console.error(`CHANGELOG.MD: could not find \`[${version}]: ...\` compare link`); + process.exit(1); +} +const repoUrl = versionLinkMatch[1]; +const unreleasedLink = `[Unreleased]: ${repoUrl}/compare/release/${version}...HEAD`; + +result = result.replace(versionLinkRegex, `${unreleasedLink}\n${versionLinkMatch[0]}`); + +writeFileSync(path, result); +console.log(`changelog: prepared next cycle after ${version}`); diff --git a/.github/skills/release-proprietary/scripts/changelog-release.mjs b/.github/skills/release-proprietary/scripts/changelog-release.mjs new file mode 100755 index 000000000..db97d5aea --- /dev/null +++ b/.github/skills/release-proprietary/scripts/changelog-release.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// Mutate CHANGELOG.MD for a release: +// - Rename `# [Unreleased]` heading to `# [{version}] - {date}` +// - Drop empty `## Added|Changed|Fixed|Removed` subsections inside that block +// - Swap the bottom compare link +// `[Unreleased]: ...compare/release/{prev}...HEAD` +// for +// `[{version}]: ...compare/release/{prev}...release/{version}` +// +// Usage: node changelog-release.mjs <version> <date> <repo-dir> + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const [, , version, date, repoDirArg] = process.argv; + +if (!version || !date || !repoDirArg) { + console.error('usage: changelog-release.mjs <version> <date> <repo-dir>'); + process.exit(1); +} + +const path = join(resolve(repoDirArg), 'CHANGELOG.MD'); +const original = readFileSync(path, 'utf8'); + +const unreleasedHeading = original.match(/^# \[Unreleased\][^\n]*$/m); +if (!unreleasedHeading) { + console.error('CHANGELOG.MD: could not find `# [Unreleased]` heading'); + process.exit(1); +} + +const start = unreleasedHeading.index; +const after = original.slice(start + unreleasedHeading[0].length); +const nextHeadingRel = after.match(/^# \[/m); +if (!nextHeadingRel) { + console.error('CHANGELOG.MD: could not find next `# [` heading after Unreleased'); + process.exit(1); +} +const blockEnd = start + unreleasedHeading[0].length + nextHeadingRel.index; +const block = original.slice(start, blockEnd); + +const lines = block.split('\n'); +const out = [`# [${version}] - ${date}`]; +let i = 1; +while (i < lines.length) { + const line = lines[i]; + if (line.startsWith('## ')) { + let j = i + 1; + while (j < lines.length && !lines[j].startsWith('## ')) j++; + const body = lines.slice(i + 1, j).join('\n').trim(); + if (body.length > 0) { + out.push(line); + for (let k = i + 1; k < j; k++) out.push(lines[k]); + } + i = j; + } else { + out.push(line); + i++; + } +} + +let newBlock = out.join('\n'); +if (!newBlock.endsWith('\n\n')) { + newBlock = newBlock.replace(/\n*$/, '\n\n'); +} + +let result = original.slice(0, start) + newBlock + original.slice(blockEnd); + +const linkRegex = /^\[Unreleased\]: (https:\/\/github\.com\/[^/]+\/[^/]+)\/compare\/release\/([0-9A-Za-z._-]+)\.\.\.HEAD\s*$/m; +const linkMatch = result.match(linkRegex); +if (!linkMatch) { + console.error('CHANGELOG.MD: could not find `[Unreleased]: .../compare/release/<prev>...HEAD` link'); + process.exit(1); +} +const repoUrl = linkMatch[1]; +const prev = linkMatch[2]; +const newLink = `[${version}]: ${repoUrl}/compare/release/${prev}...release/${version}`; +result = result.replace(linkRegex, newLink); + +writeFileSync(path, result); +console.log(`changelog: released ${version} (date ${date}, previous ${prev})`); diff --git a/.github/skills/release-proprietary/scripts/check-ci.sh b/.github/skills/release-proprietary/scripts/check-ci.sh new file mode 100755 index 000000000..f9cf727cc --- /dev/null +++ b/.github/skills/release-proprietary/scripts/check-ci.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Verify that the "Main CI/CD Pipeline" workflow on the latest commit of the +# main branch of a given GitHub repository is green. +# +# Usage: check-ci.sh <owner/repo> +# Exit codes: +# 0 - latest run on main concluded successfully (green) +# 1 - latest run failed / cancelled / no run found / gh auth missing +# 2 - latest run is still in progress or queued (not red, just not done) + +set -euo pipefail + +REPO="${1:?usage: check-ci.sh <owner/repo>}" + +if ! command -v gh >/dev/null 2>&1; then + echo "❌ gh CLI not found in PATH" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "❌ jq not found in PATH" >&2 + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "❌ gh is not authenticated — run \`gh auth login\` first" >&2 + exit 1 +fi + +HEAD_SHA=$(gh api "repos/${REPO}/commits/main" --jq .sha 2>/dev/null || true) +if [ -z "${HEAD_SHA}" ]; then + echo "❌ ${REPO}: could not resolve main HEAD SHA (does the repo exist and do you have access?)" >&2 + exit 1 +fi +SHORT_SHA="${HEAD_SHA:0:7}" + +RUN_JSON=$(gh run list \ + --repo "${REPO}" \ + --workflow=main.yml \ + --branch=main \ + --limit=20 \ + --json databaseId,status,conclusion,headSha,url,createdAt) + +MATCH=$(echo "${RUN_JSON}" | jq --arg sha "${HEAD_SHA}" ' + [ .[] | select(.headSha == $sha) ] | sort_by(.createdAt) | reverse | .[0] // empty +') + +if [ -z "${MATCH}" ] || [ "${MATCH}" = "null" ]; then + echo "❌ ${REPO}: no Main CI/CD Pipeline run found for current main HEAD (${SHORT_SHA})" >&2 + echo " Most recent runs on main:" >&2 + echo "${RUN_JSON}" | jq -r '.[] | " - sha=\(.headSha[0:7]) status=\(.status) conclusion=\(.conclusion) \(.url)"' >&2 + exit 1 +fi + +STATUS=$(echo "${MATCH}" | jq -r '.status') +CONCL=$(echo "${MATCH}" | jq -r '.conclusion') +URL=$(echo "${MATCH}" | jq -r '.url') + +case "${STATUS}" in + completed) + case "${CONCL}" in + success) + echo "✅ ${REPO}: Main CI/CD Pipeline green on main (${SHORT_SHA})" + echo " ${URL}" + exit 0 + ;; + *) + echo "❌ ${REPO}: Main CI/CD Pipeline concluded '${CONCL}' on main (${SHORT_SHA})" >&2 + echo " ${URL}" >&2 + exit 1 + ;; + esac + ;; + queued|in_progress|waiting|requested|pending) + echo "⏳ ${REPO}: Main CI/CD Pipeline still '${STATUS}' on main (${SHORT_SHA})" >&2 + echo " ${URL}" >&2 + echo " This is not a failure — re-run check-ci.sh after the run completes." >&2 + exit 2 + ;; + *) + echo "❌ ${REPO}: Main CI/CD Pipeline status '${STATUS}' on main (${SHORT_SHA}) — unexpected state" >&2 + echo " ${URL}" >&2 + exit 1 + ;; +esac diff --git a/.github/skills/release-proprietary/scripts/check-oss-sync-pr.sh b/.github/skills/release-proprietary/scripts/check-oss-sync-pr.sh new file mode 100755 index 000000000..20cac1bae --- /dev/null +++ b/.github/skills/release-proprietary/scripts/check-oss-sync-pr.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Verify there is no open `oss-sync` PR on the proprietary repository. +# +# Background: the sync-from-oss-repository workflow fast-forwards OSS commits +# onto proprietary main when there are no conflicts. When conflicts occur, it +# pushes branch `oss-sync` and opens a PR for manual review instead. If such +# a PR is open when we run a release, Phase 2 polling will never find the +# release commit on origin/main and will time out. +# +# Usage: check-oss-sync-pr.sh <owner/repo> +# Exit codes: +# 0 - no open oss-sync PR +# 1 - open oss-sync PR found (PR details printed to stderr) +# 2 - gh CLI / auth issue + +set -euo pipefail + +REPO="${1:?usage: check-oss-sync-pr.sh <owner/repo>}" + +if ! command -v gh >/dev/null 2>&1; then + echo "❌ gh CLI not found in PATH" >&2 + exit 2 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "❌ gh is not authenticated — run \`gh auth login\` first" >&2 + exit 2 +fi + +PR_JSON=$(gh pr list \ + --repo "${REPO}" \ + --head oss-sync \ + --state open \ + --json number,url,title) + +COUNT=$(echo "${PR_JSON}" | jq 'length') + +if [ "${COUNT}" -gt 0 ]; then + echo "❌ ${REPO}: an open oss-sync PR is blocking the auto-sync — resolve it before releasing." >&2 + echo "${PR_JSON}" | jq -r '.[] | " - PR #\(.number) \(.title) — \(.url)"' >&2 + exit 1 +fi + +echo "✅ ${REPO}: no open oss-sync PR" diff --git a/.github/skills/release-proprietary/scripts/commit-next-cycle.sh b/.github/skills/release-proprietary/scripts/commit-next-cycle.sh new file mode 100755 index 000000000..d03bd627c --- /dev/null +++ b/.github/skills/release-proprietary/scripts/commit-next-cycle.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Stage CHANGELOG.MD in <repo-dir>, commit with the exact subject +# `chore: prepare next development cycle`, and push main to origin. +# +# Usage: commit-next-cycle.sh <repo-dir> + +set -euo pipefail + +REPO_DIR="${1:?usage: commit-next-cycle.sh <repo-dir>}" + +cd "${REPO_DIR}" + +git add -- CHANGELOG.MD + +if git diff --cached --quiet; then + echo "❌ ${REPO_DIR}: nothing staged for next-cycle commit — did changelog-next.mjs run?" >&2 + exit 1 +fi + +git commit -m "chore: prepare next development cycle" +git push origin main + +echo "✅ ${REPO_DIR}: pushed \"chore: prepare next development cycle\"" diff --git a/.github/skills/release-proprietary/scripts/commit-release.sh b/.github/skills/release-proprietary/scripts/commit-release.sh new file mode 100755 index 000000000..166a11d3d --- /dev/null +++ b/.github/skills/release-proprietary/scripts/commit-release.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Stage the 4 release files in <repo-dir>, commit with the exact subject +# `chore: release <version>`, and push main to origin. +# +# Usage: commit-release.sh <repo-dir> <version> +# +# Never uses --no-verify. If a hook fails, fix the root cause and re-run. + +set -euo pipefail + +REPO_DIR="${1:?usage: commit-release.sh <repo-dir> <version>}" +VERSION="${2:?usage: commit-release.sh <repo-dir> <version>}" + +FILES=( + "package.json" + "apps/api/docker-package.json" + "package-lock.json" + "CHANGELOG.MD" +) + +cd "${REPO_DIR}" + +for f in "${FILES[@]}"; do + if [ ! -f "${f}" ]; then + echo "❌ ${REPO_DIR}: expected release file is missing: ${f}" >&2 + exit 1 + fi +done + +git add -- "${FILES[@]}" + +if git diff --cached --quiet; then + echo "❌ ${REPO_DIR}: nothing staged for release commit — did the bump/changelog scripts run?" >&2 + exit 1 +fi + +git commit -m "chore: release ${VERSION}" +git push origin main + +echo "✅ ${REPO_DIR}: pushed release commit \"chore: release ${VERSION}\"" diff --git a/.github/skills/release-proprietary/scripts/tag-release.sh b/.github/skills/release-proprietary/scripts/tag-release.sh new file mode 100755 index 000000000..56ffae1ff --- /dev/null +++ b/.github/skills/release-proprietary/scripts/tag-release.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Create the `release/<version>` tag in <repo-dir> and push it to origin. +# +# Usage: tag-release.sh <repo-dir> <version> [sha] +# +# If <sha> is provided, the tag is created at that exact commit. The +# commit's subject is verified before tagging — but for proprietary, the +# SHA we tag is a merge commit (subject "Merge remote-tracking branch +# 'upstream/main'") whose 2nd parent is the OSS release commit. So the +# check accepts EITHER: +# - a commit whose subject is `chore: release <version>` (OSS side, or +# a direct release commit), OR +# - a merge commit whose 2nd parent's subject is `chore: release <version>` +# (proprietary sync-merge of the OSS release). +# This safety net ensures we don't tag the wrong commit when the SHA is +# passed in by hand or by a partially recovered flow. +# +# If <sha> is omitted, the tag is created at HEAD (use on the OSS repo +# immediately after pushing the release commit, before any next-cycle commit). +# HEAD's subject must be exactly `chore: release <version>` in that case. + +set -euo pipefail + +REPO_DIR="${1:?usage: tag-release.sh <repo-dir> <version> [sha]}" +VERSION="${2:?usage: tag-release.sh <repo-dir> <version> [sha]}" +SHA_ARG="${3:-}" +TAG="release/${VERSION}" +EXPECTED_SUBJECT="chore: release ${VERSION}" + +cd "${REPO_DIR}" + +# Verify that <sha> is either the release commit itself OR a merge commit +# whose 2nd parent is the release commit. Returns 0 if OK, 1 otherwise, +# and prints a human-readable description of the match on success. +verify_release_target() { + local sha=$1 + local actual_subject + actual_subject=$(git log -1 --format='%s' "${sha}") + if [ "${actual_subject}" = "${EXPECTED_SUBJECT}" ]; then + echo "direct release commit" + return 0 + fi + # Maybe it's a merge commit whose 2nd parent is the release. + local second_parent + second_parent=$(git log -1 --format='%P' "${sha}" | awk '{print $2}') + if [ -n "${second_parent}" ]; then + local parent_subject + parent_subject=$(git log -1 --format='%s' "${second_parent}") + if [ "${parent_subject}" = "${EXPECTED_SUBJECT}" ]; then + echo "merge commit (2nd parent ${second_parent:0:7} is the release)" + return 0 + fi + fi + echo "neither subject \"${actual_subject}\" nor any merged parent matches \"${EXPECTED_SUBJECT}\"" + return 1 +} + +if [ -n "${SHA_ARG}" ]; then + TARGET_SHA=$(git rev-parse --verify "${SHA_ARG}" 2>/dev/null || true) + if [ -z "${TARGET_SHA}" ]; then + echo "❌ ${REPO_DIR}: provided SHA '${SHA_ARG}' is not a valid object" >&2 + exit 1 + fi + if ! MATCH_DESC=$(verify_release_target "${TARGET_SHA}"); then + echo "❌ ${REPO_DIR}: ${TARGET_SHA:0:7} ${MATCH_DESC}" >&2 + exit 1 + fi + echo "ℹ️ ${REPO_DIR}: ${TARGET_SHA:0:7} accepted — ${MATCH_DESC}" +else + TARGET_SHA=$(git rev-parse HEAD) + ACTUAL_SUBJECT=$(git log -1 --format='%s' HEAD) + if [ "${ACTUAL_SUBJECT}" != "${EXPECTED_SUBJECT}" ]; then + echo "❌ ${REPO_DIR}: HEAD subject is \"${ACTUAL_SUBJECT}\", expected \"${EXPECTED_SUBJECT}\"" >&2 + echo " Refusing to tag a commit that isn't the release commit." >&2 + exit 1 + fi +fi + +if git rev-parse --verify --quiet "refs/tags/${TAG}" >/dev/null; then + EXISTING_SHA=$(git rev-parse "refs/tags/${TAG}") + if [ "${EXISTING_SHA}" != "${TARGET_SHA}" ]; then + echo "❌ ${REPO_DIR}: tag ${TAG} already exists at ${EXISTING_SHA:0:7}, refusing to retag at ${TARGET_SHA:0:7}" >&2 + exit 1 + fi + echo "ℹ️ ${REPO_DIR}: tag ${TAG} already exists at target — skipping creation" +else + git tag "${TAG}" "${TARGET_SHA}" +fi + +git push origin "${TAG}" + +echo "✅ ${REPO_DIR}: pushed tag ${TAG} at ${TARGET_SHA:0:7}" diff --git a/.github/skills/release-proprietary/scripts/wait-for-oss-sync.sh b/.github/skills/release-proprietary/scripts/wait-for-oss-sync.sh new file mode 100755 index 000000000..f234dee23 --- /dev/null +++ b/.github/skills/release-proprietary/scripts/wait-for-oss-sync.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Poll the proprietary repo's origin/main until the OSS release commit has +# been merged in by the auto-sync workflow, then emit the SHA of the +# **proprietary merge commit** (NOT the OSS release commit itself). +# +# Why the merge commit and not the release commit: +# The OSS release commit (e.g. `chore: release 1.15.0`) is created on OSS, +# so its tree contains OSS-only files. When the proprietary auto-sync +# brings it into proprietary main, the resulting merge commit's tree +# combines the OSS release with the proprietary-only files. Tagging the +# merge commit is what we want — tagging the raw OSS commit would point +# `release/<version>` at an OSS-only tree, breaking proprietary +# deployments (the build can't find `@packmind/proprietary/*` modules). +# +# The script: +# 1. Polls origin/main for the OSS release commit (by exact subject). +# 2. Once found, locates the proprietary merge commit whose **2nd parent** +# is that OSS commit. The 2nd parent is the upstream side of the sync +# merge. Requiring the OSS release commit to be the *direct* upstream +# parent (not a grandparent) ensures the merge's tree is exactly +# "OSS at release + proprietary state at that moment" — i.e. version +# X.Y.Z, not X.Y.(Z+1)-next. +# 3. Emits the merge SHA on stdout. Progress messages go to stderr. +# +# Usage: wait-for-oss-sync.sh <prop-repo-dir> <version> [timeout-seconds] +# Defaults: timeout-seconds=600 (10 minutes) +# Exit codes: +# 0 - proprietary merge commit detected (SHA on stdout) +# 1 - timed out, or sync arrived but no suitable merge commit exists +# (e.g. fast-forward, or auto-sync batched release + next-cycle into +# one merge — in that case the operator must resolve manually). + +set -euo pipefail + +PROP_DIR="${1:?usage: wait-for-oss-sync.sh <prop-repo-dir> <version> [timeout-seconds]}" +VERSION="${2:?usage: wait-for-oss-sync.sh <prop-repo-dir> <version> [timeout-seconds]}" +TIMEOUT="${3:-600}" + +SUBJECT="chore: release ${VERSION}" +INTERVAL=5 +ELAPSED=0 + +echo "⏳ Polling ${PROP_DIR} origin/main for OSS commit \"${SUBJECT}\" and its proprietary merge (timeout ${TIMEOUT}s)…" >&2 + +while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do + git -C "${PROP_DIR}" fetch --quiet origin main + + # Step 1: find the OSS release commit on proprietary main (by exact subject) + OSS_SHA=$(git -C "${PROP_DIR}" log origin/main --format='%H%x09%s' -500 \ + | awk -F '\t' -v subj="${SUBJECT}" '$2 == subj { print $1; exit }') + + if [ -n "${OSS_SHA}" ]; then + # Step 2: find the proprietary merge commit whose 2nd parent is the OSS release. + # %P emits parents space-separated; on a merge commit, $2 is parent1 + # (prior proprietary tip) and $3 is parent2 (the upstream side). + MERGE_SHA=$(git -C "${PROP_DIR}" log origin/main --merges --format='%H %P' -500 \ + | awk -v oss="${OSS_SHA}" '$3 == oss { print $1; exit }') + + if [ -n "${MERGE_SHA}" ]; then + echo "✅ Found proprietary merge commit: ${MERGE_SHA:0:7} (merges OSS release ${OSS_SHA:0:7})" >&2 + echo "${MERGE_SHA}" + exit 0 + fi + + echo "⚠️ OSS release commit ${OSS_SHA:0:7} is on proprietary origin/main, but no merge commit has it as its direct upstream (2nd) parent." >&2 + echo " This can happen if the auto-sync fast-forwarded (unlikely if proprietary has local commits) or batched the release with later OSS commits into one merge." >&2 + echo " Proprietary cannot be tagged automatically — investigate the sync history before proceeding." >&2 + exit 1 + fi + + sleep "${INTERVAL}" + ELAPSED=$((ELAPSED + INTERVAL)) +done + +echo "❌ Timed out after ${TIMEOUT}s waiting for OSS release commit \"${SUBJECT}\" to land on proprietary origin/main" >&2 +echo " Check the sync-from-oss-repository workflow status and any pending oss-sync PR." >&2 +exit 1 diff --git a/.github/skills/upgrade-runtime-stack/SKILL.md b/.github/skills/upgrade-runtime-stack/SKILL.md new file mode 100644 index 000000000..04da3a3de --- /dev/null +++ b/.github/skills/upgrade-runtime-stack/SKILL.md @@ -0,0 +1,137 @@ +--- +name: 'upgrade-runtime-stack' +description: 'Check whether newer stable versions of Node.js (24.x line), Nx, or Vite are available and, if so, generate a detailed upgrade plan markdown file at the repo root. Use this skill whenever the user asks to "check for runtime upgrades", "upgrade Node/NX/Vite", "is our Node version current", "plan a Node 24 upgrade", "refresh our runtime stack", "monthly stack check", or anything along those lines — even if they don''t name a specific tool. Also use it when the user wants a recurring/cadence check of build-toolchain currency. Output is a plan only — does NOT mutate package.json, Dockerfiles, lockfiles, or any other repo file. CI/CD wrappers can invoke this skill to keep the runtime stack fresh.' +--- + +# Upgrade Runtime Stack + +Goal: detect available stable upgrades of **Node.js (24.x line only)**, **Nx**, and **Vite**, then emit an actionable, ready-to-execute upgrade plan as a markdown file at the repo root. Stop at the plan — never edit application files. + +## Why this skill exists + +Manual runtime upgrades drift months between attempts and produce avoidable risk. This skill captures the canonical file map (mined from the prior Node 22 → 24 migration on `emdash/migration-node24-cc0s9`) so each upgrade run reuses the same checklist instead of rediscovering it. The plan is the deliverable. A human or a CI bot decides whether to act on it. + +## Execution mode + +This skill **must run fully non-interactive**. It is invoked from CI/CD in headless mode where no human can answer prompts. + +- Never call `AskUserQuestion` or any other clarification tool. +- Never ask the user to confirm a choice. The skill makes deterministic decisions from the inputs (baked URLs + repo state) and produces the plan. +- Never wait on long-running interactive commands. Read-only file inspection, `WebFetch`, and `rg` scans are the only actions needed. +- All output goes to a single deterministic artifact: `upgrade-plan.md` at the repo root. The terminal print at the end (Phase 7) is informational only — CI can ignore stdout. + +If any input is missing or ambiguous, the skill records the gap **inside the plan** (e.g. "Vite changelog unreachable this run") and continues. It never blocks on input. + +## Inputs + +The skill takes **no arguments**. Version sources are baked in: + +| Tool | Source URL | What to extract | +|------|------------|-----------------| +| Node.js 24.x | `https://raw.githubusercontent.com/nodejs/node/refs/heads/main/doc/changelogs/CHANGELOG_V24.md` | Latest stable 24.x release | +| Nx | `https://nx.dev/changelog` | Latest stable Nx major.minor.patch | +| Vite | `https://raw.githubusercontent.com/vitejs/vite/refs/heads/main/packages/vite/CHANGELOG.md` | Latest stable Vite release | + +See `references/fetch-versions.md` for the exact parsing rules per source. + +## Workflow + +Execute phases in order. Each phase has a single clear deliverable. Do not skip steps; the value of this skill comes from the consistency of the output. + +### Phase 1 — Read current versions from the repo + +Read these exact locations and record what is currently pinned: + +- `.nvmrc` → exact Node version (e.g. `24.15.0`) +- `package.json` (root) → `engines.node`, `engines.npm`, `devDependencies.nx`, `devDependencies["@nx/*"]`, `devDependencies.vite` +- `apps/api/docker-package.json` → `engines.node`, `engines.npm` +- `dockerfile/Dockerfile.api` and `dockerfile/Dockerfile.mcp` → `FROM node:<version>-alpine<alpine-version>@sha256:<digest>` +- `docker-compose.yml` and `docker-compose.production.yml` → every `image: node:<version>-alpine<alpine-version>` occurrence +- `.github/workflows/*.yml` → default `node-version` inputs + +Record in a single in-memory table; this becomes the "Current versions" section of the plan. + +### Phase 2 — Fetch latest stable versions + +Use `WebFetch` against each source URL listed in **Inputs**. Follow the per-source parsing rules in `references/fetch-versions.md`. + +Filtering rules (apply to every source): + +- **Skip** anything tagged `next`, `beta`, `alpha`, `rc`, `canary`, `preview`, `dev`, or `pre`. +- **Node.js**: only consider `v24.x.y` entries. Ignore 22.x, 26.x, etc. — even if newer. +- **Nx**: take the highest `X.Y.Z` published as a stable release. +- **Vite**: take the highest `X.Y.Z` published as a stable release. + +For each tool, also extract the **published date** if visible and a short summary of headline changes / breaking changes from the changelog body covering the range *(current version, latest]*. This summary feeds the risk section of the plan. + +### Phase 3 — Compute delta and decide + +For each tool, compare current vs. latest stable: + +- `latest == current` → no upgrade needed for that tool. Record as "up to date". +- `latest > current` → upgrade candidate. Classify the bump as `patch`, `minor`, or `major` using semver rules. Note any breaking-change headlines from Phase 2. +- `latest < current` → unusual. Record but do not recommend a downgrade. + +If **all three** tools are up to date, still produce `upgrade-plan.md` but with a single "No upgrades available" section plus the timestamp. This keeps the CI integration deterministic. + +### Phase 4 — Resolve Docker image dependencies + +When Node is being upgraded, the Docker image pin (`node:<version>-alpine<X>@sha256:<digest>`) must change in lockstep. The plan must include: + +1. The exact new tag string to use (`node:<new-version>-alpine<X>`). +2. The current Alpine major (read from existing Dockerfiles) — keep it unless a Node breaking change requires a different base. +3. An explicit instruction line: *"Look up the sha256 digest for `node:<new-version>-alpine<X>` on Docker Hub before pinning."* Do not fabricate a digest. + +If Node is not being upgraded, the Docker section of the plan only lists which files **would** change in a future Node bump, for reference. + +### Phase 5 — Map files to modify + +Use `references/file-map.md` as the authoritative list of files that any Node / Nx / Vite upgrade must touch in this repo. The map is grouped per tool. Include in the plan only the file groups whose tool has a pending upgrade. + +After listing the bake-in files, run a quick scan to surface drift — files that match the relevant version pattern but are not yet in the map: + +- Node version drift: `rg -n "node:[0-9]+\.[0-9]+\.[0-9]+|node-version: ['\"]?[0-9]+" --hidden -g '!node_modules' -g '!dist'` +- Nx version drift: `rg -n '"nx": "[0-9]+\.[0-9]+\.[0-9]+"|"@nx/[a-z-]+": "[0-9]+\.[0-9]+\.[0-9]+"' --hidden -g '!node_modules' -g '!dist'` +- Vite version drift: `rg -n '"vite": "(\^|~)?[0-9]+\.[0-9]+\.[0-9]+"' --hidden -g '!node_modules' -g '!dist'` + +Add any hits that are not already covered to a "Drift detected" subsection of the plan so a human can decide whether to extend the file map. + +### Phase 6 — Validation harness + +Copy the validation steps from `references/validation.md` into the plan. The harness is the contract for "this upgrade did not break the repo" and must be runnable end-to-end after applying the plan. + +### Phase 7 — Write `upgrade-plan.md` + +Use the exact structure defined in `references/plan-template.md`. Write to the repo root as `upgrade-plan.md`. Overwrite any existing file at that path — the plan is always the latest snapshot. + +The first line of the plan **must** be a machine-readable status comment so CI can branch on it without parsing the body: + +``` +<!-- upgrade-status: available | none | partial-fetch-failure --> +``` + +- `available` — at least one tool has a stable upgrade. +- `none` — all three tools up to date. +- `partial-fetch-failure` — one or more source URLs could not be fetched this run. + +After writing, print to stdout (informational only — CI may discard): + +- One-line summary per tool (e.g. `Node 24.15.0 → 24.17.0 (patch)`). +- The absolute path of the generated `upgrade-plan.md`. +- Whether any drift was detected (so the file map may need updating). + +Do **not** apply edits. Stop here. + +## Hard rules + +- Never edit `package.json`, `package-lock.json`, `.nvmrc`, Dockerfiles, docker-compose files, CI workflows, or any other source file. The skill produces a plan only. +- Never invent version numbers, dates, or sha256 digests. If a source URL cannot be fetched, mark the tool as "unknown — fetch failed" in the plan and continue with the other tools. +- Never recommend Node majors other than 24. The team is on the 24.x line and a major bump is a separate, deliberate project. +- Never include `rc`, `beta`, `alpha`, `canary`, `next`, `preview`, `dev`, or `pre` versions in the recommendation, even if newer than current. + +## Reference files + +- `references/fetch-versions.md` — per-source parsing rules for the three changelog URLs. +- `references/file-map.md` — canonical list of files an upgrade of each tool touches in this repo. +- `references/plan-template.md` — exact markdown layout of `upgrade-plan.md`. +- `references/validation.md` — lint / test / build commands that act as the safety harness. \ No newline at end of file diff --git a/.github/skills/upgrade-runtime-stack/references/fetch-versions.md b/.github/skills/upgrade-runtime-stack/references/fetch-versions.md new file mode 100644 index 000000000..8ca96d4e3 --- /dev/null +++ b/.github/skills/upgrade-runtime-stack/references/fetch-versions.md @@ -0,0 +1,52 @@ +# Fetching latest stable versions + +Per-source parsing rules. All sources are fetched with `WebFetch`. Always strip pre-release tags (`rc`, `beta`, `alpha`, `canary`, `next`, `preview`, `dev`, `pre`). + +## Node.js (24.x line only) + +- URL: `https://raw.githubusercontent.com/nodejs/node/refs/heads/main/doc/changelogs/CHANGELOG_V24.md` +- The file is the changelog for the entire Node 24 line. Section headers look like: + ``` + <a id="24.15.0"></a> + ## 2025-XX-YY, Version 24.15.0 (Current), @<release-manager> + ``` +- Parse the topmost `## YYYY-MM-DD, Version 24.X.Y` heading whose tag is **not** marked `(Pre-release)`, `(RC)`, etc. +- Output: + - `latest`: e.g. `24.17.0` + - `released`: e.g. `2025-09-12` + - `highlights`: bullet headings between this version's heading and the next version heading. Limit to ~10 short bullets. Skip commit-only bullets. +- The "Current" / "LTS" annotation may be present — record it but do not gate the recommendation on it; the 24.x line moves from Current to LTS during its life. + +## Nx + +- URL: `https://nx.dev/changelog` +- The page lists release entries newest-first. Each entry looks like a section with a version (e.g. `Nx 22.7.2`) and a date. +- Parse the topmost entry whose version is **not** suffixed with `-rc.N`, `-beta.N`, `-alpha.N`, `-canary.N`, `-next.N`. +- Output: + - `latest`: e.g. `22.7.2` + - `released`: e.g. `2025-09-30` + - `highlights`: bullet section titles from that entry (e.g. "Breaking changes", "Features", "Bug Fixes"). Capture any text explicitly under "Breaking changes" verbatim — it drives the risk section. +- If the major changes between current and latest, also fetch the matching `https://nx.dev/changelog#vNN-migration` anchor or the linked migration guide and include the URL in the plan. + +## Vite + +- URL: `https://raw.githubusercontent.com/vitejs/vite/refs/heads/main/packages/vite/CHANGELOG.md` +- Standard `keep-a-changelog` style. Headings look like: + ``` + ## 8.0.3 (YYYY-MM-DD) + ``` +- Parse the topmost `## X.Y.Z` heading without a pre-release tag. +- Output: + - `latest`: e.g. `8.1.0` + - `released`: e.g. `2025-10-04` + - `highlights`: subsection titles (`### Features`, `### Bug Fixes`, `### BREAKING CHANGES`). Capture any `BREAKING CHANGES` section verbatim. + +## Fallback when a source cannot be fetched + +If `WebFetch` returns an error or an empty body, do not block the run. In the plan, record: + +``` +- <tool>: unable to fetch <URL> — skipped this run. +``` + +The two remaining tools are still processed normally. diff --git a/.github/skills/upgrade-runtime-stack/references/file-map.md b/.github/skills/upgrade-runtime-stack/references/file-map.md new file mode 100644 index 000000000..db47a227b --- /dev/null +++ b/.github/skills/upgrade-runtime-stack/references/file-map.md @@ -0,0 +1,62 @@ +# Canonical file map per tool + +These lists were mined from the Node 22 → 24 migration branch `emdash/migration-node24-cc0s9`. They represent the *minimum* set of files that an upgrade of each tool must touch. The skill's Phase 5 also scans for drift in case the repo gained a new file matching the relevant pattern. + +A file may appear under more than one tool when an upgrade of any of those tools requires editing it. + +## Node.js (24.x) + +When the Node line shifts to a new patch (or minor within 24.x), update every concrete pin so all environments agree. + +**Engine / runtime pins:** +- `.nvmrc` — full `X.Y.Z` Node version, no `v` prefix. +- `package.json` (root) — `engines.node` and `engines.npm`. +- `apps/api/docker-package.json` — `engines.node` and `engines.npm` (this file is shipped inside the API Docker image as `package.json`). + +**Docker images:** +- `dockerfile/Dockerfile.api` — `FROM node:<version>-alpine<X>@sha256:<digest>`. The sha256 digest must be looked up on Docker Hub for the new tag; do not invent it. +- `dockerfile/Dockerfile.mcp` — same pattern. +- `docker-compose.yml` — every `image: node:<version>-alpine<X>` line. There are several services. +- `docker-compose.production.yml` — every `image: node:<version>-alpine<X>` line. + +**CI workflows:** +- `.github/workflows/build.yml` — `node-version` workflow input default and every matrix entry. +- `.github/workflows/main.yml` — same. +- `.github/workflows/docker.yml` — same. +- `.github/workflows/publish-cli-release.yml` — same. +- `.github/workflows/tmp-cli-lint-windows.yml` — same. + +**Helper scripts (kept around from the prior migration; usually paired):** +- `migrate_node24.sh` — invoked by humans to switch local env after a bump. +- `downgrade_node22.sh` — escape hatch back to the previous major; only relevant on a *major* Node bump. + +When the Node bump is patch- or minor-only, the two helper scripts can be left as-is. On a major bump (e.g. 24.x → 26.x), the plan must explicitly call out rewriting/removing them. + +## Nx + +When Nx is bumped, every `@nx/*` package and `nx` itself must move to the same version. The Nx CLI's own `nx migrate` workflow handles most edits, but the plan must list: + +- `package.json` (root) — `devDependencies.nx` and every `devDependencies["@nx/*"]`. Currently used scopes: `@nx/devkit`, `@nx/esbuild`, `@nx/eslint`, `@nx/eslint-plugin`, `@nx/jest`, `@nx/js`, `@nx/nest`, `@nx/node`, `@nx/playwright`, `@nx/plugin`, `@nx/react`, `@nx/storybook`, `@nx/vite`, `@nx/vitest`, `@nx/web`, `@nx/webpack`. +- `tools/packmind-plugin/package.json` — `@nx/*` deps referenced by the local Nx plugin. +- `nx.json` — schema / plugin configuration sometimes shifts between minors. +- `package-lock.json` — regenerated by `npm install` after the version bumps. +- `migrations.json` — created by `nx migrate <version>` at the repo root. The plan must include the explicit command `npx nx migrate <new-version>` and a follow-up `npx nx migrate --run-migrations`. + +**ESLint coupling:** the Nx ESLint plugin sometimes lands new flat-config requirements that affect `eslint.config.mjs` and `packages/ui/eslint.config.mjs`. List them as "review only" in the plan unless the changelog explicitly calls them out. + +## Vite + +When Vite is bumped, the consumers of Vite in this repo are: + +- `package.json` (root) — `devDependencies.vite`. +- `apps/frontend/vite.config.ts` — config surface; major bumps often change plugin or build options. +- `packages/ui/vite.config.ts` — same. +- `apps/frontend/package.json` — peer / dev deps may need realignment if the major changes. +- `packages/ui/package.json` — same. +- `package-lock.json` — regenerated. + +**Coupling with Nx:** `@nx/vite` and `@nx/vitest` carry their own Vite peer-dep ranges. On a Vite major bump, the plan must check the Nx changelog for compatibility before recommending the move; if Nx doesn't yet support the new Vite major, the plan recommends *waiting* and notes the blocking constraint. + +## Drift scan + +Phase 5 of the skill also runs three ripgrep scans (see `SKILL.md`) and reports any additional matches. Anything reported there is a candidate for adding to this file map after the upgrade lands. diff --git a/.github/skills/upgrade-runtime-stack/references/plan-template.md b/.github/skills/upgrade-runtime-stack/references/plan-template.md new file mode 100644 index 000000000..2ec5bebd9 --- /dev/null +++ b/.github/skills/upgrade-runtime-stack/references/plan-template.md @@ -0,0 +1,103 @@ +# `upgrade-plan.md` layout + +Write the plan to the **repo root** as `upgrade-plan.md`. Overwrite any existing file at that path. The structure below is mandatory — downstream tooling (and the human reader's mental model) depends on it. + +When a tool has no pending upgrade, still emit its section but populate it with "Up to date — no action required" so the file's shape stays stable. + +```markdown +<!-- upgrade-status: available | none | partial-fetch-failure --> +# Runtime stack upgrade plan + +_Generated: <YYYY-MM-DD HH:MM TZ> by the `upgrade-runtime-stack` skill._ + +## Summary + +| Tool | Current | Latest stable | Bump | Action | +|------|---------|---------------|------|--------| +| Node.js 24.x | <X.Y.Z> | <X.Y.Z> | patch / minor / major / none | Upgrade / Skip | +| Nx | <X.Y.Z> | <X.Y.Z> | patch / minor / major / none | Upgrade / Skip | +| Vite | <X.Y.Z> | <X.Y.Z> | patch / minor / major / none | Upgrade / Skip | + +<one-paragraph recommendation: e.g. "All three tools have stable updates available. Node and Nx are patch-only and safe; Vite is a minor with one deprecation worth reviewing."> + +## Node.js + +- **Current**: <X.Y.Z> (from `.nvmrc`) +- **Latest stable**: <X.Y.Z>, released <YYYY-MM-DD> +- **Bump type**: patch / minor / major +- **Changelog highlights**: + - <bullet> + - <bullet> +- **Breaking changes**: <verbatim section from changelog, or "none documented"> + +### Files to modify + +<file map — group by category from `references/file-map.md`, with exact line patterns to change> + +### Docker image pin + +- New tag: `node:<new-version>-alpine<X>` +- Action: look up the sha256 digest for that tag on Docker Hub before editing `dockerfile/Dockerfile.api` and `dockerfile/Dockerfile.mcp`. Do **not** reuse the previous digest. +- Sample lookup: `docker manifest inspect node:<new-version>-alpine<X> | jq -r '.manifests[0].digest'` or use the Docker Hub UI. + +## Nx + +- **Current**: <X.Y.Z> +- **Latest stable**: <X.Y.Z>, released <YYYY-MM-DD> +- **Bump type**: patch / minor / major +- **Changelog highlights**: + - <bullet> +- **Breaking changes**: <verbatim> +- **Migration command**: + ``` + npx nx migrate <new-version> + npm install + npx nx migrate --run-migrations + ``` + +### Files to modify + +<from `references/file-map.md` — Nx section> + +## Vite + +- **Current**: <X.Y.Z> +- **Latest stable**: <X.Y.Z>, released <YYYY-MM-DD> +- **Bump type**: patch / minor / major +- **Changelog highlights**: + - <bullet> +- **Breaking changes**: <verbatim> +- **Nx / Vite compatibility note**: <only if Vite is a major bump — confirm `@nx/vite` and `@nx/vitest` support the new Vite major> + +### Files to modify + +<from `references/file-map.md` — Vite section> + +## Drift detected + +<empty list, or any extra hits the Phase 5 scans found that are not in the file map> + +## Validation harness + +<paste the steps from `references/validation.md` verbatim> + +## Risks + +- <pre-flagged risk: e.g. "Node 24.X.Y removes deprecated experimental flag `--foo`; the repo does not use it (verified via rg)"> +- <every breaking-change bullet from the changelogs goes here, paired with a quick repo check> + +## Rollback + +- Revert the upgrade commit and run `npm install` to regenerate the lockfile. +- For a Node major rollback, `downgrade_node22.sh` exists for the 22 ↔ 24 transition; on later majors a similar helper must be created before applying. +- Docker images are pinned by `@sha256:...` so previous deploys are reproducible. + +## Suggested commit split + +A single PR is fine for patch/minor bumps of all three tools combined. Split into separate PRs when: + +- Any tool has a **major** bump. +- The Nx and Vite bumps would interact (e.g. Vite major + `@nx/vite` major). + +Suggested split for this run: <one-paragraph proposal> +``` diff --git a/.github/skills/upgrade-runtime-stack/references/validation.md b/.github/skills/upgrade-runtime-stack/references/validation.md new file mode 100644 index 000000000..c26764221 --- /dev/null +++ b/.github/skills/upgrade-runtime-stack/references/validation.md @@ -0,0 +1,73 @@ +# Validation harness + +After the upgrade plan is applied, these steps must all succeed before merging. Copy this section verbatim into `upgrade-plan.md`. + +The order matters — fail fast on cheap checks before paying for full builds. + +## Local + +1. **Node + npm match the pins** + ``` + node --version # expect: vNEW_NODE + npm --version # expect: NEW_NPM + ``` + If `nvm` is in use: `nvm use` should read the new `.nvmrc` automatically. + +2. **Clean install** + ``` + rm -rf node_modules package-lock.json # only on major bumps; skip for patch/minor + npm install + ``` + For patch/minor bumps prefer `npm install` against the existing lockfile so the diff stays auditable. + +3. **Lint affected** + ``` + npm run lint:staged + ``` + +4. **Test affected** + ``` + npm run test:staged + ``` + +5. **Build the heaviest targets** + ``` + ./node_modules/.bin/nx build api + ./node_modules/.bin/nx build frontend + ./node_modules/.bin/nx build cli + ./node_modules/.bin/nx build mcp-server + ``` + +6. **Docker build smoke test** (only when Node is bumped) + ``` + docker build -f dockerfile/Dockerfile.api -t packmind-api:upgrade-check . + docker build -f dockerfile/Dockerfile.mcp -t packmind-mcp:upgrade-check . + docker compose -f docker-compose.yml up -d api frontend + docker compose -f docker-compose.yml ps + docker compose -f docker-compose.yml down + ``` + +## CI + +The GitHub Actions workflows already test against the `node-version` matrix entries. After the plan is applied, the **Main CI/CD Pipeline** must be green on the branch before merging: + +- `.github/workflows/build.yml` +- `.github/workflows/main.yml` +- `.github/workflows/docker.yml` + +If any workflow runs `nx affected`, it picks up the changed files automatically and runs the relevant projects. + +## Manual smoke (post-merge) + +- Spin up the local stack: `docker compose up -d`. +- Open the frontend on its dev URL and confirm the app loads. +- Hit the API health endpoint. +- Run one MCP server interaction end-to-end. + +## Failure handling + +If any harness step fails: + +1. Capture the exact error in the upgrade PR description. +2. Revert with `git revert <upgrade-commit>` rather than amending — keeps history auditable. +3. Re-run the skill on the reverted branch to regenerate a fresh plan once the upstream fix lands. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5a043e68a..ce185972b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -327,7 +327,7 @@ jobs: runs-on: ${{ vars.ACTION_RUNNER_TAG || 'self-hosted' }} timeout-minutes: 20 env: - PACKMIND_INSTANCE_URL: http://localhost:4200 + PACKMIND_INSTANCE_URL: http://localhost:4201 strategy: matrix: node-version: ['${{ inputs.node-version }}'] diff --git a/.github/workflows/deploy-staging-and-release.yml b/.github/workflows/deploy-staging-and-release.yml index 48a6c8168..065de7dbd 100644 --- a/.github/workflows/deploy-staging-and-release.yml +++ b/.github/workflows/deploy-staging-and-release.yml @@ -42,14 +42,17 @@ jobs: uses: mikefarah/yq@v4.47.2 # ======================================================================== - # PROPRIETARY EDITION - Cloud deployments (main branch only) + # UNIFIED (OSS + Enterprise) - Cloud + on-prem staging (main branch only) + # A single CI run publishes both image variants, so every commit updates + # the cloud (staging + prod) and on-prem staging values together. # ======================================================================== - - name: Update cloud values (main branch - proprietary) - if: github.ref == 'refs/heads/main' && vars.PACKMIND_EDITION == 'proprietary' + - name: Update staging + prod values (main branch) + if: github.ref == 'refs/heads/main' working-directory: helm-charts env: PROD_CLOUD_VALUES: "packmind-internal-values/values-prod-cloud.yaml" STAGING_CLOUD_VALUES: "packmind-internal-values/values-staging-cloud.yaml" + STAGING_ONPREM_VALUES: "packmind-internal-values/values-staging-onprem-oss.yaml" IMAGE_TAG: "${{ github.sha }}" run: | # Update prod cloud values @@ -62,94 +65,54 @@ jobs: yq eval '.frontend.image.tag = env(IMAGE_TAG)' -i $STAGING_CLOUD_VALUES yq eval '.mcpServer.image.tag = env(IMAGE_TAG)' -i $STAGING_CLOUD_VALUES - - name: Commit and Push Changes (main branch - proprietary) - if: github.ref == 'refs/heads/main' && vars.PACKMIND_EDITION == 'proprietary' - working-directory: helm-charts - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add packmind-internal-values/values-prod-cloud.yaml packmind-internal-values/values-staging-cloud.yaml - git commit -m "[k8s-values] - update cloud values with commit ${{ github.sha }}" - git push - - # ======================================================================== - # OSS EDITION - On-premise deployments - # ======================================================================== - - name: Update staging on-prem values (main branch - oss) - if: github.ref == 'refs/heads/main' && vars.PACKMIND_EDITION == 'oss' - working-directory: helm-charts - env: - STAGING_ONPREM_VALUES: "packmind-internal-values/values-staging-onprem-oss.yaml" - IMAGE_TAG: "${{ github.sha }}" - run: | # Update staging on-premise values yq eval '.api.image.tag = env(IMAGE_TAG)' -i $STAGING_ONPREM_VALUES yq eval '.frontend.image.tag = env(IMAGE_TAG)' -i $STAGING_ONPREM_VALUES yq eval '.mcpServer.image.tag = env(IMAGE_TAG)' -i $STAGING_ONPREM_VALUES - - name: Commit and Push Changes (main branch - oss) - if: github.ref == 'refs/heads/main' && vars.PACKMIND_EDITION == 'oss' + - name: Commit and Push Changes (main branch) + if: github.ref == 'refs/heads/main' working-directory: helm-charts run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add packmind-internal-values/values-staging-onprem-oss.yaml - git commit -m "[k8s-values] - update staging on-prem values with commit ${{ github.sha }}" + git add packmind-internal-values/values-prod-cloud.yaml packmind-internal-values/values-staging-cloud.yaml packmind-internal-values/values-staging-onprem-oss.yaml + git commit -m "[k8s-values] - update staging + prod cloud values with commit ${{ github.sha }}" git push - - name: Get package version (release tags - oss) - if: startsWith(github.ref, 'refs/tags/release/') && vars.PACKMIND_EDITION == 'oss' + # ======================================================================== + # UNIFIED (OSS + Enterprise) - On-premise release deployments + # Each release tag updates both the OSS and enterprise on-prem values. + # ======================================================================== + - name: Get package version (release tags) + if: startsWith(github.ref, 'refs/tags/release/') id: get-version run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT - - name: Update release on-prem values (release tags - oss) - if: startsWith(github.ref, 'refs/tags/release/') && vars.PACKMIND_EDITION == 'oss' + - name: Update release on-prem values (release tags) + if: startsWith(github.ref, 'refs/tags/release/') working-directory: helm-charts env: RELEASE_ONPREM_VALUES: "packmind-internal-values/values-release-onprem-oss.yaml" + RELEASE_ONPREM_ENTERPRISE_VALUES: "packmind-internal-values/values-release-onprem-enterprise.yaml" IMAGE_TAG: "${{ steps.get-version.outputs.version }}" run: | - # Update release on-premise values + # Update OSS release on-premise values yq eval '.api.image.tag = env(IMAGE_TAG)' -i $RELEASE_ONPREM_VALUES yq eval '.frontend.image.tag = env(IMAGE_TAG)' -i $RELEASE_ONPREM_VALUES yq eval '.mcpServer.image.tag = env(IMAGE_TAG)' -i $RELEASE_ONPREM_VALUES - - name: Commit and Push Changes (release tags - oss) - if: startsWith(github.ref, 'refs/tags/release/') && vars.PACKMIND_EDITION == 'oss' - working-directory: helm-charts - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add packmind-internal-values/values-release-onprem-oss.yaml - git commit -m "[k8s-values] - update release on-prem values with version v${{ steps.get-version.outputs.version }}" - git push - - # ======================================================================== - # PROPRIETARY EDITION - On-premise release deployments - # ======================================================================== - - name: Get package version (release tags - proprietary) - if: startsWith(github.ref, 'refs/tags/release/') && vars.PACKMIND_EDITION == 'proprietary' - id: get-version-proprietary - run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT - - - name: Update release on-prem enterprise values (release tags - proprietary) - if: startsWith(github.ref, 'refs/tags/release/') && vars.PACKMIND_EDITION == 'proprietary' - working-directory: helm-charts - env: - RELEASE_ONPREM_ENTERPRISE_VALUES: "packmind-internal-values/values-release-onprem-enterprise.yaml" - IMAGE_TAG: "${{ steps.get-version-proprietary.outputs.version }}" - run: | - # Update release on-premise enterprise values + # Update enterprise release on-premise values yq eval '.api.image.tag = env(IMAGE_TAG)' -i $RELEASE_ONPREM_ENTERPRISE_VALUES yq eval '.frontend.image.tag = env(IMAGE_TAG)' -i $RELEASE_ONPREM_ENTERPRISE_VALUES yq eval '.mcpServer.image.tag = env(IMAGE_TAG)' -i $RELEASE_ONPREM_ENTERPRISE_VALUES - - name: Commit and Push Changes (release tags - proprietary) - if: startsWith(github.ref, 'refs/tags/release/') && vars.PACKMIND_EDITION == 'proprietary' + - name: Commit and Push Changes (release tags) + if: startsWith(github.ref, 'refs/tags/release/') working-directory: helm-charts run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add packmind-internal-values/values-release-onprem-enterprise.yaml - git commit -m "[k8s-values] - update release on-prem enterprise values with version v${{ steps.get-version-proprietary.outputs.version }}" + git add packmind-internal-values/values-release-onprem-oss.yaml packmind-internal-values/values-release-onprem-enterprise.yaml + git commit -m "[k8s-values] - update release on-prem values with version v${{ steps.get-version.outputs.version }}" git push diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b77a233b0..cafa121a8 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -34,15 +34,26 @@ on: required: true description: 'Docker Hub access token' outputs: + # Primary tag (proprietary/enterprise): e.g. packmind/api-proprietary:SHA or packmind/api:VERSION-enterprise api-image-tag: - description: 'API Docker image tag' + description: 'API Docker image tag (proprietary)' value: ${{ jobs.build-scan-push.outputs.api-image-tag }} frontend-image-tag: - description: 'Frontend Docker image tag' + description: 'Frontend Docker image tag (proprietary)' value: ${{ jobs.build-scan-push.outputs.frontend-image-tag }} mcp-server-image-tag: - description: 'MCP Server Docker image tag' + description: 'MCP Server Docker image tag (proprietary)' value: ${{ jobs.build-scan-push.outputs.mcp-image-tag }} + # OSS tags: e.g. packmind/api-oss:SHA or packmind/api:VERSION + api-image-tag-oss: + description: 'API Docker image tag (OSS)' + value: ${{ jobs.build-scan-push.outputs.api-image-tag-oss }} + frontend-image-tag-oss: + description: 'Frontend Docker image tag (OSS)' + value: ${{ jobs.build-scan-push.outputs.frontend-image-tag-oss }} + mcp-server-image-tag-oss: + description: 'MCP Server Docker image tag (OSS)' + value: ${{ jobs.build-scan-push.outputs.mcp-image-tag-oss }} version: description: 'Package version used for Docker tags' value: ${{ jobs.build-scan-push.outputs.version }} @@ -78,11 +89,14 @@ jobs: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/release/') outputs: - # On main: outputs SHA tag (what's actually pushed) - # On release/*: outputs version tag (what's actually pushed) + # Primary tag (proprietary/enterprise): e.g. packmind/api-proprietary:SHA or packmind/api:VERSION-enterprise api-image-tag: packmind/api${{ steps.set-image-naming.outputs.name_suffix }}:${{ steps.get-image-tag.outputs.tag }} frontend-image-tag: packmind/frontend${{ steps.set-image-naming.outputs.name_suffix }}:${{ steps.get-image-tag.outputs.tag }} mcp-image-tag: packmind/mcp${{ steps.set-image-naming.outputs.name_suffix }}:${{ steps.get-image-tag.outputs.tag }} + # OSS tags: e.g. packmind/api-oss:SHA or packmind/api:VERSION + api-image-tag-oss: packmind/api${{ steps.get-image-tag.outputs.name_suffix_oss }}:${{ steps.get-image-tag.outputs.tag_oss }} + frontend-image-tag-oss: packmind/frontend${{ steps.get-image-tag.outputs.name_suffix_oss }}:${{ steps.get-image-tag.outputs.tag_oss }} + mcp-image-tag-oss: packmind/mcp${{ steps.get-image-tag.outputs.name_suffix_oss }}:${{ steps.get-image-tag.outputs.tag_oss }} version: ${{ steps.get-version.outputs.version }} steps: @@ -107,43 +121,37 @@ jobs: - name: Set image naming strategy id: set-image-naming run: | - # For release tags: use unsuffixed names (packmind/api:version) - # For main branch: use suffixed names (packmind/api-oss:sha or packmind/api-proprietary:sha) + # OSS and Proprietary repos are merged. Every run produces BOTH tag variants: + # - Proprietary / Enterprise tags (primary): -proprietary suffix or -enterprise version suffix + # - OSS tags (secondary): -oss suffix or unsuffixed version + # The primary build uses proprietary naming; OSS tags are added via docker tag. if [[ "${{ github.ref }}" == refs/tags/release/* ]]; then - # Release tags: unsuffixed image names - if [ "${{ vars.PACKMIND_EDITION }}" = "oss" ]; then - echo "name_suffix=" >> $GITHUB_OUTPUT - echo "version_suffix=" >> $GITHUB_OUTPUT - elif [ "${{ vars.PACKMIND_EDITION }}" = "proprietary" ]; then - echo "name_suffix=" >> $GITHUB_OUTPUT - echo "version_suffix=-enterprise" >> $GITHUB_OUTPUT - else - echo "name_suffix=" >> $GITHUB_OUTPUT - echo "version_suffix=-${{ vars.PACKMIND_EDITION }}" >> $GITHUB_OUTPUT - fi + # Release tags: unsuffixed image names, -enterprise version suffix + echo "name_suffix=" >> $GITHUB_OUTPUT + echo "version_suffix=-enterprise" >> $GITHUB_OUTPUT else - # Main branch: suffixed image names for internal tracking - if [ "${{ vars.PACKMIND_EDITION }}" = "oss" ]; then - echo "name_suffix=-oss" >> $GITHUB_OUTPUT - echo "version_suffix=" >> $GITHUB_OUTPUT - elif [ "${{ vars.PACKMIND_EDITION }}" = "proprietary" ]; then - echo "name_suffix=-proprietary" >> $GITHUB_OUTPUT - echo "version_suffix=-enterprise" >> $GITHUB_OUTPUT - else - echo "name_suffix=-${{ vars.PACKMIND_EDITION }}" >> $GITHUB_OUTPUT - echo "version_suffix=-${{ vars.PACKMIND_EDITION }}" >> $GITHUB_OUTPUT - fi + # Main branch: -proprietary suffixed names, -enterprise version suffix + echo "name_suffix=-proprietary" >> $GITHUB_OUTPUT + echo "version_suffix=-enterprise" >> $GITHUB_OUTPUT fi - name: Determine image tag id: get-image-tag run: | - # On release/* tags: use semantic version (e.g., 1.4.2 or 1.4.2-enterprise) + # On release/* tags: use semantic version with enterprise suffix (e.g., 1.4.2-enterprise) # On main branch: use commit SHA if [[ "${{ github.ref }}" == refs/tags/release/* ]]; then + # Proprietary: packmind/api:VERSION-enterprise echo "tag=${{ steps.get-version.outputs.version }}${{ steps.set-image-naming.outputs.version_suffix }}" >> $GITHUB_OUTPUT + # OSS: packmind/api:VERSION (unsuffixed name, unsuffixed version) + echo "name_suffix_oss=" >> $GITHUB_OUTPUT + echo "tag_oss=${{ steps.get-version.outputs.version }}" >> $GITHUB_OUTPUT else + # Proprietary: packmind/api-proprietary:SHA echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT + # OSS: packmind/api-oss:SHA + echo "name_suffix_oss=-oss" >> $GITHUB_OUTPUT + echo "tag_oss=${{ github.sha }}" >> $GITHUB_OUTPUT fi - name: Cache pnpm supply-chain verification @@ -183,12 +191,12 @@ jobs: # API-specific pre-build step - name: Generate tsconfig env: - PACKMIND_EDITION: ${{ vars.PACKMIND_EDITION }} + PACKMIND_EDITION: proprietary run: node scripts/select-tsconfig.mjs - name: Build migration bundle for Docker env: - PACKMIND_EDITION: ${{ vars.PACKMIND_EDITION }} + PACKMIND_EDITION: proprietary run: ./node_modules/.bin/nx bundle-docker migrations # Docker setup @@ -214,7 +222,7 @@ jobs: env: VERSION: ${{ steps.get-version.outputs.version }} SHA: ${{ github.sha }} - EDITION: ${{ vars.PACKMIND_EDITION }} + EDITION: proprietary IMAGE_NAME_SUFFIX: ${{ steps.set-image-naming.outputs.name_suffix }} VERSION_SUFFIX: ${{ steps.set-image-naming.outputs.version_suffix }} run: | @@ -294,18 +302,26 @@ jobs: return 1 } - # Push only SHA-tagged images (version tags are reserved for releases) + # Push proprietary-tagged images (primary) retry_push "packmind/api${{ steps.set-image-naming.outputs.name_suffix }}:${{ github.sha }}" retry_push "packmind/frontend${{ steps.set-image-naming.outputs.name_suffix }}:${{ github.sha }}" retry_push "packmind/mcp${{ steps.set-image-naming.outputs.name_suffix }}:${{ github.sha }}" + # Also tag and push OSS variants (same image, extra tags) + docker tag "packmind/api${{ steps.set-image-naming.outputs.name_suffix }}:${{ github.sha }}" "packmind/api-oss:${{ github.sha }}" + retry_push "packmind/api-oss:${{ github.sha }}" + docker tag "packmind/frontend${{ steps.set-image-naming.outputs.name_suffix }}:${{ github.sha }}" "packmind/frontend-oss:${{ github.sha }}" + retry_push "packmind/frontend-oss:${{ github.sha }}" + docker tag "packmind/mcp${{ steps.set-image-naming.outputs.name_suffix }}:${{ github.sha }}" "packmind/mcp-oss:${{ github.sha }}" + retry_push "packmind/mcp-oss:${{ github.sha }}" + # Release tags: Rebuild with multi-platform and push - name: Build and push multi-platform images (release) if: startsWith(github.ref, 'refs/tags/release/') env: VERSION: ${{ steps.get-version.outputs.version }} SHA: ${{ github.sha }} - EDITION: ${{ vars.PACKMIND_EDITION }} + EDITION: proprietary IMAGE_NAME_SUFFIX: ${{ steps.set-image-naming.outputs.name_suffix }} VERSION_SUFFIX: ${{ steps.set-image-naming.outputs.version_suffix }} run: | @@ -314,21 +330,23 @@ jobs: release \ --push - # Release tags (OSS only): Build and push public images - - name: Build and push public images (release - OSS only) - if: startsWith(github.ref, 'refs/tags/release/') && vars.PACKMIND_EDITION == 'oss' + # Release tags: Build and push public (OSS-style) images + # These are unsuffixed tags (packmind/api:VERSION, packmind/api:latest) + # alongside the enterprise tags already pushed by the bake release group + - name: Build and push public images (release) + if: startsWith(github.ref, 'refs/tags/release/') run: | - # Build and push public API image + # Build and push public API image (unsuffixed OSS tags) docker buildx build \ --platform linux/amd64,linux/arm64 \ -f dockerfile/Dockerfile.api \ - --build-arg EDITION=${{ vars.PACKMIND_EDITION }} \ + --build-arg EDITION=proprietary \ -t packmind/api:${{ steps.get-version.outputs.version }} \ -t packmind/api:latest \ --push \ . - # Build and push public Frontend image + # Build and push public Frontend image (unsuffixed OSS tags) docker buildx build \ --platform linux/amd64,linux/arm64 \ -f dockerfile/Dockerfile.frontend \ @@ -337,7 +355,7 @@ jobs: --push \ . - # Build and push public MCP image + # Build and push public MCP image (unsuffixed OSS tags) docker buildx build \ --platform linux/amd64,linux/arm64 \ -f dockerfile/Dockerfile.mcp \ diff --git a/.github/workflows/monthly-runtime-stack-upgrade.yml b/.github/workflows/monthly-runtime-stack-upgrade.yml new file mode 100644 index 000000000..8793f8fcc --- /dev/null +++ b/.github/workflows/monthly-runtime-stack-upgrade.yml @@ -0,0 +1,144 @@ +name: Monthly Runtime Stack Upgrade Check + +on: + schedule: + - cron: '0 6 1 * *' # First day of the month at 08:00 CET (06:00 UTC) + workflow_dispatch: + inputs: + model: + description: 'Claude model to use' + required: false + default: 'claude-opus-4-6' + type: string + +permissions: + contents: read + id-token: write + +jobs: + runtime-stack-upgrade: + runs-on: ${{ vars.ACTION_RUNNER_TAG || 'self-hosted' }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Run Runtime Stack Upgrade Skill + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: "/upgrade-runtime-stack" + settings: | + { + "permissions": { + "allow": [ + "Bash(echo *)", + "Bash(git remote *)", + "Bash(mkdir *)", + "Bash(rg *)", + "Bash(grep *)", + "Agent", + "Read", + "Write", + "Glob", + "Grep", + "WebFetch", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:nx.dev)", + "WebFetch(domain:github.com)" + ] + } + } + claude_args: "--model ${{ inputs.model || 'claude-opus-4-6' }} --max-turns 50" + + - name: Display report + if: always() + run: | + if [ -f "upgrade-plan.md" ]; then + echo "=== Runtime Stack Upgrade Plan ===" + cat "upgrade-plan.md" + echo "## Runtime Stack Upgrade Plan" >> "$GITHUB_STEP_SUMMARY" + cat "upgrade-plan.md" >> "$GITHUB_STEP_SUMMARY" + else + echo "No upgrade-plan.md produced." + echo "No upgrade-plan.md produced." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Detect upgrade status + id: status + if: always() && hashFiles('upgrade-plan.md') != '' + run: | + STATUS_LINE=$(head -n 1 upgrade-plan.md) + if echo "$STATUS_LINE" | grep -q "upgrade-status: available"; then + echo "status=available" >> "$GITHUB_OUTPUT" + elif echo "$STATUS_LINE" | grep -q "upgrade-status: none"; then + echo "status=none" >> "$GITHUB_OUTPUT" + elif echo "$STATUS_LINE" | grep -q "upgrade-status: partial-fetch-failure"; then + echo "status=partial-fetch-failure" >> "$GITHUB_OUTPUT" + else + echo "status=unknown" >> "$GITHUB_OUTPUT" + fi + + - name: Prepare Slack payload + if: always() && hashFiles('upgrade-plan.md') != '' + run: | + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + STATUS="${{ steps.status.outputs.status }}" + + # Headline: status emoji + plain text. + case "$STATUS" in + available) HEADLINE=":rocket: Runtime stack upgrades available" ;; + none) HEADLINE=":white_check_mark: Runtime stack is up to date" ;; + partial-fetch-failure) HEADLINE=":warning: Runtime stack check ran with fetch failures" ;; + *) HEADLINE=":grey_question: Runtime stack check produced an unrecognised status" ;; + esac + + # Extract the Summary section (between "## Summary" and the next "## "). + # Cap at 2500 chars to stay well below Slack's 3000-char per-block limit. + SUMMARY=$(awk '/^## Summary$/{flag=1; next} /^## /{flag=0} flag' upgrade-plan.md) + MAX_SUMMARY_LENGTH=2500 + if [ ${#SUMMARY} -gt $MAX_SUMMARY_LENGTH ]; then + SUMMARY="${SUMMARY:0:$MAX_SUMMARY_LENGTH}..." + fi + + ARTIFACT_LINK="$RUN_URL#artifacts" + + PAYLOAD=$(jq -n \ + --arg channel "${{ secrets.SLACK_CHANNEL_PACKMIND_SKILL_ID }}" \ + --arg headline "$HEADLINE" \ + --arg summary "$SUMMARY" \ + --arg run_url "$RUN_URL" \ + --arg artifact_url "$ARTIFACT_LINK" \ + '{ + channel: $channel, + text: $headline, + blocks: [ + { type: "header", text: { type: "plain_text", text: $headline } }, + { type: "section", text: { type: "mrkdwn", text: ($summary | if . == "" then "_(no Summary section found in upgrade-plan.md)_" else . end) } }, + { type: "context", elements: [ + { type: "mrkdwn", text: ("<" + $run_url + "|View workflow run> • <" + $artifact_url + "|Download upgrade-plan.md>") } + ] } + ] + }') + + EOF_DELIMITER="SLACKEOF_$(openssl rand -hex 8)" + echo "SLACK_PAYLOAD<<${EOF_DELIMITER}" >> "$GITHUB_ENV" + echo "$PAYLOAD" >> "$GITHUB_ENV" + echo "${EOF_DELIMITER}" >> "$GITHUB_ENV" + + - name: Send report to Slack + if: always() && hashFiles('upgrade-plan.md') != '' + uses: slackapi/slack-github-action@v2 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} + payload: ${{ env.SLACK_PAYLOAD }} + errors: true + + - name: Upload report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: runtime-stack-upgrade-plan + path: upgrade-plan.md + if-no-files-found: warn + retention-days: 90 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 39d2dd3a0..494fa6f20 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -105,6 +105,9 @@ jobs: path: ~/.cache/pnpm key: pnpm-verify-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - run: pnpm install --frozen-lockfile --prefer-offline + - name: Prepare SonarQube Cloud Scan + run: cp private/sonar-project.properties . + - name: Generate Proprietary tsconfig env: PACKMIND_EDITION: ${{ env.ENTERPRISE_EDITION }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14b9d0aa0..b8076bf46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,16 +21,12 @@ jobs: - name: Set image name and version suffix id: set-image-naming run: | - if [ "${{ vars.PACKMIND_EDITION }}" = "oss" ]; then - echo "name_suffix=" >> $GITHUB_OUTPUT - echo "version_suffix=" >> $GITHUB_OUTPUT - elif [ "${{ vars.PACKMIND_EDITION }}" = "proprietary" ]; then - echo "name_suffix=" >> $GITHUB_OUTPUT - echo "version_suffix=-enterprise" >> $GITHUB_OUTPUT - else - echo "name_suffix=-${{ vars.PACKMIND_EDITION }}" >> $GITHUB_OUTPUT - echo "version_suffix=-${{ vars.PACKMIND_EDITION }}" >> $GITHUB_OUTPUT - fi + # OSS and Proprietary repos are merged. Release produces both tag variants: + # - OSS: unsuffixed (packmind/api:VERSION) + # - Proprietary/Enterprise: -enterprise suffix (packmind/api:VERSION-enterprise) + echo "name_suffix=" >> $GITHUB_OUTPUT + echo "version_tag_oss=${{ steps.get-version.outputs.version }}" >> $GITHUB_OUTPUT + echo "version_tag_proprietary=${{ steps.get-version.outputs.version }}-enterprise" >> $GITHUB_OUTPUT - name: Extract changelog for version id: extract-changelog @@ -66,10 +62,17 @@ jobs: ## Docker Images The following Docker images have been built and pushed to Docker Hub: - - `packmind/api${{ steps.set-image-naming.outputs.name_suffix }}:${{ steps.get-version.outputs.version }}${{ steps.set-image-naming.outputs.version_suffix }}` - - `packmind/frontend${{ steps.set-image-naming.outputs.name_suffix }}:${{ steps.get-version.outputs.version }}${{ steps.set-image-naming.outputs.version_suffix }}` - - `packmind/mcp${{ steps.set-image-naming.outputs.name_suffix }}:${{ steps.get-version.outputs.version }}${{ steps.set-image-naming.outputs.version_suffix }}` - All images are also available with the `latest` tag. + ### OSS + - `packmind/api:${{ steps.set-image-naming.outputs.version_tag_oss }}` + - `packmind/frontend:${{ steps.set-image-naming.outputs.version_tag_oss }}` + - `packmind/mcp:${{ steps.set-image-naming.outputs.version_tag_oss }}` + + ### Proprietary (Enterprise) + - `packmind/api:${{ steps.set-image-naming.outputs.version_tag_proprietary }}` + - `packmind/frontend:${{ steps.set-image-naming.outputs.version_tag_proprietary }}` + - `packmind/mcp:${{ steps.set-image-naming.outputs.version_tag_proprietary }}` + + All images are also available with the `latest` tag (OSS) and `latest-enterprise` tag (Proprietary). draft: false prerelease: false diff --git a/.github/workflows/weekly-agent-skill-frontmatter-audit.yml b/.github/workflows/weekly-agent-skill-frontmatter-audit.yml new file mode 100644 index 000000000..7f2424be0 --- /dev/null +++ b/.github/workflows/weekly-agent-skill-frontmatter-audit.yml @@ -0,0 +1,102 @@ +name: Weekly Agent Skill Frontmatter Audit + +on: + schedule: + - cron: '0 6 * * 1' # Every Monday at 08:00 CET (06:00 UTC) + workflow_dispatch: + inputs: + model: + description: 'Claude model to use' + required: false + default: 'claude-opus-4-6' + type: string + +permissions: + contents: read + id-token: write + +jobs: + agent-skill-frontmatter-audit: + runs-on: ${{ vars.ACTION_RUNNER_TAG || 'self-hosted' }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Run Agent Skill Frontmatter Audit + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: "/agent-skill-frontmatter-audit" + settings: | + { + "permissions": { + "allow": [ + "Bash(echo *)", + "Bash(git remote *)", + "Bash(mkdir *)", + "Agent", + "Read", + "Write", + "Glob", + "Grep", + "WebFetch" + ] + } + } + claude_args: "--model ${{ inputs.model || 'claude-opus-4-6' }} --max-turns 50" + + - name: Display report + if: always() + run: | + REPORT_FILE=$(ls skills_properties_*.md 2>/dev/null | head -1) + if [ -n "$REPORT_FILE" ]; then + echo "=== Agent Skill Frontmatter Audit Report ===" + cat "$REPORT_FILE" + echo "## Agent Skill Frontmatter Audit Report" >> "$GITHUB_STEP_SUMMARY" + cat "$REPORT_FILE" >> "$GITHUB_STEP_SUMMARY" + else + echo "No report file found." + echo "No report file found." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Prepare Slack payload + if: always() + run: | + REPORT_FILE=$(ls skills_properties_*.md 2>/dev/null | head -1) + if [ -z "$REPORT_FILE" ]; then + echo "SKIP_SLACK=true" >> "$GITHUB_ENV" + exit 0 + fi + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + MAX_LENGTH=3800 + REPORT=$(cat "$REPORT_FILE") + if [ ${#REPORT} -gt $MAX_LENGTH ]; then + REPORT="${REPORT:0:$MAX_LENGTH}..."$'\n\n'"Full report: $RUN_URL" + else + REPORT="$REPORT"$'\n\n'"View run: $RUN_URL" + fi + PAYLOAD=$(jq -n \ + --arg channel "${{ secrets.SLACK_CHANNEL_PACKMIND_SKILL_ID }}" \ + --arg text "$REPORT" \ + '{channel: $channel, text: $text}') + EOF_DELIMITER="SLACKEOF_$(openssl rand -hex 8)" + echo "SLACK_PAYLOAD<<${EOF_DELIMITER}" >> "$GITHUB_ENV" + echo "$PAYLOAD" >> "$GITHUB_ENV" + echo "${EOF_DELIMITER}" >> "$GITHUB_ENV" + + - name: Send report to Slack + if: always() && env.SKIP_SLACK != 'true' + uses: slackapi/slack-github-action@v2 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} + payload: ${{ env.SLACK_PAYLOAD }} + + - name: Upload report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: agent-skill-frontmatter-audit-report + path: skills_properties_*.md + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/weekly-cli-version-report.yml b/.github/workflows/weekly-cli-version-report.yml new file mode 100644 index 000000000..b667cc43b --- /dev/null +++ b/.github/workflows/weekly-cli-version-report.yml @@ -0,0 +1,76 @@ +name: Weekly CLI Version Report + +on: + schedule: + - cron: '0 9 * * 1' # Every Monday at 09:00 UTC (9 AM UTC) + workflow_dispatch: + +permissions: + contents: read + +jobs: + cli-version-report: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Generate CLI version report + env: + DD_API_KEY: ${{ secrets.DD_API_KEY }} + DD_APP_KEY: ${{ secrets.DD_APPLICATION_KEY }} + run: | + node scripts/datadog-cli-version-report.mjs | tee cli-version-report.md + + - name: Publish report to step summary + if: always() && hashFiles('cli-version-report.md') != '' + run: | + { + echo "## Packmind CLI Version Usage (last 7 days)" + echo '' + echo '```' + cat cli-version-report.md + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Prepare Slack payload + if: always() && hashFiles('cli-version-report.md') != '' + run: | + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + MAX_LENGTH=3800 + REPORT=$(cat cli-version-report.md) + if [ ${#REPORT} -gt $MAX_LENGTH ]; then + REPORT="${REPORT:0:$MAX_LENGTH}..."$'\n\n'"Full report: $RUN_URL" + else + REPORT="$REPORT"$'\n\n'"View run: $RUN_URL" + fi + PAYLOAD=$(jq -n \ + --arg channel "${{ secrets.SLACK_CHANNEL_PACKMIND_SKILL_ID }}" \ + --arg text "$REPORT" \ + '{channel: $channel, text: $text}') + EOF_DELIMITER="SLACKEOF_$(openssl rand -hex 8)" + echo "SLACK_PAYLOAD<<${EOF_DELIMITER}" >> "$GITHUB_ENV" + echo "$PAYLOAD" >> "$GITHUB_ENV" + echo "${EOF_DELIMITER}" >> "$GITHUB_ENV" + + - name: Send report to Slack + if: always() && hashFiles('cli-version-report.md') != '' + uses: slackapi/slack-github-action@v2 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} + payload: ${{ env.SLACK_PAYLOAD }} + + - name: Upload report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: cli-version-report + path: cli-version-report.md + if-no-files-found: warn + retention-days: 90 diff --git a/.github/workflows/weekly-datadog-analysis.yml b/.github/workflows/weekly-datadog-analysis.yml new file mode 100644 index 000000000..d28881ce2 --- /dev/null +++ b/.github/workflows/weekly-datadog-analysis.yml @@ -0,0 +1,125 @@ +name: Weekly Datadog Error Analysis + +on: + schedule: + - cron: '0 6 * * 1' # Every Monday at 08:00 CET (06:00 UTC) + workflow_dispatch: + inputs: + model: + description: 'Claude model to use' + required: false + default: 'claude-opus-4-6' + type: string + +permissions: + contents: read + id-token: write + +jobs: + datadog-analysis: + runs-on: ${{ vars.ACTION_RUNNER_TAG || 'self-hosted' }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Create MCP config for Datadog + run: | + export DD_MCP_ENDPOINT DD_API_KEY DD_APPLICATION_KEY + envsubst '$DD_MCP_ENDPOINT $DD_API_KEY $DD_APPLICATION_KEY' > /tmp/datadog-mcp-config.json <<'MCPEOF' + { + "mcpServers": { + "datadog-mcp": { + "type": "http", + "url": "${DD_MCP_ENDPOINT}", + "headers": { + "DD_API_KEY": "${DD_API_KEY}", + "DD_APPLICATION_KEY": "${DD_APPLICATION_KEY}" + } + } + } + } + MCPEOF + env: + DD_MCP_ENDPOINT: ${{ secrets.DD_MCP_ENDPOINT }} + DD_API_KEY: ${{ secrets.DD_API_KEY }} + DD_APPLICATION_KEY: ${{ secrets.DD_APPLICATION_KEY }} + + - name: Run Datadog Error Analysis + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: "/datadog-analysis" + settings: | + { + "permissions": { + "allow": [ + "Bash(echo *)", + "Bash(git remote *)", + "Bash(mkdir *)", + "Agent", + "Read", + "Write", + "Glob", + "Grep", + "mcp__datadog-mcp__search_datadog_logs", + "mcp__datadog-mcp__analyze_datadog_logs" + ] + } + } + claude_args: "--model ${{ inputs.model || 'claude-opus-4-6' }} --max-turns 50 --mcp-config /tmp/datadog-mcp-config.json" + + - name: Display report + if: always() + run: | + REPORT_FILE=$(ls datadog_*.md 2>/dev/null | head -1) + if [ -n "$REPORT_FILE" ]; then + echo "=== Datadog Error Analysis Report ===" + cat "$REPORT_FILE" + echo "## Datadog Error Analysis Report" >> "$GITHUB_STEP_SUMMARY" + cat "$REPORT_FILE" >> "$GITHUB_STEP_SUMMARY" + else + echo "No report file found." + echo "No report file found." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Prepare Slack payload + if: always() + run: | + REPORT_FILE=$(ls datadog_*.md 2>/dev/null | head -1) + if [ -z "$REPORT_FILE" ]; then + echo "SKIP_SLACK=true" >> "$GITHUB_ENV" + exit 0 + fi + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + MAX_LENGTH=3800 + REPORT=$(cat "$REPORT_FILE") + if [ ${#REPORT} -gt $MAX_LENGTH ]; then + REPORT="${REPORT:0:$MAX_LENGTH}..."$'\n\n'"Full report: $RUN_URL" + else + REPORT="$REPORT"$'\n\n'"View run: $RUN_URL" + fi + PAYLOAD=$(jq -n \ + --arg channel "${{ secrets.SLACK_CHANNEL_PACKMIND_SKILL_ID }}" \ + --arg text "$REPORT" \ + '{channel: $channel, text: $text}') + EOF_DELIMITER="SLACKEOF_$(openssl rand -hex 8)" + echo "SLACK_PAYLOAD<<${EOF_DELIMITER}" >> "$GITHUB_ENV" + echo "$PAYLOAD" >> "$GITHUB_ENV" + echo "${EOF_DELIMITER}" >> "$GITHUB_ENV" + + - name: Send report to Slack + if: always() && env.SKIP_SLACK != 'true' + uses: slackapi/slack-github-action@v2 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} + payload: ${{ env.SLACK_PAYLOAD }} + + - name: Upload report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: datadog-analysis-report + path: datadog_*.md + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/weekly-doc-review.yml b/.github/workflows/weekly-doc-review.yml new file mode 100644 index 000000000..7891ec5f7 --- /dev/null +++ b/.github/workflows/weekly-doc-review.yml @@ -0,0 +1,95 @@ +name: Weekly Documentation Audit + +on: + schedule: + - cron: '0 6 * * 1' # Every Monday at 08:00 CET (06:00 UTC) + workflow_dispatch: + inputs: + model: + description: 'Claude model to use' + required: false + default: 'claude-opus-4-6' + type: string + +permissions: + contents: read + id-token: write + +jobs: + doc-audit: + runs-on: ${{ vars.ACTION_RUNNER_TAG || 'self-hosted' }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Run Documentation Audit + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: "/doc-audit" + settings: | + { + "permissions": { + "allow": [ + "Bash(echo *)", + "Bash(git remote *)", + "Bash(mkdir *)", + "Agent", + "Read", + "Write", + "Glob", + "Grep" + ] + } + } + claude_args: "--model ${{ inputs.model || 'claude-opus-4-6' }} --max-turns 50" + + - name: Display reports + if: always() + run: | + if [ -f "doc-audit-report.md" ]; then + echo "=== Documentation Audit Report ===" + cat "doc-audit-report.md" + echo "## Documentation Audit Report" >> "$GITHUB_STEP_SUMMARY" + cat "doc-audit-report.md" >> "$GITHUB_STEP_SUMMARY" + else + echo "No report file found." + echo "No report file found." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Prepare Slack payload + if: always() && hashFiles('doc-audit-report.md') != '' + run: | + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + MAX_LENGTH=3800 + REPORT=$(cat doc-audit-report.md) + if [ ${#REPORT} -gt $MAX_LENGTH ]; then + REPORT="${REPORT:0:$MAX_LENGTH}..."$'\n\n'"Full report: $RUN_URL" + else + REPORT="$REPORT"$'\n\n'"View run: $RUN_URL" + fi + PAYLOAD=$(jq -n \ + --arg channel "${{ secrets.SLACK_CHANNEL_PACKMIND_SKILL_ID }}" \ + --arg text "$REPORT" \ + '{channel: $channel, text: $text}') + EOF_DELIMITER="SLACKEOF_$(openssl rand -hex 8)" + echo "SLACK_PAYLOAD<<${EOF_DELIMITER}" >> "$GITHUB_ENV" + echo "$PAYLOAD" >> "$GITHUB_ENV" + echo "${EOF_DELIMITER}" >> "$GITHUB_ENV" + + - name: Send report to Slack + if: always() && hashFiles('doc-audit-report.md') != '' + uses: slackapi/slack-github-action@v2 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} + payload: ${{ env.SLACK_PAYLOAD }} + + - name: Upload reports artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: doc-audit-report + path: doc-audit-report.md + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/weekly-feature-flags-audit.yml b/.github/workflows/weekly-feature-flags-audit.yml new file mode 100644 index 000000000..eba884cee --- /dev/null +++ b/.github/workflows/weekly-feature-flags-audit.yml @@ -0,0 +1,92 @@ +name: Weekly Feature Flags Audit + +on: + schedule: + - cron: '0 6 * * 1' # Every Monday at 08:00 CET (06:00 UTC) + workflow_dispatch: + inputs: + model: + description: 'Claude model to use' + required: false + default: 'claude-opus-4-6' + type: string + +permissions: + contents: read + id-token: write + +jobs: + feature-flags-audit: + runs-on: ${{ vars.ACTION_RUNNER_TAG || 'self-hosted' }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Run Feature Flags Audit + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: "/feature-flags-audit\n\nWrite the report to `feature-flags-audit-report.md` at the project root." + settings: | + { + "permissions": { + "allow": [ + "Agent", + "Read", + "Write", + "Glob", + "Grep" + ] + } + } + claude_args: "--model ${{ inputs.model || 'claude-opus-4-6' }} --max-turns 50" + + - name: Display report + if: always() + run: | + if [ -f "feature-flags-audit-report.md" ]; then + echo "=== Feature Flags Audit Report ===" + cat "feature-flags-audit-report.md" + echo "## Feature Flags Audit Report" >> "$GITHUB_STEP_SUMMARY" + cat "feature-flags-audit-report.md" >> "$GITHUB_STEP_SUMMARY" + else + echo "No report file found." + echo "No report file found." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Prepare Slack payload + if: always() && hashFiles('feature-flags-audit-report.md') != '' + run: | + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + MAX_LENGTH=3800 + REPORT=$(cat feature-flags-audit-report.md) + if [ ${#REPORT} -gt $MAX_LENGTH ]; then + REPORT="${REPORT:0:$MAX_LENGTH}..."$'\n\n'"Full report: $RUN_URL" + else + REPORT="$REPORT"$'\n\n'"View run: $RUN_URL" + fi + PAYLOAD=$(jq -n \ + --arg channel "${{ secrets.SLACK_CHANNEL_PACKMIND_SKILL_ID }}" \ + --arg text "$REPORT" \ + '{channel: $channel, text: $text}') + EOF_DELIMITER="SLACKEOF_$(openssl rand -hex 8)" + echo "SLACK_PAYLOAD<<${EOF_DELIMITER}" >> "$GITHUB_ENV" + echo "$PAYLOAD" >> "$GITHUB_ENV" + echo "${EOF_DELIMITER}" >> "$GITHUB_ENV" + + - name: Send report to Slack + if: always() && hashFiles('feature-flags-audit-report.md') != '' + uses: slackapi/slack-github-action@v2 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} + payload: ${{ env.SLACK_PAYLOAD }} + + - name: Upload report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: feature-flags-audit-report + path: feature-flags-audit-report.md + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/weekly-packmind-skill.yml b/.github/workflows/weekly-packmind-skill.yml new file mode 100644 index 000000000..82bc797b1 --- /dev/null +++ b/.github/workflows/weekly-packmind-skill.yml @@ -0,0 +1,103 @@ +name: Weekly Packmind Skill + +on: + schedule: + - cron: '0 6 * * 1' # Every Monday at 08:00 CET (06:00 UTC) + workflow_dispatch: + inputs: + model: + description: 'Claude model to use' + required: false + default: 'claude-opus-4-6' + type: string + +permissions: + contents: read + id-token: write + +jobs: + playbook-cli-audit: + runs-on: ${{ vars.ACTION_RUNNER_TAG || 'self-hosted' }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Install Packmind CLI + run: npm i -g @packmind/cli + + - name: Run Playbook CLI Audit + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: "/playbook-cli-audit" + settings: | + { + "permissions": { + "allow": [ + "Bash(echo *)", + "Bash(git remote *)", + "Bash(mkdir *)", + "Bash(packmind-cli *)", + "Agent", + "Read", + "Write", + "Glob", + "Grep" + ] + } + } + claude_args: "--model ${{ inputs.model || 'claude-opus-4-6' }} --max-turns 50" + + - name: Display report + if: always() + run: | + if [ -f "playbook-cli-audit-report.md" ]; then + echo "=== Playbook CLI Audit Report ===" + cat "playbook-cli-audit-report.md" + echo "## Playbook CLI Audit Report" >> "$GITHUB_STEP_SUMMARY" + cat "playbook-cli-audit-report.md" >> "$GITHUB_STEP_SUMMARY" + else + echo "No report file found." + echo "No report file found." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Prepare Slack payload + if: always() && hashFiles('playbook-cli-audit-report.md') != '' + run: | + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + MAX_LENGTH=3800 + REPORT=$(cat playbook-cli-audit-report.md) + if [ ${#REPORT} -gt $MAX_LENGTH ]; then + REPORT="${REPORT:0:$MAX_LENGTH}..."$'\n\n'"Full report: $RUN_URL" + else + REPORT="$REPORT"$'\n\n'"View run: $RUN_URL" + fi + PAYLOAD=$(jq -n \ + --arg channel "${{ secrets.SLACK_CHANNEL_PACKMIND_SKILL_ID }}" \ + --arg text "$REPORT" \ + '{channel: $channel, text: $text}') + EOF_DELIMITER="SLACKEOF_$(openssl rand -hex 8)" + echo "SLACK_PAYLOAD<<${EOF_DELIMITER}" >> "$GITHUB_ENV" + echo "$PAYLOAD" >> "$GITHUB_ENV" + echo "${EOF_DELIMITER}" >> "$GITHUB_ENV" + + - name: Send report to Slack + if: always() && hashFiles('playbook-cli-audit-report.md') != '' + uses: slackapi/slack-github-action@v2 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} + payload: ${{ env.SLACK_PAYLOAD }} + + - name: Upload report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: playbook-cli-audit-report + path: playbook-cli-audit-report.md + if-no-files-found: warn + retention-days: 30 diff --git a/.gitlab/duo/chat-rules.md b/.gitlab/duo/chat-rules.md index 4677c9180..c4bbb5c42 100644 --- a/.gitlab/duo/chat-rules.md +++ b/.gitlab/duo/chat-rules.md @@ -9,9 +9,18 @@ All rules and guidelines defined in these standards are mandatory and must be fo Failure to follow these standards may lead to inconsistencies, errors, or rework. Treat them as the source of truth for how code should be written, structured, and maintained. +# Standard: Amplitude analytics usage + +* We use Amplitude to get insights about users' behavior when using our product with the different UI (CLI / MCP / web app). : +* Event name ends with the verb (e.g 'standard_created', 'user_signed_up') +* Property name should be in lower camel case +* Tracked event name should be snake cased + +Full standard is available here for further request: [Amplitude analytics usage](../../.packmind/standards/amplitude-analytics-usage.md) + # Standard: Backend Tests Redaction -Enforce Jest backend test conventions in Packmind **/*.spec.ts (verb-first names, behavioral assertions, nested `describe('when...')`, one `expect`, `afterEach` cleanup with `datasource.destroy()` and `jest.clearAllMocks()`, `toEqual` for arrays, and `stubLogger()` for typed `PackmindLogger` stubs) to improve readability, consistency, and debuggability while preventing inter-test pollution. : +This standard establishes best practices for writing backend tests using Jest in the Packmind monorepo. It focuses on clarity, maintainability, and consistency across test suites by emphasizing behavi... : * Avoid asserting on stubbed logger output like specific messages or call counts; instead verify observable behavior or return values * Avoid testing that a method is a function; instead invoke the method and assert its observable behavior * Avoid testing that registry components are defined; instead test the actual behavior and functionality of the registry methods like registration, retrieval, and error handling @@ -29,7 +38,7 @@ Full standard is available here for further request: [Backend Tests Redaction](. # Standard: Changelog -Maintain CHANGELOG.MD using Keep a Changelog format with a top [Unreleased] section linked to HEAD, ISO 8601 dates (YYYY-MM-DD), and per-release comparison links like [X.Y.Z]: https://github.com/PackmindHub/packmind/compare/release/<previous>...release/X.Y.Z to ensure accurate, consistent release documentation and version links. : +Maintain a consistent and well-structured CHANGELOG.MD file following the Keep a Changelog format to ensure all releases are properly documented with accurate version links and dates. This standard ap... : * Ensure all released versions have their corresponding comparison links defined at the bottom of the CHANGELOG.MD file in the format [X.Y.Z]: https://github.com/PackmindHub/packmind/compare/release/<previous>...release/X.Y.Z * Format all release dates using the ISO 8601 date format YYYY-MM-DD (e.g., 2025-11-21) to ensure consistent and internationally recognized date representation * Maintain an [Unreleased] section at the top of the changelog with its corresponding link at the bottom pointing to HEAD to track ongoing changes between releases @@ -38,15 +47,22 @@ Full standard is available here for further request: [Changelog](../../.packmind # Standard: Compliance - Logging Personal Information -Enforce masking of personal information in TypeScript logs, using a standard first-6-characters-plus-* format for emails and similar patterns for other identifiers, to protect user privacy, comply with data protection regulations, and reduce security risks when handling user-related log entries. : +This standard ensures personal information is not exposed in application logs across all environments (development, staging, and production). Logs are often forwarded to external processors such as Da... : * Never log personal information in clear text across all log levels. Always mask sensitive data such as emails, phone numbers, IP addresses, and other personally identifiable information before logging. * Use the standard masking format of first 6 characters followed by "*" for logging user emails. This ensures consistency across the codebase and makes it easier to audit logs for compliance. Full standard is available here for further request: [Compliance - Logging Personal Information](../../.packmind/standards/compliance-logging-personal-information.md) +# Standard: Packmind Proprietary + +. : +* Never import something from '@packmind/editions', this is for OSS only + +Full standard is available here for further request: [Packmind Proprietary](../../.packmind/standards/packmind-proprietary.md) + # Standard: Typescript good practices -Enforce TypeScript error and DTO conventions by prohibiting Object.setPrototypeOf in custom errors and requiring intersection types (DomainType & { extraField: T }) for presentation DTO enrichment to improve reliability and catch domain-field drift at compile time. : +Generic practices that can be applied for all TS code in our app : * Do not use `Object.setPrototypeOf` when defining errors. * When defining a presentation DTO that enriches a domain type, use an intersection type (`DomainType & { extraField: T }`) instead of manually re-declaring the domain type's fields, so that structural drift is caught at compile time. diff --git a/.gitlab/duo/skills/agent-skill-frontmatter-audit/SKILL.md b/.gitlab/duo/skills/agent-skill-frontmatter-audit/SKILL.md new file mode 100644 index 000000000..d1ad40de0 --- /dev/null +++ b/.gitlab/duo/skills/agent-skill-frontmatter-audit/SKILL.md @@ -0,0 +1,203 @@ +--- +name: 'agent-skill-frontmatter-audit' +description: 'Audit per-agent SKILL.md frontmatter support in Packmind against the current Agent Skills baseline specification and the latest official docs for each AI coding agent.' +--- + +# Agent Skill Frontmatter Audit + +Packmind renders skills (one of the three artefact types along with standards and commands) as a `SKILL.md` file with YAML frontmatter deployed into each AI coding agent's expected folder (`.claude/skills/`, `.cursor/skills/`, `.github/skills/`, `.agents/skills/`, `.opencode/skills/`, etc.). + +There is a shared **Agent Skills baseline specification** that all supporting agents commit to (the "core" fields every agent understands), and then each agent may publish its own documentation extending that baseline with agent-specific "additional properties". Both layers evolve over time: the baseline spec ships new versions, and vendors add, rename, or deprecate extensions. + +This skill audits Packmind's rendering against both layers and produces a dated report at the project root so drift can be tracked release over release. + +## Context + +The audit is a **four-way comparison** per agent: + +1. **Baseline spec** — the current Agent Skills specification at <https://agentskills.io/specification.md>. Defines the set of fields every compliant agent is expected to accept (typically `name`, `description`, and similar core keys). +2. **Upstream agent spec** — the agent's own public documentation page. May extend the baseline with agent-specific properties, or may be baseline-only. +3. **Packmind declared support** — the per-agent constants in `packages/types/src/skills/skillAdditionalProperties.ts`. +4. **Packmind actual rendering** — what each agent's deployer in `packages/coding-agent/src/infra/repositories/{agent}/{Agent}Deployer.ts` actually writes into the YAML frontmatter. + +The four pairs can disagree silently: + +- **Baseline vs. upstream** — a vendor page that omits a field the baseline requires is a vendor bug to note, not a Packmind action item. +- **Upstream vs. constants** — Packmind is behind (or ahead of) the vendor on agent-specific fields. +- **Constants vs. deployer** — internal drift; the "supported" promise in the codebase is a lie. +- **A dead doc URL** — the audit can no longer be trusted automatically for that agent. + +None of these produce runtime errors. A deprecated field is silently ignored by the agent; a missing field is a silent feature loss for users. The only way to catch either is an audit against the upstream spec — which is what this skill automates. + +### Core principle: baseline-only is fine + +Some agents choose to support **only** the baseline spec — no additional properties on top. That is a legitimate design choice and **must not be reported as a warning, gap, or drift**. Concretely: if an agent has no `_ADDITIONAL_FIELDS` constant in Packmind, no `filterAdditionalProperties` call in its deployer, and no agent-specific properties documented upstream, that agent is "baseline-only" and the report should mark it in sync with a short note like *"no additional properties — baseline spec only"*. + +Only flag a missing constant / missing filter **when the agent's own upstream documentation lists properties beyond the baseline**. Otherwise the absence is correct. + +## Sources in scope + +The baseline spec is fetched once. Each of the five agents is fetched individually. Keep this list verbatim — adding, removing, or re-pointing an entry is a skill update, not an ad-hoc decision. + +| Source | URL | Codebase locations | +| --- | --- | --- | +| **Baseline spec** | `https://agentskills.io/specification.md` | *(no single file — the baseline names map to core frontmatter emitted by every deployer)* | +| Claude Code | `https://code.claude.com/docs/en/skills` | `CLAUDE_CODE_ADDITIONAL_FIELDS` + `packages/coding-agent/src/infra/repositories/claude/ClaudeDeployer.ts` | +| GitHub Copilot | `https://code.visualstudio.com/docs/copilot/customization/agent-skills` | `COPILOT_ADDITIONAL_FIELDS` + `packages/coding-agent/src/infra/repositories/copilot/CopilotDeployer.ts` | +| Cursor | `https://cursor.com/docs/skills#frontmatter-fields` | `CURSOR_ADDITIONAL_FIELDS` + `packages/coding-agent/src/infra/repositories/cursor/CursorDeployer.ts` | +| OpenAI Codex | `https://developers.openai.com/codex/skills` | *(constant optional — declare one only if upstream lists additional properties)* + `packages/coding-agent/src/infra/repositories/codex/CodexDeployer.ts` | +| OpenCode | `https://opencode.ai/docs/skills/` | *(constant optional — declare one only if upstream lists additional properties)* + `packages/coding-agent/src/infra/repositories/opencode/OpenCodeDeployer.ts` | + +The canonical constants file is `packages/types/src/skills/skillAdditionalProperties.ts`. + +## Workflow + +Run the steps in order. Parallelise fetches and codebase reads whenever one step doesn't depend on another — the audit is network-heavy and the user is waiting for a report. + +### Step 1 — Fetch the baseline spec and all upstream docs + +Issue `WebFetch` calls in parallel: one for the baseline spec, plus one per agent URL. + +**For the baseline spec**, extract: +- The version identifier of the spec (if stated on the page), so the report can cite which baseline was used. +- The complete list of baseline frontmatter keys with whether each is required or optional, and a one-line description quoted from the spec. + +**For each agent doc**, extract: +- The complete list of YAML frontmatter keys the agent documents on `SKILL.md`. Separate baseline fields (those already in the baseline spec) from agent-specific additions. +- Any explicit deprecation markers (words like "deprecated", "removed", "no longer supported", "legacy", or strikethrough styling the page renders). +- A short one-line description of each agent-specific key, quoted from the vendor wording rather than invented. +- Whether the agent's docs explicitly declare "this agent supports only the baseline" or equivalent. If so, note it — it's the cleanest signal for the baseline-only classification. + +Classify the fetch result of every URL as one of: +- ✅ **Reachable and relevant** — the page loads and documents skills/frontmatter. +- ⚠️ **Redirected** — the URL resolves but lands on a different, still-relevant page. Record both the requested and resolved URLs. +- ❌ **Dead** — 404, 403, empty body, or the landing page no longer mentions skills/frontmatter. Record the exact failure signal. + +Do **not** fall back to training-data knowledge for a dead URL. If the baseline spec itself is dead, note that the entire audit cannot separate baseline from agent-specific fields and mark every per-agent classification as `(uncertain — baseline source unreachable)`. If an agent doc is dead, only that agent's findings are marked uncertain. + +### Step 2 — Read Packmind's declared support + +Read `packages/types/src/skills/skillAdditionalProperties.ts`. Extract: + +1. Each per-agent constant that exists (today: `CLAUDE_CODE_ADDITIONAL_FIELDS`, `COPILOT_ADDITIONAL_FIELDS`, `CURSOR_ADDITIONAL_FIELDS`). For each, capture the YAML key ↔ camelCase storage key mapping. Claude Code declares its fields as a `Record<string, string>` (YAML→camel); Cursor and Copilot declare a flat `string[]` of camelCase keys. Do not assume the shape is uniform. +2. `CLAUDE_CODE_ADDITIONAL_FIELDS_ORDER` — the canonical rendering order for Claude. A key that's in `CLAUDE_CODE_ADDITIONAL_FIELDS` but not in the order array (or vice-versa) is a **low-severity internal drift** worth mentioning. +3. Any new constant that may have been introduced for Codex or OpenCode since this skill was last updated. + +**The absence of a constant for Codex or OpenCode is not automatically a finding.** Whether it's a gap depends entirely on Step 1: if the agent's upstream doc lists no properties beyond the baseline, the absence is correct. If it does list extensions, then the absence is a missing-support finding. + +### Step 3 — Read the deployer for each agent + +For each agent, open its deployer file listed in the scope table. In the file, locate: + +- The method that produces the frontmatter block (typically `generateSkillMdContent` or similar). Read which keys it writes and in what order. +- Any call to `filterAdditionalProperties(...)`: which constant does it pass in? +- Any call to `sortAdditionalPropertiesKeys(...)`: missing here means non-deterministic YAML output for that agent's frontmatter. + +For Codex and OpenCode, the current pattern is to delegate multi-file skill deployment to `SingleFileDeployer`/base-class logic (see `packages/coding-agent/src/infra/repositories/utils/` and `defaultSkillsDeployer/`). If the deployer has no skill-specific frontmatter method at all, record `(inherits base deployer; baseline fields only)` as the deployer behaviour. + +Treat a missing `filterAdditionalProperties` call as a concern **only when** the agent's upstream doc lists additional properties — in that case, the deployer either bypasses the declared list (leaking everything) or emits nothing agent-specific (leaking nothing, failing to expose supported fields). Figure out which from the emitted-keys list and classify accordingly. If the agent is baseline-only upstream, no filter is needed and no finding should be raised. + +### Step 4 — Classify every property + +For each agent, build a single combined set of keys = (baseline keys) ∪ (agent-upstream keys) ∪ (constant keys) ∪ (deployer-emitted keys). For each key, assign exactly one classification: + +- ✅ **In sync (baseline)** — a baseline key, supported by the agent upstream and emitted by the deployer. Expected state; keep it concise in the table. +- ✅ **In sync (agent-specific)** — an agent-specific key that upstream lists, the constant declares, and the deployer emits. +- ➕ **Missing in Packmind** — upstream lists it (baseline or agent-specific) but Packmind doesn't support it. This is the primary "we're behind the spec" bucket. +- ➖ **Deprecated / removed upstream** — the constant or deployer supports it, but upstream no longer lists it (or explicitly deprecates it). Packmind is still shipping a field the agent will ignore. +- 🔀 **Internal drift** — constant and deployer disagree with each other, independent of upstream. Examples: the constant declares a key the deployer never emits; the deployer emits a key the constant never declared; the `_ORDER` array and the `_FIELDS` map disagree. +- ❓ **Uncertain** — the relevant upstream source is unreachable (from Step 1). Do not guess. + +**Do not classify baseline-only as a gap.** If the agent's upstream lists no extensions and Packmind declares none, there is nothing to report for additional properties beyond a single one-line note per agent saying *"baseline spec only — no additional properties"*. + +Prefer concrete, linkable evidence for every classification. "Missing" must cite the exact vendor section that lists the property; "Deprecated" must cite the exact file:line that still supports it. + +### Step 5 — Structural gaps (conditional) + +Beyond per-property drift, surface structural gaps at the agent level **only when the evidence warrants it**. Do not raise any of the following for an agent that is legitimately baseline-only: + +- Raise *"no constant declared"* **only if** upstream lists additional properties for that agent and Packmind has no way to emit them. An agent with no upstream extensions and no Packmind constant is in sync, not gapped. +- Raise *"deployer bypasses `filterAdditionalProperties`"* **only if** upstream lists additional properties and the deployer writes the additional-props blob without filtering (so unrelated fields could leak through). +- Raise *"deployer renders non-deterministic frontmatter order"* **only if** the deployer actually emits additional properties without a sort call. If there are no additional properties to sort, the absence of `sortAdditionalPropertiesKeys` is fine. +- Raise *"upstream URL redirected or 404d"* whenever Step 1 classified the URL as ⚠️ or ❌ — this is always a finding independent of property state. + +If all structural checks pass for every agent, write *"None — all agents structurally aligned with their upstream commitments."* The empty section is still worth keeping so the reader knows the check happened. + +### Step 6 — Write the report + +Produce a single markdown file at the project root, named using the current date from the system (today is e.g. `2026-04-21` → `skills_properties_2026_04_21.md`). The filename uses underscores between year, month, and day to match the user's convention. Inside the file, the heading date can use dashes (`2026-04-21`) for readability. + +Use this exact structure: + +```markdown +# Agent Skill Frontmatter Audit — YYYY-MM-DD + +## Summary + +- Baseline spec version: <version or "unversioned — fetched YYYY-MM-DD"> +- Agents audited: N +- Upstream URL health: X/(N+1) reachable and relevant (including baseline) +- Baseline-only agents: … +- Properties in sync: … +- Missing in Packmind: … +- Deprecated / removed upstream: … +- Internal drift findings: … +- Structural gaps: … + +## Upstream URL health + +| Source | Requested URL | Status | Resolved URL / Notes | +| --- | --- | --- | --- | +| Baseline spec | … | ✅/⚠️/❌ | … | +| <Agent> | … | ✅/⚠️/❌ | … | + +## Baseline spec + +- **Source:** <url> (<status>) +- **Version / fetched at:** … +- **Core fields:** list the baseline keys with required/optional and a one-line description each. + +## Per-agent findings + +### <Agent name> +- **Upstream docs:** <url> (<status>) +- **Baseline-only?** Yes / No +- **Declared constant:** `<CONSTANT_NAME>` — `packages/types/src/skills/skillAdditionalProperties.ts:<line>` *(or "none declared — expected for baseline-only agents")* +- **Deployer:** `packages/coding-agent/src/infra/repositories/<agent>/<Agent>Deployer.ts:<line>` — filters via `<constant>` / inherits base / no filter + +If the agent is baseline-only and in sync, a single sentence is enough: *"Baseline spec only — no additional properties. Packmind emits the baseline fields via the <deployer/base-class> path; no further action needed."* + +Otherwise, include the property table: + +| Property | Classification | Baseline | Upstream | Constant | Deployer | Notes | +| --- | --- | --- | --- | --- | --- | --- | +| … | ✅/➕/➖/🔀/❓ | ✓/✗ | ✓/✗ | ✓/✗ | ✓/✗ | short rationale / vendor quote | + +**Action items** (omit section if empty) +- ➕ Add `<prop>` to `<CONSTANT>` and emit in `<Deployer>` — upstream lists it under "<section>". +- ➖ Remove `<prop>` from `<CONSTANT>` (and deployer if applicable) — upstream no longer documents it. +- 🔀 `<prop>` is declared in `<CONSTANT>` but never emitted by `<Deployer>`; pick one source of truth. + +*(Repeat for each of the five agents, even if findings are empty — an empty section still documents that the agent was checked.)* + +## Structural gaps + +(List only the conditional gaps from Step 5. If none, write "None — all agents structurally aligned with their upstream commitments.") + +## Recommendations + +Prioritised, plain-language next steps. For each, name the file(s) to touch and why. When a property is newly deprecated upstream, prefer a deprecation path (leave supported for one release, emit a warning, remove next release) rather than immediate removal — users may have existing skill artefacts that rely on the field. +``` + +Write only the report — no preamble, no "here is the report" sentence. When the skill is invoked the user wants the report itself at the project root, plus a one-line confirmation of where the file was written. + +## Notes and edge cases + +- **Baseline-only is a valid state.** The skill must never treat the absence of additional properties as a problem. Codex and OpenCode today are typical examples — if their vendor docs don't list extensions beyond the baseline, they're in sync. +- **Dates use underscores in the filename.** `skills_properties_2026_04_21.md`, not `-2026-04-21-` and not without a date. If the user asks for the audit twice the same day, overwrite the file — the day's audit is the authoritative snapshot. +- **Do not invent URLs.** If the user supplies a replacement URL for a dead one, use it and note the substitution in the URL-health table. Otherwise, mark the source uncertain. +- **Do not scan deployed example SKILL.md files** (e.g. what's under `.claude/skills/` in the user's projects) to infer support. The contract is the deployer code and the constants, not the artefacts they happen to have produced so far — an unused supported field would be invisible in that sample. +- **Claude Code is the reference implementation.** Its constants map (`CLAUDE_CODE_ADDITIONAL_FIELDS`) and order array (`CLAUDE_CODE_ADDITIONAL_FIELDS_ORDER`) are the richest and most mature. When in doubt about how a new Codex/OpenCode constant should be shaped (in the event they ever need one), mirror the Claude Code pattern rather than inventing a third shape. +- **Core / baseline fields are tracked by the baseline source, not per-agent.** Put baseline-level observations in the "Baseline spec" section, not duplicated under every agent. Per-agent sections only need to confirm baseline support and then focus on additional properties. +- **Changes in scope (new agents, removed agents, URL updates) are skill edits.** Don't silently drop an agent because its URL 404s this week — that's a finding, not a scope change. +- **Run sequencing.** Fetches (Step 1, including the baseline) and codebase reads (Steps 2–3) don't depend on each other; kick them off in parallel. Steps 4–6 depend on both, so they run after. \ No newline at end of file diff --git a/.gitlab/duo/skills/amplitude-listener-events-adoption/SKILL.md b/.gitlab/duo/skills/amplitude-listener-events-adoption/SKILL.md new file mode 100644 index 000000000..950bbcf1e --- /dev/null +++ b/.gitlab/duo/skills/amplitude-listener-events-adoption/SKILL.md @@ -0,0 +1,173 @@ +--- +name: 'amplitude-listener-events-adoption' +description: 'Report organization adoption in Amplitude for the most recently added domain events in the AmplitudeEventListener. Walks git history on `packages/amplitude/src/application/AmplitudeEventListener.ts` to find the last 15 still-subscribed events, queries the Packmind V3 [CLOUD] Amplitude project via the MCP, and produces a markdown report grouped by domain prefix (text before the first underscore). Triggers when the user asks to audit recent amplitude listener events, check which orgs use new domain events, measure adoption of recently added events, or requests an amplitude report on space/playbook/standard/etc. events.' +--- + +# Amplitude Listener Events Adoption + +Produce a markdown adoption report for the **last 15 domain events** subscribed in the Amplitude listener, grouped by domain, listing organizations that triggered each event and how many times. + +## Prerequisites + +- The Amplitude MCP must be connected (tools prefixed `mcp__amplitude__`). If not, prompt the user to run `/mcp`. +- Target project: **Packmind V3 [CLOUD]**, `appId = 100032310`. Never query the DEV or legacy projects unless the user explicitly asks. + +## Inputs + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `N` | 15 | Number of most-recently-added events to report on | +| `range` | `Last 30 Days` | Amplitude date range | +| Excluded orgs | see below | Defaults applied automatically; user may extend the list | +| Excluded events | see below | Defaults applied automatically; user may extend the list | + +## Organizations exclusion + +Always exclude these internal/test organizations from the report (apply **after** querying Amplitude, before aggregation): + +- `AI Configuration orga` +- `test_package_standards` +- `packmind` +- `Joan's orga` +- `demo-orga` +- `VIncent test` +- `cedric.teyton's organization` +- `cedric-test` +- `test-skills` + +Match on the exact organization `grp:name` value returned by Amplitude. Extend this list if the user provides additional orgs to skip. + +## Events exclusion + +Always exclude these Amplitude event names from the report even if they are among the last N subscribed in the listener: + +- `user_signed_in` +- `new_organization_created` +- `user_signed_up` + +Rationale: these three events fire once per user/organization creation and produce extreme long tails (hundreds of orgs with a single event each), drowning out the actual feature-adoption signal the report is built for. + +Drop excluded events from the event-name list **before** building the Amplitude query (do not count them toward `N` — select the next most recent event to fill the quota). Extend this list if the user provides additional events to skip. + +## Workflow + +### Step 1 — Identify the last N still-subscribed events + +Source file: `packages/amplitude/src/application/AmplitudeEventListener.ts`. + +1. Read the current file and collect the set of event **class names** that appear in `this.subscribe(<Event>, ...)` inside `registerHandlers()`. This is the "still-subscribed" set. +2. For each class, also extract the corresponding **Amplitude event name** (the string literal passed as the 2nd argument to `this.emitAmplitudeEvent(event, '<name>', ...)` in the handler). This is what Amplitude stores. +3. Walk the file's git history newest→oldest: + + ```bash + git log --all --follow --format="%h|%ai|%s" -- packages/amplitude/src/application/AmplitudeEventListener.ts + ``` + +4. For each commit, extract added `this.subscribe(` lines: + + ```bash + git show --format="" <commit> -- packages/amplitude/src/application/AmplitudeEventListener.ts \ + | grep -E "^\+ *this\.subscribe\(" + ``` + +5. Walk commits in order; for every added class name that is also in the still-subscribed set, record `(amplitude_event_name, class_name, commit_date, commit_sha)` once (deduplicate by class — only keep the **most recent** addition). Stop once `N` unique events are collected. +6. Discard entries for events that were later removed or renamed out of the current file (e.g. `SpaceRenamedEvent` → filtered because no longer in the current `registerHandlers`). + +Multi-line `this.subscribe(\n EventName,\n ...)` must also be captured — widen the grep context (`grep -A2`) when a line ends just after `this.subscribe(`. + +**Efficient commit walk**: rather than running `git show` once per commit interactively, batch the first ~40 commits returned by `git log` and loop in a single `bash` call: + +```bash +for c in $(git log --all --follow --format="%h" -- packages/amplitude/src/application/AmplitudeEventListener.ts | head -40); do + echo "=== $c ===" + git show --format="" $c -- packages/amplitude/src/application/AmplitudeEventListener.ts \ + | grep -E "^\+ *this\.subscribe\(" -A1 +done +``` + +This surfaces all added `this.subscribe(` lines with commit boundaries in one tool call, which keeps token cost low and preserves the newest→oldest ordering needed for dedupe. + +### Step 2 — Query Amplitude + +Use `mcp__amplitude__query_dataset` with: + +- `projectId`: `"100032310"` +- `definition`: + ```json + { + "type": "eventsSegmentation", + "app": "100032310", + "params": { + "range": "Last 30 Days", + "events": [ /* one entry per amplitude event name */ + { "event_type": "<name>", "filters": [], "group_by": [] } + ], + "metric": "totals", + "countGroup": "organization", + "groupBy": [{ "type": "group", "group_type": "organization", "value": "grp:name" }], + "interval": 30, + "segments": [{ "conditions": [] }] + } + } + ``` +- `groupByLimit`: `200` +- `timeSeriesLimit`: `0` + +**Critical gotcha**: the CSV returned by `query_dataset` *omits* the organization name column when `groupBy` is set on a group property. Re-query using `mcp__amplitude__query_chart` with the `chartEditId` returned by `query_dataset`; that response includes the real org-name column. Always do this second call to get usable data. + +If the response payload is large, rely on `timeSeriesLimit: 0` (totals-only) to keep it compact. + +### Step 3 — Aggregate and format + +Parse the CSV rows from `query_chart`. The response contains header rows (chart name, list of events), an empty separator row, and then a data section starting with a `["Event", "name", "<interval-label-1>", "<interval-label-2>", ...]` row followed by data rows. + +- **Row shape with `timeSeriesLimit: 0`**: each data row is `[event_name, org_name, <interval_1_count>, <interval_2_count>, ...]`. There is **no trailing grand-total column** in that mode — sum the interval columns yourself to get the per-org total for the window. A 30-day range typically yields two interval columns (one per calendar month touched). +- **Ignore blank or placeholder org rows**: rows with `org_name == ""` or `org_name == "(none)"` represent events fired outside any organization context and must be skipped. + +Aggregation is mechanical and repetitive across 10+ events — use a small inline Python script via `Bash` with `python3 -` or a heredoc to load the parsed rows, apply the exclusion list, sum intervals, sort, and slice top-10 + `+K`. Doing this by hand for long tails (e.g. `standard_sample_selected` with 60+ orgs) is error-prone and wastes tokens. + +- Group events by **domain prefix** = substring before the first underscore in the Amplitude event name (`space_created` → `space`, `playbook_artefact_moved` → `playbook`, `change_proposal_submitted` → `change`, etc.). +- Inside each domain, list events in the order they appear in the source file (stable for the reader). +- Apply the **org exclusion list** before totaling. If no orgs remain for an event, treat it as zero-orgs. +- For each event: + 1. Start the line with the total number of **distinct organizations** that triggered it (after exclusions): `- <event> (N orgs): ...`. + 2. Sort organizations by their event count, descending; list at most the **top 10** with their counts: `orgA (42), orgB (18), ...`. + 3. If more than 10 orgs remain, append a trailing `+K` token where `K` = remaining org count. Example: `- space_created (23 orgs): orgA (9), orgB (7), ... orgJ (2) +13`. Never enumerate those remaining orgs by name; the `+K` is a trend signal only. +- **Collapse zero-adoption events into a single summary line** at the end of each domain (or at the end of the full report if the user prefers global grouping): `- No adoption (0 orgs): event_a, event_b, event_c`. + +### Step 4 — Emit the report + +Write the report to `amplitude-listener-events-adoption.md` at the project root (overwrite if it already exists). Also display the highlights inline in the chat reply so the user can scan them without opening the file. + +Skeleton: + +```markdown +# Amplitude adoption — last N listener events (Packmind V3 [CLOUD], <range>) + +Excluded orgs: <list>. Excluded events: <list>. + +## Domain: space +- space_created (23 orgs): orgA (9), orgB (7), orgC (6), orgD (5), orgE (4), orgF (3), orgG (3), orgH (2), orgI (2), orgJ (2) +13 +- space_members_added (4 orgs): orgA (18), orgB (12), orgC (5), orgD (1) +- ... +- No adoption (0 orgs): space_pinned, space_unpinned + +## Domain: playbook +- playbook_artefact_moved (2 orgs): orgA (40), orgC (4) + +... + +Source chart: [Open in Amplitude](<chartEditUrl>) +``` + +Keep the report concise — no per-month breakdown, no narrative paragraphs. The user wants a scan-friendly list. + +## Edge cases + +- **Fewer than N events in history**: report whatever is available and note the shortfall in one line. +- **Event renamed in listener but kept in Amplitude** (e.g. renaming `user_signup` → `user_signed_up`): trust the current file; query only the current Amplitude event name. +- **Event subscribed in listener but never emitted in prod**: it will appear as a zero-adoption event — that is the correct signal. +- **Duplicate commits touching the same subscribe line** (merges, reverts): keep the most recent non-reverted addition. +- **Group property is empty or placeholder** (`""` or `"(none)"` org name row): ignore that row — the event fired outside any org context. +- **Event also tracked with an identify call** (e.g. `OrganizationCreatedEvent` triggers `identifyOrganizationGroup` in addition to the tracked event): this does not affect the adoption query but explains why some org names appear populated only after the event fires once. +- **All remaining orgs are in the exclusion list**: emit the event under the `No adoption (0 orgs): …` summary line — do not emit a misleading `(N orgs)` header when the real external count is zero. \ No newline at end of file diff --git a/.gitlab/duo/skills/datadog-analysis/SKILL.md b/.gitlab/duo/skills/datadog-analysis/SKILL.md new file mode 100644 index 000000000..54d6d9d0b --- /dev/null +++ b/.gitlab/duo/skills/datadog-analysis/SKILL.md @@ -0,0 +1,102 @@ +--- +name: 'datadog-analysis' +description: 'Analyze Datadog error logs for Packmind production services (api-proprietary, mcp-proprietary, frontend-proprietary), group them into patterns, root-cause against the codebase, and produce a structured bug report. Triggers on Datadog, production logs, prod errors, service health, or periodic error reviews.' +--- + +# Datadog Analysis + +Analyze production error logs from Packmind Datadog services, group them into patterns, cross-reference stack traces with the codebase, and produce a structured markdown report with root causes and Datadog search patterns. + +## Prerequisites + +- The Datadog MCP server must be connected. If not connected, prompt the user to run `/mcp` first. +- Read `references/datadog_mcp.md` before making any MCP tool calls for guidance on tool usage, gotchas, and known pitfalls. + +## Services + +The analysis covers three production services. Each maps to a Datadog service name, a codebase location, and a Dockerfile: + +| Datadog service | App | Codebase | Dockerfile | Runtime | +|----------------|-----|----------|------------|---------| +| `api-proprietary` | API | `apps/api/` + all `packages/` | `dockerfile/Dockerfile.api` | Node.js (NestJS, TypeORM, Redis/ioredis, BullMQ) | +| `mcp-proprietary` | MCP Server | `apps/mcp-server/` + all `packages/` | `dockerfile/Dockerfile.mcp` | Node.js (tree-sitter, SSE) | +| `frontend-proprietary` | Frontend | `apps/frontend/` | `dockerfile/Dockerfile.frontend` | Nginx (static SPA serving) | + +Root cause analysis should trace errors back to source files in the monorepo. For Nginx (frontend), also check the Nginx configs in `dockerfile/nginx.*.conf` and the entrypoint `dockerfile/nginx-entrypoint.sh`. + +## Parameters + +| Parameter | Default | Description | +|-----------|---------|-------------| +| Days to analyze | 7 | Number of past days to look at. Override by user request (e.g., "last 3 days") | + +## Exclusions + +The following log patterns should be **discarded** and not included in the report. Skip them during pattern discovery and do not count them as errors: + +- `(node:1) [DEP0060] DeprecationWarning: The util._extend API is deprecated. Please use Object.assign() instead.` -- Known Node.js deprecation from a transitive dependency. Noise, not actionable. Filter with `-DEP0060`. + +- Nginx stale asset 404s (`open() "/usr/share/nginx/html/assets/..." failed (2: No such file or directory)`) -- Expected SPA behavior after deployments. Browsers with a cached `index.html` request old hashed JS chunks that no longer exist. Not a bug. Filter with `-"No such file or directory" -"/assets/"` on `frontend-proprietary`. + +When filtering in Phase 1, exclude these patterns from the analysis by appending the exclusion terms to Datadog queries, or remove them during report consolidation. + +## Workflow + +### Phase 1: Discover Error Patterns (all services in parallel) + +For **each** of the three services, launch two parallel MCP calls (6 calls total, all in parallel). If rate-limited by the MCP server, fall back to batching 2 calls per service sequentially. + +Every Datadog MCP call requires a `telemetry` object with an `intent` string describing the call's purpose (e.g., `{"intent": "Discover error patterns for api-proprietary over last 7 days"}`). Keep intents concise and avoid including PII or secrets. + +1. **Pattern discovery** -- Use `mcp__datadog-mcp__search_datadog_logs` with: + - `query`: `service:{service_name} status:(error OR critical OR emergency)` + - `from`: `now-{N}d` (where N = number of days, default 7) + - `use_log_patterns`: `true` + - `max_tokens`: `10000` + +2. **Error message counts** -- Use `mcp__datadog-mcp__analyze_datadog_logs` with: + - `filter`: `service:{service_name} status:(error OR critical OR emergency)` + - `sql_query`: `SELECT message, count(*) as cnt FROM logs GROUP BY message ORDER BY cnt DESC LIMIT 50` + - `from`: `now-{N}d` + - `max_tokens`: `10000` + +From these results, identify the distinct error groups per service. If a service has zero errors in the period, mention "No issues found" in the report and skip Phases 2-3 for that service. + +### Phase 2: Deep Dive Each Error Group + +For each distinct error group identified in Phase 1: + +1. **Fetch raw logs** -- Use `search_datadog_logs` with a targeted query to get full stack traces and context. Use `extra_fields: ["*"]` for tag metadata when useful. + +2. **Get daily distribution** -- Use `analyze_datadog_logs` with: + - `sql_query`: `SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt FROM logs WHERE message LIKE '%<pattern>%' GROUP BY DATE_TRUNC('day', timestamp) ORDER BY DATE_TRUNC('day', timestamp)` + +3. **Count occurrences** -- Use `analyze_datadog_logs` to get total unique occurrences grouped by message. + +Parallelize independent MCP calls wherever possible to save time. + +#### Frontend-Specific Notes + +For `frontend-proprietary`, Nginx writes all `error_log` output (including `[notice]`) to stderr. Datadog classifies stderr as `status:error`. Filter out Nginx lifecycle noise: +- Ignore patterns containing `[notice]` (worker start/stop, SIGQUIT, SIGCHLD, SIGIO) -- these are normal Nginx operations misclassified as errors +- Focus on `[error]` (404s for missing files) and `[alert]` (permission issues, config errors) + +### Phase 3: Codebase Root Cause Analysis + +For each application-level error (not infra/external): + +Before grepping, consult `references/known_patterns.md` — if the error matches a catalogued pattern, jump straight to its entry point and skip to step 2. + +1. **Grep for the error class or message** in the codebase using the Grep tool (e.g., `SpaceMembershipRequiredError`, `Recipe.*not found`) +2. **Read the source files** where the error is thrown +3. **Trace the call chain**: error class -> service/use case -> controller/adapter +4. **Identify the root cause**: missing error handling, wrong HTTP status, race condition, missing validation, Dockerfile misconfiguration, etc. + +For frontend Nginx errors, check: +- `dockerfile/Dockerfile.frontend` for permission/ownership issues +- `dockerfile/nginx.k8s.conf`, `dockerfile/nginx.k8s.no-ingress.conf`, `dockerfile/nginx.compose.conf` for config issues +- `dockerfile/nginx-entrypoint.sh` for entrypoint issues + +### Phase 4: Generate Report + +Read `references/report-template.md` before writing the report for the output path, scaffold, severity ordering, occurrence labels, and final summary table. \ No newline at end of file diff --git a/.gitlab/duo/skills/datadog-analysis/references/datadog_mcp.md b/.gitlab/duo/skills/datadog-analysis/references/datadog_mcp.md new file mode 100644 index 000000000..c69e11aae --- /dev/null +++ b/.gitlab/duo/skills/datadog-analysis/references/datadog_mcp.md @@ -0,0 +1,158 @@ +# Datadog MCP Server Reference + +Reference guide for using the Datadog MCP server tools. Read this before making any Datadog MCP calls. + +## Available Tools + +### `mcp__datadog-mcp__search_datadog_logs` + +**Purpose**: Search and retrieve raw log entries or log patterns. Best for viewing raw logs, discovering patterns, and discovering custom attributes. + +**Key parameters**: +- `query` (required): Datadog search query using `key:value` syntax +- `from` / `to`: Time range. Use relative format `now-Xh`, `now-Xd`. Default: last 1 hour +- `use_log_patterns` (boolean): When `true`, clusters similar log messages into patterns with counts instead of returning raw logs. Essential for the initial discovery phase. +- `extra_fields` (array): Include extra attributes/tags. Use `["*"]` to get all metadata (tags, pod names, container info, etc.) +- `max_tokens`: Cap response size. Default 5000, use 10000 for pattern discovery +- `start_at`: Pagination offset for large result sets + +**When to use**: +- Initial pattern discovery (`use_log_patterns: true`) +- Fetching full stack traces for specific errors +- Getting tag metadata with `extra_fields: ["*"]` + +### `mcp__datadog-mcp__analyze_datadog_logs` + +**Purpose**: Run SQL queries against logs. Best for aggregations, counts, group-bys, and time-series analysis. + +**Key parameters**: +- `sql_query` (required): SQL against a virtual `logs` table +- `filter`: Datadog search query to pre-filter logs before SQL +- `from` / `to`: Time range (same format as search) +- `extra_columns`: Extend the `logs` table with typed columns from log attributes +- `max_tokens`: Default 10000 + +**Default columns**: `timestamp`, `host`, `service`, `env`, `version`, `status`, `message` + +**When to use**: +- Counting errors by message, day, or host +- Time-series aggregations (`DATE_TRUNC`) +- Top-N queries + +## Query Syntax + +### Datadog Search Query (for `query` / `filter` parameters) + +``` +service:api-proprietary status:error # Basic tag filtering +service:api-proprietary status:(error OR critical) # OR within a tag +"exact phrase match" # Quoted exact match +service:api-proprietary "Recipe" "not found" # Multiple terms (AND) +@http.status_code:[400 TO 499] # Range on raw attribute +-version:beta # Exclusion +service:web* # Wildcard +``` + +### DDSQL (for `sql_query` parameter) + +DDSQL is a PostgreSQL subset with restrictions: +- Every non-aggregated `SELECT` column must appear in `GROUP BY` +- `SELECT` aliases **cannot** be reused in `WHERE`, `GROUP BY`, or `HAVING` -- repeat the full expression instead +- Only declared table columns may be referenced +- Use `->` or `json_extract_path_text` for JSON access (cast as needed) +- Column names with special characters like `@` must be quoted: `SELECT "@foo" FROM logs` + +**Unsupported**: `ANY()`, `->>`, `information_schema`, `current_timestamp` + +**Common patterns**: + +```sql +-- Count by message +SELECT message, count(*) as cnt FROM logs GROUP BY message ORDER BY cnt DESC LIMIT 50 + +-- Daily distribution +SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt +FROM logs +GROUP BY DATE_TRUNC('day', timestamp) +ORDER BY DATE_TRUNC('day', timestamp) + +-- Filter with LIKE +SELECT message, count(*) as cnt +FROM logs +WHERE message LIKE '%SpaceMembershipRequiredError%' +GROUP BY message ORDER BY cnt DESC LIMIT 10 + +-- Hourly breakdown +SELECT DATE_TRUNC('hour', timestamp) as hour, count(*) as cnt +FROM logs +GROUP BY DATE_TRUNC('hour', timestamp) +ORDER BY DATE_TRUNC('hour', timestamp) +``` + +## Gotchas and Lessons Learned + +### 1. Pattern search vs raw search return different formats + +- `use_log_patterns: true` returns **TSV_DATA** with columns: `first_seen`, `last_seen`, `count`, `status`, `pattern` +- Raw search returns **TSV_DATA** or **YAML_DATA** with individual log entries +- Pattern search is much more useful for initial discovery -- always start with it + +### 2. Datadog search does NOT support regex -- use LIKE in SQL as fallback + +Queries like `"Recipe * not found"` with wildcards in quoted strings will return 0 results. Instead: +- Use multiple quoted terms: `"Recipe" "not found"` (matches logs containing both) +- Or use unquoted keywords: `Recipe not found` (matches as AND) +- Wildcards only work on tag values: `service:web*` + +When `search_datadog_logs` still returns 0 results for a complex pattern, fall back to `analyze_datadog_logs` with `WHERE message LIKE '%pattern%'`. The SQL LIKE operator works on the raw message text and is more flexible than the search query syntax. + +### 3. Multi-line stack traces are separate log entries + +In Datadog, each line of a stack trace is typically a separate log entry. A single exception with a 10-line stack trace produces 10 log entries. This means: +- Raw log counts are inflated (one error = many log lines) +- Pattern discovery helps group these related lines +- To find the actual error message, filter for the line containing the exception class name (e.g., `ExceptionsHandler`) + +### 4. ANSI escape codes appear in log messages + +Production NestJS logs contain ANSI color codes like `[31m`, `[39m`, `[38;5;3m`. These appear in raw log output. When searching, ignore these codes -- they won't affect keyword matching but make messages harder to read visually. + +### 5. The `extra_fields: ["*"]` option returns extensive tag metadata + +Using `["*"]` returns all Kubernetes/cloud metadata (pod name, node, container, cluster, etc.). This is useful for: +- Identifying which pods are affected +- Determining if errors are node-specific +- Cross-referencing with infrastructure incidents + +But it significantly increases response size -- only use when needed. + +### 6. Time range format + +- Relative: Must start with `now-` (e.g., `now-5d`, `now-24h`, `now-30m`) +- Absolute: ISO 8601 (e.g., `2026-04-10T00:00:00Z`) +- Default is `now-1h` which is too short for most analyses -- always set explicitly + +### 7. Token limits and pagination + +- Default `max_tokens` is 5000 for search and 10000 for analyze +- If results are truncated, the response includes `is_truncated: true` and a `truncation_message` with the next `start_at` offset +- For pattern discovery, 10000 tokens is usually sufficient +- For raw log fetching with `extra_fields`, reduce the number of results or increase max_tokens + +### 8. DDSQL GROUP BY alias trap + +This fails: +```sql +SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt +FROM logs GROUP BY day -- ERROR: 'day' alias not recognized +``` + +This works: +```sql +SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt +FROM logs GROUP BY DATE_TRUNC('day', timestamp) -- Repeat full expression +``` + +### 9. Storage tier for older logs + +For logs older than the default retention period, use `storage_tier: "flex_and_indexes"` to also search Flex storage. This is only needed for the analyze tool; the search tool supports both `flex_and_indexes` and `online_archives_and_indexes`. diff --git a/.gitlab/duo/skills/datadog-analysis/references/known_patterns.md b/.gitlab/duo/skills/datadog-analysis/references/known_patterns.md new file mode 100644 index 000000000..18d908a7d --- /dev/null +++ b/.gitlab/duo/skills/datadog-analysis/references/known_patterns.md @@ -0,0 +1,23 @@ +# Known Error Patterns + +Pre-catalogued recurring error patterns and their codebase entry points. Consult during Phase 3 when an error matches a known signature — jump straight to the listed entry point and skip ad-hoc grepping. + +## API (`api-proprietary`) + +| Pattern | Datadog query | Codebase entry point | +|---------|--------------|---------------------| +| Redis connection failure | `service:api-proprietary status:error ETIMEDOUT OR ECONNREFUSED` | `ioredis` client, infra-level | +| Space membership check | `service:api-proprietary status:error SpaceMembershipRequiredError` | `packages/node-utils/src/application/AbstractSpaceMemberUseCase.ts` | +| Recipe not found | `service:api-proprietary status:error "Recipe" "not found"` | `packages/recipes/src/application/services/RecipeService.ts` | +| Artefact not found | `service:api-proprietary status:error "Artefact" "not found"` | `packages/playbook-change-management/src/application/services/validateArtefactInSpace.ts` | +| Sign-in failure | `service:api-proprietary status:error "Failed to sign in"` | `apps/api/src/app/auth/auth.controller.ts` | +| Onboarding status failure | `service:api-proprietary status:error "onboarding status"` | `apps/api/src/app/auth/auth.service.ts` | +| Amplitude tracking failure | `service:api-proprietary status:error Amplitude` | Amplitude Node.js SDK (external) | +| PG concurrent query | `service:api-proprietary status:error "client.query() when the client is already executing"` | `pg` driver through TypeORM | + +## Frontend (`frontend-proprietary`) + +| Pattern | Datadog query | Codebase entry point | +|---------|--------------|---------------------| +| Nginx PID unlink permission denied | `service:frontend-proprietary "unlink" "nginx.pid"` | `dockerfile/Dockerfile.frontend:17` + `dockerfile/nginx.*.conf:3` | +| Nginx notice logs misclassified | `service:frontend-proprietary status:error "[notice]"` | Datadog log pipeline config (not code) | diff --git a/.gitlab/duo/skills/datadog-analysis/references/report-template.md b/.gitlab/duo/skills/datadog-analysis/references/report-template.md new file mode 100644 index 000000000..2e18b42f6 --- /dev/null +++ b/.gitlab/duo/skills/datadog-analysis/references/report-template.md @@ -0,0 +1,101 @@ +# Datadog Report Template + +Output path, scaffold, ordering, and labels for the Phase 4 report. + +## Output path + +Write the report to `datadog_{YYYY_MM_DD}.md` at the project root, where the date is today's date. + +## Report structure + +The report is organized with **one top-level section per application**, each containing its own issues: + +````markdown +# Datadog Error Report + +**Period**: {start_date} to {end_date} ({N} days) + +--- + +# API (`api-proprietary`) + +**Total error log lines**: ~{count} + +| Day | Error count | +|-----------|------------| +| {date} | {count} | + +## 1. {Issue Title} + +**Occurrences**: {frequency description} + +**Datadog search pattern**: +``` +service:api-proprietary status:error {specific pattern keywords} +``` + +**Description**: {What the error is and how it manifests} + +**Root cause**: {Analysis with source file paths and line numbers from the codebase.} + +**Source**: `{file_path}:{line_number}` + +--- + +## 2. {Next Issue} +... + +--- + +# MCP Server (`mcp-proprietary`) + +**Total error log lines**: ~{count} + +| Day | Error count | +|-----------|------------| +| {date} | {count} | + +## 1. {Issue Title} +... + +--- + +# Frontend (`frontend-proprietary`) + +**Total error log lines**: ~{count} (excluding Nginx [notice] noise) + +| Day | Error count | +|-----------|------------| +| {date} | {count} | + +## 1. {Issue Title} +... +```` + +## Ordering (within each section) + +Sort issues by severity: +1. Infrastructure errors (Redis, database connectivity, Nginx permission errors) +2. Application bugs (unhandled exceptions returning 500, missing static assets) +3. External service failures (Amplitude, third-party APIs) +4. Deprecation warnings (Node.js, library deprecations) +5. Expected user errors logged at wrong level (failed logins) + +## Occurrence labels + +Use these labels based on the pattern: +- **ONCE** -- single occurrence in the period +- **{N} TIMES** -- N occurrences total, no clear daily pattern +- **{N} DAYS THIS WEEK** -- recurring across N distinct days +- **ALL {N} DAYS** -- present every day in the period +- **{N} lines, {M} day(s)** -- for high-volume infra errors, specify raw log line count and day spread + +## Summary table + +After writing the report, print a summary table covering all services: + +| Service | # | Issue | Occurrences | Severity | +|---------|---|-------|-------------|----------| +| API | 1 | ... | ... | High/Medium/Low | +| MCP | 1 | ... | ... | High/Medium/Low | +| Frontend | 1 | ... | ... | High/Medium/Low | diff --git a/.gitlab/duo/skills/doc-audit/SKILL.md b/.gitlab/duo/skills/doc-audit/SKILL.md new file mode 100644 index 000000000..c5ffcc215 --- /dev/null +++ b/.gitlab/duo/skills/doc-audit/SKILL.md @@ -0,0 +1,140 @@ +--- +name: 'doc-audit' +description: 'Audit Packmind end-user documentation (apps/doc/) for broken links, outdated CLI references, non-existent concepts, misleading information, and missing coverage. Produces a structured markdown report at project root. Use when docs may have drifted from the codebase, before a release, or on a regular cadence.' +--- + +# Documentation Audit + +Detect outdated, broken, or misleading documentation by cross-referencing MDX pages against the actual codebase. Produces a structured `doc-audit-report.md` at the project root. + +**This skill only detects issues — it does not fix them.** + +## Phase 1: Build Ground Truth + +Before launching any sub-agents, build a concise ground truth summary by gathering these four data sources: + +1. **Navigation structure** — Read `apps/doc/docs.json` and extract all navigation groups with their page lists +2. **CLI commands** — List files in `apps/cli/src/infra/commands/` to get current command files +3. **Domain packages** — List directories in `packages/` to get current package names +4. **Doc MDX files** — Glob `apps/doc/**/*.mdx` to get all actual pages on disk + +Compile these into a **ground truth summary** string formatted as: + +``` +## Ground Truth + +### Navigation Groups (from docs.json) +- Getting Started: index, getting-started/gs-install-cloud, ... +- Concepts: concepts/standards-management, ... +[list all groups] + +### CLI Commands (from apps/cli/src/infra/commands/) +[list all *Command.ts and *Handler.ts files] + +### Domain Packages (from packages/) +[list all package directory names] + +### MDX Files on Disk (from apps/doc/**/*.mdx) +[list all .mdx file paths relative to apps/doc/] + +### Current Date +{today's date} +``` + +## Phase 2: Launch Parallel Sub-Agents + +Launch **5 Explore sub-agents** in parallel (`subagent_type: Explore`), one per section group. Each agent receives: +- The ground truth summary from Phase 1 +- The full contents of `references/section-audit-instructions.md` (read this file and include its contents in each prompt) +- Its assigned section and list of MDX files to audit + +### Agent Assignments + +| Agent | Sections | Pages to Audit | +|-------|----------|----------------| +| 1 | Getting Started + root pages | `index.mdx`, `home.mdx` + all `getting-started/*.mdx` | +| 2 | Concepts | All `concepts/*.mdx` + `tools/import-from-knowledge-base.mdx` | +| 3 | Tools & Integrations | `tools/cli.mdx` | +| 4 | Governance + Playbook Maintenance + Linter | All `governance/*.mdx` + `playbook-maintenance/*.mdx` + `linter/*.mdx` | +| 5 | Administration + Security | All `administration/*.mdx` + `security/*.mdx` | + +### Agent Prompt Template + +Each agent's prompt should follow this structure: + +``` +You are auditing the {section_name} section of the Packmind documentation. + +## Your Assigned Pages +{list of MDX file paths to read and audit} + +## Ground Truth +{ground truth summary from Phase 1} + +## Audit Instructions +{full contents of references/section-audit-instructions.md} + +Read each assigned MDX page completely and apply all detection categories. Return your findings in the exact format specified in the instructions. +``` + +### Sequential Fallback + +If the Agent tool is unavailable, perform the audit sequentially: read each section's pages one by one and apply the same checks from `references/section-audit-instructions.md` directly. + +## Phase 3: Consolidate Report + +After all sub-agents complete: + +1. **Collect** all findings from the 5 agents +2. **Deduplicate** — remove exact duplicates (same page, same line, same issue) +3. **Sort** by severity: ERROR first, then WARNING, then INFO +4. **Group** by category within each severity level +5. **Write** the report to `doc-audit-report.md` at the project root + +### Report Format + +```markdown +# Documentation Audit Report +Generated: {date} | Pages audited: {count} + +## Summary +| Severity | Count | +|----------|-------| +| ERROR | N | +| WARNING | N | +| INFO | N | + +## Errors + +### [A] Broken Internal Links +- **{page}** (line ~{N}): Link to `{target}` — no matching MDX file exists +[... more findings] + +### [B] Outdated CLI Commands +- **{page}** (line ~{N}): References `packmind-cli {cmd}` — command not found in CLI source +[... more findings] + +### [C] Non-Existent Concepts +- **{page}** (line ~{N}): References `{concept}` — not found in codebase +[... more findings] + +## Warnings + +### [D] Misleading Information +- **{page}** (line ~{N}): "{quoted text}" — {reason} +[... more findings] + +## Info + +### [E] Missing Documentation Coverage +- CLI command `{cmd}` has no documentation +- Package `{pkg}` has no documentation page +[... more findings] +``` + +**Omit any category section that has zero findings.** Only include sections with actual results. + +After writing the report, print a brief summary: +- Total issues found per severity +- Top 3 most problematic pages (by issue count) +- The report file path \ No newline at end of file diff --git a/.gitlab/duo/skills/doc-audit/references/section-audit-instructions.md b/.gitlab/duo/skills/doc-audit/references/section-audit-instructions.md new file mode 100644 index 000000000..61ee255f0 --- /dev/null +++ b/.gitlab/duo/skills/doc-audit/references/section-audit-instructions.md @@ -0,0 +1,122 @@ +# Section Audit Instructions + +You are auditing a section of the Packmind end-user documentation. Your job is to cross-reference claims in the MDX pages against the actual codebase and ground truth data to find **concrete, verifiable issues only**. + +## Golden Rule + +**Only flag issues where you can point to a specific codebase artifact that contradicts the documentation.** Do not flag stylistic issues, subjective concerns, or things that "might" be wrong. Every finding must be backed by evidence. + +## Detection Categories + +### Category A: Broken Internal Links (ERROR) + +**What to check:** Links in MDX files that reference other doc pages (e.g., `/concepts/standards-management`, `getting-started/gs-cli-setup`). + +**How to verify:** Check if the link target resolves to an actual MDX file in the ground truth MDX file list. Mintlify resolves links relative to the `apps/doc/` root — a link to `/concepts/foo` should map to `apps/doc/concepts/foo.mdx`. + +**Valid finding:** +- Page links to `/concepts/workflow-management` but no `apps/doc/concepts/workflow-management.mdx` exists + +**Not a finding (false positive):** +- Links to external URLs (https://...) — do not check these +- Links to anchors within the same page (#section) +- Links listed in the `redirects` section of `docs.json` — these are handled by Mintlify + +### Category B: Outdated CLI Commands (ERROR) + +**What to check:** References to `packmind-cli <command>` or CLI command names in the documentation. + +**How to verify:** Check if the referenced command exists in the CLI commands ground truth (file list from `apps/cli/src/infra/commands/`). Command files follow the pattern `*Command.ts` or `*Handler.ts`. + +**Valid finding:** +- Doc references `packmind-cli migrate` but no `MigrateCommand.ts` or `migrateHandler.ts` exists in CLI source + +**Not a finding (false positive):** +- CLI flags or options (e.g., `--verbose`) — only check command names +- Generic references to "the CLI" without a specific command name + +### Category C: Non-Existent Concepts (ERROR) + +**What to check:** References to specific Packmind features, domain packages, or integrations that should exist in the codebase. + +**How to verify:** Check against the ground truth package list and codebase. Look for references to packages (e.g., `@packmind/some-package`), specific integration names, or feature names that imply codebase support. + +**Valid finding:** +- Doc describes a "Bitbucket integration" but no bitbucket-related package or code exists +- Doc references `@packmind/analytics` but that package doesn't exist in `packages/` + +**Not a finding (false positive):** +- High-level conceptual descriptions (e.g., "Packmind helps teams share knowledge") +- References to third-party tools that Packmind integrates with via configuration (not code) +- UI features that exist in the frontend but not as standalone packages +- Do not infer feature availability per edition (OSS vs. Enterprise vs. Cloud) from package paths. Edition gating is controlled by TypeScript path aliases in `tsconfig.paths.oss.json` vs `tsconfig.paths.proprietary.json`, not by directory structure — see Category D for the full explanation + +### Category D: Misleading Information (WARNING) + +**What to check:** +- Dates in the past presented as future events (e.g., "coming in Q2 2024" when it's 2026) +- Direct contradictions between two doc pages about the same feature +- References to deprecated or removed features still presented as current + +**How to verify:** Compare dates against the current date (provided in your context). Compare claims across pages for consistency. + +**Valid finding:** +- "This feature will be available in Q3 2024" — that date has passed +- Page A says "standards support Markdown only" while page B says "standards support Markdown and YAML" + +**Not a finding (false positive):** +- Vague future references ("we plan to add...") +- Minor wording differences between pages about the same concept +- Claims about feature availability per edition (e.g., "Enterprise only", "OSS only", "available in the Cloud version") cannot be verified by inspecting package directories. Edition gating in this repo is done at build time via TypeScript path aliases — `tsconfig.paths.oss.json` redirects `@packmind/<feature>` imports to `packages/editions/src/index.ts` (a **stubs aggregator** for the OSS build), while `tsconfig.paths.proprietary.json` redirects the same imports to the real top-level package such as `packages/linter/src/index.ts`. Consequences: (a) the presence of `packages/editions/src/oss/<feature>/` only means an OSS-build stub exists — it is **not evidence** the feature works in OSS; (b) the existence of a top-level `packages/<feature>/` package does **not** mean the feature ships in OSS, because the OSS tsconfig points elsewhere. Do not flag edition-availability claims based on either of these paths — verify against the two `tsconfig.paths.*.json` files if a check is unavoidable, otherwise skip. + +### Category E: Missing Documentation Coverage (INFO) + +**What to check:** CLI commands or domain packages that exist in the codebase but have no corresponding documentation page or section. + +**How to verify:** Compare the ground truth CLI commands and packages against what the documentation covers. A command is "covered" if it's mentioned on any doc page (typically `tools/cli.mdx`). A package is "covered" if its functionality is described somewhere in the docs. + +**Valid finding:** +- CLI has `SyncCommand.ts` but no doc page mentions the `sync` command +- Package `packages/linter` exists but no linter documentation page exists + +**Not a finding (false positive):** +- Internal/infrastructure packages (e.g., `node-utils`, `test-helpers`) — these are not user-facing +- Commands that are clearly internal or development-only + +## Expected Output Format + +Return your findings as a structured list, one per line, in this exact format: + +``` +[SEVERITY] [CATEGORY] **{page-path}** (line ~{N}): {description} +``` + +Where: +- `SEVERITY` is one of: `ERROR`, `WARNING`, `INFO` +- `CATEGORY` is one of: `A`, `B`, `C`, `D`, `E` +- `page-path` is the relative path from `apps/doc/` (e.g., `tools/cli.mdx`) +- `line ~{N}` is the approximate line number (use `~` since MDX rendering may shift lines) +- `description` is a concise explanation of the issue with evidence + +**Example output:** + +``` +[ERROR] [A] **getting-started/gs-onboarding.mdx** (line ~45): Link to `/concepts/workflow-management` — no matching MDX file exists in apps/doc/concepts/ +[ERROR] [B] **tools/cli.mdx** (line ~120): References `packmind-cli migrate` — no MigrateCommand.ts found in CLI source +[WARNING] [D] **concepts/standards-management.mdx** (line ~30): "Coming in Q2 2024" — date has passed (current date: 2026-03-12) +[INFO] [E] **N/A**: CLI command `SyncCommand.ts` has no documentation coverage +``` + +If you find **no issues** in your assigned section, return: + +``` +NO_ISSUES_FOUND +``` + +## Important Reminders + +- Read each MDX file **completely** — don't skip content +- Be thorough but precise — false positives waste time +- Include approximate line numbers to help locate issues +- For Category E, you only need to check commands/packages relevant to your assigned section +- Do NOT suggest improvements or rewrites — this is detection only diff --git a/.gitlab/duo/skills/feature-spec/SKILL.md b/.gitlab/duo/skills/feature-spec/SKILL.md new file mode 100644 index 000000000..7ee6ce9eb --- /dev/null +++ b/.gitlab/duo/skills/feature-spec/SKILL.md @@ -0,0 +1,135 @@ +--- +name: 'feature-spec' +description: 'Generate a Packmind feature specification from a GitHub issue, file, URL, or direct description. Breaks Phase 3 into 4 fine-grained approval gates (domain data structures, ports/use cases/routes, frontend components, implementation plan) to catch problems early. Routes work between the OSS sibling (../packmind) and the proprietary repo (cwd). Outputs spec artifacts under tmp/feature-specs/{slug}/ for use with /feature-sprint.' +--- + +# Feature Spec Skill + +Generate a comprehensive feature specification grounded in Packmind's hexagonal architecture, frontend gateway/PM-UI conventions, and the OSS/proprietary fork boundary. + +## When to Use + +Use this skill when the user wants to: +- Plan a new feature, fix, or refactor that spans Packmind's stack +- Convert a GitHub issue, design doc, or rough idea into a structured spec +- Get fine-grained validation of data structures, routes, and components before committing to a plan + +**Do NOT use** for trivial one-line fixes or pure dependency bumps. + +## Packmind Repo Layout (essential context) + +- Closed-source fork (`packmind-proprietary`): the current working directory when this skill runs. +- Open-source repo (`packmind`): the sibling directory at `../packmind` (cloned next to the proprietary repo). Resolve to an absolute path at runtime with `realpath ../packmind`. +- **Most feature work happens on the OSS repo and auto-merges into the proprietary fork.** The proprietary fork only contains paid/closed features (e.g. `packages/editions`, certain `packages/deployments` extensions). After an OSS merge, you typically pull on the proprietary side. +- Architecture: hexagonal (`packages/{domain}/{domain,application,infra}`), NestJS API at `apps/api`, React+`@packmind/ui` frontend at `apps/frontend`, TypeORM, Jest+@swc/jest. + +## Input Sources + +| Source | Examples | Detection | +|--------|----------|-----------| +| GitHub | `#123`, `owner/repo#123`, full issue URL | Pattern `#\d+` or `github.com/.../issues/` | +| File | `file:spec.md`, `path/to/spec.md` | Prefix `file:` or readable file | +| URL | `https://...` | HTTP(S) URL (non-GitHub) | +| Prompt | Any other text | Default | + +## Workflow Phases + +Execute phases **sequentially**. Phases 1 and 4 run seamlessly (no approval gate). Phases 2 and 3 require user approval before proceeding. + +| Phase | File | Purpose | Gate | +|-------|------|---------|------| +| 1 | `phase-1-resolve-source.md` | Detect source type, fetch content, decide OSS-vs-proprietary routing, create state | seamless | +| 2 | `phase-2-discovery-and-spec.md` | Research Packmind patterns (reference domain, hex layers, frontend conventions), draft acceptance criteria, generate functional spec | approval | +| 3 | `phase-3-implementation.md` | Technical approach + implementation plan (4 subtasks below) | 4 approvals | +| 3A | ↳ Subtask 3A | Validate domain data structures (entities, value objects, events, migrations) | approval | +| 3B | ↳ Subtask 3B | Validate ports, contracts, use cases, services, adapters, NestJS routes | approval | +| 3C | ↳ Subtask 3C | Validate frontend components, gateways, query hooks, PM-UI usage | approval | +| 3D | ↳ Subtask 3D | Generate full implementation plan (task breakdown, grouping, coverage) | approval | +| 4 | `phase-4-finalize.md` | Generate `context.md`, mark spec complete, report (no commit — artifacts live in `tmp/`) | seamless | + +## State Management + +State is persisted to markdown files under a git-ignored `tmp/` directory for cross-session continuity. Phase progress is inferred from which output files exist — no separate state file needed. + +``` +tmp/feature-specs/{slug}/ +├── {slug}.md # Main spec (status: DRAFT → COMPLETE in frontmatter) +├── discovery.md # Phase 2 output (YAML frontmatter + markdown) +├── functional-spec.md # Phase 2 output (informed by discovery) +├── implementation-plan.md # Phase 3 output (includes parallel groups section) +└── context.md # Phase 4 output (YAML frontmatter + markdown) +``` + +### Phase Detection (Resume Logic) + +| Files Present | Completed Phase | Resume At | +|---------------|----------------|-----------| +| `{slug}.md` only | Phase 1 | Phase 2 | +| + `discovery.md`, `functional-spec.md` | Phase 2 | Phase 3 | +| + `implementation-plan.md` | Phase 3 | Phase 4 | +| + `context.md` (status: COMPLETE) | Phase 4 | Done | + +## Invocation + +### Auto-Detection (Resuming) + +Before starting, check for an existing task directory at `tmp/feature-specs/{slug}/`: + +1. Check which output files exist to determine current phase (see Phase Detection table above) +2. Read the main spec file's YAML frontmatter for `status: DRAFT|COMPLETE` +3. If resuming, display: + ``` + **Resuming Feature Spec**: {slug} + **Current Phase**: {detected_phase} + **Status**: {status from frontmatter} + + Continue from Phase {detected_phase}? (Y/n) + ``` +4. If user confirms, proceed to the appropriate phase file +5. If no task directory found, start a new feature spec + +### Starting New + +To start a new feature spec: Read `phase-1-resolve-source.md` and follow its instructions. + +<feature-spec-rules> + <rule>Execute phases SEQUENTIALLY — never skip phases</rule> + <rule>Phases 1 and 4 proceed automatically — no approval gate</rule> + <rule>Phases 2 and 3 require USER APPROVAL via AskUserQuestion before proceeding</rule> + <rule>Save all artifacts under tmp/feature-specs/{slug}/ — never under tracked folders</rule> + <rule>Use Task tool with Explore subagent for codebase research; never read 50 files yourself</rule> + <rule>Agent NEVER implements code in this skill — output ONLY specification documents</rule> + <rule>Main spec file has status DRAFT until Phase 4 completes</rule> + <rule>Always record target_repo (oss | proprietary | both) decided in Phase 1; consumed by /feature-sprint</rule> + <rule>Never invent OSS/proprietary boundaries — if unsure, ask the user</rule> +</feature-spec-rules> + +## Output + +Final deliverable: `tmp/feature-specs/{slug}/{slug}.md` + sibling artifacts. + +Contains: +- Functional specification with acceptance criteria +- Technical approach grounded in Packmind reference patterns +- Implementation plan with phased tasks (each task has reference `file:line` patterns) +- Parallel Groups section (for parallel execution via `/feature-sprint`) +- `target_repo`: oss | proprietary | both + +## Parallel Execution Support + +Phase 3 includes a grouping step that analyzes task dependencies and creates parallel execution groups: + +**How grouping works:** +1. After generating the implementation plan, a Plan subagent analyzes tasks +2. Tasks are grouped by file dependencies (tasks sharing files go together) +3. Groups are non-overlapping (no file belongs to multiple groups) +4. Results are stored in `implementation-plan.md` (Parallel Groups section) and `context.md` (YAML frontmatter) + +**When grouping is skipped:** +- Tasks have no clear file dependencies +- Single-file or trivial implementations +- User opts out during Phase 3 review + +## Handoff + +When the spec is complete (Phase 4), the next step is `/feature-sprint {slug}` — the companion skill that executes the implementation plan. \ No newline at end of file diff --git a/.gitlab/duo/skills/feature-spec/phase-1-resolve-source.md b/.gitlab/duo/skills/feature-spec/phase-1-resolve-source.md new file mode 100644 index 000000000..6f21a1240 --- /dev/null +++ b/.gitlab/duo/skills/feature-spec/phase-1-resolve-source.md @@ -0,0 +1,171 @@ +# Phase 1: Resolve Source & Decide Fork Routing + +Detect the source type, fetch content, decide where the work will land (OSS fork or proprietary), and create the initial state directory. + +## Input + +User provides `task_input` — one of: +- GitHub issue: `#123`, `owner/repo#123`, or `https://github.com/.../issues/123` +- File path: `file:spec.md`, `path/to/spec.md` +- URL: `https://example.com/...` +- Direct prompt: any other text + +## Steps + +### 1.1 Detect Source Type + +| Pattern | Source Type | +|---------|-------------| +| `#\d+` or `owner/repo#\d+` or `github.com/.../issues/` | github | +| `file:` prefix or readable file path with `.md`/`.txt`/`.json` extension | file | +| `https?://` (non-GitHub) | url | +| Anything else | prompt | + +### 1.2 Generate Task Slug + +Create `task_slug` from the user-provided title or first meaningful phrase: +- Convert to lowercase +- Replace spaces and special characters with hyphens +- Strip leading/trailing hyphens, collapse consecutive hyphens +- Keep it under 60 characters + +### 1.3 Create State Directory + +```bash +mkdir -p tmp/feature-specs/{task_slug}/ +``` + +`tmp/` is git-ignored — these files never get committed. + +### 1.4 Create Initial Spec File (DRAFT) + +Write `tmp/feature-specs/{task_slug}/{task_slug}.md`: + +```markdown +--- +status: DRAFT +task_slug: {task_slug} +created: {ISO-8601 timestamp} +finished: null +--- + +# {task_name} + +_Specification in progress. See state directory for current phase._ +``` + +### 1.5 Fetch Content + +Use the appropriate tool based on source type: + +**GitHub:** +- If `gh` is available, use `gh issue view {issue_ref} --json title,body,labels,url,author` +- Otherwise use WebFetch on the issue URL +- Extract: `task_id` (e.g. `GH-{number}`), `task_name`, `task_description`, `task_url`, `labels` + +**File:** +- Read the file with the Read tool +- `task_name` = first heading or filename; `task_description` = full body + +**URL:** +- Use WebFetch +- Extract: `task_name`, `task_description`, `task_url` + +**Prompt:** +- `task_description` = the raw input +- `task_name` = first line (truncated to ~80 chars) +- `task_id` = `PROMPT-{timestamp}` + +### 1.6 Decide Fork Routing (Required) + +Packmind ships from two repos: +- **OSS** (`../packmind`): public packages and features. **Most code changes go here.** After merge, the proprietary fork pulls from upstream automatically. +- **Proprietary** (this repo): closed-source extensions. Examples: `packages/editions` (forbidden import elsewhere), some `packages/deployments` proprietary flows, billing, enterprise auth. + +Before continuing, classify the work using these heuristics: + +| Signal | Likely target | +|--------|---------------| +| Touches `packages/editions/`, billing, license, enterprise auth, paid deploy flows | proprietary | +| Touches `apps/api`, `apps/frontend`, `apps/cli`, `apps/mcp-server`, or any package present in BOTH repos | oss | +| Mentions enterprise-only features or paid customers in the issue | likely proprietary | +| Public bug, generic feature, OSS-visible UI | oss | +| Unsure | ask the user | + +Use `AskUserQuestion` to confirm: + +```json +{ + "questions": [{ + "question": "Where should this feature be implemented?", + "header": "Fork routing", + "multiSelect": false, + "options": [ + {"label": "OSS (../packmind) (Recommended)", "description": "Default for most work. Auto-merges into proprietary; you pull afterward."}, + {"label": "Proprietary (this repo)", "description": "Closed-source only: editions, paid deployments, enterprise features."}, + {"label": "Both (split)", "description": "Some tasks land on OSS, others on proprietary. Spec will split them in Phase 3."} + ] + }] +} +``` + +Record the answer as `target_repo` in `oss | proprietary | both`. + +**If `target_repo == "oss"`**: verify the OSS repo exists at `../packmind`. If missing, warn the user and ask whether to proceed targeting proprietary instead. + +### 1.7 Check for Existing Documentation + +Look for prior work on the same topic: +- Glob: `tmp/feature-specs/*{task_slug_keyword}*/` +- Glob: `.claude/specs/*{task_slug_keyword}*.md` (existing design specs) +- Glob: `.claude/plans/*{task_slug_keyword}*.md` (existing plans) + +If matches are found, read them and surface them in the summary so the user can decide whether to extend or supersede. + +### 1.8 Update Spec Frontmatter + +Update `tmp/feature-specs/{task_slug}/{task_slug}.md` with full metadata: + +```markdown +--- +status: DRAFT +task_slug: {task_slug} +task_id: {task_id} +source_type: {github | file | url | prompt} +source_url: {url or null} +task_name: {task_name} +target_repo: {oss | proprietary | both} +created: {ISO-8601 timestamp} +finished: null +--- + +# {task_name} + +{task_description} + +## Related Prior Work + +{Optional: list of existing specs/plans found in step 1.7, or "None found."} +``` + +This frontmatter is the source of truth for task metadata. Phase progress is inferred from which output files exist in the directory — no separate state file needed. + +## Task Summary + +Display before proceeding: + +``` +**Phase 1 complete.** Source resolved. + +**Task:** {task_name} +**ID:** {task_id} +**Source:** {source_type} +**Target repo:** {target_repo} +**Prior work:** {none | list of matched files} + +{task_description (first 500 chars)} +``` + +## Next Phase + +Proceed automatically to `phase-2-discovery-and-spec.md`. diff --git a/.gitlab/duo/skills/feature-spec/phase-2-discovery-and-spec.md b/.gitlab/duo/skills/feature-spec/phase-2-discovery-and-spec.md new file mode 100644 index 000000000..ec9a0dd34 --- /dev/null +++ b/.gitlab/duo/skills/feature-spec/phase-2-discovery-and-spec.md @@ -0,0 +1,443 @@ +# Phase 2: Discovery & Functional Spec + +Discover how Packmind actually does this kind of thing, then draft requirements grounded in that reality. + +## Purpose + +Two goals in one phase: +1. Find **the closest existing implementation** in Packmind — which domain package, which use case, which frontend component — and trace its full hex stack +2. Draft **functional requirements** informed by that discovery, so acceptance criteria match what Packmind's architecture can actually support + +## Prerequisites + +- Phase 1 completed (source resolved, `target_repo` decided) +- `tmp/feature-specs/{task_slug}/{task_slug}.md` exists with `status: DRAFT` + +## Steps + +### 2.1 Load Source Context + +Read `tmp/feature-specs/{task_slug}/{task_slug}.md`: +- YAML frontmatter: `task_name`, `source_type`, `target_repo` +- Body: `task_description` + +Extract key terms from `task_description`: +- **Domain entities** mentioned (e.g., "standard", "recipe", "space", "deployment", "user") +- **Actions/verbs** (e.g., "create", "import", "deploy", "rename", "preview") +- **Cross-cutting concerns** (e.g., "event", "migration", "permission", "rate limit") + +### 2.2 Pick the Discovery Root + +Based on `target_repo`: +- `oss` → run discovery in `../packmind` (most patterns live there) +- `proprietary` → run discovery in `.` (this repo) +- `both` → discovery in `../packmind` first; if a needed reference doesn't exist there, also search `.` + +Subagents should be told explicitly which root(s) to search. + +### 2.3 Launch Research Subagents (PARALLEL) + +Launch **TWO** subagents in parallel using the Task tool. Both should run concurrently in the same message. + +#### Subagent A: Packmind Pattern Finder + +Use `Agent` tool with `subagent_type="Explore"`, `description="Find Packmind reference patterns"`. Run "very thorough" search: + +``` +Find Packmind reference patterns for: {task_name} + +Search root: {discovery_root absolute path} +Key terms: {extracted_terms} +Domain entities mentioned: {entities} +Task description: {task_description} + +## What to find + +Trace ACTUAL code paths for the closest similar feature in Packmind. Don't just list files — explain HOW the feature flows through the hex layers. + +### 1. Reference domain package +Find the closest existing `packages/{domain}/` whose problem matches. Example domains: accounts, deployments, spaces, standards, recipes, skills, coding-agent, playbook-change-management. + +For the chosen reference domain, document: +- **Domain layer** (`packages/{domain}/src/domain/`): + - Entities used: file path + key fields + - Repository interfaces: `IFooRepository` file path + - Events: any domain events emitted + - Errors: domain error classes +- **Application layer** (`packages/{domain}/src/application/`): + - Closest use case: `useCases/{name}/{name}.usecase.ts` with the abstract base it extends (AbstractMemberUseCase, AbstractSpaceMemberUseCase, AbstractAdminUseCase) + - Services involved + - Adapter: `adapter/{Domain}Adapter.ts` and which ports it exposes +- **Infrastructure layer** (`packages/{domain}/src/infra/`): + - Repository impl + TypeORM Schema + - Migration pattern reference (under `packages/migrations/`) +- **Types package** (`packages/types/src/{domain}/`): + - Port interface + - Contract for the use case (request/response shapes) +- **Hexa facade** (`{Domain}Hexa.ts`) and `index.ts` exports + +Capture every `file:line` reference. + +### 2. Reference API route +Search `apps/api/src/` for the closest NestJS controller method. Document: +- Controller file + method name + decorators +- DTO file (if any) under `apps/api/src/` or types package +- How it resolves the use case (`HexaRegistry.getAdapter<IFooPort>(...)`) + +### 3. Reference frontend feature +Search `apps/frontend/` for the closest existing component that does this kind of thing. Document: +- **Gateway** (`apps/frontend/src/.../gateways/{Name}Gateway.ts`) — how it wraps the API call +- **Query/mutation hook** (TanStack Query or local equivalent) +- **Page / container component** that orchestrates queries + UI +- **UI components** built from `@packmind/ui` (PM-prefixed wrappers around Chakra) +- Form/validation library, if any (Zod, react-hook-form, etc.) + +### 4. Test patterns +For each layer touched, find the matching test file pattern (e.g. `*.usecase.spec.ts`, `*.repository.spec.ts`, `*.tsx` with React Testing Library). Note where integration tests live (`packages/integration-tests/`). + +### 5. Conventions +Cross-reference 2-3 similar features and note: +- Naming conventions for use cases, ports, contracts +- Authorization base class typically used +- Error-mapping approach (domain errors → HTTP) +- How events propagate cross-domain +- Frontend data-flow shape (gateway → query hook → component) + +### 6. Constraints +Note any: +- Files under `packages/editions/` (forbidden to import from elsewhere when on proprietary) +- Feature flags involved +- Existing migrations that overlap + +## Output Format + +Return JSON: +{ + "discovery_root": "absolute path searched", + "reference_domain": { + "package": "packages/standards", + "domain": [{"file": "", "line": 0, "role": ""}], + "application": [{"file": "", "line": 0, "role": ""}], + "infra": [{"file": "", "line": 0, "role": ""}], + "types": [{"file": "", "line": 0, "role": ""}], + "hexa_facade": {"file": "", "line": 0} + }, + "reference_route": {"controller": "", "method": "", "file": "", "line": 0, "uses_port": ""}, + "reference_frontend": { + "gateway": {"file": "", "line": 0}, + "queries": [{"file": "", "line": 0}], + "page": {"file": "", "line": 0}, + "ui_components": [{"name": "", "file": "", "line": 0}] + }, + "test_patterns": {"usecase_spec": "", "repository_spec": "", "frontend_component_spec": "", "integration_tests_dir": ""}, + "conventions": { + "naming": "", + "authorization": "", + "error_mapping": "", + "events": "", + "frontend_data_flow": "" + }, + "constraints": ["..."] +} +``` + +#### Subagent B: Documentation Researcher + +Use `Agent` tool with `subagent_type="Explore"`, `description="Search Packmind docs for context"`, "medium" thoroughness: + +``` +Search documentation for context on: {task_name} + +Search roots: {discovery_root}, and also the proprietary repo at {proprietary_root} if different + +## Where to look + +1. `apps/doc/` — public Mintlify end-user docs +2. `AGENTS.md`, `CLAUDE.md`, root `README.md` +3. `.claude/specs/` — prior design specs in this repo +4. `.claude/plans/` — prior implementation plans in this repo +5. `.claude/rules/packmind/*.md` — coding standards (frontmatter `paths` shows which paths each governs) +6. `tmp/feature-specs/` — earlier feature specs (any directory matching the task keywords) +7. Package-level `*.md` files (e.g., `packages/standards/README.md`) + +## Find + +- Related specifications, RFCs, or design docs +- Coding standards whose `paths` glob will match the files this feature touches +- Planned work that might overlap or conflict +- Historical context or decisions + +## Output Format + +Return JSON: +{ + "related_docs": [{"path": "", "relevance": "high|medium|low", "summary": ""}], + "applicable_standards": [{"path": ".claude/rules/packmind/foo.md", "paths_glob": "", "summary": ""}], + "planned_work": [{"path": "", "description": "", "potential_overlap": ""}], + "historical_context": [""] +} +``` + +Wait for BOTH subagents to complete before proceeding. + +### 2.4 Synthesize & Present Discovery + +Combine subagent results and present to the user (informational — no approval gate here): + +``` +## Discovery Summary + +### Target repo +{target_repo} (discovery root: {discovery_root}) + +### Reference Domain Package: `packages/{name}/` +Full hex stack trace: + +**Domain layer** +- `{file}:{line}` — {role} + +**Application layer** +- `{file}:{line}` — {role} + +**Infrastructure layer** +- `{file}:{line}` — {role} + +**Types package** (`packages/types/src/{domain}/`) +- `{file}:{line}` — {role} + +**Hexa facade** +- `{file}:{line}` + +### Reference API Route +- `{controller_file}:{line}` — `{METHOD} {path}` → uses port `{port_name}` + +### Reference Frontend Feature +- Gateway: `{file}:{line}` +- Queries: `{file}:{line}` +- Page: `{file}:{line}` +- UI components: {list of PM-* components used} + +### Test Patterns +- Use case spec: `{file_pattern}` +- Repository spec: `{file_pattern}` +- Frontend component spec: `{file_pattern}` +- Integration tests: `{dir}` + +### Conventions Found +- Naming: {conventions.naming} +- Authorization: {conventions.authorization} +- Error mapping: {conventions.error_mapping} +- Events: {conventions.events} +- Frontend data flow: {conventions.frontend_data_flow} + +### Applicable Coding Standards +{For each from documentation researcher:} +- `{path}` (governs: `{paths_glob}`) — {summary} + +### Related Documentation +{For each:} +- `{path}` ({relevance}) — {summary} + +### Constraints +{constraints list, including OSS/proprietary boundary callouts if relevant} +``` + +### 2.5 Draft Acceptance Criteria + +Using the discovery context, analyze `task_description` for: +- Existing acceptance criteria (checkboxes, numbered lists) +- User stories or personas mentioned +- Technical constraints or requirements +- Scope boundaries (what's included/excluded) + +Validate patterns against the task: + +1. **Relevance check** — Does the reference domain cover the layers this task needs? +2. **Consistency check** — Do similar features follow the same conventions? +3. **Gap check** — Does the task require something no existing feature does (e.g., a new port type, a new event)? + +Then draft acceptance criteria and present everything for approval: + +``` +### Pattern Validation + +**Reference template:** `packages/{name}` — {applicable | partially applicable | not applicable} +**Convention consistency:** {consistent across N features | divergences noted: ...} +**Gaps:** {none | list of things no existing feature covers} + +**Derived approach:** Follow `packages/{name}` pattern across {layers}. {Gap handling, if any.} + +## Draft Acceptance Criteria + +Based on the source description and discovery analysis: + +- [ ] {Criterion 1 — extracted from source} +- [ ] {Criterion 2 — extracted from source} +- [ ] {Criterion N — inferred from discovery patterns, e.g., "Soft delete supported following standard repository pattern"} + +### Potential Gaps +{List anything the source doesn't cover but the reference feature handles, e.g., authorization, events, error mapping} + +### Potential Over-scope +{List anything in the source that may be too broad or vague} +``` + +### 2.6 Approval Gate + +Use the `AskUserQuestion` tool: + +```json +{ + "questions": [{ + "question": "Do the discovered patterns and draft acceptance criteria look correct?", + "header": "Phase 2", + "multiSelect": false, + "options": [ + {"label": "Yes, proceed", "description": "Patterns and criteria are accurate, continue to generate the full functional spec"}, + {"label": "Needs adjustment", "description": "I'll clarify what's wrong or missing"} + ] + }] +} +``` + +If the user needs adjustment, incorporate their feedback and re-present step 2.5. + +### 2.7 Generate Functional Specification + +Create `tmp/feature-specs/{task_slug}/functional-spec.md`: + +```markdown +## Functional Specification + +### Overview +{1-2 paragraphs: what the feature does and why} +{Reference the derived approach from pattern validation} + +### Target Repo +{target_repo} — most changes land in {discovery_root}. Note any tasks that must live in the proprietary fork (e.g., editions, paid deployments). + +### Business Requirements +1. {Requirement 1} +2. {Requirement 2} +3. {Requirement 3} + +### User Stories +- As a {role}, I want to {action}, so that {benefit} +- As a {role}, I want to {action}, so that {benefit} + +### Acceptance Criteria +- [ ] {Criterion 1 — specific, measurable} +- [ ] {Criterion 2 — specific, measurable} +- [ ] {Criterion 3 — specific, measurable} + +### Constraints +- {Technical constraint, e.g., "Must follow AbstractSpaceMemberUseCase authorization pattern"} +- {Performance requirement} +- {Compatibility requirement} +- {OSS/proprietary boundary — e.g., "No imports from @packmind/editions in OSS tasks"} +{Include constraints from discovery if relevant} + +### Out of Scope +- {Explicitly excluded item} + +### Dependencies +{List known dependencies — other packages, ports, planned work that must precede this} +``` + +### 2.8 Save Discovery + +Write `tmp/feature-specs/{task_slug}/discovery.md` with YAML frontmatter capturing everything the implementation plan subagent will need: + +```markdown +--- +target_repo: {oss | proprietary | both} +discovery_root: "{absolute path}" + +reference_domain: + package: "packages/{name}" + domain_files: + - file: "packages/{name}/src/domain/entities/{Entity}.ts" + line: 1 + role: "Main entity" + application_files: + - file: "packages/{name}/src/application/useCases/{name}/{name}.usecase.ts" + line: 1 + role: "Closest existing use case" + infra_files: + - file: "packages/{name}/src/infra/repositories/{Entity}Repository.ts" + line: 1 + role: "Repository implementation" + types_files: + - file: "packages/types/src/{name}/ports/I{Name}Port.ts" + line: 1 + role: "Port interface" + hexa_facade: + file: "packages/{name}/src/{Name}Hexa.ts" + line: 1 + +reference_route: + controller: "apps/api/src/controllers/{Name}Controller.ts" + method: "create" + line: 42 + http: "POST /api/{path}" + uses_port: "I{Name}Port" + +reference_frontend: + gateway: + file: "apps/frontend/src/.../{Name}Gateway.ts" + line: 1 + queries: + - file: "apps/frontend/src/.../use{Name}.ts" + line: 1 + page: + file: "apps/frontend/src/.../{Name}Page.tsx" + line: 1 + ui_components: + - name: "PMButton" + file: "packages/ui/src/PMButton.tsx" + +test_patterns: + usecase_spec: "packages/{name}/src/application/useCases/{name}/{name}.usecase.spec.ts" + repository_spec: "packages/{name}/src/infra/repositories/{Entity}Repository.spec.ts" + frontend_component_spec: "apps/frontend/src/.../{Name}Page.spec.tsx" + integration_tests_dir: "packages/integration-tests" + +conventions: + naming: "{from subagent}" + authorization: "AbstractSpaceMemberUseCase | AbstractMemberUseCase | AbstractAdminUseCase" + error_mapping: "{from subagent}" + events: "{from subagent}" + frontend_data_flow: "gateway → query hook → component, PM-prefixed UI from @packmind/ui" + +applicable_standards: + - path: ".claude/rules/packmind/packmind-proprietary.md" + paths_glob: "**/*" + summary: "Forbid imports from @packmind/editions in proprietary code" + +constraints: + - "{e.g., must use soft-delete-aware AbstractRepository}" + +pattern_validation: + reference_applicable: true + convention_consistency: "consistent across N features" + gaps: [] + derived_approach: "Follow packages/{name} pattern across {layers}" + +related_documentation: + - path: "{path}" + relevance: "high" + summary: "..." +--- + +## Discovery Summary + +{Mirror the human-readable view from step 2.4} + +## Pattern Validation + +{Mirror the pattern-validation block from step 2.5} +``` + +## Next Phase + +Proceed automatically to `phase-3-implementation.md`. diff --git a/.gitlab/duo/skills/feature-spec/phase-3-implementation.md b/.gitlab/duo/skills/feature-spec/phase-3-implementation.md new file mode 100644 index 000000000..290349702 --- /dev/null +++ b/.gitlab/duo/skills/feature-spec/phase-3-implementation.md @@ -0,0 +1,403 @@ +# Phase 3: Implementation Plan + +Generate the technical approach and detailed task breakdown, with Packmind-specific reference patterns and file targets. + +Phase 3 is split into **four subtasks**, each with its own approval gate. Foundational decisions are validated before generating the full plan, so problems get caught early. + +| Subtask | Purpose | Gate | +|---------|---------|------| +| 3A | Validate **domain data structures** (entities, value objects, events, repositories, schemas, migrations) | approval | +| 3B | Validate **ports, contracts, use cases, services, adapters, NestJS routes** | approval | +| 3C | Validate **frontend components** (page/container, gateway, query hooks, PM-UI usage, forms) | approval | +| 3D | Generate full **implementation plan** (task breakdown, grouping, coverage) | approval | + +## Prerequisites + +- Phase 2 completed +- If resuming a fresh session, read `tmp/feature-specs/{task_slug}/functional-spec.md` and `discovery.md` + +## Steps + +### 3.1 Present Technical Approach + +Using functional spec + discovery, present a brief technical approach to the user: + +``` +## Technical Approach + +### Target Repo +{target_repo} — implementation root: `{discovery_root}`. Tasks landing in this fork (proprietary) will be tagged; otherwise default to OSS. + +### Stack +- API: NestJS (`apps/api`) +- Domain packages: hexagonal layout under `packages/{domain}/src/{domain,application,infra}` +- Types/ports/contracts: `packages/types/src/{domain}` +- Frontend: React + `@packmind/ui` (PM-prefixed Chakra wrappers) (`apps/frontend`) +- Tests: Jest + `@swc/jest`; integration tests in `packages/integration-tests` +- DB: TypeORM + PostgreSQL; migrations under `packages/migrations` + +### Reference Patterns (from discovery) +- Reference domain: `{reference_domain.package}` +- Reference use case: `{reference_domain.application_files[0].file}:{line}` +- Reference route: `{reference_route.controller}:{line}` (`{reference_route.http}`) +- Reference frontend page: `{reference_frontend.page.file}:{line}` + +### Architecture Decisions +- {derived_approach from pattern_validation} +- Authorization: {convention chosen, e.g. AbstractSpaceMemberUseCase} +- Error mapping: {convention} +- Cross-domain: {via injected port or via domain event} + +### New Patterns Required +{If any:} +- {e.g., "New port `IFooPort` — no existing port covers this responsibility"} +- {e.g., "New domain event `FooDeletedEvent` — to notify deployments domain"} + +{Else: "None — fully covered by existing patterns."} +``` + +If new patterns are identified, use `AskUserQuestion` for each decision (one question per decision): + +```json +{ + "questions": [{ + "question": "{Describe the specific new pattern decision}", + "header": "New pattern", + "multiSelect": false, + "options": [ + {"label": "{Option A}", "description": "{What this means concretely}"}, + {"label": "{Option B}", "description": "{What this means concretely}"} + ] + }] +} +``` + +If no new patterns are needed, proceed directly. + +### 3.2 Append Technical Approach to discovery.md + +Append the finalized technical approach to `tmp/feature-specs/{task_slug}/discovery.md` after the YAML frontmatter: + +```markdown +## Technical Approach + +### Target Repo +... + +### Stack +... + +### Reference Patterns +... + +### Architecture Decisions +... +``` + +--- + +## Subtask 3A: Validate Domain Data Structures + +### 3A.1 Extract Data Structure Changes + +From functional spec + discovery, identify ALL data-structure impacts in the **domain** and **infra** layers: + +**New entities** (`packages/{domain}/src/domain/entities/`) — domain objects that don't exist yet: +- Name, purpose, key fields with TS types +- Relationships to existing entities (1:1, 1:N, N:M) +- Whether it carries an ID type (`{Entity}Id` branded type in `packages/types`) + +**Entity modifications** — changes to existing domain objects: +- New fields (name, type, nullable, default) +- Modified fields (what changes and why) +- Removed fields (what + migration impact) + +**Value objects / branded ID types** (`packages/types/src/{domain}/`): +- Name, shape + +**Domain events** (`packages/types/src/events/{EventName}.ts`): +- Event name, payload shape +- Which domain emits it, which listeners consume it +- Reference the `event.md` component guide + +**Repository interfaces** (`packages/{domain}/src/domain/repositories/I{Entity}Repository.ts`): +- Methods needed (findById, findByX, etc.) +- Whether `AbstractRepository<T>` (soft-delete) is appropriate + +**TypeORM schemas** (`packages/{domain}/src/infra/schemas/{Entity}Schema.ts`): +- Columns + types + indexes + foreign keys + +**Migrations** (`packages/migrations/src/migrations/`): +- New tables, altered columns, new indexes +- Reference the `how-to-write-typeorm-migrations-in-packmind` skill +- Down-migration plan + +**Domain errors** (`packages/{domain}/src/domain/errors/`): +- New error classes for invariant violations + +Use `discovery.md` reference paths to ground every naming and structure decision. + +### 3A.2 Present Data Structures for Validation + +Render the **Subtask 3A** presentation template from `references/spec-templates.md`, substituting the items extracted in 3A.1. Do not paraphrase the section headings — they keep 3A → 3D consistent. + +### 3A.3 Approval Gate — Data Structures + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Are the proposed domain data structures correct? (entities, events, repositories, schemas, migrations)", + "header": "Subtask 3A", + "multiSelect": false, + "options": [ + {"label": "Approved", "description": "Data structures are correct, proceed to ports/use cases/routes"}, + {"label": "Needs changes", "description": "I'll specify what to adjust"} + ] + }] +} +``` + +If "Needs changes", incorporate feedback and re-present 3A.2. + +If there are no data-structure changes for this task, state so briefly and skip the approval gate — proceed directly to Subtask 3B. + +--- + +## Subtask 3B: Validate Ports, Contracts, Use Cases, Services, Adapters, Routes + +### 3B.1 Extract Application + API Changes + +From functional spec + discovery, identify ALL impacts in the **application** layer and **API** layer: + +**New ports** (`packages/types/src/{domain}/ports/I{Domain}Port.ts`): +- Port name, methods exposed +- Which adapter implements it +- Justify: why a new port (vs. extending an existing one) + +**New use case contracts** (`packages/types/src/{domain}/contracts/{UseCaseName}.ts`): +- Request shape, Response shape +- Which port method returns these + +**New use cases** (`packages/{domain}/src/application/useCases/{name}/{name}.usecase.ts`): +- Name, purpose +- Which `Abstract*UseCase` base class (`AbstractMemberUseCase`, `AbstractSpaceMemberUseCase`, `AbstractAdminUseCase`) +- Authorization rules (who can call) +- Which services / repos it uses +- Which events it emits + +**Modified use cases** — changes to existing use cases: +- What changes, backward-compatibility notes + +**New services** (`packages/{domain}/src/application/services/{Name}Service.ts`): +- Name, methods, callers (use cases) + +**New adapter methods** (`packages/{domain}/src/application/adapter/{Domain}Adapter.ts`): +- Which port methods are now implemented + +**New listeners** (`packages/{domain}/src/application/listeners/{Domain}Listener.ts`): +- Which events listened to, what it does + +**New / modified NestJS routes** (`apps/api/src/`): +- Method + path (e.g., `POST /api/standards/{id}/preview`) +- Request/response DTOs (referencing contracts from above) +- Controller file + method +- How it resolves the port: `HexaRegistry.getAdapter<IFooPort>('foo')` + +**Removed routes**: +- Path + reason + +Use `discovery.md` references for path conventions, base classes, and adapter patterns. + +### 3B.2 Present for Validation + +Render the **Subtask 3B** presentation template from `references/spec-templates.md`, substituting the items extracted in 3B.1. + +### 3B.3 Approval Gate — Application + Routes + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Are the proposed ports, contracts, use cases, services, adapters, and routes correct?", + "header": "Subtask 3B", + "multiSelect": false, + "options": [ + {"label": "Approved", "description": "Application + API layer is correct, proceed to frontend"}, + {"label": "Needs changes", "description": "I'll specify what to adjust"} + ] + }] +} +``` + +If "Needs changes", incorporate feedback and re-present 3B.2. + +If nothing changes in this layer, state so and skip the gate — proceed to Subtask 3C. + +--- + +## Subtask 3C: Validate Frontend Components + +### 3C.1 Extract Frontend Changes + +From functional spec + discovery, identify ALL frontend impacts in `apps/frontend/`: + +**New page / route components** — top-level orchestrators: +- File path under `apps/frontend/src/` +- Route it mounts on (React Router or app router) +- Which gateway queries/mutations it owns +- Which container/domain components it renders + +**New domain / container components** — feature-scoped UI: +- File path, props interface (TypeScript) +- Pure-display vs. stateful (owns queries?) +- Form components: validation lib + schema, submit callback shape +- List components: item shape, empty state, loading skeleton +- Detail components: data shown, actions + +**Modified domain components**: +- File + line, what changes, props added/removed + +**New gateways** (`apps/frontend/src/.../gateways/{Name}Gateway.ts`): +- Method signatures wrapping API calls +- Reference the existing gateway pattern (see `gateway-pattern-implementation-in-packmind-frontend` command) +- Type mapping (contract → frontend type) + +**New query / mutation hooks**: +- Hook name, query key, return shape +- Cache invalidation rules + +**`@packmind/ui` usage** (PM-prefixed Chakra wrappers): +- Which existing PM-* components are used (PMButton, PMDialog, PMField, etc.) +- Whether any new wrapper is needed (see `wrapping-chakra-ui-with-slot-components` command and `working-with-pm-design-kit` skill) + +**Layout / navigation changes**: +- New nav links, sidebar items, page tabs + +**Microcopy**: +- New user-facing strings — flag for `ux-microcopy` skill if there are non-trivial messages (errors, empty states, dialogs) + +Use `discovery.md`'s reference frontend feature to ground naming and structure decisions. + +### 3C.2 Present for Validation + +Render the **Subtask 3C** presentation template from `references/spec-templates.md`, substituting the items extracted in 3C.1. + +### 3C.3 Approval Gate — Frontend Components + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Are the proposed frontend components correct? (pages, containers, gateways, queries, PM-UI usage)", + "header": "Subtask 3C", + "multiSelect": false, + "options": [ + {"label": "Approved", "description": "Frontend is correct, proceed to full implementation plan"}, + {"label": "Needs changes", "description": "I'll specify what to adjust"} + ] + }] +} +``` + +If "Needs changes", incorporate feedback and re-present 3C.2. + +If no frontend changes, state so and skip — proceed to Subtask 3D. + +--- + +## Subtask 3D: Generate Implementation Plan + +### 3D.1 Skill Loading (conditional) + +Load Packmind skills based on which layers the task touches: + +**Backend (domain / application / infra) is touched:** +1. Read `.claude/skills/hexagonal-architecture/SKILL.md` +2. Read `.claude/skills/hexagonal-architecture/components/usecase.md` +3. Read `.claude/skills/hexagonal-architecture/components/repository.md` +4. Read `.claude/skills/repository-implementation-and-testing-pattern/SKILL.md` (if it exists) + +**Migrations are touched:** +5. Read `.claude/skills/how-to-write-typeorm-migrations-in-packmind/SKILL.md` +6. Read `.claude/skills/create-or-update-model-and-typeorm-schemas/SKILL.md` + +**Frontend is touched:** +7. Read `.claude/skills/working-with-pm-design-kit/SKILL.md` +8. Read `.claude/commands/gateway-pattern-implementation-in-packmind-frontend.md` +9. Read `.claude/commands/wrapping-chakra-ui-with-slot-components.md` (only if new PM wrappers needed) + +**CLI tests are touched:** +10. Read `.claude/skills/cli-e2e-test-authoring/SKILL.md` + +Skip a category entirely if no task in that layer. + +### 3D.2 Launch Implementation Plan Subagent + +Read the bundled prompt template at `.claude/skills/feature-spec/references/implementation-plan-prompt.md`. Substitute its placeholders (`{task_name}`, `{task_slug}`, `{target_repo}`, and the three `{APPROVED_3*_BLOCK}` blocks pasted verbatim from the prior approval gates). Include or omit the Backend / Frontend / Migration constraint sections based on which layers the prior subtasks produced work for — guidance is at the top of the reference file. + +Invoke `Agent` with `subagent_type="general-purpose"` and the substituted prompt. Wait for the subagent to complete. + +Wait for the subagent to complete. + +### 3D.3 Validate Plan Output + +Read `tmp/feature-specs/{task_slug}/implementation-plan.md` and verify: +- Every acceptance criterion is covered in the Coverage Matrix +- Every task has a reference pattern (`file:line`) +- Every task has target files and a `repo` tag +- Plan is consistent with 3A, 3B, 3C decisions (no extra entities, no missing routes) +- Parallel Groups section exists with at least 1 group +- All tasks belong to exactly one group; no file appears in multiple groups + +If validation fails: tell the user what's missing and ask whether to refine manually or relaunch the subagent. + +### 3D.4 Approval Gate — Implementation Plan + +Display the summary: + +``` +**Subtask 3D complete.** Implementation plan generated. + +**Phases:** {count} +**Total tasks:** {count} +**Parallel groups:** {group_count} +**Repos used:** {oss only | proprietary only | both, with count} + +Review the plan in `tmp/feature-specs/{task_slug}/implementation-plan.md` + +Questions to consider: +- Does each task follow the identified patterns? +- Are file references specific enough? +- Are inter-group dependencies clear? +- Are OSS-vs-proprietary tags correct? +``` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Is the implementation plan approved? Proceed to finalize?", + "header": "Subtask 3D", + "multiSelect": false, + "options": [ + {"label": "Yes, proceed", "description": "Approve plan and continue to Phase 4: Finalize"}, + {"label": "Need changes", "description": "I need to modify the implementation plan first"} + ] + }] +} +``` + +Do not continue until user confirms via AskUserQuestion response. + +### 3.7 Phase Complete + +The existence of `implementation-plan.md` marks Phase 3 as complete. No separate state update needed. + +## Next Phase + +After approval, proceed automatically to `phase-4-finalize.md`. diff --git a/.gitlab/duo/skills/feature-spec/phase-4-finalize.md b/.gitlab/duo/skills/feature-spec/phase-4-finalize.md new file mode 100644 index 000000000..842d1d6da --- /dev/null +++ b/.gitlab/duo/skills/feature-spec/phase-4-finalize.md @@ -0,0 +1,89 @@ +# Phase 4: Finalize + +Generate the implementation context, mark the spec as complete, and report. This phase runs seamlessly — no approval gate. + +Artifacts live in `tmp/feature-specs/{task_slug}/` which is git-ignored, so **no commit step** here. The user commits implementation code via `/feature-sprint`. + +## Prerequisites + +- Phases 1-3 completed +- If resuming a fresh session, read `discovery.md`, `functional-spec.md`, and `implementation-plan.md` from `tmp/feature-specs/{task_slug}/` + +## Steps + +### 4.1 Generate Implementation Context + +**Purpose**: produce a structured `context.md` that `/feature-sprint` will read to set up parallel execution. + +Read `.claude/skills/feature-spec/references/context-schema.md` — it contains the full YAML template, the body template, and the ordered extraction process (sources: `discovery.md` frontmatter, main spec frontmatter, `functional-spec.md` body, `implementation-plan.md` Parallel Groups table). + +Write the substituted result to `tmp/feature-specs/{task_slug}/context.md`. + +### 4.2 Mark Spec Complete + +Update the main spec file's YAML frontmatter: + +```markdown +--- +status: COMPLETE +... +finished: {ISO-8601 timestamp} +--- +``` + +The body of the main spec should now include a brief summary (one paragraph) and links to the sibling artifacts: + +```markdown +# {task_name} + +{1-paragraph summary of the feature} + +## Artifacts + +- [Discovery](./discovery.md) — reference patterns and conventions +- [Functional Spec](./functional-spec.md) — acceptance criteria and scope +- [Implementation Plan](./implementation-plan.md) — task breakdown and parallel groups +- [Context](./context.md) — machine-readable handoff for `/feature-sprint` + +## Next Step + +``` +/feature-sprint {task_slug} +``` +``` + +### 4.3 Completion Report + +Output to the user: + +``` +## ✅ FEATURE SPEC COMPLETE + +**Task**: {task_name} +**Slug**: {task_slug} +**Target repo**: {target_repo} + +### Summary +- Acceptance criteria: {count} items +- Implementation phases: {phase_count} +- Total tasks: {task_count} +- Parallel groups: {group_count} + +### Artifacts +- `tmp/feature-specs/{task_slug}/{task_slug}.md` +- `tmp/feature-specs/{task_slug}/discovery.md` +- `tmp/feature-specs/{task_slug}/functional-spec.md` +- `tmp/feature-specs/{task_slug}/implementation-plan.md` +- `tmp/feature-specs/{task_slug}/context.md` + +### Reminder +Artifacts live in `tmp/` (git-ignored). They will NOT be committed automatically — they're scratch state for `/feature-sprint`. + +### Next Steps +1. Review the artifacts above +2. Execute: `/feature-sprint {task_slug}` +``` + +## End of Workflow + +The feature spec is ready. The companion skill `/feature-sprint` will pick it up and execute the implementation plan. diff --git a/.gitlab/duo/skills/feature-spec/references/context-schema.md b/.gitlab/duo/skills/feature-spec/references/context-schema.md new file mode 100644 index 000000000..a3175418c --- /dev/null +++ b/.gitlab/duo/skills/feature-spec/references/context-schema.md @@ -0,0 +1,141 @@ +# context.md Schema + +Loaded by `phase-4-finalize.md` step 4.1. Defines the structured artifact +that `/feature-sprint` consumes to set up parallel execution. + +## Extraction process (in order) + +1. Read `discovery.md` YAML frontmatter → `target_repo`, `discovery_root`, + `reference_domain.*`, `reference_route.*`, `reference_frontend.*`, + `applicable_standards`. +2. Read `{task_slug}.md` YAML frontmatter → `task_id`, `task_name`, + `source_type`, `source_url`. +3. Parse `functional-spec.md` body → scope (`in_scope`, `out_of_scope`, + `constraints`). These need light prose-to-list conversion; keep items + short and verbatim where possible. +4. Parse `implementation-plan.md` "Parallel Groups" table → populate + `parallel_groups[]`. Each row maps to one entry; `task_ids` is the + comma-split task IDs column. +5. Set `version: "1.1"` if `parallel_groups[]` is non-empty, else `"1.0"`. + +## Template + +Write to `tmp/feature-specs/{task_slug}/context.md`: + +```markdown +--- +version: "1.0" # bump to "1.1" if parallel_groups non-empty +task_slug: "{task_slug}" +task_id: "{from main spec frontmatter}" +task_name: "{task_name}" + +source: + type: "{source_type from main spec}" + url: "{source_url from main spec}" + +target_repo: "{oss | proprietary | both}" +discovery_root: "{absolute path from discovery.md}" +proprietary_root: "{absolute path of the proprietary repo — i.e. `realpath .` when running this skill from the proprietary repo root}" +oss_root: "{absolute path of the OSS sibling — i.e. `realpath ../packmind`}" + +stack: + api: "NestJS (apps/api)" + domain_packages: "packages/{domain}/src/{domain,application,infra}" + types: "packages/types" + frontend: "React + @packmind/ui (apps/frontend)" + tests: "Jest + @swc/jest" + integration_tests: "packages/integration-tests" + db: "TypeORM + PostgreSQL" + migrations: "packages/migrations" + +reference: + domain_package: "{from discovery.reference_domain.package}" + usecase_file: "{from discovery}" + api_controller: "{from discovery}" + frontend_page: "{from discovery}" + +discovery_summary: + patterns: "{1-2 sentence summary of the derived approach}" + applicable_standards: + - path: ".claude/rules/packmind/packmind-proprietary.md" + summary: "Forbid imports from @packmind/editions" + +scope_definition: + in_scope: + - "{extracted from functional-spec.md}" + out_of_scope: + - "{extracted from functional-spec.md}" + constraints: + - "{extracted from functional-spec.md}" + +quality_gates: + # Nx targets to run for the projects this feature touches. + # /feature-sprint will resolve which projects need each target. + lint: true + test: true + build: true + extra: [] + +parallel_groups: # Populate from implementation-plan.md "Parallel Groups" section + - group_id: "A" + group_name: "backend-{domain}" + task_ids: ["1.1", "1.2", "1.3"] + target_files: ["packages/{domain}/..."] + repo: "oss" + blocked_by: [] + - group_id: "B" + group_name: "frontend-{domain}" + task_ids: ["2.1", "2.2"] + target_files: ["apps/frontend/..."] + repo: "oss" + blocked_by: [] + - group_id: "C" + group_name: "tests" + task_ids: ["3.1", "3.2"] + target_files: ["packages/integration-tests/..."] + repo: "oss" + blocked_by: ["A", "B"] +--- + +## Implementation Context + +**Task:** {task_name} +**Slug:** {task_slug} +**Target repo:** {target_repo} + +### Discovery summary +- Reference domain: `{reference.domain_package}` +- Reference use case: `{reference.usecase_file}` +- Reference API: `{reference.api_controller}` +- Reference frontend: `{reference.frontend_page}` + +### Scope +**In scope:** +- {items} + +**Out of scope:** +- {items} + +**Constraints:** +- {items} + +### Quality gates +- Lint: nx lint on affected projects +- Test: nx test on affected projects +- Build: nx build on affected projects + +### Parallel groups +| Group | Name | Tasks | Repo | Blocked by | +|-------|------|-------|------|------------| +| A | {name} | 1.1, 1.2, 1.3 | oss | — | +| B | {name} | 2.1, 2.2 | oss | — | +| C | {name} | 3.1, 3.2 | oss | A, B | +``` + +## Notes + +- `parallel_groups[*].repo` is independent of the top-level `target_repo`: + one feature can have OSS and proprietary groups in `both` mode. +- `blocked_by` may be empty `[]`. Don't omit the key. +- If a group has no obvious file area (e.g., cross-cutting tests), name it + by purpose (`tests`, `wiring`) rather than by file path. diff --git a/.gitlab/duo/skills/feature-spec/references/implementation-plan-prompt.md b/.gitlab/duo/skills/feature-spec/references/implementation-plan-prompt.md new file mode 100644 index 000000000..96254d2fd --- /dev/null +++ b/.gitlab/duo/skills/feature-spec/references/implementation-plan-prompt.md @@ -0,0 +1,138 @@ +# Implementation Plan Subagent Prompt Template + +Loaded by `phase-3-implementation.md` step 3D.2 to generate +`tmp/feature-specs/{task_slug}/implementation-plan.md`. The orchestrator +substitutes every `{placeholder}` and then passes the result as the `prompt` +to `Agent` with `subagent_type="general-purpose"`. + +## Placeholders + +| Placeholder | Source | +|-------------|--------| +| `{task_name}` | main spec frontmatter | +| `{task_slug}` | spec directory name | +| `{target_repo}` | discovery.md frontmatter (`oss \| proprietary \| both`) | +| `{APPROVED_3A_BLOCK}` | the approved 3A.2 summary, pasted verbatim | +| `{APPROVED_3B_BLOCK}` | the approved 3B.2 summary, pasted verbatim | +| `{APPROVED_3C_BLOCK}` | the approved 3C.2 summary, pasted verbatim | + +## Conditional sections + +The template includes layer-specific constraint sections. Include or skip each +based on whether any task in that layer exists: + +- Backend section → include if 3A or 3B produced any work +- Frontend section → include if 3C produced any work +- Migration section → include if 3A produced any migration entries + +**Do not** paste the contents of referenced skills inline. The subagent has +access to the project's `.claude/skills/` directory and should `Read` them +directly. Inline pastes would force the orchestrator to grow with every +upstream skill change. + +## Template + +``` +Design implementation tasks for: {task_name} + +IMPORTANT: Do NOT use EnterPlanMode. Write the plan directly to the file path in the Output section below. + +## Context files (Read these first) +- tmp/feature-specs/{task_slug}/functional-spec.md (requirements, acceptance criteria) +- tmp/feature-specs/{task_slug}/discovery.md (patterns, reference paths in YAML frontmatter + Technical Approach) + +## Validated decisions (from earlier subtasks — treat as LOCKED) + +The following have been reviewed and approved by the user. The implementation plan MUST be consistent — do not add, remove, or rename anything listed here. + +### Domain Data Structures (Subtask 3A) +{APPROVED_3A_BLOCK} + +### Ports, Contracts, Use Cases, Routes (Subtask 3B) +{APPROVED_3B_BLOCK} + +### Frontend Components (Subtask 3C) +{APPROVED_3C_BLOCK} + +## Architecture Constraints — Backend (include only if backend touched) + +Read these skills before producing backend tasks. Do NOT paste their contents into this prompt — they are loaded conditionally and may evolve: + +- `.claude/skills/hexagonal-architecture/SKILL.md` +- `.claude/skills/hexagonal-architecture/components/usecase.md` +- `.claude/skills/hexagonal-architecture/components/repository.md` +- `.claude/skills/repository-implementation-and-testing-pattern/SKILL.md` (if it exists) + +For each backend task ensure: +- Layer is explicit (domain, application, infra) +- Cross-domain communication goes through a port in `packages/types/` or a domain event — NEVER direct cross-package imports +- The right `Abstract*UseCase` base class is used for authorization +- Domain errors are mapped at the API layer, not raised as HTTP exceptions inside the domain +- New repositories use `AbstractRepository<T>` with soft-delete support unless explicitly justified otherwise + +## Testing Constraints + +For each backend task involving logic, plan a sibling `*.spec.ts` task using Jest + `@swc/jest`. For repositories, follow the test pattern from `repository-implementation-and-testing-pattern` skill (factory-driven tests). For end-to-end behavior, add an `integration-tests` task in `packages/integration-tests`. + +## Frontend Constraints (include only if frontend touched) + +Read these skills before producing frontend tasks: + +- `.claude/skills/working-with-pm-design-kit/SKILL.md` +- `.claude/commands/gateway-pattern-implementation-in-packmind-frontend.md` + +For each frontend task ensure: +- UI is built from `@packmind/ui` PM-prefixed components, NOT raw Chakra primitives (unless wrapping a new slot component — then plan a slot-wrapping task referencing `.claude/commands/wrapping-chakra-ui-with-slot-components.md`) +- API calls go through a Gateway, not directly from components or hooks +- TanStack Query (or the project's equivalent) is used via the existing hook pattern from discovery +- Tests use React Testing Library at the component layer; integration scenarios go to e2e + +## Migration Constraints (include only if migrations touched) + +Read: `.claude/skills/how-to-write-typeorm-migrations-in-packmind/SKILL.md` + +Each migration task must: +- Create both `up` and `down` methods +- Use the standard logger +- Land under `packages/migrations/src/migrations/{timestamp}-{name}.ts` + +## OSS / Proprietary Constraints + +`target_repo` for this spec: `{target_repo}`. + +- If `target_repo == "oss"`, all tasks must be implementable in `../packmind` (the OSS sibling next to the proprietary repo). Verify no task references `packages/editions/` or paid-only paths. +- If `target_repo == "proprietary"`, mark each task with its repo. Never import from `@packmind/editions` outside files that already live in the editions package (see `.claude/rules/packmind/packmind-proprietary.md`). +- If `target_repo == "both"`, tag every task with `repo: oss | proprietary`. Place OSS tasks first (they unblock proprietary ones after auto-merge). + +## Requirements + +Generate a phased implementation plan that: +1. Maps each acceptance criterion to specific tasks (Coverage Matrix at end) +2. Groups tasks logically: domain entities/events → repositories/migrations → use cases → API routes → frontend gateway/queries → frontend components → tests +3. Uses reference patterns from discovery.md for every task (`file:line`) +4. Specifies target files (existing or new) for every task +5. Includes test tasks for every behavioral change +6. Groups tasks into parallel execution groups by file dependencies + +## Task format + +See `.claude/skills/feature-spec/references/spec-templates.md` for the canonical task block shape and section structure. The format must match exactly so `/feature-sprint`'s parse_implementation_plan.py can read it. + +## Parallel grouping rules + +After defining all tasks, append a "Parallel Groups" section. + +- Tasks sharing the SAME target files MUST be in the same group +- Tasks where A writes a file B reads MUST be in the same group +- Backend domain/application tasks usually share `packages/{domain}/`, so they often go in one group +- Frontend tasks under `apps/frontend/` often share the gateway file and form a second group +- Migration tasks usually stand alone +- Name groups by dominant file area (e.g. `backend-{domain}`, `frontend-{domain}`, `migrations`) +- Maximize parallelism while respecting file conflicts + +## Output + +Write to: `tmp/feature-specs/{task_slug}/implementation-plan.md` + +Use the section structure documented in `.claude/skills/feature-spec/references/spec-templates.md` (Implementation Plan section). Then return a summary: `{phase_count}` phases, `{task_count}` tasks, `{group_count}` parallel groups, coverage complete/incomplete. +``` diff --git a/.gitlab/duo/skills/feature-spec/references/spec-templates.md b/.gitlab/duo/skills/feature-spec/references/spec-templates.md new file mode 100644 index 000000000..77b7530a9 --- /dev/null +++ b/.gitlab/duo/skills/feature-spec/references/spec-templates.md @@ -0,0 +1,253 @@ +# Spec Presentation & Plan Templates + +Reusable markdown skeletons for Phase 3 (Subtasks 3A/3B/3C) and Phase 3D +(implementation plan + task format). Keeps `phase-3-implementation.md` lean +and procedural; this file holds the bulky presentation structure. + +The templates are fill-in-the-blank — substitute the `{placeholder}` tokens +from `discovery.md` + the prior subtask's approved decisions. Always preserve +the exact section ordering: `/feature-sprint` parses the resulting +`implementation-plan.md` and the order matters for it. + +## Subtask 3A: Domain Data Structures (presentation template) + +``` +## Subtask 3A: Domain Data Structures + +### Target Repo +{target_repo} — these changes live in {oss | proprietary}. + +### New Entities +{For each:} +- **`{EntityName}`** — {purpose} + - File: `packages/{domain}/src/domain/entities/{EntityName}.ts` + - Fields: + - `id: {EntityName}Id` (branded type in `packages/types/src/{domain}/`) + - `{field}: {type}` {constraints} + - Relationships: {description} + - **Reference**: `{discovery.reference_domain.domain_files[*].file}:{line}` — follows pattern + +### Entity Modifications +{For each:} +- **`{EntityName}`** (`{existing_file}:{line}`) + - Add: `{field}: {type}` — {reason} + - Modify: `{field}: {old}` → `{new}` — {reason + migration impact} + - Remove: `{field}` — {reason + migration note} + +### New Value Objects / Branded IDs +{For each:} +- **`{Name}`** (`packages/types/src/{domain}/{Name}.ts`) + +### New Domain Events +{For each:} +- **`{EventName}`** (`packages/types/src/events/{EventName}.ts`) + - Payload: `{shape}` + - Emitted by: `{Domain}` (use case `{name}`) + - Listened by: `{ListenerDomain}` — `application/listeners/{Name}Listener.ts` + +### New Repository Interfaces +{For each:} +- **`I{Entity}Repository`** (`packages/{domain}/src/domain/repositories/`) + - Methods: `{methods}` + - Extends `AbstractRepository<{Entity}>`: yes/no + +### TypeORM Schemas +{For each:} +- **`{Entity}Schema`** (`packages/{domain}/src/infra/schemas/`) + - Table: `{table_name}` + - Columns: {list} + - Indexes: {list} + - FKs: {list} + +### Migrations +{For each:} +- **`{TimestampMigrationName}`** under `packages/migrations/src/migrations/` + - Up: {description} + - Down: {description} + - Reference pattern: `{existing_migration_file}` + +### New Domain Errors +{For each:} +- **`{ErrorClass}`** — thrown when {invariant} + +{If no data-structure changes: "No domain data-structure changes — this task only touches application/UI behavior."} +``` + +## Subtask 3B: Ports, Contracts, Use Cases, Routes (presentation template) + +``` +## Subtask 3B: Ports, Contracts, Use Cases, Routes + +### Target Repo +{target_repo} + +### New Ports +{For each:} +- **`I{Name}Port`** (`packages/types/src/{domain}/ports/`) + - Methods: `{signatures}` + - Implemented by: `{Domain}Adapter` + - Why new: {justification} + +### New Contracts +{For each:} +- **`{UseCaseName}`** (`packages/types/src/{domain}/contracts/`) + - Request: `{shape}` + - Response: `{shape}` + +### New Use Cases +{For each:} +- **`{name}.usecase.ts`** (`packages/{domain}/src/application/useCases/{name}/`) — {purpose} + - Base class: `AbstractSpaceMemberUseCase` (etc.) + - Authorization: {who can call} + - Uses repos: {list} + - Uses services: {list} + - Emits events: {list, referencing 3A} + - **Reference**: `{discovery.reference_domain.application_files[*].file}:{line}` + +### Modified Use Cases +{For each:} +- **`{file}:{line}`** — {change description} + +### New Services +{For each:} +- **`{Name}Service`** (`packages/{domain}/src/application/services/`) + - Methods: {signatures} + - Used by: {use cases} + +### New Adapter Methods +{For each:} +- **`{Domain}Adapter.{method}()`** (`packages/{domain}/src/application/adapter/`) + - Implements port: `I{Name}Port.{method}` + +### New Listeners +{For each:} +- **`{Domain}Listener.handle{Event}()`** — reacts to `{EventName}` + +### New API Routes +{For each:} +- **`{METHOD} {path}`** — {purpose} + - Controller: `apps/api/src/controllers/{Name}Controller.ts` (new or extends existing) + - Request DTO: `{shape or file}` + - Response DTO: `{shape or file}` + - Resolves port: `IFooPort.{method}` via `HexaRegistry` + - **Reference**: `{discovery.reference_route.controller}:{line}` + +### Modified API Routes +{For each:} +- **`{METHOD} {path}`** (`{file}:{line}`) + - Change: {description} + - Backward compatible: yes/no + detail + +### Removed Routes +{For each:} +- **`{METHOD} {path}`** — {reason + deprecation plan} + +{If no application/API changes: "No application or API changes — this task only modifies data or UI."} +``` + +## Subtask 3C: Frontend Components (presentation template) + +``` +## Subtask 3C: Frontend Components + +### Target Repo +{target_repo} + +### New Page / Route Components +{For each:} +- **`{ComponentName}`** (`apps/frontend/src/.../{ComponentName}.tsx`) — {purpose} + - Route: `{path}` + - Queries/mutations owned: {list} + - Renders: {list of child components} + - **Reference**: `{discovery.reference_frontend.page.file}:{line}` + +### New Domain / Container Components +{For each:} +- **`{ComponentName}`** (`apps/frontend/src/.../{ComponentName}.tsx`) — {purpose} + - Props: `{prop}: {type}` + - Behavior: pure display | form (validation: {schema}) | stateful with queries + - **Reference**: `{similar_component_file}:{line}` + +### Modified Domain Components +{For each:} +- **`{ComponentName}`** (`{file}:{line}`) — {change} + +### New Gateways +{For each:} +- **`{Name}Gateway`** (`apps/frontend/src/.../gateways/{Name}Gateway.ts`) + - Methods: `{signatures}` wrapping `{contract from 3B}` + - **Reference**: `{discovery.reference_frontend.gateway.file}:{line}` + +### New Query / Mutation Hooks +{For each:} +- **`use{Name}`** (`apps/frontend/src/.../{name}.ts`) + - Query key: `{key}` + - Returns: `{shape}` + - Invalidates: `{keys}` + +### `@packmind/ui` Usage +- Reused PM components: {list} +- New PM wrapper components needed: {list or "none"} +- **If new wrappers**: see `wrapping-chakra-ui-with-slot-components` command before implementing + +### Layout / Navigation +{For each change:} +- **`{file}`** — {description} + +### Microcopy +- {Flag any non-trivial user-facing strings that should be reviewed with the `ux-microcopy` skill} + +{If no frontend changes: "No frontend changes — this task only touches backend/data."} +``` + +## 3D: Task & Implementation Plan Format + +The implementation-plan.md generated by 3D must use this exact shape so that +`/feature-sprint`'s `parse_implementation_plan.py` can read it. + +### Task block + +``` +- [ ] **{PHASE}.{TASK}: {short description}** + - Repo: `oss | proprietary` + - Layer: `domain | application | infra | api | frontend | tests | migrations` + - Files: `{paths}` + - Reference: `{file}:{line}` — {pattern_role} + - Notes: {short implementation notes} +``` + +Phases are numbered (1, 2, 3...). Tasks within a phase use decimals (1.1, +1.2, 2.1). Indentation is exactly two spaces for the metadata lines — the +parser depends on it. + +### Plan-level structure + +``` +# Implementation Plan: {task_name} + +## Phase 1: {short label} +- [ ] **1.1: ...** + - Repo, Layer, Files, Reference, Notes +- [ ] **1.2: ...** + ... + +## Phase 2: ... + +## Coverage Matrix + +| Acceptance Criterion | Task IDs | +|----------------------|----------| +| {criterion} | 1.1, 2.3 | + +## Parallel Groups + +| group_id | group_name | task_ids | target_files | rationale | +|----------|-----------|----------|--------------|-----------| +| A | backend-standards | 1.1, 1.2, 1.3 | packages/standards/... | shared domain package | +| B | frontend-standards | 2.1, 2.2 | apps/frontend/src/standards/... | shared gateway + page | +| C | tests | 3.1, 3.2 | varies | after A and B | + +## Dependency Notes +- Group C blocked by Groups A and B +- ... +``` diff --git a/.gitlab/duo/skills/feature-sprint/SKILL.md b/.gitlab/duo/skills/feature-sprint/SKILL.md new file mode 100644 index 000000000..06e1aa8a6 --- /dev/null +++ b/.gitlab/duo/skills/feature-sprint/SKILL.md @@ -0,0 +1,165 @@ +--- +name: 'feature-sprint' +description: 'Execute the implementation plan produced by /feature-spec. Loads tasks from tmp/feature-specs/{slug}/implementation-plan.md, hydrates them as Claude Tasks, runs parallel groups via subagents in the correct repo (OSS sibling at ../packmind or the proprietary repo in cwd), syncs progress back to checkboxes, commits per group, and runs Nx quality gates. Resumable across sessions via checkbox state.' +--- + +# Feature Sprint Skill + +Execute a Packmind feature implementation plan via subagents with automatic parallel execution and sync-back. Companion to `/feature-spec`. + +## When to Use + +| Scenario | Use feature-sprint | Use plan + architect-executor | +|----------|--------------------|-----------------------------------| +| Fast execution of an already-specified feature | ✅ | ❌ | +| Parallel group execution | ✅ | partial | +| Multi-repo (OSS + proprietary) execution | ✅ | ❌ | +| Heavy TDD enforcement, per-task escalation | ❌ | ✅ | +| Specs in `tmp/feature-specs/` (this flow) | ✅ | ❌ | +| Specs in `.claude/specs/` and `.claude/plans/` | ❌ | ✅ | + +**Rule of thumb**: `/feature-sprint` is the executor for `/feature-spec`. For more careful, single-task-at-a-time execution with the global `plan` skill, use `architect-executor` instead. + +## Prerequisites + +A completed feature spec at `tmp/feature-specs/{slug}/`: +- `{slug}.md` with `status: COMPLETE` in frontmatter +- `context.md` with YAML frontmatter (version 1.0+) +- `implementation-plan.md` with task checkboxes + +If missing or DRAFT: tell the user to run `/feature-spec {source}` first. + +## Architecture: Orchestration + Subagents + Sync-Back + +**Core Principle**: The main session **orchestrates only**. All implementation happens in Task subagents — that keeps the main context clean and lets agents do heavy reading without poisoning your conversation. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Persistent Layer (tmp/, git-ignored) │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ implementation-plan.md │ │ +│ │ Source of truth: task checkboxes [ ] / [x] │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ ▲ + │ Hydrate (session start) │ Sync-back (per group) + ▼ │ +┌─────────────────────────────────────────────────────────────────┐ +│ Main Session (ORCHESTRATION ONLY) │ +│ • Load spec & state • Spawn subagents (one per group) │ +│ • Parse agent output • Sync checkboxes │ +│ • Run Nx quality gates • Commit per group │ +│ • NEVER implement tasks directly │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ Spawn (parallel or sequential) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Subagent Layer (IMPLEMENTATION) │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────┐ │ +│ │ Group A agent │ │ Group B agent │ │ Group C agent │ │ +│ │ Backend tasks │ │ Frontend tasks │ │ Tests / integration│ │ +│ │ cwd: OSS repo │ │ cwd: OSS repo │ │ cwd: depends │ │ +│ └────────────────┘ └────────────────┘ └────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Hydration (session start) +1. Read `implementation-plan.md` → extract tasks with checkbox state (`[ ]` pending, `[x]` done) +2. Read `context.md` → extract `parallel_groups`, `target_repo`, `quality_gates` +3. Use `TaskCreate` for each pending task (visual tracking) +4. Wire `addBlockedBy` from `parallel_groups[*].blocked_by` + +### Subagent delegation +Every group runs in a Task subagent — parallel or sequential. The main session never touches code, never reads source files, never runs Nx commands itself. + +### Sync-back (automatic) +After each group's subagent completes: +1. Parse output for `TASK_COMPLETE: {id}` markers +2. Update `implementation-plan.md` checkboxes: `[ ]` → `[x]` +3. Update Claude Tasks status + +Progress is durable: kill the session, restart `/feature-sprint {slug}`, and it resumes from where checkboxes left off. + +## Workflow Phases + +| Phase | File | Purpose | +|-------|------|---------| +| 1 | `phase-1-init.md` | Load spec, hydrate tasks, get approval | +| 2 | `phase-2-execute.md` | Parallel execution via subagents with auto sync-back + per-group commits | +| 3 | `phase-3-finalize.md` | Run Nx quality gates, final report, optional archive | + +## State Management + +``` +tmp/feature-specs/{slug}/ +├── {slug}.md # status: COMPLETE +├── context.md # parallel_groups, target_repo, quality_gates +├── implementation-plan.md # tasks with checkboxes — SOURCE OF TRUTH for progress +├── discovery.md # reference patterns +└── functional-spec.md # acceptance criteria +``` + +Progress is tracked entirely through `implementation-plan.md` checkboxes — no separate state file. + +## OSS / Proprietary Routing + +`context.md` declares `target_repo` and each parallel group declares its `repo` field. The main session sets `cwd` for each subagent accordingly: + +- `repo: oss` → `cwd: ../packmind` (the OSS sibling cloned next to the proprietary repo) +- `repo: proprietary` → `cwd: .` (this repo) + +After all OSS work is committed and merged upstream, remind the user to `git pull` here so the proprietary fork picks up the auto-merge before any proprietary tasks run. + +## Commit Strategy + +**Each parallel group gets its own commit** when its subagent reports success (one commit per group, not per task). This matches the Packmind CLAUDE.md rule "Each sub-task should have its own commit" — we treat a parallel group as a coherent sub-task unit. + +Commits follow the `git-commit-guidelines` skill format (gitmoji + Conventional Commits, never auto-closing issues). The main session always shows the proposed message and asks for approval before running `git commit`. + +## Invocation + +### New sprint + +``` +/feature-sprint {task_slug} +``` + +If `{task_slug}` is omitted, the skill lists available specs in `tmp/feature-specs/` and asks the user to pick one. + +### Resume + +Same command. The skill detects existing checkbox state and offers to continue. + +## Status + +Show progress across active sprints: + +``` +/feature-sprint status +``` + +(Implemented by listing `tmp/feature-specs/*/implementation-plan.md` and counting checkbox state in each.) + +## Error Handling + +| Situation | Action | +|-----------|--------| +| Spec not found | Error: "Run /feature-spec first" | +| Spec DRAFT | Error: "Complete the spec first — `status` is DRAFT" | +| No parallel groups | Fall back to single-group sequential execution | +| `repo: oss` but `../packmind` missing | Ask user — switch to proprietary or abort | +| Agent fails | Sync completed tasks, pause sprint, report error | +| Quality gate fails | Sync progress, report failures, prompt for fix-and-retry | + +## Integration with /feature-spec + +``` +/feature-spec #123 # produces tmp/feature-specs/{slug}/ +/feature-sprint {slug} # executes the plan +``` + +Sprint reads: +- `context.md` → `parallel_groups`, `target_repo`, `quality_gates` +- `implementation-plan.md` → task list with checkboxes (source of truth) +- `{slug}.md` → verify `status: COMPLETE` \ No newline at end of file diff --git a/.gitlab/duo/skills/feature-sprint/phase-1-init.md b/.gitlab/duo/skills/feature-sprint/phase-1-init.md new file mode 100644 index 000000000..4cdd6225c --- /dev/null +++ b/.gitlab/duo/skills/feature-sprint/phase-1-init.md @@ -0,0 +1,267 @@ +# Phase 1: Initialize Sprint + +Load the feature spec, hydrate Claude Tasks, detect parallel groups, decide OSS-vs-proprietary execution roots, get user approval. + +## Prerequisites + +- Feature spec exists at `tmp/feature-specs/{task_slug}/` +- Spec `status: COMPLETE` + +## Steps + +### 1.1 Resolve Task Slug + +Extract `{task_slug}` from user input. If not provided: + +```bash +ls tmp/feature-specs/ +``` + +For each candidate directory, read `{slug}.md` YAML frontmatter: +- Skip directories without `status: COMPLETE` +- Capture `task_name`, `source_type`, `target_repo` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Which feature spec do you want to sprint?", + "header": "Task", + "multiSelect": false, + "options": [ + {"label": "{slug-1}", "description": "[{target_repo}] {task_name}"}, + {"label": "{slug-2}", "description": "[{target_repo}] {task_name}"} + ] + }] +} +``` + +If `tmp/feature-specs/` has no COMPLETE specs, tell the user to run `/feature-spec` first. + +### 1.2 Load Specification Files + +Read from `tmp/feature-specs/{task_slug}/`: + +1. **`{task_slug}.md`** — Read YAML frontmatter, verify `status: COMPLETE`. If DRAFT or missing, error and stop. + +2. **`context.md`** — Extract YAML frontmatter fields: + - `version` (≥ 1.0) + - `target_repo` (`oss | proprietary | both`) + - `oss_root`, `proprietary_root`, `discovery_root` + - `parallel_groups[]` (may be empty for trivial features) + - `quality_gates` (`lint`, `test`, `build`, `extra`) + +3. **`implementation-plan.md`** — Parse via the bundled script (do NOT hand-roll the regex; it must handle the `_(BLOCKED: …)_` annotation and indented metadata blocks): + + ```bash + python3 .claude/skills/feature-sprint/scripts/parse_implementation_plan.py \ + tmp/feature-specs/{task_slug}/implementation-plan.md + ``` + + Output is JSON: `{tasks: [{id, completed, description, metadata, blocked_reason, raw_block}], summary}`. Keep each task's `raw_block` — Phase 2 pastes it verbatim into the subagent prompt. + +### 1.3 Verify Repos Exist + +For each parallel group, check the repo it targets exists: + +- `repo: oss` → confirm `oss_root` directory exists (the OSS sibling, typically `realpath ../packmind` from the proprietary repo) +- `repo: proprietary` → confirm `proprietary_root` exists (the current working directory when /feature-spec was run) + +If a required repo is missing, ask the user: + +```json +{ + "questions": [{ + "question": "OSS repo at `{oss_root}` is missing. How to proceed?", + "header": "Missing repo", + "multiSelect": false, + "options": [ + {"label": "Abort", "description": "Cancel sprint; user will set up the repo"}, + {"label": "Switch to proprietary", "description": "Run all groups in this repo regardless of repo tag"}, + {"label": "Skip OSS groups", "description": "Only execute groups tagged proprietary"} + ] + }] +} +``` + +### 1.4 Map Tasks to Groups + +If `context.md` has non-empty `parallel_groups`: + +```javascript +const groupedTasks = {}; +for (const group of parallel_groups) { + groupedTasks[group.group_id] = { + name: group.group_name, + tasks: group.task_ids, + target_files: group.target_files, + repo: group.repo, + blocked_by: group.blocked_by ?? [], + status: 'pending', + }; +} +``` + +If `parallel_groups` is empty, create a single sequential group covering all tasks, using `target_repo` from context for `repo`. + +### 1.5 Detect Resume State + +Use the plan-wide totals (`completed`, `pending`, `total`, `percent`) from the `summary` field already returned by `parse_implementation_plan.py`. + +For the **per-group** breakdown table below, also run the readiness resolver and use each group's `completed_ids`/`task_ids` lengths: + +```bash +python3 .claude/skills/feature-sprint/scripts/resolve_ready_groups.py \ + tmp/feature-specs/{task_slug}/context.md \ + tmp/feature-specs/{task_slug}/implementation-plan.md +``` + +Map each `groups[*]` entry to a row — `Tasks = len(task_ids)`, `Completed = len(completed_ids)`, `Status` from `status` (`ready` → ✅ Ready / 🔄 Partial / ⏳ Blocked / ✅ Done). + +If any tasks are already completed: + +``` +**Resuming Sprint: {task_slug}** + +Previous progress: +- Completed: {n}/{total} ({percent}%) +- Pending: {pending} + +| Group | Repo | Tasks | Completed | Status | +|-------|------|-------|-----------|--------| +| A: {name} | oss | 3 | 3 | ✅ Done | +| B: {name} | oss | 2 | 1 | 🔄 Partial | +| C: {name} | oss | 2 | 0 | ⏳ Blocked (after A, B) | +``` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Resume sprint from previous progress?", + "header": "Resume", + "multiSelect": false, + "options": [ + {"label": "Continue (Recommended)", "description": "Resume from {completed}/{total} done"}, + {"label": "Restart", "description": "Reset all checkboxes to [ ] and start over"} + ] + }] +} +``` + +If "Restart": rewrite `implementation-plan.md` replacing every `- [x]` with `- [ ]` and stripping any `_(BLOCKED: …)_` annotations (a single `sed` pass is fine since this is destructive on purpose). + +### 1.6 Pull-Before-Sprint (proprietary only) + +If `target_repo` is `proprietary` or `both`: + +Remind the user (informational, no gate): +``` +Heads up: most OSS work auto-merges into this proprietary fork. +Before starting, you may want to run `git pull` here so the fork is up to date. +``` + +If `target_repo` is `oss` only, skip this reminder. + +### 1.7 Hydrate Claude Tasks + +For visual feedback, use `TaskCreate` for each pending task: + +``` +TaskCreate({ + subject: `${task.id}: ${task.description}`, + description: `Repo: ${task.repo} | Layer: ${task.layer} | Files: ${task.files}`, + activeForm: `Implementing ${task.id}...`, +}) +``` + +Capture each returned task ID into a `sessionTasks` map keyed by `task.id`. + +### 1.8 Set Up Task Dependencies + +For each group with `blocked_by`: + +``` +for blockerGroupId in group.blocked_by: + for taskId in group.tasks: + TaskUpdate( + taskId: sessionTasks[taskId], + addBlockedBy: blockerGroup.tasks.map(t => sessionTasks[t]) + ) +``` + +This is purely visual — the actual execution gating is enforced in Phase 2 by ready-group selection. + +### 1.9 Display Execution Plan + +``` +**Sprint Ready: {task_slug}** + +**Target repo:** {target_repo} +**Tasks:** {pending}/{total} pending ({completed} already done) + +**Parallel Execution Plan:** +| Group | Repo | Tasks | Status | Dependencies | +|-------|------|-------|--------|--------------| +| A: {name} | oss | {ids} | ✅ Ready | None | +| B: {name} | oss | {ids} | ✅ Ready | None | +| C: {name} | oss | {ids} | ⏳ Blocked | After A, B | + +**Quality gates (final phase):** Nx lint / test / build on affected projects +**Commit strategy:** one commit per group via git-commit-guidelines +``` + +### 1.10 Authorize Subagent File Edits + +Phase 2 spawns Agent subagents that call Edit / Write / Bash on files in the target repo. **Subagents inherit the parent session's permission mode** — in default mode every tool call pauses for approval, which serializes parallel execution and stalls the sprint. + +**Before continuing, the user must enable auto-accept mode in the Claude Code TUI** (`Shift+Tab` cycles through modes; pick "accept edits" or "bypass permissions"). The orchestrator cannot toggle this on the user's behalf. + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Confirm auto-accept (Shift+Tab) is enabled so subagents can edit files without prompts?", + "header": "Authorize", + "multiSelect": false, + "options": [ + {"label": "Yes, ready to sprint", "description": "Auto-accept is on; subagents will edit files without per-call prompts"}, + {"label": "Not yet — cancel", "description": "Stop the sprint; I'll toggle auto-accept and re-run /feature-sprint"} + ] + }] +} +``` + +If "Not yet — cancel": stop and leave state untouched. Otherwise continue to §1.11. + +### 1.11 Get Approval + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Start sprint execution?", + "header": "Sprint", + "multiSelect": false, + "options": [ + {"label": "Start parallel (Recommended)", "description": "Execute all ready groups in parallel via subagents"}, + {"label": "Sequential only", "description": "One group at a time — slower but easier to debug"}, + {"label": "Cancel", "description": "Don't start"} + ] + }] +} +``` + +Do not continue until the user confirms. + +- "Start parallel" → set `execution_mode = "parallel"` +- "Sequential only" → set `execution_mode = "sequential"` +- "Cancel" → stop, leave state untouched + +### 1.12 Proceed + +Read `phase-2-execute.md` and continue. diff --git a/.gitlab/duo/skills/feature-sprint/phase-2-execute.md b/.gitlab/duo/skills/feature-sprint/phase-2-execute.md new file mode 100644 index 000000000..5507d67c5 --- /dev/null +++ b/.gitlab/duo/skills/feature-sprint/phase-2-execute.md @@ -0,0 +1,222 @@ +# Phase 2: Execute Sprint + +Execute tasks via subagents with automatic sync-back and per-group commits. Runs autonomously after Phase 1 approval. + +## Critical Rule: Subagent-Only Execution + +**The main session MUST NOT implement tasks directly.** All implementation lives in Task subagents. + +- Main session = orchestration (spawn agents, parse output, sync state, run commits) +- Subagents = implementation (read specs, write code, run small validation commands) + +This keeps the main context clean and lets agents read heavily without poisoning your conversation. + +## Prerequisites + +- Phase 1 completed +- `execution_mode` chosen (parallel or sequential) +- `sessionTasks` map populated +- `groupedTasks` populated from `parallel_groups` + +## Execution Modes + +### Parallel Mode (Default) + +Spawn Task subagents for ALL ready groups **simultaneously in a single message**. Agents run concurrently. + +### Sequential Mode + +Spawn Task subagents **one at a time**. Wait for each to complete before spawning the next. Used when: +- No `parallel_groups` defined (single sequential group) +- User chose "Sequential only" in Phase 1 +- Only one ready group exists + +## Steps + +### 2.1 Identify Ready Groups + +Call the bundled script — it reads `context.md` + `implementation-plan.md` and classifies every group as `ready | completed | partial | blocked`: + +```bash +python3 .claude/skills/feature-sprint/scripts/resolve_ready_groups.py \ + tmp/feature-specs/{task_slug}/context.md \ + tmp/feature-specs/{task_slug}/implementation-plan.md +``` + +Output JSON contains `ready_group_ids[]` and per-group `status`/`completed_ids`/`blocked_ids`. Use this every iteration of the loop in step 2.10 — never hand-compute the readiness check. + +### 2.2 Pick Working Directory per Group + +For each ready group: + +| group.repo | cwd | +|------------|-----| +| `oss` | `oss_root` from context (the OSS sibling at `../packmind`, resolved to absolute path at spec time) | +| `proprietary` | `proprietary_root` from context (the proprietary repo, the cwd when /feature-spec ran) | + +This `cwd` is passed to the subagent's prompt as the *implementation root*. The subagent must Bash with absolute paths so it works regardless of the agent's own cwd. + +### 2.3 Build Subagent Prompt + +Read the bundled template once: `.claude/skills/feature-sprint/references/group-prompt-template.md`. Substitute the placeholders documented at the top of that file. For `{TASKS_BLOCK}`, concatenate the `raw_block` of each task in `group.task_ids` (from the Phase 1 `parse_implementation_plan.py` output) separated by blank lines. + +Do not paraphrase the template — it is the authoritative contract subagents respond to (`TASK_COMPLETE`, `GROUP_COMPLETE`, `BLOCKED`, `FILES_MODIFIED` markers are parsed in step 2.5). + +### 2.4 Spawn Subagents + +**Parallel mode**: send a single message containing one `Agent` tool call per ready group. They run concurrently. + +**Sequential mode**: send one `Agent` call, wait for it to return, then move to the next. + +In both cases, set `subagent_type="general-purpose"`. + +### 2.5 Parse Subagent Output + +For each agent that returns, parse: + +- `TASK_COMPLETE: {id}` markers — collect into `completed[]` +- `GROUP_COMPLETE: {id}` marker — group finished cleanly +- `BLOCKED: {id} - {reason}` markers — collect into `blocked[]` +- `FILES_MODIFIED: ...` — capture for the commit step + +### 2.6 Sync Checkboxes to implementation-plan.md + +Apply both completed and blocked updates in one pass via the bundled script: + +```bash +python3 .claude/skills/feature-sprint/scripts/sync_checkboxes.py \ + tmp/feature-specs/{task_slug}/implementation-plan.md \ + --complete {comma-separated ids from completed[]} \ + --blocked "{id1}:{reason1}" "{id2}:{reason2}" +``` + +`--blocked` takes one `id:reason` per argument (so reasons may contain commas freely). `--complete` is a single comma-joined ID list. + +**Omit a flag entirely when its list is empty** — do not pass an unquoted empty value (`--complete `) since argparse will treat the next flag as the value. If `completed[]` and `blocked[]` are both empty, skip this step (nothing to sync). + +The script returns JSON with `applied_complete`, `applied_blocked`, and `missing` (any task ID that didn't match — surface these as a warning; don't retry the agent). Exit code is `1` if anything was missing, `0` otherwise. + +### 2.7 Update Claude Tasks + +For every task in `completed[]`: + +``` +TaskUpdate({ taskId: sessionTasks[task_id], status: "completed" }) +``` + +For tasks currently being executed but not yet complete (shouldn't normally happen here since groups are atomic), mark them `in_progress`. + +### 2.8 Commit the Group + +After the agent finishes (whether all tasks completed or some blocked), commit the changes if anything was modified: + +1. Switch to the group's working repo (use absolute path): + ``` + git -C {repo_root} status --short + ``` + If nothing changed, skip the commit step for this group. + +2. Stage only the files listed in `FILES_MODIFIED` (don't `git add -A`): + ``` + git -C {repo_root} add {files...} + ``` + +3. Build the commit message using the `git-commit-guidelines` skill conventions: + - Gitmoji prefix (✨ for new features, 🐛 for fixes, ♻️ for refactor, ✅ for tests, etc.) + - Conventional Commits format: `<type>(<scope>): <subject>` + - NEVER use `Close`/`Fix #` keywords (no auto-closing of issues) + - Body lists completed task IDs and references the spec slug + - Include `Co-Authored-By: Claude <noreply@anthropic.com>` + + Template: + ``` + {emoji} {type}({scope}): {one-line subject derived from group_name} + + Sprint group {group_id} ({group_name}) for feature `{task_slug}`. + + Completed tasks: + - {1.1}: {description} + - {1.2}: {description} + + Refs: tmp/feature-specs/{task_slug}/ + + Co-Authored-By: Claude <noreply@anthropic.com> + ``` + +4. Show the message to the user and ask for approval: + + ```json + { + "questions": [{ + "question": "Commit group {group_id} ({group_name})?", + "header": "Commit", + "multiSelect": false, + "options": [ + {"label": "Commit (Recommended)", "description": "Apply the proposed message"}, + {"label": "Edit message", "description": "Provide a new commit message"}, + {"label": "Skip commit", "description": "Leave changes staged for manual commit later"} + ] + }] + } + ``` + +5. On approval, run: + ``` + git -C {repo_root} commit -m "$(cat <<'EOF' + {message} + EOF + )" + ``` + + If a pre-commit hook fails, do NOT use `--no-verify`. Fix the underlying issue or pass control back to the user. + +### 2.9 Refresh Group Status + +Group status is derived from checkbox state on the next call to `resolve_ready_groups.py` — no in-memory bookkeeping required. The sync step in 2.6 is the only state mutation. + +### 2.10 Loop Until No Ready Groups Remain + +After each round, re-run `resolve_ready_groups.py` (it reads from disk, so it picks up the sync from 2.6): + +1. If `ready_group_ids[]` is non-empty → repeat steps 2.3–2.8 for the new ready groups +2. If `ready_group_ids[]` is empty AND `all_completed` is false → every remaining group is blocked (status `blocked` or `partial`). Report blockers and exit to Phase 3. +3. If `all_completed` is true → proceed to Phase 3. + +### 2.11 Final Sync Summary + +Before handing off to Phase 3, print a summary: + +``` +**Execution Phase Complete** + +| Group | Repo | Tasks | Status | Commit | +|-------|------|-------|--------|--------| +| A: backend-{domain} | oss | 3/3 | ✅ Complete | abc123 | +| B: frontend-{domain} | oss | 2/2 | ✅ Complete | def456 | +| C: tests | oss | 1/2 | 🔄 Partial | ghi789 | + +Total: {completed}/{total} tasks done +Blocked: {list of blocked task IDs with reasons} +``` + +## Error Recovery + +| Scenario | Action | +|----------|--------| +| Agent times out | Sync completed tasks, mark group `partial`, continue | +| Agent crashes | Sync completed tasks, log the error, pause sprint and ask user | +| Lint/test fails inside subagent | Subagent should self-correct; if it can't, it emits `BLOCKED` | +| File conflict (group A writes while group B is reading) | Shouldn't happen if Phase 3D grouping was correct. If it does: pause, report, ask user to resolve | +| All ready groups are blocked | Pause sprint, report blockers, suggest fixes | +| `git commit` fails | Surface the error verbatim; do NOT retry with `--no-verify` | + +## Handling Interruption + +If the session is killed mid-sprint: +- Completed tasks already have `[x]` in `implementation-plan.md` +- Blocked tasks have the `_(BLOCKED: ...)_` annotation +- Re-running `/feature-sprint {task_slug}` resumes from those checkboxes + +## Next Phase + +After all reachable groups are completed or blocked, read `phase-3-finalize.md`. diff --git a/.gitlab/duo/skills/feature-sprint/phase-3-finalize.md b/.gitlab/duo/skills/feature-sprint/phase-3-finalize.md new file mode 100644 index 000000000..ac7f3f426 --- /dev/null +++ b/.gitlab/duo/skills/feature-sprint/phase-3-finalize.md @@ -0,0 +1,227 @@ +# Phase 3: Finalize Sprint + +Run Nx quality gates on affected projects, produce the final report, optionally archive the spec. + +## Prerequisites + +- Phase 2 completed (all reachable groups completed or blocked) +- Per-group commits already created (or skipped) in each working repo + +## Steps + +### 3.1 Load Final State + +Read `tmp/feature-specs/{task_slug}/implementation-plan.md`: +- Count `[x]` vs `[ ]` +- Find any `_(BLOCKED: ...)_` annotations + +Read `tmp/feature-specs/{task_slug}/context.md`: +- `quality_gates` (lint/test/build flags + extras) +- `parallel_groups[]` to derive which Nx projects were touched + +### 3.2 Resolve Affected Nx Projects + +For each completed group, look at `target_files`: +- A path under `packages/{name}/` → Nx project `{name}` +- A path under `apps/{name}/` → Nx project `{name}` + +Build a deduplicated set of `affected_projects[]`. Also note which **repo** each project belongs to (`oss` or `proprietary`) — gates run in the right repo. + +If unsure or you want a broader gate, the Nx convention is: +``` +./node_modules/.bin/nx affected -t lint +./node_modules/.bin/nx affected -t test +./node_modules/.bin/nx affected -t build +``` +But narrowly targeting the specific projects is faster and clearer when you know them. + +### 3.3 Run Quality Gates + +For each `affected_project` × each gate flag in `quality_gates`: + +```bash +# Always cd to the repo via -C; use absolute paths. +{ensure node version per .nvmrc, e.g. via nvm if available} + +# Lint +./node_modules/.bin/nx lint {project} + +# Test +./node_modules/.bin/nx test {project} + +# Build (only if quality_gates.build is true) +./node_modules/.bin/nx build {project} +``` + +Set `PACKMIND_EDITION=proprietary` in the environment when running gates against the proprietary repo (per `CLAUDE.md`). + +Capture for each: `passed: bool`, `duration`, truncated output. + +If any extras are listed in `quality_gates.extra`, run them too (e.g., a project-specific e2e command). + +### 3.4 Handle Gate Failures + +If any gate fails, display the failure: + +``` +**Quality Gate Failed: {project} / {gate}** + +Repo: {repo} +Command: {command} +Exit: {code} + +{Last 30 lines of output} +``` + +Then use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Quality gate '{project}/{gate}' failed. How to proceed?", + "header": "Gate Failed", + "multiSelect": false, + "options": [ + {"label": "Fix and retry (Recommended)", "description": "I'll fix the issue, then retry the gate"}, + {"label": "Spawn fixer subagent", "description": "Delegate the fix to a fresh general-purpose subagent in the right repo"}, + {"label": "Skip gate", "description": "Continue without this gate passing (risky)"}, + {"label": "Pause sprint", "description": "Stop here, investigate manually"} + ] + }] +} +``` + +- "Fix and retry" → wait for the user to make changes, then re-run the gate +- "Spawn fixer subagent" → launch an `Agent` with `subagent_type="general-purpose"`. Prompt: working repo, the failed command and output, the relevant files. Instruct it to make the smallest fix and re-run the gate. On success, commit the fix as a follow-up commit (with user approval, per the per-group commit policy). +- "Skip gate" → record skipped, continue +- "Pause sprint" → exit; progress is preserved in checkboxes + commits + +### 3.5 Verify Final Checkbox State + +Re-read `implementation-plan.md`: +- Count completed (`[x]`) +- Count remaining (`[ ]`) — these should all be annotated `_(BLOCKED: ...)_` or the sprint is incomplete + +If any unblocked `[ ]` tasks remain, the sprint is not finished. Tell the user and offer to resume Phase 2. + +### 3.6 Offer to Archive + +``` +**Sprint Complete: {task_slug}** + +All reachable tasks executed, gates run, commits created. + +Move spec artifacts to an archive folder? (Still in tmp/ — git-ignored.) +- From: tmp/feature-specs/{task_slug}/ +- To: tmp/feature-specs/done/{task_slug}/ +``` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Move completed spec to tmp/feature-specs/done/?", + "header": "Archive", + "multiSelect": false, + "options": [ + {"label": "Yes, archive", "description": "Move under tmp/feature-specs/done/ so /feature-spec sees a clean pending list"}, + {"label": "Keep in place (Recommended)", "description": "Leave it; you may want to revisit it"} + ] + }] +} +``` + +If "Yes, archive": +```bash +mkdir -p tmp/feature-specs/done/ +mv tmp/feature-specs/{task_slug} tmp/feature-specs/done/{task_slug} +``` + +(No commit needed — `tmp/` is git-ignored.) + +### 3.7 Pull Reminder (OSS → proprietary) + +If any commits landed in the OSS repo (`oss_root`), remind the user: + +``` +✅ OSS commits created in {oss_root}. + +When upstream auto-merges into the proprietary fork, run: + git -C {proprietary_root} pull +to pick up the changes here. + +If this feature has proprietary-only tasks pending (e.g., editions wiring), do that pull before resuming /feature-sprint {task_slug}. +``` + +Skip this section if `target_repo` was `proprietary`. + +### 3.8 Final Report + +``` +## ✅ SPRINT COMPLETE + +**Task**: {task_name} +**Slug**: {task_slug} +**Target repo**: {target_repo} + +### Execution Summary + +| Metric | Value | +|--------|-------| +| Tasks completed | {n}/{total} | +| Parallel groups | {completed_groups}/{total_groups} | +| Commits created | {commit_count} | +| Files modified | {file_count} | +| Quality gates | {passed}/{total} passed{, X skipped if any} | + +### Commits + +| Repo | Group | SHA | Message | +|------|-------|-----|---------| +| oss | A: backend-... | abc123 | ✨ feat(...): ... | +| oss | B: frontend-... | def456 | ✨ feat(...): ... | + +### Quality Gates + +| Project | Gate | Status | Duration | +|---------|------|--------|----------| +| standards | lint | ✅ | 4s | +| standards | test | ✅ | 22s | +| frontend | lint | ✅ | 8s | +| frontend | test | ✅ | 31s | + +### Blocked Tasks + +{if any:} +| ID | Reason | +|----|--------| +| 3.2 | {reason} | + +{else: "None."} + +### Files Modified + +{abbreviated git status -s output per repo} + +--- + +**Spec**: `tmp/feature-specs/{pending|done}/{task_slug}/` +**Plan**: `tmp/feature-specs/{pending|done}/{task_slug}/implementation-plan.md` + +Sprint complete. Changes committed locally (not pushed). +``` + +### 3.9 Cleanup Session Tasks + +``` +for (taskId, sessionTaskId) in sessionTasks: + TaskUpdate({ taskId: sessionTaskId, status: "completed" }) +``` + +`sessionTasks` is ephemeral — nothing else to clean up. + +## End of Sprint + +To run another: `/feature-sprint {another_task_slug}` +To check overall progress: `/feature-sprint status` diff --git a/.gitlab/duo/skills/feature-sprint/references/group-prompt-template.md b/.gitlab/duo/skills/feature-sprint/references/group-prompt-template.md new file mode 100644 index 000000000..d928f20ba --- /dev/null +++ b/.gitlab/duo/skills/feature-sprint/references/group-prompt-template.md @@ -0,0 +1,84 @@ +# Per-Group Subagent Prompt Template + +Loaded by `phase-2-execute.md` step 2.3. The orchestrator substitutes every +`{placeholder}` (and the `{TASKS_BLOCK}` section) with concrete values, then +passes the result as the `prompt` argument to `Agent` with +`subagent_type="general-purpose"`. + +## Placeholders + +| Placeholder | Source | +|-------------|--------| +| `{group_id}` | `context.md` → `parallel_groups[*].group_id` | +| `{group_name}` | `context.md` → `parallel_groups[*].group_name` | +| `{task_slug}` | spec directory name (`tmp/feature-specs/{slug}/`) | +| `{repo}` | `parallel_groups[*].repo` (`oss` or `proprietary`) | +| `{repo_root}` | absolute path: `oss_root` or `proprietary_root` from `context.md` | +| `{proprietary_root}` | always `context.md` → `proprietary_root` (spec artifacts live here) | +| `{TASKS_BLOCK}` | concatenation of each task's `raw_block` from `parse_implementation_plan.py` output | + +## Template + +``` +Execute sprint group {group_id}: {group_name} for feature {task_slug}. + +## Working repo +- Repo: {repo} +- Root (absolute path): {repo_root} +- All file paths in tasks are relative to this root unless otherwise noted. + +## Context files (read in this exact order) +1. {proprietary_root}/tmp/feature-specs/{task_slug}/context.md +2. {proprietary_root}/tmp/feature-specs/{task_slug}/functional-spec.md +3. {proprietary_root}/tmp/feature-specs/{task_slug}/discovery.md +4. {proprietary_root}/tmp/feature-specs/{task_slug}/implementation-plan.md + +Note: spec artifacts live in the PROPRIETARY repo's tmp/ even when implementation +targets OSS. Always read from `{proprietary_root}/tmp/feature-specs/{task_slug}/`. + +## Your tasks (in this exact order) + +{TASKS_BLOCK} + +## Rules + +1. For each task in order: + a. Re-read the task block in implementation-plan.md for full detail + b. Open the Reference file:line — that is your pattern template + c. Implement the task following the Packmind conventions established in discovery.md + d. If the task is a backend domain/application/infra task, respect the hexagonal-architecture skill at `{proprietary_root}/.claude/skills/hexagonal-architecture/` + e. If the task is a frontend task, respect `working-with-pm-design-kit` and `gateway-pattern-implementation-in-packmind-frontend` + f. If the task is a migration, respect `how-to-write-typeorm-migrations-in-packmind` + g. After finishing the task, output exactly: `TASK_COMPLETE: {task_id}` + +2. NEVER import from `@packmind/editions` unless the file you're editing already lives inside `packages/editions/` (this is enforced by `.claude/rules/packmind/packmind-proprietary.md`). + +3. Run `./node_modules/.bin/nx lint <project>` and `./node_modules/.bin/nx test <project>` after finishing each Nx project's tasks within the group. If anything fails, fix it before moving on. + +4. After ALL tasks in this group are complete, output exactly: + ``` + GROUP_COMPLETE: {group_id} + COMPLETED_TASKS: <comma-separated task_ids> + FILES_MODIFIED: <list of absolute file paths you changed> + ``` + +5. If you encounter a blocker: + - Output: `BLOCKED: <task_id> - <short reason>` + - Continue with later tasks in this group only if they don't depend on the blocked task + - At the end, list blocked tasks in your group summary + +## Hard constraints + +- Do NOT commit changes — the main session handles commits per group +- Do NOT modify `implementation-plan.md` checkboxes — the main session handles sync-back +- Do NOT modify any file in `{proprietary_root}/tmp/feature-specs/{task_slug}/` +- Focus only on implementing the listed task IDs; do not "improve" unrelated files +- Use absolute paths in your Bash calls so they work regardless of your cwd + +## Tools you should rely on + +- Read, Edit, Write for code changes +- Bash with absolute paths for `nx lint`, `nx test`, `nx build` in the working repo +- Glob/Grep within the working repo for navigation +- Avoid spawning further subagents (`Agent`) — flatten everything in this one +``` diff --git a/.gitlab/duo/skills/feature-sprint/scripts/parse_implementation_plan.py b/.gitlab/duo/skills/feature-sprint/scripts/parse_implementation_plan.py new file mode 100644 index 000000000..b8e6068c2 --- /dev/null +++ b/.gitlab/duo/skills/feature-sprint/scripts/parse_implementation_plan.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Parse a feature-sprint implementation-plan.md into structured JSON. + +Reads checkbox tasks of the form: + + - [ ] **1.2: short description** + - Repo: oss + - Layer: application + - Files: packages/foo/... + - Reference: packages/bar/baz.ts:42 + - Notes: free text + +and emits JSON to stdout: + + { + "tasks": [ + { + "id": "1.2", + "completed": false, + "description": "short description", + "metadata": { + "repo": "oss", + "layer": "application", + "files": "packages/foo/...", + "reference": "packages/bar/baz.ts:42", + "notes": "free text" + }, + "blocked_reason": null, + "raw_block": "..." + } + ], + "summary": {"total": 1, "completed": 0, "pending": 1, "blocked": 0} + } + +Tasks annotated with `_(BLOCKED: reason)_` after the description carry that +reason on `blocked_reason`. The full markdown block (including indented +metadata lines) is preserved on `raw_block` so the orchestrator can paste it +verbatim into subagent prompts without re-reading the file. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +TASK_LINE = re.compile( + r"""^-\s\[(?P<state>[ x])\]\s\*\* + (?P<id>\d+(?:\.\d+)+):\s + (?P<desc>.+?)\*\* + (?:\s+_\(BLOCKED:\s(?P<blocked>.+?)\)_)? + \s*$""", + re.VERBOSE, +) +META_LINE = re.compile(r"^\s+-\s(?P<key>[A-Za-z]+):\s*(?P<value>.*)$") + + +def parse(content: str) -> dict: + tasks: list[dict] = [] + current: dict | None = None + raw_lines: list[str] = [] + + def flush() -> None: + if current is None: + return + current["raw_block"] = "\n".join(raw_lines).rstrip() + tasks.append(current) + + for line in content.splitlines(): + task_match = TASK_LINE.match(line) + if task_match: + flush() + raw_lines = [line] + current = { + "id": task_match.group("id"), + "completed": task_match.group("state") == "x", + "description": task_match.group("desc").strip(), + "metadata": {}, + "blocked_reason": task_match.group("blocked"), + "raw_block": "", + } + continue + + if current is None: + continue + + meta_match = META_LINE.match(line) + if meta_match: + raw_lines.append(line) + key = meta_match.group("key").lower() + value = meta_match.group("value").strip() + current["metadata"][key] = value + continue + + if line.startswith("- [") or (line.strip() == "" and len(raw_lines) > 1): + flush() + current = None + raw_lines = [] + continue + + if line.strip(): + raw_lines.append(line) + + flush() + + completed = sum(1 for t in tasks if t["completed"]) + blocked = sum(1 for t in tasks if t["blocked_reason"]) + return { + "tasks": tasks, + "summary": { + "total": len(tasks), + "completed": completed, + "pending": len(tasks) - completed, + "blocked": blocked, + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("plan", help="Path to implementation-plan.md") + parser.add_argument( + "--ids-only", + action="store_true", + help="Print only the task IDs (one per line) instead of JSON", + ) + parser.add_argument( + "--pending-only", + action="store_true", + help="Filter to non-completed tasks before printing", + ) + args = parser.parse_args() + + path = Path(args.plan) + if not path.exists(): + print(f"Error: plan file not found: {path}", file=sys.stderr) + return 1 + + result = parse(path.read_text(encoding="utf-8")) + + if args.pending_only: + result["tasks"] = [t for t in result["tasks"] if not t["completed"]] + + if args.ids_only: + for task in result["tasks"]: + print(task["id"]) + return 0 + + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.gitlab/duo/skills/feature-sprint/scripts/resolve_ready_groups.py b/.gitlab/duo/skills/feature-sprint/scripts/resolve_ready_groups.py new file mode 100644 index 000000000..b6da2306a --- /dev/null +++ b/.gitlab/duo/skills/feature-sprint/scripts/resolve_ready_groups.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Compute which parallel groups are ready to execute next. + +Reads: +- context.md (YAML frontmatter with `parallel_groups[]`) +- implementation-plan.md (checkbox state per task) + +A group is **completed** when every task in `task_ids` is checked `[x]` AND +not annotated `_(BLOCKED: ...)_`. A group is **partial** when some tasks are +done and some are blocked. A group is **ready** when its status is not yet +`completed` and every group it depends on (`blocked_by`) has status +`completed`. + +Output (stdout, JSON): + + { + "groups": [ + { + "group_id": "A", + "group_name": "backend-foo", + "repo": "oss", + "status": "ready" | "completed" | "partial" | "blocked", + "task_ids": ["1.1", "1.2"], + "completed_ids": ["1.1"], + "blocked_ids": [], + "blocked_by": [] + } + ], + "ready_group_ids": ["A"], + "all_completed": false + } + +Requires PyYAML. Exit 1 on missing files or malformed YAML. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +try: + import yaml +except ImportError: + print( + "Error: PyYAML is required (pip install pyyaml)", + file=sys.stderr, + ) + sys.exit(1) + + +FRONTMATTER = re.compile(r"^---\n(.*?)\n---", re.DOTALL) +TASK_STATE = re.compile( + r"^-\s\[(?P<state>[ x])\]\s\*\*(?P<id>\d+(?:\.\d+)+):\s.+?\*\*" + r"(?P<blocked>\s+_\(BLOCKED:.+?\)_)?\s*$", + re.MULTILINE, +) + + +def load_frontmatter(path: Path) -> dict: + text = path.read_text(encoding="utf-8") + match = FRONTMATTER.match(text) + if not match: + print(f"Error: no YAML frontmatter in {path}", file=sys.stderr) + sys.exit(1) + try: + data = yaml.safe_load(match.group(1)) + except yaml.YAMLError as exc: + print(f"Error: invalid YAML in {path}: {exc}", file=sys.stderr) + sys.exit(1) + if not isinstance(data, dict): + print(f"Error: frontmatter must be a mapping in {path}", file=sys.stderr) + sys.exit(1) + return data + + +def extract_task_state(plan_path: Path) -> dict[str, dict]: + states: dict[str, dict] = {} + for match in TASK_STATE.finditer(plan_path.read_text(encoding="utf-8")): + states[match.group("id")] = { + "completed": match.group("state") == "x", + "blocked": bool(match.group("blocked")), + } + return states + + +def classify_group(group: dict, task_state: dict[str, dict]) -> dict: + task_ids = group.get("task_ids", []) or [] + completed: list[str] = [] + blocked: list[str] = [] + for task_id in task_ids: + info = task_state.get(task_id) + if info is None: + continue + if info["completed"]: + completed.append(task_id) + elif info["blocked"]: + blocked.append(task_id) + + if task_ids and len(completed) == len(task_ids): + status = "completed" + elif blocked and (len(completed) + len(blocked)) == len(task_ids): + status = "partial" + else: + status = "pending" # may become "ready" after dep check + + return { + "group_id": group.get("group_id"), + "group_name": group.get("group_name"), + "repo": group.get("repo"), + "task_ids": task_ids, + "blocked_by": group.get("blocked_by", []) or [], + "target_files": group.get("target_files", []) or [], + "status": status, + "completed_ids": completed, + "blocked_ids": blocked, + } + + +def resolve_readiness(groups: list[dict]) -> None: + by_id = {g["group_id"]: g for g in groups} + for group in groups: + if group["status"] in ("completed", "partial"): + continue + deps_done = all( + by_id.get(dep, {}).get("status") == "completed" + for dep in group["blocked_by"] + ) + group["status"] = "ready" if deps_done else "blocked" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("context", help="Path to context.md") + parser.add_argument("plan", help="Path to implementation-plan.md") + args = parser.parse_args() + + context_path = Path(args.context) + plan_path = Path(args.plan) + for path, label in [(context_path, "context"), (plan_path, "plan")]: + if not path.exists(): + print(f"Error: {label} file not found: {path}", file=sys.stderr) + return 1 + + frontmatter = load_frontmatter(context_path) + parallel_groups = frontmatter.get("parallel_groups") or [] + if not isinstance(parallel_groups, list): + print("Error: parallel_groups must be a list", file=sys.stderr) + return 1 + + task_state = extract_task_state(plan_path) + groups = [classify_group(g, task_state) for g in parallel_groups] + resolve_readiness(groups) + + ready_ids = [g["group_id"] for g in groups if g["status"] == "ready"] + all_completed = bool(groups) and all(g["status"] == "completed" for g in groups) + + result = { + "groups": groups, + "ready_group_ids": ready_ids, + "all_completed": all_completed, + } + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.gitlab/duo/skills/feature-sprint/scripts/sync_checkboxes.py b/.gitlab/duo/skills/feature-sprint/scripts/sync_checkboxes.py new file mode 100644 index 000000000..0253477fa --- /dev/null +++ b/.gitlab/duo/skills/feature-sprint/scripts/sync_checkboxes.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Batch-update task checkboxes in a feature-sprint implementation-plan.md. + +Supports two operations in a single pass: + + --complete 1.1,1.2,2.3 Flip `- [ ] **{id}:` to `- [x] **{id}:` + --blocked '3.1:reason' '3.2:r2' Annotate `- [ ] **{id}: desc**` with + `_(BLOCKED: reason)_` (keeps the checkbox + unchecked so resume picks it up). + +`--blocked` accepts one pair per argument so reasons can contain commas +freely (e.g. `'3.1:waiting on API, see #123'`). Pass either flag without +values, or omit it entirely, when the corresponding list is empty. + +Already-completed tasks (`[x]`) are left alone. Tasks already annotated +BLOCKED have their annotation replaced if a new reason is supplied. Any task +ID passed but not found in the file is reported on stderr; exit code is 1 if +any IDs were not applied, 0 otherwise. + +Output (stdout, JSON): + + { + "applied_complete": ["1.1", "1.2"], + "applied_blocked": [{"id": "3.1", "reason": "..."}], + "missing": ["9.9"] + } +""" + +import argparse +import json +import re +import sys +from pathlib import Path + + +def parse_id_list(raw: str | None) -> list[str]: + if not raw: + return [] + return [item.strip() for item in raw.split(",") if item.strip()] + + +def parse_blocked_list(items: list[str] | None) -> list[tuple[str, str]]: + if not items: + return [] + pairs = [] + for item in items: + item = item.strip() + if not item: + continue + if ":" not in item: + print( + f"Error: --blocked entry '{item}' missing ':reason'", + file=sys.stderr, + ) + sys.exit(2) + task_id, reason = item.split(":", 1) + pairs.append((task_id.strip(), reason.strip())) + return pairs + + +def task_line_pattern(task_id: str) -> re.Pattern[str]: + # Match the exact task line, preserving the description and any trailing + # markup. Captures: 1=state ([ ] or [x]), 2=description, 3=optional + # BLOCKED annotation already present. + return re.compile( + rf"^-\s\[(?P<state>[ x])\]\s\*\*{re.escape(task_id)}:\s(?P<desc>.+?)\*\*" + r"(?P<blocked>\s+_\(BLOCKED:.+?\)_)?\s*$", + re.MULTILINE, + ) + + +def apply_complete(content: str, ids: list[str]) -> tuple[str, list[str], list[str]]: + applied: list[str] = [] + missing: list[str] = [] + for task_id in ids: + pattern = task_line_pattern(task_id) + match = pattern.search(content) + if not match: + missing.append(task_id) + continue + if match.group("state") == "x": + applied.append(task_id) # idempotent — already complete + continue + # Replace the matched line: flip state, strip any BLOCKED annotation + new_line = f"- [x] **{task_id}: {match.group('desc')}**" + content = content[: match.start()] + new_line + content[match.end():] + applied.append(task_id) + return content, applied, missing + + +def apply_blocked( + content: str, pairs: list[tuple[str, str]] +) -> tuple[str, list[dict], list[str]]: + applied: list[dict] = [] + missing: list[str] = [] + for task_id, reason in pairs: + pattern = task_line_pattern(task_id) + match = pattern.search(content) + if not match: + missing.append(task_id) + continue + if match.group("state") == "x": + # Already complete — don't re-annotate + continue + new_line = ( + f"- [ ] **{task_id}: {match.group('desc')}** " + f"_(BLOCKED: {reason})_" + ) + content = content[: match.start()] + new_line + content[match.end():] + applied.append({"id": task_id, "reason": reason}) + return content, applied, missing + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("plan", help="Path to implementation-plan.md") + parser.add_argument( + "--complete", + help="Comma-separated task IDs to mark complete (e.g. 1.1,1.2)", + ) + parser.add_argument( + "--blocked", + nargs="*", + default=[], + help=( + "One or more 'id:reason' pairs, each as a single argument so " + "reasons may contain commas " + "(e.g. --blocked '3.1:waiting on API, see #123' '3.2:other')" + ), + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would change without writing the file", + ) + args = parser.parse_args() + + path = Path(args.plan) + if not path.exists(): + print(f"Error: plan file not found: {path}", file=sys.stderr) + return 1 + + completes = parse_id_list(args.complete) + blocks = parse_blocked_list(args.blocked) + + if not completes and not blocks: + print( + "Error: at least one of --complete or --blocked is required", + file=sys.stderr, + ) + return 2 + + content = path.read_text(encoding="utf-8") + content, applied_complete, missing_complete = apply_complete(content, completes) + content, applied_blocked, missing_blocked = apply_blocked(content, blocks) + + if not args.dry_run: + path.write_text(content, encoding="utf-8") + + result = { + "applied_complete": applied_complete, + "applied_blocked": applied_blocked, + "missing": sorted(set(missing_complete + missing_blocked)), + "dry_run": args.dry_run, + } + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + + return 1 if result["missing"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.gitlab/duo/skills/packmind-cli-list-commands/README.md b/.gitlab/duo/skills/packmind-cli-list-commands/README.md new file mode 100644 index 000000000..de94fe1d0 --- /dev/null +++ b/.gitlab/duo/skills/packmind-cli-list-commands/README.md @@ -0,0 +1,26 @@ +# Packmind CLI List Commands + +A reference skill that helps AI coding agents discover available standards, commands, skills, and packages via the Packmind CLI. + +## What This Skill Provides + +Quick reference for all CLI listing commands: +- `packmind-cli standards list` - List coding standards +- `packmind-cli commands list` - List reusable commands +- `packmind-cli skills list` - List available skills +- `packmind-cli install --list` - List available packages + +## How to Use + +The AI agent will automatically use this skill when it needs to discover available artifacts in the Packmind organization. + +## Prerequisites + +Before using these commands, ensure you have: + +- **packmind-cli**: Required for all commands +- **Packmind account**: Login via `packmind-cli login` + +## License + +Apache 2.0 - See LICENSE.txt for details. diff --git a/.gitlab/duo/skills/playbook-cli-audit/SKILL.md b/.gitlab/duo/skills/playbook-cli-audit/SKILL.md new file mode 100644 index 000000000..aa57be232 --- /dev/null +++ b/.gitlab/duo/skills/playbook-cli-audit/SKILL.md @@ -0,0 +1,203 @@ +--- +name: 'playbook-cli-audit' +description: 'Audit all packmind-* skills for CLI compatibility issues: deprecated commands, invalid options, wrong file formats, missing flags, and version mismatches against the locally installed Packmind CLI. Use when you want to check that skills referencing packmind-cli are up to date, after a CLI upgrade, before a release, or whenever you suspect skills may reference outdated CLI commands. Also triggers on phrases like "audit CLI usage", "check skills for CLI issues", "are my skills up to date with the CLI", or "CLI compatibility check".' +--- + +# Playbook CLI Audit + +Detect incompatibilities between the packmind-* skills deployed in `.claude/skills/` and the current Packmind CLI. Skills evolve independently from the CLI — when the CLI deprecates or removes commands, renames flags, or changes accepted file formats, skills can silently break. This audit catches those issues before users hit them at runtime. + +**This skill only detects issues — it does not fix them.** + +## Phase 1: Establish CLI Surface + +Build the authoritative map of what the CLI currently supports. + +### 1.1 Get CLI version + +```bash +node ./dist/apps/cli/main.cjs --version +``` + +Remember this as `$CLI_VERSION` (e.g., `0.26.0`). If the command fails, try `packmind-cli --version` as a fallback. If both fail, stop and tell the user the CLI is not available. + +### 1.2 Build the command map from CLI source code + +The CLI command definitions live in `apps/cli/src/infra/commands/`. Read the command registration files to build a complete map of: + +- **Available top-level commands** and their subcommands +- **Accepted options/flags** for each command (names, types, whether required) +- **Accepted argument types** (file paths, strings, etc.) and any format constraints +- **Deprecated commands** — look for `[Deprecated]` in descriptions and `has been removed` in handler code +- **Migration paths** — what the deprecated command's error message suggests instead + +Structure this internally as a reference table. Here is the expected shape — adapt if the source reveals more or fewer commands: + +| Command | Status | Accepted Inputs | Key Flags | Migration | +|---|---|---|---|---| +| `standards create` | REMOVED | JSON file/stdin | `--space`, `--from-skill` | `playbook add <path>` | +| `commands create` | REMOVED | JSON file/stdin | `--space`, `--from-skill` | `playbook add <path>` | +| `skills add` | REMOVED | directory path | `--space`, `--from-skill` | `playbook add <path>` | +| `install --list` | REMOVED | — | — | `packages list` | +| `install --show` | REMOVED | — | — | `packages show <slug>` | +| `diff` | DEPRECATED | — | `--submit`, `-m` | `playbook diff`, `playbook submit` | +| `diff add` | DEPRECATED | file path | `-m` | `playbook add` | +| `lint --diff` | DEPRECATED | — | — | `--changed-files` / `--changed-lines` | +| `playbook add` | ACTIVE | `.md`, `.mdc` files | `--space` | — | +| `playbook submit` | ACTIVE | — | `-m` (needed in non-TTY) | — | +| `packages add` | ACTIVE | — | `--to`, `--standard`/`--command`/`--skill` (one type per call) | — | +| ... | ... | ... | ... | ... | + +Do not hardcode this table — **read the actual source** in `apps/cli/src/infra/commands/` to build it. The table above is a starting point to orient your search, not the source of truth. + +### 1.3 Print summary + +``` +CLI version: $CLI_VERSION +Commands mapped: N active, M deprecated/removed +``` + +## Phase 2: Discover and Scan Skills + +### 2.1 Find all packmind-* skills + +Glob `.claude/skills/packmind-*/` to find all skill directories. For each, collect: +- `SKILL.md` (the main instruction file) +- All files in subdirectories (`references/`, `steps/`, `scripts/`, `packmind-versions/`, etc.) + +### 2.2 Handle version-specific subdirectories + +Some skills contain `packmind-versions/<semver>/` directories with version-specific instructions. The version resolution logic works like this: + +1. List all version directories (e.g., `0.21.0/`, `0.23.0/`) +2. Compare each against `$CLI_VERSION` +3. A version directory is **in scope** if it is the highest version that is `<=` `$CLI_VERSION` +4. Version directories that are **not in scope** (lower versions superseded by a higher one that is still `<= $CLI_VERSION`) should be flagged as INFO-level findings ("version `0.21.0` is superseded by `0.23.0` for CLI `$CLI_VERSION`") but their CLI invocations still need to be checked — they may still be reachable by users with older CLI versions + +**Example**: If `$CLI_VERSION` is `0.25.0` and the skill has `0.21.0/` and `0.23.0/`: +- `0.23.0/` is the **active version** (highest `<= 0.25.0`) +- `0.21.0/` is **superseded** but still scanned + +### 2.3 Extract CLI invocations + +For every file in scope, extract all lines that invoke the CLI. Look for these patterns: +- `packmind-cli <anything>` — the primary pattern +- `node ./dist/apps/cli/main.cjs <anything>` — local dev invocation +- Code blocks containing CLI commands (inside ` ``` ` fences) +- Inline code references (inside `` ` `` backticks) +- Plain-text references in prose (e.g., "Run `packmind-cli standards create`") + +For each invocation, parse: +- **Command**: the top-level command (e.g., `standards`) +- **Subcommand**: if any (e.g., `create`) +- **Arguments**: positional args (e.g., file paths) +- **Flags/options**: named options (e.g., `--space`, `-m`) +- **Source location**: file path and line number + +## Phase 3: Cross-Reference and Detect Issues + +For each extracted CLI invocation, check it against the command map from Phase 1. Detect these categories of issues: + +### CRITICAL — Will fail at runtime + +| Check | Description | Example | +|---|---|---| +| **Removed command** | Invocation uses a command the CLI has removed | `standards create` → removed, use `playbook add` | +| **Non-existent command** | Command or subcommand does not exist in the CLI | `packmind-cli list standards` (correct: `standards list`) | +| **Invalid file format** | File type passed to a command that doesn't accept it | `.json` file to `playbook add` (accepts only `.md`/`.mdc`) | +| **Mixed artifact types** | `packages add` called with multiple artifact type flags | `--standard X --command Y` in one call | + +### WARNING — May fail or produce unexpected behavior + +| Check | Description | Example | +|---|---|---| +| **Deprecated command** | Command works but shows deprecation warning | `diff add` → use `playbook add` | +| **Missing required flag in agent context** | Command lacks a flag needed in non-interactive/CI contexts | `playbook submit` without `-m` opens an editor | +| **Deprecated flag** | A specific flag is deprecated | `lint --diff` → use `--changed-files` | +| **Incorrect install syntax** | `install --list` or `install --show` used instead of `packages list`/`packages show` | `packmind-cli install --list` | + +### INFO — Worth noting + +| Check | Description | Example | +|---|---|---| +| **Superseded version directory** | A version-specific directory is no longer the active one | `0.21.0/` when `0.23.0/` exists and CLI is `>= 0.23.0` | +| **CLI version metadata mismatch** | Skill frontmatter declares `packmind-cli-version` that conflicts with installed version | `packmind-cli-version: "< 0.25.0"` when CLI is `0.26.0` | +| **Undocumented flag usage** | A flag is used that doesn't appear in the command definition | May indicate a removed or renamed flag | + +## Phase 4: Generate Report + +Write the report to `playbook-cli-audit-report.md` at the project root. + +### Report Structure + +```markdown +# Playbook CLI Audit Report + +**Generated**: {date} +**CLI version**: {$CLI_VERSION} +**Skills scanned**: {count} +**Files analyzed**: {count} + +## Summary + +| Severity | Count | +|---|---| +| CRITICAL | {n} | +| WARNING | {n} | +| INFO | {n} | + +## Findings + +### CRITICAL + +#### {n}. [{skill-name}] {short description} + +- **File**: `{path}:{line}` +- **Invocation**: `{the CLI command as written in the skill}` +- **Issue**: {what's wrong} +- **Fix**: {what the command should be replaced with} + +### WARNING + +...same structure... + +### INFO + +...same structure... + +## Skills Scanned + +| Skill | Files | Invocations | Issues | +|---|---|---|---| +| packmind-onboard | 19 | 12 | 5 CRITICAL, 1 WARNING | +| packmind-update-playbook | 8 | 15 | 0 | +| ... | ... | ... | ... | + +## CLI Command Surface Reference + +List of all active commands with their accepted inputs, for quick cross-reference. +``` + +### If no issues are found + +```markdown +# Playbook CLI Audit Report + +**Generated**: {date} +**CLI version**: {$CLI_VERSION} +**Skills scanned**: {count} + +All packmind-* skills are compatible with the installed CLI version. No issues found. +``` + +## Phase 5: Present to User + +After writing the report: + +1. Print a summary to the conversation: + - Total CRITICAL / WARNING / INFO counts + - Top 3 most affected skills + - The report file path +2. If there are CRITICAL findings, highlight them explicitly — these are commands that **will fail** when users invoke the skill + +Do not suggest fixes automatically. The user decides how to address each finding. \ No newline at end of file diff --git a/.gitlab/duo/skills/product-map/SKILL.md b/.gitlab/duo/skills/product-map/SKILL.md new file mode 100644 index 000000000..0a47422f8 --- /dev/null +++ b/.gitlab/duo/skills/product-map/SKILL.md @@ -0,0 +1,190 @@ +--- +name: 'product-map' +description: 'Produces a functional cartography of the Packmind application (domains × features × usage signals) as `product-map.md` at the project root. Manual-only skill — invoke ONLY when the user explicitly runs `/product-map` or asks to "map the application", "cartographier l''application", "list every feature", or "find decommission candidates". Do NOT auto-load on generic mentions of features, domains, spaces, or standards.' +--- + +# Product Map + +Build a point-in-time functional map of the Packmind application by scanning the backend (use cases + endpoints), the frontend (routes + pages), and the CLI (commands). Aggregate features by functional domain, compute a usage signal per feature, and write a single Markdown report at `product-map.md` (project root). The report supports decommission decisions: features with weak signals (no frontend entry, no recent activity, no tests) become candidates for removal. + +**Manual-only.** Run only when the user explicitly invokes `/product-map` or asks for a functional cartography. The description above intentionally avoids generic trigger keywords. + +**This skill only maps — it does not delete anything.** Decommission decisions stay with the user. + +## Phase 0: Confirm Scope + +Before scanning, confirm with the user: + +- **Target output path** — default `product-map.md` at project root; offer to override. +- **Scope override** — default scans `apps/api`, `apps/frontend`, `apps/cli`, and `packages/*`. Ask if any package should be excluded (e.g., infra-only packages like `migrations`, `logger`, `node-utils`). +- **Decommission tolerance** — confirm signal heuristics (see Phase 4); user may want stricter or looser flagging. + +If the user invoked the skill with explicit instructions, skip confirmation and proceed with stated parameters. + +## Phase 1: Seed Domain Taxonomy + +Before launching subagents, read `packages/` and `apps/` to discover the domain taxonomy. Use folder names as the seed list — do NOT invent domains. + +The seed domains in this codebase (verify by `ls packages/` at run time, this list may drift): + +- **Spaces** — `packages/spaces`, `packages/spaces-management` +- **Standards** — `packages/standards`, `packages/linter`, `packages/linter-ast`, `packages/linter-execution` +- **Skills** — `packages/skills` +- **Recipes / Playbooks** — `packages/recipes` +- **Change Proposals** — `packages/playbook-change-applier`, `packages/playbook-change-management` +- **Users / Auth / Organizations** — `packages/accounts` +- **AI Agents / Coding Agent** — `packages/coding-agent` +- **Git Integration** — `packages/git` +- **Analytics** — `packages/amplitude` +- **Support / Crisp** — `packages/crisp` +- **Deployments** — `packages/deployments` +- **LLM Infrastructure** — `packages/llm` +- **Legacy Import** — `packages/import-practices-legacy` + +Cross-cutting infra packages (`logger`, `node-utils`, `migrations`, `types`, `ui`, `frontend`, `editions`, `test-utils`, `integration-tests`, `plugins`) are NOT user-facing domains — exclude from the map unless they expose user-facing features (e.g., `editions` may expose subscription/edition selection). + +Pass this seed taxonomy to all subagents so they classify consistently. + +## Phase 2: Launch 3 Parallel Source Subagents + +Launch three `Agent(general-purpose)` subagents simultaneously. Each scans one source and returns a structured feature list classified by domain. + +| Subagent | Source | Scans | +|----------|--------|-------| +| 1. Backend | Use cases + API endpoints | `packages/*/src/application/useCases/**/*.ts`, NestJS controllers in `apps/api/src/**/*.controller.ts` | +| 2. Frontend | Routes + pages | `apps/frontend/src` router config + page components | +| 3. CLI | Commander commands | `apps/cli/src` command definitions | + +### Subagent Prompt Template + +Each subagent receives: +1. The seed domain taxonomy from Phase 1 +2. The source-specific instructions below +3. A request for structured output + +``` +You are mapping the Packmind application. Your scope: <SOURCE_NAME>. + +# Domain taxonomy (use these labels — do not invent new domains unless you find a clear standalone area): +<seed taxonomy> + +# Your task +Scan <PATHS> and produce a list of every user-facing feature exposed through this source. +Group features under their domain. A "feature" is a user-meaningful capability (e.g. +"create a space", "invite a member", "archive a standard"), not a technical implementation +detail (e.g. "TypeORM repository method"). + +For each feature, return: +- domain: <one of the seed labels, or "Other (justify)"> +- feature: short imperative phrase (≤ 8 words) +- evidence: file path(s) + relevant export/route/command name +- visibility: "user-facing" or "admin-only" or "internal" + +Skip: +- Pure infra plumbing (logging, config loading, health checks) +- Cross-cutting middleware (auth guards, rate limiters) — list them once under "Auth" +- Test utilities + +# Output format +Markdown list grouped by domain. One bullet per feature. End with a 2-line summary +(total features, anything unclassifiable). +``` + +### Source-Specific Instructions + +**Backend subagent**: scan use cases first (they map 1:1 to features), then controllers to confirm the endpoint exists. A use case with no controller binding is an "orphan" — flag it as `visibility: internal`. + +**Frontend subagent**: scan the router config first to enumerate routes, then read each route's page component to determine what the user actually does there. A route that only renders a placeholder or 404 is a candidate for decommission — flag it. + +**CLI subagent**: enumerate every Commander `.command(...)` registration. Subcommands count as separate features (e.g. `playbook add` and `playbook submit` are two features under "Change Proposals"). + +### Sequential Fallback + +If the Agent tool is unavailable, perform the three scans sequentially in the current session using `Grep` + `Read`. Maintain the same output structure. + +## Phase 3: Merge Sources Per Feature + +After subagents return, merge their lists into a single feature table. Two source entries belong to the same feature when: +- Same domain, AND +- Feature phrases describe the same capability (e.g. "create a space" backend ↔ "Create Space" frontend route ↔ `space create` CLI command) + +For each unified feature row, record which sources cover it: + +| Domain | Feature | Backend | Frontend | CLI | Evidence | +|--------|---------|:-:|:-:|:-:|----------| +| Spaces | Create a space | ✓ | ✓ | ✓ | `packages/spaces/src/.../createSpace.ts`, `/spaces/new`, `space create` | + +If a feature exists in only one source, that is a signal — keep the row, leave the missing sources blank. + +## Phase 4: Compute Usage Signal + +For each feature row, compute additional signal columns. These power the decommission section. + +| Signal | How to compute | +|--------|----------------| +| **Tests** | Grep for the use case class or controller path in `**/*.spec.ts` and `**/*.test.ts`. Mark ✓ if at least one test references it. | +| **Amplitude** | Grep for the feature's domain prefix in `packages/amplitude/src/application/AmplitudeEventListener.ts`. Mark ✓ if any event for this domain is subscribed AND the event name plausibly matches the feature. | +| **Recent activity** | `git log --since="6 months ago" --pretty=format:%H -- <evidence path>`. Mark ✓ if at least one commit. | + +A feature is a **decommission candidate** when ANY of the following holds: +- No frontend coverage AND no CLI coverage (= backend-only, possibly orphaned) +- No tests AND no recent activity (= cold code) +- Frontend route exists but backend use case missing (= dead UI) +- Backend use case exists but no entry point in any client (= dead endpoint) + +Do NOT auto-delete — only flag. + +## Phase 5: Write Report + +Write `product-map.md` at the project root with this structure: + +```markdown +# Packmind Product Map — <YYYY-MM-DD> + +> Snapshot of every user-facing feature, grouped by functional domain. +> Generated by the `product-map` skill. Manual invocation only. + +## Scope + +- Sources scanned: backend use cases & endpoints, frontend routes & pages, CLI commands +- Packages excluded: <list> +- Git revision: <short SHA> + +## Functional Map + +| Domain | Feature | Backend | Frontend | CLI | Tests | Amplitude | Recent | Notes | +|--------|---------|:-:|:-:|:-:|:-:|:-:|:-:|-------| +| Spaces | Create a space | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| Spaces | Archive a space | ✓ | – | – | – | – | – | backend-only orphan | +| ... + +## Decommission Candidates + +Features matching at least one decommission heuristic (see methodology). Sorted by +weakest signal first. + +| Feature | Reason | Evidence | +|---------|--------|----------| +| Archive a space | backend-only, no tests, no commits in 6 months | `packages/spaces/src/.../archiveSpace.ts` | +| ... + +## Methodology + +- A feature = a user-meaningful capability, not a technical implementation detail. +- Sources merged when same domain + same capability across backend/frontend/CLI. +- Signal heuristics: see SKILL.md Phase 4. +- This is a point-in-time snapshot — re-run to refresh. +``` + +End by printing to the user: +- Path of the generated report +- Total features mapped +- Total decommission candidates +- Top 3 domains by feature count + +## Operating Rules + +- **Never delete code based on the report.** Decommission decisions belong to the user. +- **Do not invent domains.** If a feature does not fit the seed taxonomy, place it under "Other" with a one-line justification — the user will refine the taxonomy on re-run. +- **Stay factual.** Every row must cite an evidence path. No inferred features. +- **Re-runnable.** The skill produces a fresh snapshot each run; overwrite the previous report after confirming with the user. \ No newline at end of file diff --git a/.gitlab/duo/skills/qa-review/SKILL.md b/.gitlab/duo/skills/qa-review/SKILL.md new file mode 100644 index 000000000..ca218bd8b --- /dev/null +++ b/.gitlab/duo/skills/qa-review/SKILL.md @@ -0,0 +1,192 @@ +--- +name: 'qa-review' +description: 'Review a user story implementation against its Example Mapping (EM) specification.' +--- + +# QA Review + +Audit a user story implementation against its Example Mapping specification. Reads the EM +markdown, finds all implementing code in the codebase, then runs two parallel agents — one for +functional coverage, one for code review. Produces a single compact report. + +**This skill only detects issues — it does not fix them.** + +## Reference + +A ready-to-fill EM template is available at `em_template.md` (in this skill's directory). Share it with the user when they need to write a new spec from scratch. + +## 1. Parse the EM Spec + +Read the markdown file at the provided path. Extract a structured summary with these sections: + +### What to extract + +1. **User Story title** — the first line or heading describing the US +2. **Rules** — each `# Rule N: <title>` block. For each rule, extract: + - Rule number and title + - Each `## Example N` with its setup/action/outcome narrative (preserve the full text) +3. **Technical Rules** — bullet points under a `# Technical rules` heading (implementation-focused constraints) +4. **User Events** — content under `# User Events` heading: event names, properties, schemas +5. **Check Also items** — bullet points after "Check also" markers (additional rules/constraints, often separated by dashes) +6. **Code References** — all backtick-quoted terms across the entire spec (class names, field names, event names like `ConflictDetector`, `space_created`, `decision`) +7. **Domain Keywords** — key nouns and verbs from rule titles and examples (e.g., "space", "create", "slug", "rename", "conflict") + +### Handling ambiguity + +If a spec item is ambiguous or explicitly deferred (e.g., "TBD", "on verra plus tard", "later"), flag it in the Parsed Spec Summary but exclude it from coverage assessment. Note it in the report as "Deferred — not assessed." + +Compile everything into a **Parsed Spec Summary** formatted as below. The "Full Examples" section preserves the complete raw text of every example — sub-agents need this full context to accurately assess coverage. + +``` +## Parsed Spec Summary + +### User Story +{title} + +### Rules and Examples +Rule 1: {title} + Example 1: {one-line summary of scenario} + Example 2: {one-line summary of scenario} +Rule 2: {title} + Example 1: {one-line summary of scenario} +[...] + +### Full Examples (raw text) +{Copy the complete text of every example verbatim from the spec, preserving setup/action/outcome narratives. Do not summarize here — this section is passed to sub-agents so they can assess nuanced behaviors.} + +### Technical Rules +- {rule text} +[...] + +### Deferred Items +- {item text} — Deferred, not assessed +[...] + +### User Events +- {event_name}: {properties} +[...] + +### Check Also +- {constraint text} +[...] + +### Code References (from backticks) +{list of all backtick-quoted terms} + +### Domain Keywords +{list of distinctive nouns/verbs extracted from rules} +``` + +## 2. Select Target Domains + +Ask the user which domains this user story touches. Use **AskUserQuestion** with `multiSelect: true`: + +| Option | Directories | +|--------|-------------| +| CLI (apps/cli) | `apps/cli/src/**` | +| API (apps/api) | `apps/api/src/**` | +| Packages (packages/*) | `packages/*/src/**`, `packages/types/src/**` | +| Frontend (apps/frontend) | `apps/frontend/app/**`, `apps/frontend/src/**` | +| MCP (apps/mcp-server) | `apps/mcp-server/src/**` | + +**Packages is always included**, even if the user does not select it — it contains domain logic, contracts, events, and infra that all other layers depend on. + +Store the user's selection as `{target_domains}` — a list of domain labels and their directory patterns. All subsequent steps use this to scope their searches. + +## 3. Validate Implementation Exists + +Grep 2–3 of the most distinctive backtick-quoted terms from the spec, **scoped to the `{target_domains}` directories only**. If fewer than 3 implementing files are found, **stop and ask the user** to confirm that the US has been fully implemented. Do not proceed with a full review on a partially-implemented or not-yet-started US — the report would be misleading. + +## 4. Build Code Map + +Launch a **Code Map Agent** (`subagent_type: general-purpose`) using the prompt from `agents/code-map-agent.md`. Replace: +- `{parsed_spec_summary}` with the Parsed Spec Summary from step 1 +- `{target_domains}` with the selected domains and their directory patterns from step 2 + +The agent will search only within the target domain directories, then return a structured Code Map organized by architectural layer. Only layers matching the selected domains will appear in the output. + +Wait for the agent to complete before proceeding to step 5. + +## 5. Pre-filter Packmind Standards + +Before launching the review agents, collect the applicable Packmind coding standards: + +1. Glob for `**/.claude/rules/packmind/*.md` across the repository +2. Read the YAML frontmatter of each file to extract its `paths` glob patterns +3. Match the Code Map file paths against each standard's `paths` patterns +4. For each standard that matches at least one Code Map file, read its full content + +Compile the applicable standards into `{applicable_standards}` — the full text of each matching standard, prefixed with its name. If no standards match, set `{applicable_standards}` to "None". + +## 6. Launch Parallel Sub-Agents + +Launch **two sub-agents in parallel** (same turn), each receiving the Parsed Spec Summary (including the Full Examples section with raw text), Code Map, and target domains as context: + +1. **Functional Coverage Agent** (`subagent_type: general-purpose`) — prompt built from `agents/functional-coverage-agent.md`. Replace `{parsed_spec_summary}`, `{code_map}`, and `{target_domains}`. +2. **Code Review Agent** (`subagent_type: general-purpose`) — prompt built from `agents/code-review-agent.md`. Replace `{parsed_spec_summary}`, `{code_map}`, `{target_domains}`, and `{applicable_standards}`. + +Launch both agents simultaneously. The full raw example text is critical — sub-agents need the complete setup/action/outcome narratives to assess nuanced behaviors, not just one-line summaries. + +### Sequential Fallback + +If the Agent tool is unavailable, perform both reviews sequentially yourself, following the instructions from each agent prompt file. + +## 7. Combine & Write Report + +Once both agents complete, merge their outputs into a single report. + +### Output path + +Derive from the input path: if input is `path/to/my-spec.md`, output is `path/to/my-spec-report.md`. + +### Report template + +```markdown +# QA Review Report + +**Spec**: {filename} | **Date**: {date} | **Branch**: {branch} | **Commit**: {short-sha} +**Rules**: {N} | **Examples**: {N} | **Tech Rules**: {N} | **Events**: {N} + +## Summary + +| Metric | Count | +|--------|-------| +| Covered | N | +| Partially Covered | N | +| Not Covered | N | +| Code Findings | N (Critical: X, High: Y, Medium: Z) | +| Standards Violations | N | + +## Functional Coverage + +### Coverage Matrix + +{coverage matrix table from functional coverage agent} + +### Gaps + +{reproduction steps from functional coverage agent — omit this subsection if all items are Covered} + +## Code Review + +### Findings + +{findings from code review agent — omit this section if no issues found} + +## Deferred Items + +{list of items marked as deferred/TBD in the spec — not assessed in this review} + +--- +*Static analysis only. No code was executed during this review.* +``` + +**Omit any section that has zero content.** Only include sections with actual results. + +### Print Summary + +After writing the report, print a brief summary to the console: +- Total rules/examples in the spec +- Coverage stats (Covered / Partially / Not Covered counts) +- Code review findings count by severity +- The report file path \ No newline at end of file diff --git a/.gitlab/duo/skills/qa-review/agents/code-map-agent.md b/.gitlab/duo/skills/qa-review/agents/code-map-agent.md new file mode 100644 index 000000000..fb083cef1 --- /dev/null +++ b/.gitlab/duo/skills/qa-review/agents/code-map-agent.md @@ -0,0 +1,133 @@ +You are a codebase navigator building a Code Map for a user story implementation. Your job is to find all files involved in implementing the feature described in the Parsed Spec Summary below, **scoped to the target domains only**. + +## Target Domains + +{target_domains} + +**Only search within the directories listed above.** Do not search domains that are not listed. + +## Parsed Spec Summary + +{parsed_spec_summary} + +## Available Tools + +You have access to **Glob**, **Grep**, and **Read** (read-only). Use them to systematically search the codebase. + +## Search Strategy + +Using the code references (backtick-quoted terms) and domain keywords from the spec, follow this funnel (precise → broad). **All searches must be scoped to the target domain directories above.** + +### Step 1: Backtick terms first (highest signal) + +Grep each backtick-quoted term, but only within the target domain directories. These are author-curated pointers into the codebase and almost always resolve to exact matches. + +### Step 2: Domain keyword search + +Grep the 3–5 most distinctive domain keywords against the target domain directories. Here is the mapping of domains to their search patterns — **only use patterns for selected domains**: + +**Backend (packages/*):** +- `packages/*/src/application/useCases/**/*.ts` and `packages/*/src/application/usecases/**/*.ts` (use cases — both casing variants exist) +- `packages/*/src/infra/**/*.ts` (repository implementations, TypeORM schemas, BullMQ job factories) +- `packages/types/src/*/events/**/*.ts` (domain events — centralized in @packmind/types) +- `packages/types/src/*/contracts/**/*.ts` (use case contracts — Command, Response, IUseCase interfaces) + +**API (apps/api):** +- `apps/api/src/app/**/*.ts` (NestJS controllers, guards, modules) + +**Frontend (apps/frontend):** +- `apps/frontend/app/routes/**/*.tsx` (file-based route components with clientLoaders) +- `apps/frontend/src/domain/**/*.ts` (gateways extending PackmindGateway, TanStack Query hooks) + +**CLI (apps/cli):** +- `apps/cli/src/**/*.ts` (CLI commands and handlers) + +**MCP (apps/mcp-server):** +- `apps/mcp-server/src/**/*.ts` (MCP server tools and prompts) + +### Step 3: Package-level scan (Backend only) + +Skip if Backend is not in the target domains. Once a relevant package is identified (e.g., `packages/spaces/`), list its `application/useCases/` directory (or `application/usecases/` — both casing variants exist) to find all related use cases. + +### Step 4: Hexagonal registry check (Backend only) + +Skip if Backend is not in the target domains. Once a relevant package is identified, read its `{PackageName}Hexa.ts` file (e.g., `packages/standards/src/StandardsHexa.ts`) to understand which ports, adapters, and use cases are registered. This reveals the full wiring of the feature. + +### Step 5: Contract discovery (Backend only) + +Skip if Backend is not in the target domains. For each use case found, check `packages/types/src/{domain}/contracts/` for the corresponding contract file, which defines `{Name}Command`, `{Name}Response`, and `I{Name}UseCase`. + +### Step 6: Test file discovery + +For each source file found within target domain directories, check for a sibling `.spec.ts` equivalent. + +### Step 7: Event tracking (Backend only) + +Skip if Backend is not in the target domains. Grep event names from the "User Events" section. Look for event class definitions in `packages/types/src/{domain}/events/`, emission via `eventEmitterService.emit(new {EventName}Event(`, and consumption via `PackmindListener` classes with `this.subscribe({EventName}Event, ...)`. + +### Step 8: Cross-US dependencies + +When a rule depends on behavior from another feature (e.g., conflict detection logic that belongs to a different US), include those files in the Code Map but mark them as `[DEPENDENCY]`. Only follow dependencies within the target domain directories. The functional agent should verify the integration point works, not re-audit the dependency itself. + +## Output Format + +Compile results into a **Code Map** organized by layer: + +``` +## Code Map + +### Hexagonal Registry +- packages/{pkg}/src/{Pkg}Hexa.ts (port/adapter wiring) + +### Contracts (@packmind/types) +- packages/types/src/{domain}/contracts/I{UseCaseName}UseCase.ts + ({Name}Command, {Name}Response, I{Name}UseCase) + +### Backend Domain +- packages/{pkg}/src/application/useCases/{useCaseName}/{UseCaseName}UseCase.ts + Test: {path}.spec.ts [EXISTS | NOT FOUND] +- packages/{pkg}/src/domain/{Entity}.ts + Test: [EXISTS | NOT FOUND] + +### Infra (repositories, schemas, jobs) +- packages/{pkg}/src/infra/repositories/{Repository}.ts + Test: [EXISTS | NOT FOUND] +- packages/{pkg}/src/infra/schemas/{Schema}.ts +- packages/{pkg}/src/infra/jobs/{JobFactory}.ts (BullMQ background jobs) + +### API Layer (NestJS) +- apps/api/src/app/{...}/{controller}.ts + Guards: [OrganizationAccessGuard | SpaceAccessGuard | None] + Adapter injection: [@Inject{Pkg}Adapter()] + Test: [EXISTS | NOT FOUND] + +### Frontend +- apps/frontend/app/routes/{...}.tsx (route component + clientLoader) +- apps/frontend/src/domain/{entity}/api/gateways/{Entity}GatewayApi.ts (extends PackmindGateway) +- apps/frontend/src/domain/{entity}/api/queries/{Entity}Queries.ts (TanStack Query v5 hooks) + Test: [EXISTS | NOT FOUND] + +### CLI +- apps/cli/src/{...}/{command|handler}.ts + Test: [EXISTS | NOT FOUND] + +### MCP Server +- apps/mcp-server/src/app/tools/{toolName}/{toolName}.tool.ts + Test: [EXISTS | NOT FOUND] + +### Domain Events (@packmind/types) +- packages/types/src/{domain}/events/{EventName}Event.ts (extends UserEvent/SystemEvent) +- {files containing eventEmitterService.emit()} +- {files containing PackmindListener / this.subscribe()} + +### Background Jobs (BullMQ) +- packages/{pkg}/src/infra/jobs/{JobFactory}.ts (WorkerQueue registration) + +### Dependencies (from other USs — verify integration only) +- {files that implement behavior from another feature but are used by this US} [DEPENDENCY] + +### Other Relevant Files +- {any other files found via keyword search} +``` + +Only include sections that have actual files **and** match the target domains. Omit empty sections and sections for domains not in the target list. diff --git a/.gitlab/duo/skills/qa-review/agents/code-review-agent.md b/.gitlab/duo/skills/qa-review/agents/code-review-agent.md new file mode 100644 index 000000000..4d4563ddd --- /dev/null +++ b/.gitlab/duo/skills/qa-review/agents/code-review-agent.md @@ -0,0 +1,183 @@ +You are a senior engineer performing a technical code review on a user story implementation. Your focus is exclusively on actual bugs, edge cases, and inconsistencies — not style, naming, formatting, or minor improvements. Report findings at **Medium** severity or above. If no Critical, High, or Medium issues are found, include **Low** severity findings instead. + +## Target Domains + +{target_domains} + +**Only review code within the target domains listed above.** Skip layers not in scope. + +## Spec Context + +{parsed_spec_summary} + +Understanding the spec helps you know what the code is *supposed* to do, which makes it easier to spot where it does something wrong or incomplete. + +## Code Map + +{code_map} + +## Available Tools + +You have access to **Glob**, **Grep**, and **Read** (read-only). Use them to read every file in the Code Map and trace the logic across target layers only. + +## Your Process + +### Step 1: Read All Implementing Files (within target domains) + +Read every file listed in the Code Map that belongs to the target domains. Build a mental model of the relevant flows — **only for selected domains**: + +**Backend (packages/*):** +- The hexagonal data flow: use case → domain entity → infra repository → TypeORM schema → database +- How contracts in `@packmind/types` define the shared interface (`{Name}Command`/`{Name}Response`/`I{Name}UseCase`) +- How domain events are emitted (`eventEmitterService.emit()`) and consumed (`PackmindListener` with `this.subscribe()`) +- How background jobs are registered and processed (BullMQ `WorkerQueue`, `JobsService`) +- How the Hexa registry (`{Pkg}Hexa.ts`) wires ports to adapters + +**API (apps/api):** +- NestJS controller → adapter (via `@Inject{Pkg}Adapter()`) → use case +- Where validations and guards are applied (NestJS guards like `OrganizationAccessGuard`) + +**Frontend (apps/frontend):** +- Route `clientLoader` → `queryClient.ensureQueryData()` → TanStack Query hook → gateway (extends `PackmindGateway`) → API call + +**CLI (apps/cli):** +- CLI command structure, output formatting, error handling + +**MCP (apps/mcp-server):** +- MCP tool implementation, use case invocation, result formatting + +### Step 2: Check for These Issue Categories + +#### A. Actual Bugs (Critical / High) + +- **Logic errors**: wrong conditions, inverted checks, off-by-one, missing null checks on required paths +- **Data integrity**: unique constraints that can be bypassed (e.g., race conditions on concurrent create), missing database constraints that the spec requires +- **Missing error handling**: operations that can throw but have no try/catch or return no meaningful error to the caller +- **Incorrect queries**: TypeORM queries that don't match the intended behavior (wrong where clause, missing relations, incorrect join) +- **Security gaps**: missing authorization checks the spec explicitly requires (e.g., "only admins can...") + +#### B. Edge Cases (Medium / High) + +- **Boundary values**: empty strings, max length violations, special characters (accents, unicode), whitespace handling +- **Concurrent operations**: two users performing the same action simultaneously (e.g., creating a resource with the same name at the same time) +- **State transitions**: operations that can leave data inconsistent if they fail partway through (e.g., entity created but event not emitted) + +#### C. Cross-File Inconsistencies (Medium / High) + +Look specifically for mismatches **between** files within the target domains. Only check mismatches between layers that are both in scope: +- **Gateway-to-contract mismatch** (Frontend + Backend): Frontend gateway method sends a field name or shape that differs from the `@packmind/types` contract (`{Name}Command`/`{Name}Response`) +- **Contract-to-API mismatch** (API + Backend): NestJS controller params or response types don't match the `@packmind/types` contract +- **Validation layer inconsistency** (any two selected layers): API validates a constraint that the domain use case does not enforce (or vice versa — validation only in one layer) +- **Event payload drift** (Backend): Event class in `packages/types/src/{domain}/events/` defines one payload shape, but `eventEmitterService.emit()` passes different properties, or `PackmindListener` handler expects different fields +- **Entity-to-schema drift** (Backend): Domain entity field names don't match TypeORM schema column names in `infra/schemas/` +- **Duplicate logic** (any two selected layers): Same logic implemented differently across layers (e.g., slug generation in both frontend gateway and backend use case with different rules) +- **Hexa wiring gap** (Backend): Adapter registered in `{Pkg}Hexa.ts` but the corresponding use case not wired, or a new use case not added to the Hexa registry + +#### D. Missing Validations (Medium) + +Constraints explicitly mentioned in the spec but not enforced in code: +- Max length for a field (spec says 64, no validation in DTO or entity) +- Authorization checks (spec says "only admins", no guard or role check) +- Uniqueness constraints (spec says "cannot have the same name", no unique check before insert) +- Required fields that accept null/undefined + +#### E. Technical Rules Violations (Medium / High) + +Systematically verify **every** technical rule from the "Technical Rules" section of the Parsed Spec Summary. For each technical rule: + +1. Identify which files in the Code Map are relevant — technical rules can apply to **any layer** (backend domain, infra, API, frontend, CLI, MCP server, background jobs, etc.) +2. Read those files and check whether the constraint is enforced +3. Report a finding if a technical rule is not implemented or is implemented incorrectly + +Common technical rule patterns to look for — **only check layers within the target domains**: +- **Backend**: missing domain validations, incorrect repository queries, missing event emissions, wrong error types, missing guards or authorization checks +- **Frontend**: missing form validations, incorrect API call parameters, missing loading/error states, wrong route guards, missing optimistic updates or cache invalidation +- **Cross-cutting**: naming conventions not followed, missing logging, incorrect feature flag checks, missing analytics events, wrong data transformations + +If a technical rule is ambiguous about which layer should enforce it, check only within the target domains. + +#### F. Hexagonal Architecture & NestJS Issues (Medium / High) + +- **Missing Hexa registration**: A new use case or port exists but is not registered in the package's `{Pkg}Hexa.ts` registry +- **Missing adapter injection**: NestJS controller uses a port but doesn't inject it via `@Inject{Pkg}Adapter()` decorator +- **Missing guard**: Controller endpoint modifies data but doesn't use `@UseGuards(OrganizationAccessGuard)` or appropriate guard +- **Event listener not registered**: A `PackmindListener` class exists but isn't wired to receive the expected events via `this.subscribe()` +- **Background job not registered**: A `WorkerQueue` is defined but `JobsService` doesn't register or submit it +- **Contract not exported**: A new use case contract exists in `@packmind/types` but isn't exported from the package's barrel file + +### Step 3: Check Test Quality + +If test files exist for the implementing code: +- Are the tests testing the **right behavior** or just mocking everything away? A test that mocks the repository and only checks the mock was called does not catch real bugs. +- Are there assertions that would catch regressions on the spec's key behaviors? +- Are error paths and edge cases tested, or only the happy path? + +Only flag test quality issues if they represent a **Medium+ risk** — e.g., a critical validation has no test at all, or tests mock away the exact layer where a bug exists. + +### Step 4: Check Pre-loaded Packmind Standards + +The following Packmind standards have been pre-filtered by the orchestrator and apply to files in the Code Map: + +{applicable_standards} + +If "None" is shown above, skip this step entirely. + +**Verification:** +- Read each applicable standard's rules carefully +- Check every file in the Code Map that matches the standard's scope +- Report violations at the same severity levels as other findings (Critical/High/Medium depending on impact) +- In the **Spec Reference** field, reference the standard name (e.g., "Standard: Testing Good Practices") + +## Severity Definitions + +- **Critical**: Data loss, security vulnerability, or crash in a production code path. Would block a release. +- **High**: Incorrect behavior that users will encounter in normal usage. A bug that produces wrong results or breaks a flow. +- **Medium**: Edge case that could cause issues under specific conditions. Missing validation that the spec explicitly requires. Cross-file inconsistency that could cause subtle bugs. +- **Low**: Minor issues — small inconsistencies, non-critical missing validations, minor edge cases unlikely to affect normal usage, slight contract mismatches that don't break functionality. + +**Do NOT report**: Informational, cosmetic, style preferences, missing comments, naming suggestions, or "nice to have" improvements. If you find fewer than 2 issues, that is perfectly fine — do not manufacture findings to fill the report. + +## Output Format + +Return findings in this exact format: + +``` +### Findings + +#### [CRITICAL] {Short descriptive title} +- **Category**: Bug | Edge Case | Inconsistency | Missing Validation | Technical Rule Violation +- **File**: `{file-path}:{line}` +- **Description**: {What is wrong, why it matters, and what could happen} +- **Spec Reference**: {Which rule/example/technical rule this relates to, e.g., "Rule 2 / Example 1", "Check Also: max length 64", or "Technical Rule: ..."} +- **Suggested Fix**: {Brief direction — not full code, just the approach} + +--- + +#### [HIGH] {Short descriptive title} +- **Category**: ... +[same format] + +--- + +#### [MEDIUM] {Short descriptive title} +- **Category**: ... +[same format] +``` + +**Order findings by severity**: Critical first, then High, then Medium. + +**Low-severity fallback**: If you found **no** Critical, High, or Medium issues, include any Low findings using this format: + +``` +#### [LOW] {Short descriptive title} +- **Category**: ... +[same format] +``` + +If no issues are found at any severity, return exactly: + +``` +### Findings + +No significant issues found. The implementation appears sound for the behaviors described in the spec. +``` diff --git a/.gitlab/duo/skills/qa-review/agents/functional-coverage-agent.md b/.gitlab/duo/skills/qa-review/agents/functional-coverage-agent.md new file mode 100644 index 000000000..d82534b83 --- /dev/null +++ b/.gitlab/duo/skills/qa-review/agents/functional-coverage-agent.md @@ -0,0 +1,116 @@ +You are a QA analyst reviewing whether a user story implementation fully covers its Example Mapping specification. Your job is evidence-based: every assessment must cite specific files and code patterns you found (or did not find) in the codebase. + +## Target Domains + +{target_domains} + +**Only trace rules through the layers listed above.** Skip layers not in the target domains — they are out of scope for this review. + +## Spec to Review + +{parsed_spec_summary} + +## Code Map + +{code_map} + +## Available Tools + +You have access to **Glob**, **Grep**, and **Read** (read-only). Use them to: +- Read source files identified in the Code Map +- Search for specific behavior implementations (Grep for method names, conditions, error messages) +- Check test file existence and content (Glob for `*.spec.ts` patterns) + +The Code Map is a starting index — always verify by reading the actual source files. + +## Your Process + +### Step 1: Trace Each Rule and Example Across Target Layers + +For every Rule + Example pair in the spec, trace the behavior through **only the target domain layers listed above**. A rule is only fully covered when all target layers that should implement it actually do. + +1. **Identify the expected behavior** from the example's setup/action/outcome +2. **Search for the implementation across target layers only**. Below are the checks for each domain — **only perform checks for domains in the target list**: + + **Backend (packages/*):** + - **Contract layer** (`@packmind/types`): Read the use case contract (`I{UseCaseName}UseCase.ts` in `packages/types/src/{domain}/contracts/`). Does it define the correct `{Name}Command` input shape and `{Name}Response` output shape for this behavior? + - **Backend domain**: Trace the hexagonal flow: NestJS controller → adapter (via `@Inject{Pkg}Adapter()`) → use case → domain entity. Does the use case handle the precondition, perform the action, and produce the expected outcome? Is the Hexa registry (`{Pkg}Hexa.ts`) wiring the correct adapter? + - **Infra layer**: Do the repository implementations in `infra/` correctly persist or query the data? Are TypeORM schemas aligned with domain entities? Are BullMQ jobs (`WorkerQueue`) registered for async operations? + + **API (apps/api):** + - **API layer** (NestJS): Does the controller expose this behavior at the correct route under `/organizations/:orgId`? Is `@UseGuards(OrganizationAccessGuard)` (or appropriate guard) present? Are the request/response types aligned with the `@packmind/types` contract? + + **Frontend (apps/frontend):** + - **Frontend**: Trace the data flow: route `clientLoader` → `queryClient.ensureQueryData()` → TanStack Query hook → gateway (extends `PackmindGateway`) → API call. Does each layer handle this scenario? Does the gateway method signature match the API endpoint and `@packmind/types` contract? + + **CLI (apps/cli):** + - **CLI**: Does the CLI command implement the expected behavior for this rule? Correct output, error handling, flags? + + **MCP (apps/mcp-server):** + - **MCP Server**: Does the tool in `apps/mcp-server/src/app/tools/` correctly invoke the use case and return the expected result? + + **Cross-layer consistency** (check only between selected domains): Do field names match between `@packmind/types` contracts, NestJS controller params, frontend gateway methods, and CLI handlers? Are validations applied consistently across selected layers (not just in one)? +3. **Check for test coverage** — look for test cases that exercise this specific scenario. Not just that test files exist, but that a test covers this particular case (look for describe/it blocks, test data, assertions matching the example). +4. **Assess coverage level**: + - **Covered**: Implementation code exists AND handles this specific case across all target layers. Cite the file:line where the behavior is implemented. + - **Partially Covered**: Implementation exists but is incomplete — e.g., backend handles it but frontend doesn't (when both are in scope), happy path only, missing error case, missing edge case from the example. Cite what exists and what is missing. + - **Not Covered**: No implementation found for this behavior in any target layer. Describe what you searched for and where. + +### Step 2: Check Technical Rules + +For each bullet under "Technical rules": +- Search for the implementation of the described technical constraint +- Verify the code matches what the spec says (e.g., if the spec says "ConflictDetector uses the `decision` field if available, payload otherwise", read the ConflictDetector and verify this logic) + +### Step 3: Check User Events + +For each event described: +- Check the event class definition in `packages/types/src/{domain}/events/{EventName}Event.ts`. Verify it extends `UserEvent<TPayload>` or `SystemEvent<TPayload>` with the correct payload type. +- Grep for `eventEmitterService.emit(new {EventName}Event(` to find where it is emitted. Verify it is emitted in the correct use case after the action succeeds. +- Grep for `PackmindListener` classes that `this.subscribe({EventName}Event, ...)` to verify the event is consumed where expected. +- Check the event payload properties match the spec (property names, types, optional/required flags). + +### Step 4: Check "Check Also" Items + +For each bullet under "Check also": +- Search for the constraint or validation in the code +- Assess coverage the same way as rules (Covered / Partially Covered / Not Covered) + +## Output Format + +Return **two sections**: + +### Coverage Matrix + +Use this exact table format, one row per rule+example, technical rule, user event, and check-also item. The **Layer** column indicates where the behavior is implemented (Contract, Backend Domain, Infra, API, Frontend (Route/Gateway/Query), CLI, MCP Server, Event, Background Job, or Cross-layer) — this helps route fixes to the right team/area: + +``` +| ID | Rule / Item | Layer | Status | Evidence | Test Coverage | +|----|-------------|-------|--------|----------|---------------| +| R1-E1 | Rule 1: {title} / Example 1: {summary} | Backend Domain | Covered | `file.ts:45` - {what the code does} | `file.spec.ts:23` - {test description} | +| R1-E2 | Rule 1: {title} / Example 2: {summary} | Frontend | Not Covered | No uniqueness check found in {file} | None | +| R2-E1 | Rule 2: {title} / Example 1: {summary} | Backend Domain | Partially Covered | `file.ts:80` - slug generated but not immutable | None | +| T1 | Tech: {description} | Cross-layer | Covered | `ConflictDetector.ts:12` - uses decision field | `ConflictDetector.spec.ts:5` | +| EV1 | Event: {event_name} | Event | Not Covered | No emit found for this event | None | +| C1 | Check: {description} | API | Covered | `guard.ts:15` - admin role check | None | +``` + +**ID format**: `R{rule}-E{example}` for rules, `T{n}` for technical rules, `EV{n}` for events, `C{n}` for check-also items. + +### Reproduction Steps for Gaps + +For each item marked **Not Covered** or **Partially Covered**, provide: + +``` +#### [{ID}] {Rule title} / {Example summary} +**Status**: Not Covered | Partially Covered +**What is missing**: {specific behavior not implemented} +**Where to look**: {file paths where this should be implemented} +**How to reproduce**: +1. {step-by-step reproduction of the scenario from the example} +2. {what happens vs what should happen} +``` + +Keep reproduction steps concise — focus on the minimum steps to demonstrate the gap. + +If everything is fully covered, state: "All rules and examples are fully covered." diff --git a/.gitlab/duo/skills/qa-review/em_template.md b/.gitlab/duo/skills/qa-review/em_template.md new file mode 100644 index 000000000..a2a483b55 --- /dev/null +++ b/.gitlab/duo/skills/qa-review/em_template.md @@ -0,0 +1,50 @@ +User Story Review: {Title of the user story} + +# Rule 1: {Short rule title describing the expected behavior} + +## Example 1 + +{Setup: describe the initial state} + +{Action: describe what the user does} + +{Outcome: describe the expected result} + +## Example 2 + +{Setup} + +{Action} + +{Outcome} + +# Rule 2: {Short rule title} + +## Example 1 + +{Setup} + +{Action} + +{Outcome} + +# Technical rules + +- {Implementation constraint that applies across frontend and backend, e.g., "Slug generation must be deterministic and locale-independent"} +- {Performance or infrastructure constraint, e.g., "The operation must be idempotent"} +- {Security or authorization constraint, e.g., "Only organization admins can perform this action"} +- {Data constraint, e.g., "Max length for the name field: 64 characters"} +- {Integration constraint, e.g., "Must work on both Linux and Windows (normalize paths)"} + +# User Events + +- `{event_name}`: emitted when {trigger description}. Properties: `{property1}`, `{property2}` +- `{event_name}`: emitted when {trigger description}. Properties: `{property1}` + +--- + +Check also the following rules are applied: + +- {Additional business rule or constraint not covered by the rules above} +- {Edge case to verify, e.g., "Two items in the same scope cannot have the same name"} +- {Default behavior, e.g., "By default, the user lands on the default space"} diff --git a/.gitlab/duo/skills/release-proprietary/SKILL.md b/.gitlab/duo/skills/release-proprietary/SKILL.md new file mode 100644 index 000000000..e20ce1386 --- /dev/null +++ b/.gitlab/duo/skills/release-proprietary/SKILL.md @@ -0,0 +1,207 @@ +--- +name: 'release-proprietary' +description: 'Orchestrate a full Packmind proprietary release: verify Main CI/CD Pipeline is green on both OSS and proprietary main, drive the release on the OSS sibling repo (version bumps, CHANGELOG, tag, push), wait for the oss-sync workflow to land the release commit on the proprietary fork, then tag and push `release/{{version}}` on the proprietary repo. Invoke from the proprietary repo.' +--- + +Create a full Packmind proprietary release with version `{{version}}`. + +This skill orchestrates a cross-repository release that spans the OSS sibling (`../packmind`, cloned next to the proprietary repo) and the proprietary repo itself (the current working directory). All real release content (version bumps, CHANGELOG) lives on OSS; the proprietary side receives the OSS commit through the `oss-sync` workflow and gets its own `release/{{version}}` tag pointing at a **different SHA** than the OSS tag — see below. + +**Invocation requirement:** run this skill from the root of the proprietary repo, with the OSS sibling checked out at `../packmind`. All commands below assume that layout. + +**Dual-SHA model — important:** + +* On **OSS**, the `release/{{version}}` tag points at the release commit itself (subject `chore: release {{version}}`). That commit's tree contains OSS code, which is what OSS deployments need. +* On **proprietary**, the same tag points at the **sync-merge commit** that brought the OSS release into proprietary `main`. The OSS release commit has no proprietary-only files in its tree, so tagging it on proprietary would break deployments (the build can't find `@packmind/proprietary/*` modules). The sync-merge commit's tree combines OSS-at-release with the proprietary-only files, which is what proprietary deployments need. + +The wait/tag scripts handle this for you — Phase 2 emits the proprietary merge SHA, and Phase 3 tags that SHA. + +Repository layout assumed by every command in this file: + +* OSS repo: `../packmind` (remote `PackmindHub/packmind`) +* Proprietary repo: `.` — the current working directory (remote `PackmindHub/packmind-proprietary`) + +Scripts live next to this file under `./scripts/`. From the proprietary repo root, paths are `.claude/skills/release-proprietary/scripts/<name>`. + +## Phase 0 — Pre-flight (proprietary repo) + +### 0.1 Feature flags audit gate (MANDATORY) + +Before doing anything, stop and ask the user: + +> Has the `feature-flags-audit` skill been run on the **proprietary** repo for this release? (yes / no) + +If the answer is anything other than an explicit `yes`, abort and tell the user to run `feature-flags-audit` on the proprietary repo first. + +If the audit surfaced any loose / stale flags that should have been removed, instruct the user to: + +1. Check whether each loose flag also exists in the OSS sibling at `../packmind`. Most code flows from OSS → proprietary via `oss-sync`, so flags should generally be cleaned up on the OSS side. +2. Remove them in OSS first, let the oss-sync workflow land the cleanup on proprietary, then re-invoke this skill. + +### 0.2 No open `oss-sync` PR + +```bash +./.claude/skills/release-proprietary/scripts/check-oss-sync-pr.sh PackmindHub/packmind-proprietary +``` + +When the auto-sync hits a merge conflict it opens a PR on branch `oss-sync` instead of fast-forwarding. If such a PR is open, Phase 2 polling cannot succeed. Abort and ask the user to resolve and merge the PR first. + +### 0.3 CI green on both repos + +Run the check script for both repositories: + +```bash +./.claude/skills/release-proprietary/scripts/check-ci.sh PackmindHub/packmind +./.claude/skills/release-proprietary/scripts/check-ci.sh PackmindHub/packmind-proprietary +``` + +Exit codes: + +* `0` — green, proceed. +* `1` — the run failed (or no run found, or auth missing). Abort, surface the URL. +* `2` — the run is still in progress / queued. Not a failure: ask the user whether to wait and retry, or abort. + +### 0.4 Clean working tree on proprietary and no local divergence + +```bash +git status --porcelain # must be empty +git fetch origin main +git merge-base --is-ancestor HEAD origin/main \ + || (echo "local main has diverged from origin/main — reconcile before releasing" && exit 1) +``` + +If the working tree is dirty, or local `HEAD` is not an ancestor of `origin/main`, abort and ask the user to reconcile. + +## Phase 1 — Drive the OSS release (`../packmind`) + +All commands in this phase target the OSS repo via `git -C` or `cd`. + +### 1.1 Verify clean OSS state and update + +```bash +git -C ../packmind status --porcelain # must be empty +git -C ../packmind checkout main +git -C ../packmind pull --ff-only origin main +``` + +If the working tree is dirty or the pull is not fast-forward, abort and ask the user to reconcile. + +### 1.2 Bump versions + +```bash +node ./.claude/skills/release-proprietary/scripts/bump-versions.mjs {{version}} ../packmind +( cd ../packmind && npm install ) +``` + +`npm install` regenerates `package-lock.json` with the new version. + +### 1.3 Rewrite CHANGELOG for release + +Resolve today's date in ISO 8601 (`YYYY-MM-DD`) — call it `{{today}}`. + +```bash +node ./.claude/skills/release-proprietary/scripts/changelog-release.mjs {{version}} {{today}} ../packmind +``` + +### 1.4 Commit the release and push main + +```bash +./.claude/skills/release-proprietary/scripts/commit-release.sh ../packmind {{version}} +``` + +This stages `package.json`, `apps/api/docker-package.json`, `package-lock.json`, and `CHANGELOG.MD`, commits with the exact subject `chore: release {{version}}` (Phase 2 polls for this fixed string), and pushes main. The script never uses `--no-verify`. + +### 1.5 Tag the release and push the tag + +```bash +./.claude/skills/release-proprietary/scripts/tag-release.sh ../packmind {{version}} +``` + +Creates `release/{{version}}` at HEAD (the release commit just pushed) and pushes the tag. If the tag already exists, the script verifies it points at HEAD before re-pushing. + +### 1.6 Prepare next dev cycle on OSS + +```bash +node ./.claude/skills/release-proprietary/scripts/changelog-next.mjs {{version}} ../packmind +./.claude/skills/release-proprietary/scripts/commit-next-cycle.sh ../packmind +``` + +## Phase 2 — Wait for oss-sync to land on proprietary + +The `sync-from-oss-repository.yml` workflow on the proprietary repo merges OSS commits into proprietary `main` automatically (usually under a minute). It will land BOTH the release commit AND the subsequent "prepare next development cycle" commit; each goes through its own sync-merge commit on proprietary `main`. + +```bash +PROP_TAG_SHA=$(./.claude/skills/release-proprietary/scripts/wait-for-oss-sync.sh \ + . {{version}}) +``` + +The script writes the **proprietary sync-merge SHA** to stdout (captured into `PROP_TAG_SHA`) and progress to stderr. Internally it: + +1. Polls `origin/main` every 5 seconds for the OSS release commit (subject exactly `chore: release {{version}}`), with a 10-minute timeout. +2. Once found, locates the proprietary merge commit whose **2nd parent** is that OSS commit — that's the upstream side of the sync merge. Requiring the OSS release to be the direct 2nd parent (not a grandparent) ensures the merge's tree is exactly "OSS at release + proprietary state at that moment" — i.e. version `{{version}}`, not `{{next}}-next`. + +This is the SHA you tag on proprietary. It is NOT the OSS release SHA — that one's tree contains only OSS code and would break proprietary deployments. See the "Dual-SHA model" note at the top. + +If it times out, abort and instruct the user to: + +* Check the `sync-from-oss-repository` workflow on `PackmindHub/packmind-proprietary`. +* Check whether an open `oss-sync` PR appeared after Phase 0 ran. + +If the script reports `OSS release commit … is on proprietary origin/main, but no merge commit has it as its direct upstream (2nd) parent`, the auto-sync either fast-forwarded or batched release + next-cycle into one merge. The operator must resolve manually before continuing — there is no merge commit with the right tree to tag. + +Once `PROP_TAG_SHA` is set, fast-forward the local proprietary checkout (purely to keep the working tree in sync — we do NOT rely on HEAD for the tag): + +```bash +git checkout main +git pull --ff-only origin main +``` + +## Phase 3 — Tag proprietary + +### 3.1 Re-check proprietary CI green + +The sync just pushed new commits to proprietary main; the Phase 0 CI gate is now stale. Re-run it (allowing exit 2 = still running) and either wait or abort: + +```bash +./.claude/skills/release-proprietary/scripts/check-ci.sh PackmindHub/packmind-proprietary +``` + +### 3.2 Tag the proprietary release commit + +```bash +./.claude/skills/release-proprietary/scripts/tag-release.sh \ + . {{version}} "${PROP_TAG_SHA}" +``` + +Passing the SHA explicitly is essential — tagging `HEAD` would tag a later sync-merge or the next-cycle commit. The script re-verifies that the target is either a direct release commit OR a merge commit whose 2nd parent is the release commit, and refuses to tag otherwise. It will not allow retagging an existing tag at a different commit. + +### 3.3 Done + +Report to the user: + +* OSS release commit URL: `https://github.com/PackmindHub/packmind/releases/tag/release/{{version}}` (or the compare link) +* Proprietary tag URL: `https://github.com/PackmindHub/packmind-proprietary/releases/tag/release/{{version}}` +* Reminder: any deployment workflow that watches `release/*` tags will trigger. + +## Important notes + +* Do NOT use `--no-verify` on any commit. If a hook fails, fix the root cause and create a new commit. +* The release commit subject MUST be exactly `chore: release {{version}}` — Phase 2 matches subjects by exact equality and `tag-release.sh` verifies subject equality before tagging. Any prefix/suffix (e.g. gitmoji) will break the flow. +* Date format MUST be ISO 8601 (`YYYY-MM-DD`). +* Verify each commit and push succeeded before proceeding to the next step. +* If anything fails mid-way after the OSS tag is pushed, do NOT delete the OSS tag — coordinate a follow-up patch release instead. + +## Recovery cheatsheet + +If the flow fails at a known phase, recover as follows: + +* **After 1.4, before 1.5** (release commit pushed, no OSS tag yet): re-run `tag-release.sh <oss-dir> {{version}}` from Phase 1.5. The script tags HEAD only if HEAD's subject is `chore: release {{version}}`. +* **After 1.5, before 1.6** (OSS is tagged, no next-cycle commit): re-run `changelog-next.mjs` + `commit-next-cycle.sh`. Then proceed to Phase 2. +* **After 1.6, Phase 2 timed out**: investigate the sync workflow / oss-sync PR. Once unstuck, re-run only Phase 2 + Phase 3. +* **After Phase 3 failure** (OSS tagged, proprietary not tagged): once any blocker is cleared, re-run only Phase 3, passing the same `PROP_TAG_SHA`. Recover it by re-running `wait-for-oss-sync.sh` (idempotent — it finds and emits the same merge SHA as before) or manually with: + ```bash + OSS_SHA=$(git log origin/main \ + --format='%H%x09%s' -500 | awk -F '\t' -v subj="chore: release {{version}}" '$2 == subj { print $1; exit }') + git log origin/main --merges \ + --format='%H %P' -500 | awk -v oss="${OSS_SHA}" '$3 == oss { print $1; exit }' + ``` \ No newline at end of file diff --git a/.gitlab/duo/skills/release-proprietary/scripts/bump-versions.mjs b/.gitlab/duo/skills/release-proprietary/scripts/bump-versions.mjs new file mode 100755 index 000000000..86f17a5ac --- /dev/null +++ b/.gitlab/duo/skills/release-proprietary/scripts/bump-versions.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// Bump version field in package.json and apps/api/docker-package.json to the +// provided version. Preserves 2-space indentation and trailing newline. +// +// Usage: node bump-versions.mjs <version> <repo-dir> + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const [, , version, repoDirArg] = process.argv; + +if (!version || !repoDirArg) { + console.error('usage: bump-versions.mjs <version> <repo-dir>'); + process.exit(1); +} + +const repoDir = resolve(repoDirArg); +const targets = ['package.json', 'apps/api/docker-package.json']; + +for (const rel of targets) { + const path = join(repoDir, rel); + const raw = readFileSync(path, 'utf8'); + const json = JSON.parse(raw); + const previous = json.version; + json.version = version; + const endsWithNewline = raw.endsWith('\n'); + writeFileSync(path, JSON.stringify(json, null, 2) + (endsWithNewline ? '\n' : '')); + console.log(`bumped ${rel}: ${previous} → ${version}`); +} diff --git a/.gitlab/duo/skills/release-proprietary/scripts/changelog-next.mjs b/.gitlab/duo/skills/release-proprietary/scripts/changelog-next.mjs new file mode 100755 index 000000000..1d74c49e5 --- /dev/null +++ b/.gitlab/duo/skills/release-proprietary/scripts/changelog-next.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// Mutate CHANGELOG.MD for the next development cycle: +// - Prepend a fresh `# [Unreleased]` section with empty Added/Changed/Fixed/Removed +// - Insert `[Unreleased]: ...compare/release/{version}...HEAD` link just above +// the `[{version}]: ...` link in the bottom link section +// +// Usage: node changelog-next.mjs <version> <repo-dir> + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const [, , version, repoDirArg] = process.argv; + +if (!version || !repoDirArg) { + console.error('usage: changelog-next.mjs <version> <repo-dir>'); + process.exit(1); +} + +const path = join(resolve(repoDirArg), 'CHANGELOG.MD'); +const original = readFileSync(path, 'utf8'); + +const firstHeadingMatch = original.match(/^# \[/m); +if (!firstHeadingMatch) { + console.error('CHANGELOG.MD: could not find any `# [` heading'); + process.exit(1); +} +const insertAt = firstHeadingMatch.index; + +const unreleasedBlock = + '# [Unreleased]\n\n## Added\n\n## Changed\n\n## Fixed\n\n## Removed\n\n'; + +let result = original.slice(0, insertAt) + unreleasedBlock + original.slice(insertAt); + +const versionLinkRegex = new RegExp( + `^\\[${version.replace(/\./g, '\\.')}\\]: (https://github\\.com/[^/]+/[^/]+)/compare/release/[0-9A-Za-z._-]+\\.\\.\\.release/${version.replace(/\./g, '\\.')}\\s*$`, + 'm', +); +const versionLinkMatch = result.match(versionLinkRegex); +if (!versionLinkMatch) { + console.error(`CHANGELOG.MD: could not find \`[${version}]: ...\` compare link`); + process.exit(1); +} +const repoUrl = versionLinkMatch[1]; +const unreleasedLink = `[Unreleased]: ${repoUrl}/compare/release/${version}...HEAD`; + +result = result.replace(versionLinkRegex, `${unreleasedLink}\n${versionLinkMatch[0]}`); + +writeFileSync(path, result); +console.log(`changelog: prepared next cycle after ${version}`); diff --git a/.gitlab/duo/skills/release-proprietary/scripts/changelog-release.mjs b/.gitlab/duo/skills/release-proprietary/scripts/changelog-release.mjs new file mode 100755 index 000000000..db97d5aea --- /dev/null +++ b/.gitlab/duo/skills/release-proprietary/scripts/changelog-release.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// Mutate CHANGELOG.MD for a release: +// - Rename `# [Unreleased]` heading to `# [{version}] - {date}` +// - Drop empty `## Added|Changed|Fixed|Removed` subsections inside that block +// - Swap the bottom compare link +// `[Unreleased]: ...compare/release/{prev}...HEAD` +// for +// `[{version}]: ...compare/release/{prev}...release/{version}` +// +// Usage: node changelog-release.mjs <version> <date> <repo-dir> + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const [, , version, date, repoDirArg] = process.argv; + +if (!version || !date || !repoDirArg) { + console.error('usage: changelog-release.mjs <version> <date> <repo-dir>'); + process.exit(1); +} + +const path = join(resolve(repoDirArg), 'CHANGELOG.MD'); +const original = readFileSync(path, 'utf8'); + +const unreleasedHeading = original.match(/^# \[Unreleased\][^\n]*$/m); +if (!unreleasedHeading) { + console.error('CHANGELOG.MD: could not find `# [Unreleased]` heading'); + process.exit(1); +} + +const start = unreleasedHeading.index; +const after = original.slice(start + unreleasedHeading[0].length); +const nextHeadingRel = after.match(/^# \[/m); +if (!nextHeadingRel) { + console.error('CHANGELOG.MD: could not find next `# [` heading after Unreleased'); + process.exit(1); +} +const blockEnd = start + unreleasedHeading[0].length + nextHeadingRel.index; +const block = original.slice(start, blockEnd); + +const lines = block.split('\n'); +const out = [`# [${version}] - ${date}`]; +let i = 1; +while (i < lines.length) { + const line = lines[i]; + if (line.startsWith('## ')) { + let j = i + 1; + while (j < lines.length && !lines[j].startsWith('## ')) j++; + const body = lines.slice(i + 1, j).join('\n').trim(); + if (body.length > 0) { + out.push(line); + for (let k = i + 1; k < j; k++) out.push(lines[k]); + } + i = j; + } else { + out.push(line); + i++; + } +} + +let newBlock = out.join('\n'); +if (!newBlock.endsWith('\n\n')) { + newBlock = newBlock.replace(/\n*$/, '\n\n'); +} + +let result = original.slice(0, start) + newBlock + original.slice(blockEnd); + +const linkRegex = /^\[Unreleased\]: (https:\/\/github\.com\/[^/]+\/[^/]+)\/compare\/release\/([0-9A-Za-z._-]+)\.\.\.HEAD\s*$/m; +const linkMatch = result.match(linkRegex); +if (!linkMatch) { + console.error('CHANGELOG.MD: could not find `[Unreleased]: .../compare/release/<prev>...HEAD` link'); + process.exit(1); +} +const repoUrl = linkMatch[1]; +const prev = linkMatch[2]; +const newLink = `[${version}]: ${repoUrl}/compare/release/${prev}...release/${version}`; +result = result.replace(linkRegex, newLink); + +writeFileSync(path, result); +console.log(`changelog: released ${version} (date ${date}, previous ${prev})`); diff --git a/.gitlab/duo/skills/release-proprietary/scripts/check-ci.sh b/.gitlab/duo/skills/release-proprietary/scripts/check-ci.sh new file mode 100755 index 000000000..f9cf727cc --- /dev/null +++ b/.gitlab/duo/skills/release-proprietary/scripts/check-ci.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Verify that the "Main CI/CD Pipeline" workflow on the latest commit of the +# main branch of a given GitHub repository is green. +# +# Usage: check-ci.sh <owner/repo> +# Exit codes: +# 0 - latest run on main concluded successfully (green) +# 1 - latest run failed / cancelled / no run found / gh auth missing +# 2 - latest run is still in progress or queued (not red, just not done) + +set -euo pipefail + +REPO="${1:?usage: check-ci.sh <owner/repo>}" + +if ! command -v gh >/dev/null 2>&1; then + echo "❌ gh CLI not found in PATH" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "❌ jq not found in PATH" >&2 + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "❌ gh is not authenticated — run \`gh auth login\` first" >&2 + exit 1 +fi + +HEAD_SHA=$(gh api "repos/${REPO}/commits/main" --jq .sha 2>/dev/null || true) +if [ -z "${HEAD_SHA}" ]; then + echo "❌ ${REPO}: could not resolve main HEAD SHA (does the repo exist and do you have access?)" >&2 + exit 1 +fi +SHORT_SHA="${HEAD_SHA:0:7}" + +RUN_JSON=$(gh run list \ + --repo "${REPO}" \ + --workflow=main.yml \ + --branch=main \ + --limit=20 \ + --json databaseId,status,conclusion,headSha,url,createdAt) + +MATCH=$(echo "${RUN_JSON}" | jq --arg sha "${HEAD_SHA}" ' + [ .[] | select(.headSha == $sha) ] | sort_by(.createdAt) | reverse | .[0] // empty +') + +if [ -z "${MATCH}" ] || [ "${MATCH}" = "null" ]; then + echo "❌ ${REPO}: no Main CI/CD Pipeline run found for current main HEAD (${SHORT_SHA})" >&2 + echo " Most recent runs on main:" >&2 + echo "${RUN_JSON}" | jq -r '.[] | " - sha=\(.headSha[0:7]) status=\(.status) conclusion=\(.conclusion) \(.url)"' >&2 + exit 1 +fi + +STATUS=$(echo "${MATCH}" | jq -r '.status') +CONCL=$(echo "${MATCH}" | jq -r '.conclusion') +URL=$(echo "${MATCH}" | jq -r '.url') + +case "${STATUS}" in + completed) + case "${CONCL}" in + success) + echo "✅ ${REPO}: Main CI/CD Pipeline green on main (${SHORT_SHA})" + echo " ${URL}" + exit 0 + ;; + *) + echo "❌ ${REPO}: Main CI/CD Pipeline concluded '${CONCL}' on main (${SHORT_SHA})" >&2 + echo " ${URL}" >&2 + exit 1 + ;; + esac + ;; + queued|in_progress|waiting|requested|pending) + echo "⏳ ${REPO}: Main CI/CD Pipeline still '${STATUS}' on main (${SHORT_SHA})" >&2 + echo " ${URL}" >&2 + echo " This is not a failure — re-run check-ci.sh after the run completes." >&2 + exit 2 + ;; + *) + echo "❌ ${REPO}: Main CI/CD Pipeline status '${STATUS}' on main (${SHORT_SHA}) — unexpected state" >&2 + echo " ${URL}" >&2 + exit 1 + ;; +esac diff --git a/.gitlab/duo/skills/release-proprietary/scripts/check-oss-sync-pr.sh b/.gitlab/duo/skills/release-proprietary/scripts/check-oss-sync-pr.sh new file mode 100755 index 000000000..20cac1bae --- /dev/null +++ b/.gitlab/duo/skills/release-proprietary/scripts/check-oss-sync-pr.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Verify there is no open `oss-sync` PR on the proprietary repository. +# +# Background: the sync-from-oss-repository workflow fast-forwards OSS commits +# onto proprietary main when there are no conflicts. When conflicts occur, it +# pushes branch `oss-sync` and opens a PR for manual review instead. If such +# a PR is open when we run a release, Phase 2 polling will never find the +# release commit on origin/main and will time out. +# +# Usage: check-oss-sync-pr.sh <owner/repo> +# Exit codes: +# 0 - no open oss-sync PR +# 1 - open oss-sync PR found (PR details printed to stderr) +# 2 - gh CLI / auth issue + +set -euo pipefail + +REPO="${1:?usage: check-oss-sync-pr.sh <owner/repo>}" + +if ! command -v gh >/dev/null 2>&1; then + echo "❌ gh CLI not found in PATH" >&2 + exit 2 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "❌ gh is not authenticated — run \`gh auth login\` first" >&2 + exit 2 +fi + +PR_JSON=$(gh pr list \ + --repo "${REPO}" \ + --head oss-sync \ + --state open \ + --json number,url,title) + +COUNT=$(echo "${PR_JSON}" | jq 'length') + +if [ "${COUNT}" -gt 0 ]; then + echo "❌ ${REPO}: an open oss-sync PR is blocking the auto-sync — resolve it before releasing." >&2 + echo "${PR_JSON}" | jq -r '.[] | " - PR #\(.number) \(.title) — \(.url)"' >&2 + exit 1 +fi + +echo "✅ ${REPO}: no open oss-sync PR" diff --git a/.gitlab/duo/skills/release-proprietary/scripts/commit-next-cycle.sh b/.gitlab/duo/skills/release-proprietary/scripts/commit-next-cycle.sh new file mode 100755 index 000000000..d03bd627c --- /dev/null +++ b/.gitlab/duo/skills/release-proprietary/scripts/commit-next-cycle.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Stage CHANGELOG.MD in <repo-dir>, commit with the exact subject +# `chore: prepare next development cycle`, and push main to origin. +# +# Usage: commit-next-cycle.sh <repo-dir> + +set -euo pipefail + +REPO_DIR="${1:?usage: commit-next-cycle.sh <repo-dir>}" + +cd "${REPO_DIR}" + +git add -- CHANGELOG.MD + +if git diff --cached --quiet; then + echo "❌ ${REPO_DIR}: nothing staged for next-cycle commit — did changelog-next.mjs run?" >&2 + exit 1 +fi + +git commit -m "chore: prepare next development cycle" +git push origin main + +echo "✅ ${REPO_DIR}: pushed \"chore: prepare next development cycle\"" diff --git a/.gitlab/duo/skills/release-proprietary/scripts/commit-release.sh b/.gitlab/duo/skills/release-proprietary/scripts/commit-release.sh new file mode 100755 index 000000000..166a11d3d --- /dev/null +++ b/.gitlab/duo/skills/release-proprietary/scripts/commit-release.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Stage the 4 release files in <repo-dir>, commit with the exact subject +# `chore: release <version>`, and push main to origin. +# +# Usage: commit-release.sh <repo-dir> <version> +# +# Never uses --no-verify. If a hook fails, fix the root cause and re-run. + +set -euo pipefail + +REPO_DIR="${1:?usage: commit-release.sh <repo-dir> <version>}" +VERSION="${2:?usage: commit-release.sh <repo-dir> <version>}" + +FILES=( + "package.json" + "apps/api/docker-package.json" + "package-lock.json" + "CHANGELOG.MD" +) + +cd "${REPO_DIR}" + +for f in "${FILES[@]}"; do + if [ ! -f "${f}" ]; then + echo "❌ ${REPO_DIR}: expected release file is missing: ${f}" >&2 + exit 1 + fi +done + +git add -- "${FILES[@]}" + +if git diff --cached --quiet; then + echo "❌ ${REPO_DIR}: nothing staged for release commit — did the bump/changelog scripts run?" >&2 + exit 1 +fi + +git commit -m "chore: release ${VERSION}" +git push origin main + +echo "✅ ${REPO_DIR}: pushed release commit \"chore: release ${VERSION}\"" diff --git a/.gitlab/duo/skills/release-proprietary/scripts/tag-release.sh b/.gitlab/duo/skills/release-proprietary/scripts/tag-release.sh new file mode 100755 index 000000000..56ffae1ff --- /dev/null +++ b/.gitlab/duo/skills/release-proprietary/scripts/tag-release.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Create the `release/<version>` tag in <repo-dir> and push it to origin. +# +# Usage: tag-release.sh <repo-dir> <version> [sha] +# +# If <sha> is provided, the tag is created at that exact commit. The +# commit's subject is verified before tagging — but for proprietary, the +# SHA we tag is a merge commit (subject "Merge remote-tracking branch +# 'upstream/main'") whose 2nd parent is the OSS release commit. So the +# check accepts EITHER: +# - a commit whose subject is `chore: release <version>` (OSS side, or +# a direct release commit), OR +# - a merge commit whose 2nd parent's subject is `chore: release <version>` +# (proprietary sync-merge of the OSS release). +# This safety net ensures we don't tag the wrong commit when the SHA is +# passed in by hand or by a partially recovered flow. +# +# If <sha> is omitted, the tag is created at HEAD (use on the OSS repo +# immediately after pushing the release commit, before any next-cycle commit). +# HEAD's subject must be exactly `chore: release <version>` in that case. + +set -euo pipefail + +REPO_DIR="${1:?usage: tag-release.sh <repo-dir> <version> [sha]}" +VERSION="${2:?usage: tag-release.sh <repo-dir> <version> [sha]}" +SHA_ARG="${3:-}" +TAG="release/${VERSION}" +EXPECTED_SUBJECT="chore: release ${VERSION}" + +cd "${REPO_DIR}" + +# Verify that <sha> is either the release commit itself OR a merge commit +# whose 2nd parent is the release commit. Returns 0 if OK, 1 otherwise, +# and prints a human-readable description of the match on success. +verify_release_target() { + local sha=$1 + local actual_subject + actual_subject=$(git log -1 --format='%s' "${sha}") + if [ "${actual_subject}" = "${EXPECTED_SUBJECT}" ]; then + echo "direct release commit" + return 0 + fi + # Maybe it's a merge commit whose 2nd parent is the release. + local second_parent + second_parent=$(git log -1 --format='%P' "${sha}" | awk '{print $2}') + if [ -n "${second_parent}" ]; then + local parent_subject + parent_subject=$(git log -1 --format='%s' "${second_parent}") + if [ "${parent_subject}" = "${EXPECTED_SUBJECT}" ]; then + echo "merge commit (2nd parent ${second_parent:0:7} is the release)" + return 0 + fi + fi + echo "neither subject \"${actual_subject}\" nor any merged parent matches \"${EXPECTED_SUBJECT}\"" + return 1 +} + +if [ -n "${SHA_ARG}" ]; then + TARGET_SHA=$(git rev-parse --verify "${SHA_ARG}" 2>/dev/null || true) + if [ -z "${TARGET_SHA}" ]; then + echo "❌ ${REPO_DIR}: provided SHA '${SHA_ARG}' is not a valid object" >&2 + exit 1 + fi + if ! MATCH_DESC=$(verify_release_target "${TARGET_SHA}"); then + echo "❌ ${REPO_DIR}: ${TARGET_SHA:0:7} ${MATCH_DESC}" >&2 + exit 1 + fi + echo "ℹ️ ${REPO_DIR}: ${TARGET_SHA:0:7} accepted — ${MATCH_DESC}" +else + TARGET_SHA=$(git rev-parse HEAD) + ACTUAL_SUBJECT=$(git log -1 --format='%s' HEAD) + if [ "${ACTUAL_SUBJECT}" != "${EXPECTED_SUBJECT}" ]; then + echo "❌ ${REPO_DIR}: HEAD subject is \"${ACTUAL_SUBJECT}\", expected \"${EXPECTED_SUBJECT}\"" >&2 + echo " Refusing to tag a commit that isn't the release commit." >&2 + exit 1 + fi +fi + +if git rev-parse --verify --quiet "refs/tags/${TAG}" >/dev/null; then + EXISTING_SHA=$(git rev-parse "refs/tags/${TAG}") + if [ "${EXISTING_SHA}" != "${TARGET_SHA}" ]; then + echo "❌ ${REPO_DIR}: tag ${TAG} already exists at ${EXISTING_SHA:0:7}, refusing to retag at ${TARGET_SHA:0:7}" >&2 + exit 1 + fi + echo "ℹ️ ${REPO_DIR}: tag ${TAG} already exists at target — skipping creation" +else + git tag "${TAG}" "${TARGET_SHA}" +fi + +git push origin "${TAG}" + +echo "✅ ${REPO_DIR}: pushed tag ${TAG} at ${TARGET_SHA:0:7}" diff --git a/.gitlab/duo/skills/release-proprietary/scripts/wait-for-oss-sync.sh b/.gitlab/duo/skills/release-proprietary/scripts/wait-for-oss-sync.sh new file mode 100755 index 000000000..f234dee23 --- /dev/null +++ b/.gitlab/duo/skills/release-proprietary/scripts/wait-for-oss-sync.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Poll the proprietary repo's origin/main until the OSS release commit has +# been merged in by the auto-sync workflow, then emit the SHA of the +# **proprietary merge commit** (NOT the OSS release commit itself). +# +# Why the merge commit and not the release commit: +# The OSS release commit (e.g. `chore: release 1.15.0`) is created on OSS, +# so its tree contains OSS-only files. When the proprietary auto-sync +# brings it into proprietary main, the resulting merge commit's tree +# combines the OSS release with the proprietary-only files. Tagging the +# merge commit is what we want — tagging the raw OSS commit would point +# `release/<version>` at an OSS-only tree, breaking proprietary +# deployments (the build can't find `@packmind/proprietary/*` modules). +# +# The script: +# 1. Polls origin/main for the OSS release commit (by exact subject). +# 2. Once found, locates the proprietary merge commit whose **2nd parent** +# is that OSS commit. The 2nd parent is the upstream side of the sync +# merge. Requiring the OSS release commit to be the *direct* upstream +# parent (not a grandparent) ensures the merge's tree is exactly +# "OSS at release + proprietary state at that moment" — i.e. version +# X.Y.Z, not X.Y.(Z+1)-next. +# 3. Emits the merge SHA on stdout. Progress messages go to stderr. +# +# Usage: wait-for-oss-sync.sh <prop-repo-dir> <version> [timeout-seconds] +# Defaults: timeout-seconds=600 (10 minutes) +# Exit codes: +# 0 - proprietary merge commit detected (SHA on stdout) +# 1 - timed out, or sync arrived but no suitable merge commit exists +# (e.g. fast-forward, or auto-sync batched release + next-cycle into +# one merge — in that case the operator must resolve manually). + +set -euo pipefail + +PROP_DIR="${1:?usage: wait-for-oss-sync.sh <prop-repo-dir> <version> [timeout-seconds]}" +VERSION="${2:?usage: wait-for-oss-sync.sh <prop-repo-dir> <version> [timeout-seconds]}" +TIMEOUT="${3:-600}" + +SUBJECT="chore: release ${VERSION}" +INTERVAL=5 +ELAPSED=0 + +echo "⏳ Polling ${PROP_DIR} origin/main for OSS commit \"${SUBJECT}\" and its proprietary merge (timeout ${TIMEOUT}s)…" >&2 + +while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do + git -C "${PROP_DIR}" fetch --quiet origin main + + # Step 1: find the OSS release commit on proprietary main (by exact subject) + OSS_SHA=$(git -C "${PROP_DIR}" log origin/main --format='%H%x09%s' -500 \ + | awk -F '\t' -v subj="${SUBJECT}" '$2 == subj { print $1; exit }') + + if [ -n "${OSS_SHA}" ]; then + # Step 2: find the proprietary merge commit whose 2nd parent is the OSS release. + # %P emits parents space-separated; on a merge commit, $2 is parent1 + # (prior proprietary tip) and $3 is parent2 (the upstream side). + MERGE_SHA=$(git -C "${PROP_DIR}" log origin/main --merges --format='%H %P' -500 \ + | awk -v oss="${OSS_SHA}" '$3 == oss { print $1; exit }') + + if [ -n "${MERGE_SHA}" ]; then + echo "✅ Found proprietary merge commit: ${MERGE_SHA:0:7} (merges OSS release ${OSS_SHA:0:7})" >&2 + echo "${MERGE_SHA}" + exit 0 + fi + + echo "⚠️ OSS release commit ${OSS_SHA:0:7} is on proprietary origin/main, but no merge commit has it as its direct upstream (2nd) parent." >&2 + echo " This can happen if the auto-sync fast-forwarded (unlikely if proprietary has local commits) or batched the release with later OSS commits into one merge." >&2 + echo " Proprietary cannot be tagged automatically — investigate the sync history before proceeding." >&2 + exit 1 + fi + + sleep "${INTERVAL}" + ELAPSED=$((ELAPSED + INTERVAL)) +done + +echo "❌ Timed out after ${TIMEOUT}s waiting for OSS release commit \"${SUBJECT}\" to land on proprietary origin/main" >&2 +echo " Check the sync-from-oss-repository workflow status and any pending oss-sync PR." >&2 +exit 1 diff --git a/.gitlab/duo/skills/upgrade-runtime-stack/SKILL.md b/.gitlab/duo/skills/upgrade-runtime-stack/SKILL.md new file mode 100644 index 000000000..04da3a3de --- /dev/null +++ b/.gitlab/duo/skills/upgrade-runtime-stack/SKILL.md @@ -0,0 +1,137 @@ +--- +name: 'upgrade-runtime-stack' +description: 'Check whether newer stable versions of Node.js (24.x line), Nx, or Vite are available and, if so, generate a detailed upgrade plan markdown file at the repo root. Use this skill whenever the user asks to "check for runtime upgrades", "upgrade Node/NX/Vite", "is our Node version current", "plan a Node 24 upgrade", "refresh our runtime stack", "monthly stack check", or anything along those lines — even if they don''t name a specific tool. Also use it when the user wants a recurring/cadence check of build-toolchain currency. Output is a plan only — does NOT mutate package.json, Dockerfiles, lockfiles, or any other repo file. CI/CD wrappers can invoke this skill to keep the runtime stack fresh.' +--- + +# Upgrade Runtime Stack + +Goal: detect available stable upgrades of **Node.js (24.x line only)**, **Nx**, and **Vite**, then emit an actionable, ready-to-execute upgrade plan as a markdown file at the repo root. Stop at the plan — never edit application files. + +## Why this skill exists + +Manual runtime upgrades drift months between attempts and produce avoidable risk. This skill captures the canonical file map (mined from the prior Node 22 → 24 migration on `emdash/migration-node24-cc0s9`) so each upgrade run reuses the same checklist instead of rediscovering it. The plan is the deliverable. A human or a CI bot decides whether to act on it. + +## Execution mode + +This skill **must run fully non-interactive**. It is invoked from CI/CD in headless mode where no human can answer prompts. + +- Never call `AskUserQuestion` or any other clarification tool. +- Never ask the user to confirm a choice. The skill makes deterministic decisions from the inputs (baked URLs + repo state) and produces the plan. +- Never wait on long-running interactive commands. Read-only file inspection, `WebFetch`, and `rg` scans are the only actions needed. +- All output goes to a single deterministic artifact: `upgrade-plan.md` at the repo root. The terminal print at the end (Phase 7) is informational only — CI can ignore stdout. + +If any input is missing or ambiguous, the skill records the gap **inside the plan** (e.g. "Vite changelog unreachable this run") and continues. It never blocks on input. + +## Inputs + +The skill takes **no arguments**. Version sources are baked in: + +| Tool | Source URL | What to extract | +|------|------------|-----------------| +| Node.js 24.x | `https://raw.githubusercontent.com/nodejs/node/refs/heads/main/doc/changelogs/CHANGELOG_V24.md` | Latest stable 24.x release | +| Nx | `https://nx.dev/changelog` | Latest stable Nx major.minor.patch | +| Vite | `https://raw.githubusercontent.com/vitejs/vite/refs/heads/main/packages/vite/CHANGELOG.md` | Latest stable Vite release | + +See `references/fetch-versions.md` for the exact parsing rules per source. + +## Workflow + +Execute phases in order. Each phase has a single clear deliverable. Do not skip steps; the value of this skill comes from the consistency of the output. + +### Phase 1 — Read current versions from the repo + +Read these exact locations and record what is currently pinned: + +- `.nvmrc` → exact Node version (e.g. `24.15.0`) +- `package.json` (root) → `engines.node`, `engines.npm`, `devDependencies.nx`, `devDependencies["@nx/*"]`, `devDependencies.vite` +- `apps/api/docker-package.json` → `engines.node`, `engines.npm` +- `dockerfile/Dockerfile.api` and `dockerfile/Dockerfile.mcp` → `FROM node:<version>-alpine<alpine-version>@sha256:<digest>` +- `docker-compose.yml` and `docker-compose.production.yml` → every `image: node:<version>-alpine<alpine-version>` occurrence +- `.github/workflows/*.yml` → default `node-version` inputs + +Record in a single in-memory table; this becomes the "Current versions" section of the plan. + +### Phase 2 — Fetch latest stable versions + +Use `WebFetch` against each source URL listed in **Inputs**. Follow the per-source parsing rules in `references/fetch-versions.md`. + +Filtering rules (apply to every source): + +- **Skip** anything tagged `next`, `beta`, `alpha`, `rc`, `canary`, `preview`, `dev`, or `pre`. +- **Node.js**: only consider `v24.x.y` entries. Ignore 22.x, 26.x, etc. — even if newer. +- **Nx**: take the highest `X.Y.Z` published as a stable release. +- **Vite**: take the highest `X.Y.Z` published as a stable release. + +For each tool, also extract the **published date** if visible and a short summary of headline changes / breaking changes from the changelog body covering the range *(current version, latest]*. This summary feeds the risk section of the plan. + +### Phase 3 — Compute delta and decide + +For each tool, compare current vs. latest stable: + +- `latest == current` → no upgrade needed for that tool. Record as "up to date". +- `latest > current` → upgrade candidate. Classify the bump as `patch`, `minor`, or `major` using semver rules. Note any breaking-change headlines from Phase 2. +- `latest < current` → unusual. Record but do not recommend a downgrade. + +If **all three** tools are up to date, still produce `upgrade-plan.md` but with a single "No upgrades available" section plus the timestamp. This keeps the CI integration deterministic. + +### Phase 4 — Resolve Docker image dependencies + +When Node is being upgraded, the Docker image pin (`node:<version>-alpine<X>@sha256:<digest>`) must change in lockstep. The plan must include: + +1. The exact new tag string to use (`node:<new-version>-alpine<X>`). +2. The current Alpine major (read from existing Dockerfiles) — keep it unless a Node breaking change requires a different base. +3. An explicit instruction line: *"Look up the sha256 digest for `node:<new-version>-alpine<X>` on Docker Hub before pinning."* Do not fabricate a digest. + +If Node is not being upgraded, the Docker section of the plan only lists which files **would** change in a future Node bump, for reference. + +### Phase 5 — Map files to modify + +Use `references/file-map.md` as the authoritative list of files that any Node / Nx / Vite upgrade must touch in this repo. The map is grouped per tool. Include in the plan only the file groups whose tool has a pending upgrade. + +After listing the bake-in files, run a quick scan to surface drift — files that match the relevant version pattern but are not yet in the map: + +- Node version drift: `rg -n "node:[0-9]+\.[0-9]+\.[0-9]+|node-version: ['\"]?[0-9]+" --hidden -g '!node_modules' -g '!dist'` +- Nx version drift: `rg -n '"nx": "[0-9]+\.[0-9]+\.[0-9]+"|"@nx/[a-z-]+": "[0-9]+\.[0-9]+\.[0-9]+"' --hidden -g '!node_modules' -g '!dist'` +- Vite version drift: `rg -n '"vite": "(\^|~)?[0-9]+\.[0-9]+\.[0-9]+"' --hidden -g '!node_modules' -g '!dist'` + +Add any hits that are not already covered to a "Drift detected" subsection of the plan so a human can decide whether to extend the file map. + +### Phase 6 — Validation harness + +Copy the validation steps from `references/validation.md` into the plan. The harness is the contract for "this upgrade did not break the repo" and must be runnable end-to-end after applying the plan. + +### Phase 7 — Write `upgrade-plan.md` + +Use the exact structure defined in `references/plan-template.md`. Write to the repo root as `upgrade-plan.md`. Overwrite any existing file at that path — the plan is always the latest snapshot. + +The first line of the plan **must** be a machine-readable status comment so CI can branch on it without parsing the body: + +``` +<!-- upgrade-status: available | none | partial-fetch-failure --> +``` + +- `available` — at least one tool has a stable upgrade. +- `none` — all three tools up to date. +- `partial-fetch-failure` — one or more source URLs could not be fetched this run. + +After writing, print to stdout (informational only — CI may discard): + +- One-line summary per tool (e.g. `Node 24.15.0 → 24.17.0 (patch)`). +- The absolute path of the generated `upgrade-plan.md`. +- Whether any drift was detected (so the file map may need updating). + +Do **not** apply edits. Stop here. + +## Hard rules + +- Never edit `package.json`, `package-lock.json`, `.nvmrc`, Dockerfiles, docker-compose files, CI workflows, or any other source file. The skill produces a plan only. +- Never invent version numbers, dates, or sha256 digests. If a source URL cannot be fetched, mark the tool as "unknown — fetch failed" in the plan and continue with the other tools. +- Never recommend Node majors other than 24. The team is on the 24.x line and a major bump is a separate, deliberate project. +- Never include `rc`, `beta`, `alpha`, `canary`, `next`, `preview`, `dev`, or `pre` versions in the recommendation, even if newer than current. + +## Reference files + +- `references/fetch-versions.md` — per-source parsing rules for the three changelog URLs. +- `references/file-map.md` — canonical list of files an upgrade of each tool touches in this repo. +- `references/plan-template.md` — exact markdown layout of `upgrade-plan.md`. +- `references/validation.md` — lint / test / build commands that act as the safety harness. \ No newline at end of file diff --git a/.gitlab/duo/skills/upgrade-runtime-stack/references/fetch-versions.md b/.gitlab/duo/skills/upgrade-runtime-stack/references/fetch-versions.md new file mode 100644 index 000000000..8ca96d4e3 --- /dev/null +++ b/.gitlab/duo/skills/upgrade-runtime-stack/references/fetch-versions.md @@ -0,0 +1,52 @@ +# Fetching latest stable versions + +Per-source parsing rules. All sources are fetched with `WebFetch`. Always strip pre-release tags (`rc`, `beta`, `alpha`, `canary`, `next`, `preview`, `dev`, `pre`). + +## Node.js (24.x line only) + +- URL: `https://raw.githubusercontent.com/nodejs/node/refs/heads/main/doc/changelogs/CHANGELOG_V24.md` +- The file is the changelog for the entire Node 24 line. Section headers look like: + ``` + <a id="24.15.0"></a> + ## 2025-XX-YY, Version 24.15.0 (Current), @<release-manager> + ``` +- Parse the topmost `## YYYY-MM-DD, Version 24.X.Y` heading whose tag is **not** marked `(Pre-release)`, `(RC)`, etc. +- Output: + - `latest`: e.g. `24.17.0` + - `released`: e.g. `2025-09-12` + - `highlights`: bullet headings between this version's heading and the next version heading. Limit to ~10 short bullets. Skip commit-only bullets. +- The "Current" / "LTS" annotation may be present — record it but do not gate the recommendation on it; the 24.x line moves from Current to LTS during its life. + +## Nx + +- URL: `https://nx.dev/changelog` +- The page lists release entries newest-first. Each entry looks like a section with a version (e.g. `Nx 22.7.2`) and a date. +- Parse the topmost entry whose version is **not** suffixed with `-rc.N`, `-beta.N`, `-alpha.N`, `-canary.N`, `-next.N`. +- Output: + - `latest`: e.g. `22.7.2` + - `released`: e.g. `2025-09-30` + - `highlights`: bullet section titles from that entry (e.g. "Breaking changes", "Features", "Bug Fixes"). Capture any text explicitly under "Breaking changes" verbatim — it drives the risk section. +- If the major changes between current and latest, also fetch the matching `https://nx.dev/changelog#vNN-migration` anchor or the linked migration guide and include the URL in the plan. + +## Vite + +- URL: `https://raw.githubusercontent.com/vitejs/vite/refs/heads/main/packages/vite/CHANGELOG.md` +- Standard `keep-a-changelog` style. Headings look like: + ``` + ## 8.0.3 (YYYY-MM-DD) + ``` +- Parse the topmost `## X.Y.Z` heading without a pre-release tag. +- Output: + - `latest`: e.g. `8.1.0` + - `released`: e.g. `2025-10-04` + - `highlights`: subsection titles (`### Features`, `### Bug Fixes`, `### BREAKING CHANGES`). Capture any `BREAKING CHANGES` section verbatim. + +## Fallback when a source cannot be fetched + +If `WebFetch` returns an error or an empty body, do not block the run. In the plan, record: + +``` +- <tool>: unable to fetch <URL> — skipped this run. +``` + +The two remaining tools are still processed normally. diff --git a/.gitlab/duo/skills/upgrade-runtime-stack/references/file-map.md b/.gitlab/duo/skills/upgrade-runtime-stack/references/file-map.md new file mode 100644 index 000000000..db47a227b --- /dev/null +++ b/.gitlab/duo/skills/upgrade-runtime-stack/references/file-map.md @@ -0,0 +1,62 @@ +# Canonical file map per tool + +These lists were mined from the Node 22 → 24 migration branch `emdash/migration-node24-cc0s9`. They represent the *minimum* set of files that an upgrade of each tool must touch. The skill's Phase 5 also scans for drift in case the repo gained a new file matching the relevant pattern. + +A file may appear under more than one tool when an upgrade of any of those tools requires editing it. + +## Node.js (24.x) + +When the Node line shifts to a new patch (or minor within 24.x), update every concrete pin so all environments agree. + +**Engine / runtime pins:** +- `.nvmrc` — full `X.Y.Z` Node version, no `v` prefix. +- `package.json` (root) — `engines.node` and `engines.npm`. +- `apps/api/docker-package.json` — `engines.node` and `engines.npm` (this file is shipped inside the API Docker image as `package.json`). + +**Docker images:** +- `dockerfile/Dockerfile.api` — `FROM node:<version>-alpine<X>@sha256:<digest>`. The sha256 digest must be looked up on Docker Hub for the new tag; do not invent it. +- `dockerfile/Dockerfile.mcp` — same pattern. +- `docker-compose.yml` — every `image: node:<version>-alpine<X>` line. There are several services. +- `docker-compose.production.yml` — every `image: node:<version>-alpine<X>` line. + +**CI workflows:** +- `.github/workflows/build.yml` — `node-version` workflow input default and every matrix entry. +- `.github/workflows/main.yml` — same. +- `.github/workflows/docker.yml` — same. +- `.github/workflows/publish-cli-release.yml` — same. +- `.github/workflows/tmp-cli-lint-windows.yml` — same. + +**Helper scripts (kept around from the prior migration; usually paired):** +- `migrate_node24.sh` — invoked by humans to switch local env after a bump. +- `downgrade_node22.sh` — escape hatch back to the previous major; only relevant on a *major* Node bump. + +When the Node bump is patch- or minor-only, the two helper scripts can be left as-is. On a major bump (e.g. 24.x → 26.x), the plan must explicitly call out rewriting/removing them. + +## Nx + +When Nx is bumped, every `@nx/*` package and `nx` itself must move to the same version. The Nx CLI's own `nx migrate` workflow handles most edits, but the plan must list: + +- `package.json` (root) — `devDependencies.nx` and every `devDependencies["@nx/*"]`. Currently used scopes: `@nx/devkit`, `@nx/esbuild`, `@nx/eslint`, `@nx/eslint-plugin`, `@nx/jest`, `@nx/js`, `@nx/nest`, `@nx/node`, `@nx/playwright`, `@nx/plugin`, `@nx/react`, `@nx/storybook`, `@nx/vite`, `@nx/vitest`, `@nx/web`, `@nx/webpack`. +- `tools/packmind-plugin/package.json` — `@nx/*` deps referenced by the local Nx plugin. +- `nx.json` — schema / plugin configuration sometimes shifts between minors. +- `package-lock.json` — regenerated by `npm install` after the version bumps. +- `migrations.json` — created by `nx migrate <version>` at the repo root. The plan must include the explicit command `npx nx migrate <new-version>` and a follow-up `npx nx migrate --run-migrations`. + +**ESLint coupling:** the Nx ESLint plugin sometimes lands new flat-config requirements that affect `eslint.config.mjs` and `packages/ui/eslint.config.mjs`. List them as "review only" in the plan unless the changelog explicitly calls them out. + +## Vite + +When Vite is bumped, the consumers of Vite in this repo are: + +- `package.json` (root) — `devDependencies.vite`. +- `apps/frontend/vite.config.ts` — config surface; major bumps often change plugin or build options. +- `packages/ui/vite.config.ts` — same. +- `apps/frontend/package.json` — peer / dev deps may need realignment if the major changes. +- `packages/ui/package.json` — same. +- `package-lock.json` — regenerated. + +**Coupling with Nx:** `@nx/vite` and `@nx/vitest` carry their own Vite peer-dep ranges. On a Vite major bump, the plan must check the Nx changelog for compatibility before recommending the move; if Nx doesn't yet support the new Vite major, the plan recommends *waiting* and notes the blocking constraint. + +## Drift scan + +Phase 5 of the skill also runs three ripgrep scans (see `SKILL.md`) and reports any additional matches. Anything reported there is a candidate for adding to this file map after the upgrade lands. diff --git a/.gitlab/duo/skills/upgrade-runtime-stack/references/plan-template.md b/.gitlab/duo/skills/upgrade-runtime-stack/references/plan-template.md new file mode 100644 index 000000000..2ec5bebd9 --- /dev/null +++ b/.gitlab/duo/skills/upgrade-runtime-stack/references/plan-template.md @@ -0,0 +1,103 @@ +# `upgrade-plan.md` layout + +Write the plan to the **repo root** as `upgrade-plan.md`. Overwrite any existing file at that path. The structure below is mandatory — downstream tooling (and the human reader's mental model) depends on it. + +When a tool has no pending upgrade, still emit its section but populate it with "Up to date — no action required" so the file's shape stays stable. + +```markdown +<!-- upgrade-status: available | none | partial-fetch-failure --> +# Runtime stack upgrade plan + +_Generated: <YYYY-MM-DD HH:MM TZ> by the `upgrade-runtime-stack` skill._ + +## Summary + +| Tool | Current | Latest stable | Bump | Action | +|------|---------|---------------|------|--------| +| Node.js 24.x | <X.Y.Z> | <X.Y.Z> | patch / minor / major / none | Upgrade / Skip | +| Nx | <X.Y.Z> | <X.Y.Z> | patch / minor / major / none | Upgrade / Skip | +| Vite | <X.Y.Z> | <X.Y.Z> | patch / minor / major / none | Upgrade / Skip | + +<one-paragraph recommendation: e.g. "All three tools have stable updates available. Node and Nx are patch-only and safe; Vite is a minor with one deprecation worth reviewing."> + +## Node.js + +- **Current**: <X.Y.Z> (from `.nvmrc`) +- **Latest stable**: <X.Y.Z>, released <YYYY-MM-DD> +- **Bump type**: patch / minor / major +- **Changelog highlights**: + - <bullet> + - <bullet> +- **Breaking changes**: <verbatim section from changelog, or "none documented"> + +### Files to modify + +<file map — group by category from `references/file-map.md`, with exact line patterns to change> + +### Docker image pin + +- New tag: `node:<new-version>-alpine<X>` +- Action: look up the sha256 digest for that tag on Docker Hub before editing `dockerfile/Dockerfile.api` and `dockerfile/Dockerfile.mcp`. Do **not** reuse the previous digest. +- Sample lookup: `docker manifest inspect node:<new-version>-alpine<X> | jq -r '.manifests[0].digest'` or use the Docker Hub UI. + +## Nx + +- **Current**: <X.Y.Z> +- **Latest stable**: <X.Y.Z>, released <YYYY-MM-DD> +- **Bump type**: patch / minor / major +- **Changelog highlights**: + - <bullet> +- **Breaking changes**: <verbatim> +- **Migration command**: + ``` + npx nx migrate <new-version> + npm install + npx nx migrate --run-migrations + ``` + +### Files to modify + +<from `references/file-map.md` — Nx section> + +## Vite + +- **Current**: <X.Y.Z> +- **Latest stable**: <X.Y.Z>, released <YYYY-MM-DD> +- **Bump type**: patch / minor / major +- **Changelog highlights**: + - <bullet> +- **Breaking changes**: <verbatim> +- **Nx / Vite compatibility note**: <only if Vite is a major bump — confirm `@nx/vite` and `@nx/vitest` support the new Vite major> + +### Files to modify + +<from `references/file-map.md` — Vite section> + +## Drift detected + +<empty list, or any extra hits the Phase 5 scans found that are not in the file map> + +## Validation harness + +<paste the steps from `references/validation.md` verbatim> + +## Risks + +- <pre-flagged risk: e.g. "Node 24.X.Y removes deprecated experimental flag `--foo`; the repo does not use it (verified via rg)"> +- <every breaking-change bullet from the changelogs goes here, paired with a quick repo check> + +## Rollback + +- Revert the upgrade commit and run `npm install` to regenerate the lockfile. +- For a Node major rollback, `downgrade_node22.sh` exists for the 22 ↔ 24 transition; on later majors a similar helper must be created before applying. +- Docker images are pinned by `@sha256:...` so previous deploys are reproducible. + +## Suggested commit split + +A single PR is fine for patch/minor bumps of all three tools combined. Split into separate PRs when: + +- Any tool has a **major** bump. +- The Nx and Vite bumps would interact (e.g. Vite major + `@nx/vite` major). + +Suggested split for this run: <one-paragraph proposal> +``` diff --git a/.gitlab/duo/skills/upgrade-runtime-stack/references/validation.md b/.gitlab/duo/skills/upgrade-runtime-stack/references/validation.md new file mode 100644 index 000000000..c26764221 --- /dev/null +++ b/.gitlab/duo/skills/upgrade-runtime-stack/references/validation.md @@ -0,0 +1,73 @@ +# Validation harness + +After the upgrade plan is applied, these steps must all succeed before merging. Copy this section verbatim into `upgrade-plan.md`. + +The order matters — fail fast on cheap checks before paying for full builds. + +## Local + +1. **Node + npm match the pins** + ``` + node --version # expect: vNEW_NODE + npm --version # expect: NEW_NPM + ``` + If `nvm` is in use: `nvm use` should read the new `.nvmrc` automatically. + +2. **Clean install** + ``` + rm -rf node_modules package-lock.json # only on major bumps; skip for patch/minor + npm install + ``` + For patch/minor bumps prefer `npm install` against the existing lockfile so the diff stays auditable. + +3. **Lint affected** + ``` + npm run lint:staged + ``` + +4. **Test affected** + ``` + npm run test:staged + ``` + +5. **Build the heaviest targets** + ``` + ./node_modules/.bin/nx build api + ./node_modules/.bin/nx build frontend + ./node_modules/.bin/nx build cli + ./node_modules/.bin/nx build mcp-server + ``` + +6. **Docker build smoke test** (only when Node is bumped) + ``` + docker build -f dockerfile/Dockerfile.api -t packmind-api:upgrade-check . + docker build -f dockerfile/Dockerfile.mcp -t packmind-mcp:upgrade-check . + docker compose -f docker-compose.yml up -d api frontend + docker compose -f docker-compose.yml ps + docker compose -f docker-compose.yml down + ``` + +## CI + +The GitHub Actions workflows already test against the `node-version` matrix entries. After the plan is applied, the **Main CI/CD Pipeline** must be green on the branch before merging: + +- `.github/workflows/build.yml` +- `.github/workflows/main.yml` +- `.github/workflows/docker.yml` + +If any workflow runs `nx affected`, it picks up the changed files automatically and runs the relevant projects. + +## Manual smoke (post-merge) + +- Spin up the local stack: `docker compose up -d`. +- Open the frontend on its dev URL and confirm the app loads. +- Hit the API health endpoint. +- Run one MCP server interaction end-to-end. + +## Failure handling + +If any harness step fails: + +1. Capture the exact error in the upgrade PR description. +2. Revert with `git revert <upgrade-commit>` rather than amending — keeps history auditable. +3. Re-run the skill on the reverted branch to regenerate a fresh plan once the upstream fix lands. diff --git a/.opencode/skills/agent-skill-frontmatter-audit/SKILL.md b/.opencode/skills/agent-skill-frontmatter-audit/SKILL.md new file mode 100644 index 000000000..d1ad40de0 --- /dev/null +++ b/.opencode/skills/agent-skill-frontmatter-audit/SKILL.md @@ -0,0 +1,203 @@ +--- +name: 'agent-skill-frontmatter-audit' +description: 'Audit per-agent SKILL.md frontmatter support in Packmind against the current Agent Skills baseline specification and the latest official docs for each AI coding agent.' +--- + +# Agent Skill Frontmatter Audit + +Packmind renders skills (one of the three artefact types along with standards and commands) as a `SKILL.md` file with YAML frontmatter deployed into each AI coding agent's expected folder (`.claude/skills/`, `.cursor/skills/`, `.github/skills/`, `.agents/skills/`, `.opencode/skills/`, etc.). + +There is a shared **Agent Skills baseline specification** that all supporting agents commit to (the "core" fields every agent understands), and then each agent may publish its own documentation extending that baseline with agent-specific "additional properties". Both layers evolve over time: the baseline spec ships new versions, and vendors add, rename, or deprecate extensions. + +This skill audits Packmind's rendering against both layers and produces a dated report at the project root so drift can be tracked release over release. + +## Context + +The audit is a **four-way comparison** per agent: + +1. **Baseline spec** — the current Agent Skills specification at <https://agentskills.io/specification.md>. Defines the set of fields every compliant agent is expected to accept (typically `name`, `description`, and similar core keys). +2. **Upstream agent spec** — the agent's own public documentation page. May extend the baseline with agent-specific properties, or may be baseline-only. +3. **Packmind declared support** — the per-agent constants in `packages/types/src/skills/skillAdditionalProperties.ts`. +4. **Packmind actual rendering** — what each agent's deployer in `packages/coding-agent/src/infra/repositories/{agent}/{Agent}Deployer.ts` actually writes into the YAML frontmatter. + +The four pairs can disagree silently: + +- **Baseline vs. upstream** — a vendor page that omits a field the baseline requires is a vendor bug to note, not a Packmind action item. +- **Upstream vs. constants** — Packmind is behind (or ahead of) the vendor on agent-specific fields. +- **Constants vs. deployer** — internal drift; the "supported" promise in the codebase is a lie. +- **A dead doc URL** — the audit can no longer be trusted automatically for that agent. + +None of these produce runtime errors. A deprecated field is silently ignored by the agent; a missing field is a silent feature loss for users. The only way to catch either is an audit against the upstream spec — which is what this skill automates. + +### Core principle: baseline-only is fine + +Some agents choose to support **only** the baseline spec — no additional properties on top. That is a legitimate design choice and **must not be reported as a warning, gap, or drift**. Concretely: if an agent has no `_ADDITIONAL_FIELDS` constant in Packmind, no `filterAdditionalProperties` call in its deployer, and no agent-specific properties documented upstream, that agent is "baseline-only" and the report should mark it in sync with a short note like *"no additional properties — baseline spec only"*. + +Only flag a missing constant / missing filter **when the agent's own upstream documentation lists properties beyond the baseline**. Otherwise the absence is correct. + +## Sources in scope + +The baseline spec is fetched once. Each of the five agents is fetched individually. Keep this list verbatim — adding, removing, or re-pointing an entry is a skill update, not an ad-hoc decision. + +| Source | URL | Codebase locations | +| --- | --- | --- | +| **Baseline spec** | `https://agentskills.io/specification.md` | *(no single file — the baseline names map to core frontmatter emitted by every deployer)* | +| Claude Code | `https://code.claude.com/docs/en/skills` | `CLAUDE_CODE_ADDITIONAL_FIELDS` + `packages/coding-agent/src/infra/repositories/claude/ClaudeDeployer.ts` | +| GitHub Copilot | `https://code.visualstudio.com/docs/copilot/customization/agent-skills` | `COPILOT_ADDITIONAL_FIELDS` + `packages/coding-agent/src/infra/repositories/copilot/CopilotDeployer.ts` | +| Cursor | `https://cursor.com/docs/skills#frontmatter-fields` | `CURSOR_ADDITIONAL_FIELDS` + `packages/coding-agent/src/infra/repositories/cursor/CursorDeployer.ts` | +| OpenAI Codex | `https://developers.openai.com/codex/skills` | *(constant optional — declare one only if upstream lists additional properties)* + `packages/coding-agent/src/infra/repositories/codex/CodexDeployer.ts` | +| OpenCode | `https://opencode.ai/docs/skills/` | *(constant optional — declare one only if upstream lists additional properties)* + `packages/coding-agent/src/infra/repositories/opencode/OpenCodeDeployer.ts` | + +The canonical constants file is `packages/types/src/skills/skillAdditionalProperties.ts`. + +## Workflow + +Run the steps in order. Parallelise fetches and codebase reads whenever one step doesn't depend on another — the audit is network-heavy and the user is waiting for a report. + +### Step 1 — Fetch the baseline spec and all upstream docs + +Issue `WebFetch` calls in parallel: one for the baseline spec, plus one per agent URL. + +**For the baseline spec**, extract: +- The version identifier of the spec (if stated on the page), so the report can cite which baseline was used. +- The complete list of baseline frontmatter keys with whether each is required or optional, and a one-line description quoted from the spec. + +**For each agent doc**, extract: +- The complete list of YAML frontmatter keys the agent documents on `SKILL.md`. Separate baseline fields (those already in the baseline spec) from agent-specific additions. +- Any explicit deprecation markers (words like "deprecated", "removed", "no longer supported", "legacy", or strikethrough styling the page renders). +- A short one-line description of each agent-specific key, quoted from the vendor wording rather than invented. +- Whether the agent's docs explicitly declare "this agent supports only the baseline" or equivalent. If so, note it — it's the cleanest signal for the baseline-only classification. + +Classify the fetch result of every URL as one of: +- ✅ **Reachable and relevant** — the page loads and documents skills/frontmatter. +- ⚠️ **Redirected** — the URL resolves but lands on a different, still-relevant page. Record both the requested and resolved URLs. +- ❌ **Dead** — 404, 403, empty body, or the landing page no longer mentions skills/frontmatter. Record the exact failure signal. + +Do **not** fall back to training-data knowledge for a dead URL. If the baseline spec itself is dead, note that the entire audit cannot separate baseline from agent-specific fields and mark every per-agent classification as `(uncertain — baseline source unreachable)`. If an agent doc is dead, only that agent's findings are marked uncertain. + +### Step 2 — Read Packmind's declared support + +Read `packages/types/src/skills/skillAdditionalProperties.ts`. Extract: + +1. Each per-agent constant that exists (today: `CLAUDE_CODE_ADDITIONAL_FIELDS`, `COPILOT_ADDITIONAL_FIELDS`, `CURSOR_ADDITIONAL_FIELDS`). For each, capture the YAML key ↔ camelCase storage key mapping. Claude Code declares its fields as a `Record<string, string>` (YAML→camel); Cursor and Copilot declare a flat `string[]` of camelCase keys. Do not assume the shape is uniform. +2. `CLAUDE_CODE_ADDITIONAL_FIELDS_ORDER` — the canonical rendering order for Claude. A key that's in `CLAUDE_CODE_ADDITIONAL_FIELDS` but not in the order array (or vice-versa) is a **low-severity internal drift** worth mentioning. +3. Any new constant that may have been introduced for Codex or OpenCode since this skill was last updated. + +**The absence of a constant for Codex or OpenCode is not automatically a finding.** Whether it's a gap depends entirely on Step 1: if the agent's upstream doc lists no properties beyond the baseline, the absence is correct. If it does list extensions, then the absence is a missing-support finding. + +### Step 3 — Read the deployer for each agent + +For each agent, open its deployer file listed in the scope table. In the file, locate: + +- The method that produces the frontmatter block (typically `generateSkillMdContent` or similar). Read which keys it writes and in what order. +- Any call to `filterAdditionalProperties(...)`: which constant does it pass in? +- Any call to `sortAdditionalPropertiesKeys(...)`: missing here means non-deterministic YAML output for that agent's frontmatter. + +For Codex and OpenCode, the current pattern is to delegate multi-file skill deployment to `SingleFileDeployer`/base-class logic (see `packages/coding-agent/src/infra/repositories/utils/` and `defaultSkillsDeployer/`). If the deployer has no skill-specific frontmatter method at all, record `(inherits base deployer; baseline fields only)` as the deployer behaviour. + +Treat a missing `filterAdditionalProperties` call as a concern **only when** the agent's upstream doc lists additional properties — in that case, the deployer either bypasses the declared list (leaking everything) or emits nothing agent-specific (leaking nothing, failing to expose supported fields). Figure out which from the emitted-keys list and classify accordingly. If the agent is baseline-only upstream, no filter is needed and no finding should be raised. + +### Step 4 — Classify every property + +For each agent, build a single combined set of keys = (baseline keys) ∪ (agent-upstream keys) ∪ (constant keys) ∪ (deployer-emitted keys). For each key, assign exactly one classification: + +- ✅ **In sync (baseline)** — a baseline key, supported by the agent upstream and emitted by the deployer. Expected state; keep it concise in the table. +- ✅ **In sync (agent-specific)** — an agent-specific key that upstream lists, the constant declares, and the deployer emits. +- ➕ **Missing in Packmind** — upstream lists it (baseline or agent-specific) but Packmind doesn't support it. This is the primary "we're behind the spec" bucket. +- ➖ **Deprecated / removed upstream** — the constant or deployer supports it, but upstream no longer lists it (or explicitly deprecates it). Packmind is still shipping a field the agent will ignore. +- 🔀 **Internal drift** — constant and deployer disagree with each other, independent of upstream. Examples: the constant declares a key the deployer never emits; the deployer emits a key the constant never declared; the `_ORDER` array and the `_FIELDS` map disagree. +- ❓ **Uncertain** — the relevant upstream source is unreachable (from Step 1). Do not guess. + +**Do not classify baseline-only as a gap.** If the agent's upstream lists no extensions and Packmind declares none, there is nothing to report for additional properties beyond a single one-line note per agent saying *"baseline spec only — no additional properties"*. + +Prefer concrete, linkable evidence for every classification. "Missing" must cite the exact vendor section that lists the property; "Deprecated" must cite the exact file:line that still supports it. + +### Step 5 — Structural gaps (conditional) + +Beyond per-property drift, surface structural gaps at the agent level **only when the evidence warrants it**. Do not raise any of the following for an agent that is legitimately baseline-only: + +- Raise *"no constant declared"* **only if** upstream lists additional properties for that agent and Packmind has no way to emit them. An agent with no upstream extensions and no Packmind constant is in sync, not gapped. +- Raise *"deployer bypasses `filterAdditionalProperties`"* **only if** upstream lists additional properties and the deployer writes the additional-props blob without filtering (so unrelated fields could leak through). +- Raise *"deployer renders non-deterministic frontmatter order"* **only if** the deployer actually emits additional properties without a sort call. If there are no additional properties to sort, the absence of `sortAdditionalPropertiesKeys` is fine. +- Raise *"upstream URL redirected or 404d"* whenever Step 1 classified the URL as ⚠️ or ❌ — this is always a finding independent of property state. + +If all structural checks pass for every agent, write *"None — all agents structurally aligned with their upstream commitments."* The empty section is still worth keeping so the reader knows the check happened. + +### Step 6 — Write the report + +Produce a single markdown file at the project root, named using the current date from the system (today is e.g. `2026-04-21` → `skills_properties_2026_04_21.md`). The filename uses underscores between year, month, and day to match the user's convention. Inside the file, the heading date can use dashes (`2026-04-21`) for readability. + +Use this exact structure: + +```markdown +# Agent Skill Frontmatter Audit — YYYY-MM-DD + +## Summary + +- Baseline spec version: <version or "unversioned — fetched YYYY-MM-DD"> +- Agents audited: N +- Upstream URL health: X/(N+1) reachable and relevant (including baseline) +- Baseline-only agents: … +- Properties in sync: … +- Missing in Packmind: … +- Deprecated / removed upstream: … +- Internal drift findings: … +- Structural gaps: … + +## Upstream URL health + +| Source | Requested URL | Status | Resolved URL / Notes | +| --- | --- | --- | --- | +| Baseline spec | … | ✅/⚠️/❌ | … | +| <Agent> | … | ✅/⚠️/❌ | … | + +## Baseline spec + +- **Source:** <url> (<status>) +- **Version / fetched at:** … +- **Core fields:** list the baseline keys with required/optional and a one-line description each. + +## Per-agent findings + +### <Agent name> +- **Upstream docs:** <url> (<status>) +- **Baseline-only?** Yes / No +- **Declared constant:** `<CONSTANT_NAME>` — `packages/types/src/skills/skillAdditionalProperties.ts:<line>` *(or "none declared — expected for baseline-only agents")* +- **Deployer:** `packages/coding-agent/src/infra/repositories/<agent>/<Agent>Deployer.ts:<line>` — filters via `<constant>` / inherits base / no filter + +If the agent is baseline-only and in sync, a single sentence is enough: *"Baseline spec only — no additional properties. Packmind emits the baseline fields via the <deployer/base-class> path; no further action needed."* + +Otherwise, include the property table: + +| Property | Classification | Baseline | Upstream | Constant | Deployer | Notes | +| --- | --- | --- | --- | --- | --- | --- | +| … | ✅/➕/➖/🔀/❓ | ✓/✗ | ✓/✗ | ✓/✗ | ✓/✗ | short rationale / vendor quote | + +**Action items** (omit section if empty) +- ➕ Add `<prop>` to `<CONSTANT>` and emit in `<Deployer>` — upstream lists it under "<section>". +- ➖ Remove `<prop>` from `<CONSTANT>` (and deployer if applicable) — upstream no longer documents it. +- 🔀 `<prop>` is declared in `<CONSTANT>` but never emitted by `<Deployer>`; pick one source of truth. + +*(Repeat for each of the five agents, even if findings are empty — an empty section still documents that the agent was checked.)* + +## Structural gaps + +(List only the conditional gaps from Step 5. If none, write "None — all agents structurally aligned with their upstream commitments.") + +## Recommendations + +Prioritised, plain-language next steps. For each, name the file(s) to touch and why. When a property is newly deprecated upstream, prefer a deprecation path (leave supported for one release, emit a warning, remove next release) rather than immediate removal — users may have existing skill artefacts that rely on the field. +``` + +Write only the report — no preamble, no "here is the report" sentence. When the skill is invoked the user wants the report itself at the project root, plus a one-line confirmation of where the file was written. + +## Notes and edge cases + +- **Baseline-only is a valid state.** The skill must never treat the absence of additional properties as a problem. Codex and OpenCode today are typical examples — if their vendor docs don't list extensions beyond the baseline, they're in sync. +- **Dates use underscores in the filename.** `skills_properties_2026_04_21.md`, not `-2026-04-21-` and not without a date. If the user asks for the audit twice the same day, overwrite the file — the day's audit is the authoritative snapshot. +- **Do not invent URLs.** If the user supplies a replacement URL for a dead one, use it and note the substitution in the URL-health table. Otherwise, mark the source uncertain. +- **Do not scan deployed example SKILL.md files** (e.g. what's under `.claude/skills/` in the user's projects) to infer support. The contract is the deployer code and the constants, not the artefacts they happen to have produced so far — an unused supported field would be invisible in that sample. +- **Claude Code is the reference implementation.** Its constants map (`CLAUDE_CODE_ADDITIONAL_FIELDS`) and order array (`CLAUDE_CODE_ADDITIONAL_FIELDS_ORDER`) are the richest and most mature. When in doubt about how a new Codex/OpenCode constant should be shaped (in the event they ever need one), mirror the Claude Code pattern rather than inventing a third shape. +- **Core / baseline fields are tracked by the baseline source, not per-agent.** Put baseline-level observations in the "Baseline spec" section, not duplicated under every agent. Per-agent sections only need to confirm baseline support and then focus on additional properties. +- **Changes in scope (new agents, removed agents, URL updates) are skill edits.** Don't silently drop an agent because its URL 404s this week — that's a finding, not a scope change. +- **Run sequencing.** Fetches (Step 1, including the baseline) and codebase reads (Steps 2–3) don't depend on each other; kick them off in parallel. Steps 4–6 depend on both, so they run after. \ No newline at end of file diff --git a/.opencode/skills/amplitude-listener-events-adoption/SKILL.md b/.opencode/skills/amplitude-listener-events-adoption/SKILL.md new file mode 100644 index 000000000..950bbcf1e --- /dev/null +++ b/.opencode/skills/amplitude-listener-events-adoption/SKILL.md @@ -0,0 +1,173 @@ +--- +name: 'amplitude-listener-events-adoption' +description: 'Report organization adoption in Amplitude for the most recently added domain events in the AmplitudeEventListener. Walks git history on `packages/amplitude/src/application/AmplitudeEventListener.ts` to find the last 15 still-subscribed events, queries the Packmind V3 [CLOUD] Amplitude project via the MCP, and produces a markdown report grouped by domain prefix (text before the first underscore). Triggers when the user asks to audit recent amplitude listener events, check which orgs use new domain events, measure adoption of recently added events, or requests an amplitude report on space/playbook/standard/etc. events.' +--- + +# Amplitude Listener Events Adoption + +Produce a markdown adoption report for the **last 15 domain events** subscribed in the Amplitude listener, grouped by domain, listing organizations that triggered each event and how many times. + +## Prerequisites + +- The Amplitude MCP must be connected (tools prefixed `mcp__amplitude__`). If not, prompt the user to run `/mcp`. +- Target project: **Packmind V3 [CLOUD]**, `appId = 100032310`. Never query the DEV or legacy projects unless the user explicitly asks. + +## Inputs + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `N` | 15 | Number of most-recently-added events to report on | +| `range` | `Last 30 Days` | Amplitude date range | +| Excluded orgs | see below | Defaults applied automatically; user may extend the list | +| Excluded events | see below | Defaults applied automatically; user may extend the list | + +## Organizations exclusion + +Always exclude these internal/test organizations from the report (apply **after** querying Amplitude, before aggregation): + +- `AI Configuration orga` +- `test_package_standards` +- `packmind` +- `Joan's orga` +- `demo-orga` +- `VIncent test` +- `cedric.teyton's organization` +- `cedric-test` +- `test-skills` + +Match on the exact organization `grp:name` value returned by Amplitude. Extend this list if the user provides additional orgs to skip. + +## Events exclusion + +Always exclude these Amplitude event names from the report even if they are among the last N subscribed in the listener: + +- `user_signed_in` +- `new_organization_created` +- `user_signed_up` + +Rationale: these three events fire once per user/organization creation and produce extreme long tails (hundreds of orgs with a single event each), drowning out the actual feature-adoption signal the report is built for. + +Drop excluded events from the event-name list **before** building the Amplitude query (do not count them toward `N` — select the next most recent event to fill the quota). Extend this list if the user provides additional events to skip. + +## Workflow + +### Step 1 — Identify the last N still-subscribed events + +Source file: `packages/amplitude/src/application/AmplitudeEventListener.ts`. + +1. Read the current file and collect the set of event **class names** that appear in `this.subscribe(<Event>, ...)` inside `registerHandlers()`. This is the "still-subscribed" set. +2. For each class, also extract the corresponding **Amplitude event name** (the string literal passed as the 2nd argument to `this.emitAmplitudeEvent(event, '<name>', ...)` in the handler). This is what Amplitude stores. +3. Walk the file's git history newest→oldest: + + ```bash + git log --all --follow --format="%h|%ai|%s" -- packages/amplitude/src/application/AmplitudeEventListener.ts + ``` + +4. For each commit, extract added `this.subscribe(` lines: + + ```bash + git show --format="" <commit> -- packages/amplitude/src/application/AmplitudeEventListener.ts \ + | grep -E "^\+ *this\.subscribe\(" + ``` + +5. Walk commits in order; for every added class name that is also in the still-subscribed set, record `(amplitude_event_name, class_name, commit_date, commit_sha)` once (deduplicate by class — only keep the **most recent** addition). Stop once `N` unique events are collected. +6. Discard entries for events that were later removed or renamed out of the current file (e.g. `SpaceRenamedEvent` → filtered because no longer in the current `registerHandlers`). + +Multi-line `this.subscribe(\n EventName,\n ...)` must also be captured — widen the grep context (`grep -A2`) when a line ends just after `this.subscribe(`. + +**Efficient commit walk**: rather than running `git show` once per commit interactively, batch the first ~40 commits returned by `git log` and loop in a single `bash` call: + +```bash +for c in $(git log --all --follow --format="%h" -- packages/amplitude/src/application/AmplitudeEventListener.ts | head -40); do + echo "=== $c ===" + git show --format="" $c -- packages/amplitude/src/application/AmplitudeEventListener.ts \ + | grep -E "^\+ *this\.subscribe\(" -A1 +done +``` + +This surfaces all added `this.subscribe(` lines with commit boundaries in one tool call, which keeps token cost low and preserves the newest→oldest ordering needed for dedupe. + +### Step 2 — Query Amplitude + +Use `mcp__amplitude__query_dataset` with: + +- `projectId`: `"100032310"` +- `definition`: + ```json + { + "type": "eventsSegmentation", + "app": "100032310", + "params": { + "range": "Last 30 Days", + "events": [ /* one entry per amplitude event name */ + { "event_type": "<name>", "filters": [], "group_by": [] } + ], + "metric": "totals", + "countGroup": "organization", + "groupBy": [{ "type": "group", "group_type": "organization", "value": "grp:name" }], + "interval": 30, + "segments": [{ "conditions": [] }] + } + } + ``` +- `groupByLimit`: `200` +- `timeSeriesLimit`: `0` + +**Critical gotcha**: the CSV returned by `query_dataset` *omits* the organization name column when `groupBy` is set on a group property. Re-query using `mcp__amplitude__query_chart` with the `chartEditId` returned by `query_dataset`; that response includes the real org-name column. Always do this second call to get usable data. + +If the response payload is large, rely on `timeSeriesLimit: 0` (totals-only) to keep it compact. + +### Step 3 — Aggregate and format + +Parse the CSV rows from `query_chart`. The response contains header rows (chart name, list of events), an empty separator row, and then a data section starting with a `["Event", "name", "<interval-label-1>", "<interval-label-2>", ...]` row followed by data rows. + +- **Row shape with `timeSeriesLimit: 0`**: each data row is `[event_name, org_name, <interval_1_count>, <interval_2_count>, ...]`. There is **no trailing grand-total column** in that mode — sum the interval columns yourself to get the per-org total for the window. A 30-day range typically yields two interval columns (one per calendar month touched). +- **Ignore blank or placeholder org rows**: rows with `org_name == ""` or `org_name == "(none)"` represent events fired outside any organization context and must be skipped. + +Aggregation is mechanical and repetitive across 10+ events — use a small inline Python script via `Bash` with `python3 -` or a heredoc to load the parsed rows, apply the exclusion list, sum intervals, sort, and slice top-10 + `+K`. Doing this by hand for long tails (e.g. `standard_sample_selected` with 60+ orgs) is error-prone and wastes tokens. + +- Group events by **domain prefix** = substring before the first underscore in the Amplitude event name (`space_created` → `space`, `playbook_artefact_moved` → `playbook`, `change_proposal_submitted` → `change`, etc.). +- Inside each domain, list events in the order they appear in the source file (stable for the reader). +- Apply the **org exclusion list** before totaling. If no orgs remain for an event, treat it as zero-orgs. +- For each event: + 1. Start the line with the total number of **distinct organizations** that triggered it (after exclusions): `- <event> (N orgs): ...`. + 2. Sort organizations by their event count, descending; list at most the **top 10** with their counts: `orgA (42), orgB (18), ...`. + 3. If more than 10 orgs remain, append a trailing `+K` token where `K` = remaining org count. Example: `- space_created (23 orgs): orgA (9), orgB (7), ... orgJ (2) +13`. Never enumerate those remaining orgs by name; the `+K` is a trend signal only. +- **Collapse zero-adoption events into a single summary line** at the end of each domain (or at the end of the full report if the user prefers global grouping): `- No adoption (0 orgs): event_a, event_b, event_c`. + +### Step 4 — Emit the report + +Write the report to `amplitude-listener-events-adoption.md` at the project root (overwrite if it already exists). Also display the highlights inline in the chat reply so the user can scan them without opening the file. + +Skeleton: + +```markdown +# Amplitude adoption — last N listener events (Packmind V3 [CLOUD], <range>) + +Excluded orgs: <list>. Excluded events: <list>. + +## Domain: space +- space_created (23 orgs): orgA (9), orgB (7), orgC (6), orgD (5), orgE (4), orgF (3), orgG (3), orgH (2), orgI (2), orgJ (2) +13 +- space_members_added (4 orgs): orgA (18), orgB (12), orgC (5), orgD (1) +- ... +- No adoption (0 orgs): space_pinned, space_unpinned + +## Domain: playbook +- playbook_artefact_moved (2 orgs): orgA (40), orgC (4) + +... + +Source chart: [Open in Amplitude](<chartEditUrl>) +``` + +Keep the report concise — no per-month breakdown, no narrative paragraphs. The user wants a scan-friendly list. + +## Edge cases + +- **Fewer than N events in history**: report whatever is available and note the shortfall in one line. +- **Event renamed in listener but kept in Amplitude** (e.g. renaming `user_signup` → `user_signed_up`): trust the current file; query only the current Amplitude event name. +- **Event subscribed in listener but never emitted in prod**: it will appear as a zero-adoption event — that is the correct signal. +- **Duplicate commits touching the same subscribe line** (merges, reverts): keep the most recent non-reverted addition. +- **Group property is empty or placeholder** (`""` or `"(none)"` org name row): ignore that row — the event fired outside any org context. +- **Event also tracked with an identify call** (e.g. `OrganizationCreatedEvent` triggers `identifyOrganizationGroup` in addition to the tracked event): this does not affect the adoption query but explains why some org names appear populated only after the event fires once. +- **All remaining orgs are in the exclusion list**: emit the event under the `No adoption (0 orgs): …` summary line — do not emit a misleading `(N orgs)` header when the real external count is zero. \ No newline at end of file diff --git a/.opencode/skills/cli-e2e-test-authoring/SKILL.md b/.opencode/skills/cli-e2e-test-authoring/SKILL.md new file mode 100644 index 000000000..57840e50b --- /dev/null +++ b/.opencode/skills/cli-e2e-test-authoring/SKILL.md @@ -0,0 +1,192 @@ +--- +name: 'cli-e2e-test-authoring' +description: 'Guide for adding new end-to-end tests for the Packmind CLI. This skill should be used when creating new test specs in the `apps/cli-e2e-tests/` directory that exercise CLI commands against a real binary and API.' +--- + +# CLI E2E Test Authoring + +## Overview + +Add end-to-end tests that exercise the Packmind CLI binary (`dist/apps/cli/main.cjs`) in realistic conditions. Tests run the actual CLI as a child process and, when authentication is needed, interact with a running API via HTTP gateways. + +## Test File Location + +All spec files live in `apps/cli-e2e-tests/src/` and follow the naming convention `<command>.spec.ts`. + +## Choosing a Test Wrapper + +Two wrappers are available depending on whether the command requires authentication. + +### Unauthenticated commands — `describeWithTempSpace` + +Provides an isolated temporary directory. No API required. + +```typescript +import { describeWithTempSpace, runCli } from './helpers'; + +describeWithTempSpace('my-command without auth', (getContext) => { + let result: Awaited<ReturnType<typeof runCli>>; + + beforeEach(async () => { + const { testDir } = await getContext(); + result = await runCli('my-command', { cwd: testDir }); + }); + + it('exits with code 1', () => { + expect(result.returnCode).toBe(1); + }); + + it('shows an error message', () => { + expect(result.stdout).toContain('No credentials found'); + }); +}); +``` + +### Authenticated commands — `describeWithUserSignedUp` + +Extends `describeWithTempSpace` by creating a real user account, signing in, and generating an API key. Requires a running API at `http://localhost:4200`. + +```typescript +import { describeWithUserSignedUp, runCli } from './helpers'; + +describeWithUserSignedUp('my-command with auth', (getContext) => { + let result: Awaited<ReturnType<typeof runCli>>; + + beforeEach(async () => { + const { apiKey, testDir } = await getContext(); + result = await runCli('my-command', { apiKey, cwd: testDir }); + }); + + it('succeeds', () => { + expect(result.returnCode).toBe(0); + }); + + it('displays expected output', () => { + expect(result.stdout).toContain('Expected text'); + }); +}); +``` + +The context object from `describeWithUserSignedUp` provides: + +| Field | Description | +|----------------|--------------------------------------------------| +| `testDir` | Isolated temporary directory | +| `apiKey` | Valid API key for the created user | +| `user` | User object (email, etc.) | +| `organization` | Organization the user belongs to | +| `spaceId` | Global space ID for the organization | +| `gateway` | Authenticated gateway to call the API directly | + +## Setting Up Test Preconditions + +### Git repository + +Commands that interact with git (e.g. `diff`, `install`) require a git repo. Call `setupGitRepo` in `beforeEach`: + +```typescript +import { setupGitRepo } from './helpers'; + +beforeEach(async () => { + const { testDir } = await getContext(); + await setupGitRepo(testDir); +}); +``` + +### Creating API resources + +Use `gateway` from the context to seed data before running the CLI: + +```typescript +beforeEach(async () => { + const { gateway, spaceId, testDir } = await getContext(); + await setupGitRepo(testDir); + await gateway.commands.create({ spaceId, /* ... */ }); + await gateway.packages.create({ spaceId, /* ... */ }); +}); +``` + +### File manipulation + +Use file helpers to create or modify files in the test directory: + +```typescript +import { readFile, updateFile, fileExists } from './helpers'; + +const content = readFile('path/to/file.md', testDir); +updateFile('path/to/file.md', 'new content', testDir); +const exists = fileExists('path/to/file.md', testDir); +``` + +## Assertion Patterns + +Split assertions into individual `it` blocks. Store the CLI result in a block-scoped `let` variable populated by `beforeEach`: + +```typescript +let result: Awaited<ReturnType<typeof runCli>>; + +beforeEach(async () => { + result = await runCli('some-command', { apiKey, cwd: testDir }); +}); + +it('succeeds', () => { + expect(result.returnCode).toBe(0); +}); + +it('displays the summary', () => { + expect(result.stdout).toContain('1 change submitted'); +}); +``` + +## Nesting Describes for Multi-Step Scenarios + +For commands that require chained operations (e.g. install then modify then diff), nest `describe` blocks. Each level adds its own `beforeEach` that builds on the parent context: + +```typescript +describeWithUserSignedUp('diff command', (getContext) => { + beforeEach(async () => { + // setup: git repo + seed data + install + }); + + describe('when a change is submitted', () => { + beforeEach(async () => { + // modify file + run diff --submit + }); + + it('succeeds', () => { /* ... */ }); + + describe('when running diff again', () => { + beforeEach(async () => { + // run diff without --submit + }); + + it('excludes the already-submitted change', () => { /* ... */ }); + }); + }); +}); +``` + +## Feature Flags + +Before writing the test, ask the user: **"Is the feature under test gated by a feature flag?"** + +If yes, the test must create a user whose email is at `@packmind.com` so the flag is enabled. This is controlled by the `underFeatureFlag` fixture option. Add `.use({ underFeatureFlag: true })` immediately after the test wrapper import, before any `describe` or `it` blocks: + +```typescript +// When using testWithApi: +testWithApi.use({ underFeatureFlag: true }); + +// When using testWithUserSignedUp: +testWithUserSignedUp.use({ underFeatureFlag: true }); +``` + +Without this option, the fixture creates a user at `@example.com`, which does not have feature flags enabled, and the feature under test would not be accessible. + +## Adding a New Gateway + +When the CLI command under test requires seeding a new type of API resource: + +1. Add the interface method to `helpers/IPackmindGateway.ts`. All exposed methods must be typed with `Gateway<IXxxUseCase>` or `PublicGateway<IXxxUseCase>` (imported from `@packmind/types`) +2. Create `helpers/gateways/NewResourceGateway.ts` implementing the interface +3. Add a lazy getter to `helpers/gateways/PackmindGateway.ts` (reset on `initializeWithApiKey`) +4. Re-export from `helpers/index.ts` \ No newline at end of file diff --git a/.opencode/skills/datadog-analysis/SKILL.md b/.opencode/skills/datadog-analysis/SKILL.md new file mode 100644 index 000000000..54d6d9d0b --- /dev/null +++ b/.opencode/skills/datadog-analysis/SKILL.md @@ -0,0 +1,102 @@ +--- +name: 'datadog-analysis' +description: 'Analyze Datadog error logs for Packmind production services (api-proprietary, mcp-proprietary, frontend-proprietary), group them into patterns, root-cause against the codebase, and produce a structured bug report. Triggers on Datadog, production logs, prod errors, service health, or periodic error reviews.' +--- + +# Datadog Analysis + +Analyze production error logs from Packmind Datadog services, group them into patterns, cross-reference stack traces with the codebase, and produce a structured markdown report with root causes and Datadog search patterns. + +## Prerequisites + +- The Datadog MCP server must be connected. If not connected, prompt the user to run `/mcp` first. +- Read `references/datadog_mcp.md` before making any MCP tool calls for guidance on tool usage, gotchas, and known pitfalls. + +## Services + +The analysis covers three production services. Each maps to a Datadog service name, a codebase location, and a Dockerfile: + +| Datadog service | App | Codebase | Dockerfile | Runtime | +|----------------|-----|----------|------------|---------| +| `api-proprietary` | API | `apps/api/` + all `packages/` | `dockerfile/Dockerfile.api` | Node.js (NestJS, TypeORM, Redis/ioredis, BullMQ) | +| `mcp-proprietary` | MCP Server | `apps/mcp-server/` + all `packages/` | `dockerfile/Dockerfile.mcp` | Node.js (tree-sitter, SSE) | +| `frontend-proprietary` | Frontend | `apps/frontend/` | `dockerfile/Dockerfile.frontend` | Nginx (static SPA serving) | + +Root cause analysis should trace errors back to source files in the monorepo. For Nginx (frontend), also check the Nginx configs in `dockerfile/nginx.*.conf` and the entrypoint `dockerfile/nginx-entrypoint.sh`. + +## Parameters + +| Parameter | Default | Description | +|-----------|---------|-------------| +| Days to analyze | 7 | Number of past days to look at. Override by user request (e.g., "last 3 days") | + +## Exclusions + +The following log patterns should be **discarded** and not included in the report. Skip them during pattern discovery and do not count them as errors: + +- `(node:1) [DEP0060] DeprecationWarning: The util._extend API is deprecated. Please use Object.assign() instead.` -- Known Node.js deprecation from a transitive dependency. Noise, not actionable. Filter with `-DEP0060`. + +- Nginx stale asset 404s (`open() "/usr/share/nginx/html/assets/..." failed (2: No such file or directory)`) -- Expected SPA behavior after deployments. Browsers with a cached `index.html` request old hashed JS chunks that no longer exist. Not a bug. Filter with `-"No such file or directory" -"/assets/"` on `frontend-proprietary`. + +When filtering in Phase 1, exclude these patterns from the analysis by appending the exclusion terms to Datadog queries, or remove them during report consolidation. + +## Workflow + +### Phase 1: Discover Error Patterns (all services in parallel) + +For **each** of the three services, launch two parallel MCP calls (6 calls total, all in parallel). If rate-limited by the MCP server, fall back to batching 2 calls per service sequentially. + +Every Datadog MCP call requires a `telemetry` object with an `intent` string describing the call's purpose (e.g., `{"intent": "Discover error patterns for api-proprietary over last 7 days"}`). Keep intents concise and avoid including PII or secrets. + +1. **Pattern discovery** -- Use `mcp__datadog-mcp__search_datadog_logs` with: + - `query`: `service:{service_name} status:(error OR critical OR emergency)` + - `from`: `now-{N}d` (where N = number of days, default 7) + - `use_log_patterns`: `true` + - `max_tokens`: `10000` + +2. **Error message counts** -- Use `mcp__datadog-mcp__analyze_datadog_logs` with: + - `filter`: `service:{service_name} status:(error OR critical OR emergency)` + - `sql_query`: `SELECT message, count(*) as cnt FROM logs GROUP BY message ORDER BY cnt DESC LIMIT 50` + - `from`: `now-{N}d` + - `max_tokens`: `10000` + +From these results, identify the distinct error groups per service. If a service has zero errors in the period, mention "No issues found" in the report and skip Phases 2-3 for that service. + +### Phase 2: Deep Dive Each Error Group + +For each distinct error group identified in Phase 1: + +1. **Fetch raw logs** -- Use `search_datadog_logs` with a targeted query to get full stack traces and context. Use `extra_fields: ["*"]` for tag metadata when useful. + +2. **Get daily distribution** -- Use `analyze_datadog_logs` with: + - `sql_query`: `SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt FROM logs WHERE message LIKE '%<pattern>%' GROUP BY DATE_TRUNC('day', timestamp) ORDER BY DATE_TRUNC('day', timestamp)` + +3. **Count occurrences** -- Use `analyze_datadog_logs` to get total unique occurrences grouped by message. + +Parallelize independent MCP calls wherever possible to save time. + +#### Frontend-Specific Notes + +For `frontend-proprietary`, Nginx writes all `error_log` output (including `[notice]`) to stderr. Datadog classifies stderr as `status:error`. Filter out Nginx lifecycle noise: +- Ignore patterns containing `[notice]` (worker start/stop, SIGQUIT, SIGCHLD, SIGIO) -- these are normal Nginx operations misclassified as errors +- Focus on `[error]` (404s for missing files) and `[alert]` (permission issues, config errors) + +### Phase 3: Codebase Root Cause Analysis + +For each application-level error (not infra/external): + +Before grepping, consult `references/known_patterns.md` — if the error matches a catalogued pattern, jump straight to its entry point and skip to step 2. + +1. **Grep for the error class or message** in the codebase using the Grep tool (e.g., `SpaceMembershipRequiredError`, `Recipe.*not found`) +2. **Read the source files** where the error is thrown +3. **Trace the call chain**: error class -> service/use case -> controller/adapter +4. **Identify the root cause**: missing error handling, wrong HTTP status, race condition, missing validation, Dockerfile misconfiguration, etc. + +For frontend Nginx errors, check: +- `dockerfile/Dockerfile.frontend` for permission/ownership issues +- `dockerfile/nginx.k8s.conf`, `dockerfile/nginx.k8s.no-ingress.conf`, `dockerfile/nginx.compose.conf` for config issues +- `dockerfile/nginx-entrypoint.sh` for entrypoint issues + +### Phase 4: Generate Report + +Read `references/report-template.md` before writing the report for the output path, scaffold, severity ordering, occurrence labels, and final summary table. \ No newline at end of file diff --git a/.opencode/skills/datadog-analysis/references/datadog_mcp.md b/.opencode/skills/datadog-analysis/references/datadog_mcp.md new file mode 100644 index 000000000..c69e11aae --- /dev/null +++ b/.opencode/skills/datadog-analysis/references/datadog_mcp.md @@ -0,0 +1,158 @@ +# Datadog MCP Server Reference + +Reference guide for using the Datadog MCP server tools. Read this before making any Datadog MCP calls. + +## Available Tools + +### `mcp__datadog-mcp__search_datadog_logs` + +**Purpose**: Search and retrieve raw log entries or log patterns. Best for viewing raw logs, discovering patterns, and discovering custom attributes. + +**Key parameters**: +- `query` (required): Datadog search query using `key:value` syntax +- `from` / `to`: Time range. Use relative format `now-Xh`, `now-Xd`. Default: last 1 hour +- `use_log_patterns` (boolean): When `true`, clusters similar log messages into patterns with counts instead of returning raw logs. Essential for the initial discovery phase. +- `extra_fields` (array): Include extra attributes/tags. Use `["*"]` to get all metadata (tags, pod names, container info, etc.) +- `max_tokens`: Cap response size. Default 5000, use 10000 for pattern discovery +- `start_at`: Pagination offset for large result sets + +**When to use**: +- Initial pattern discovery (`use_log_patterns: true`) +- Fetching full stack traces for specific errors +- Getting tag metadata with `extra_fields: ["*"]` + +### `mcp__datadog-mcp__analyze_datadog_logs` + +**Purpose**: Run SQL queries against logs. Best for aggregations, counts, group-bys, and time-series analysis. + +**Key parameters**: +- `sql_query` (required): SQL against a virtual `logs` table +- `filter`: Datadog search query to pre-filter logs before SQL +- `from` / `to`: Time range (same format as search) +- `extra_columns`: Extend the `logs` table with typed columns from log attributes +- `max_tokens`: Default 10000 + +**Default columns**: `timestamp`, `host`, `service`, `env`, `version`, `status`, `message` + +**When to use**: +- Counting errors by message, day, or host +- Time-series aggregations (`DATE_TRUNC`) +- Top-N queries + +## Query Syntax + +### Datadog Search Query (for `query` / `filter` parameters) + +``` +service:api-proprietary status:error # Basic tag filtering +service:api-proprietary status:(error OR critical) # OR within a tag +"exact phrase match" # Quoted exact match +service:api-proprietary "Recipe" "not found" # Multiple terms (AND) +@http.status_code:[400 TO 499] # Range on raw attribute +-version:beta # Exclusion +service:web* # Wildcard +``` + +### DDSQL (for `sql_query` parameter) + +DDSQL is a PostgreSQL subset with restrictions: +- Every non-aggregated `SELECT` column must appear in `GROUP BY` +- `SELECT` aliases **cannot** be reused in `WHERE`, `GROUP BY`, or `HAVING` -- repeat the full expression instead +- Only declared table columns may be referenced +- Use `->` or `json_extract_path_text` for JSON access (cast as needed) +- Column names with special characters like `@` must be quoted: `SELECT "@foo" FROM logs` + +**Unsupported**: `ANY()`, `->>`, `information_schema`, `current_timestamp` + +**Common patterns**: + +```sql +-- Count by message +SELECT message, count(*) as cnt FROM logs GROUP BY message ORDER BY cnt DESC LIMIT 50 + +-- Daily distribution +SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt +FROM logs +GROUP BY DATE_TRUNC('day', timestamp) +ORDER BY DATE_TRUNC('day', timestamp) + +-- Filter with LIKE +SELECT message, count(*) as cnt +FROM logs +WHERE message LIKE '%SpaceMembershipRequiredError%' +GROUP BY message ORDER BY cnt DESC LIMIT 10 + +-- Hourly breakdown +SELECT DATE_TRUNC('hour', timestamp) as hour, count(*) as cnt +FROM logs +GROUP BY DATE_TRUNC('hour', timestamp) +ORDER BY DATE_TRUNC('hour', timestamp) +``` + +## Gotchas and Lessons Learned + +### 1. Pattern search vs raw search return different formats + +- `use_log_patterns: true` returns **TSV_DATA** with columns: `first_seen`, `last_seen`, `count`, `status`, `pattern` +- Raw search returns **TSV_DATA** or **YAML_DATA** with individual log entries +- Pattern search is much more useful for initial discovery -- always start with it + +### 2. Datadog search does NOT support regex -- use LIKE in SQL as fallback + +Queries like `"Recipe * not found"` with wildcards in quoted strings will return 0 results. Instead: +- Use multiple quoted terms: `"Recipe" "not found"` (matches logs containing both) +- Or use unquoted keywords: `Recipe not found` (matches as AND) +- Wildcards only work on tag values: `service:web*` + +When `search_datadog_logs` still returns 0 results for a complex pattern, fall back to `analyze_datadog_logs` with `WHERE message LIKE '%pattern%'`. The SQL LIKE operator works on the raw message text and is more flexible than the search query syntax. + +### 3. Multi-line stack traces are separate log entries + +In Datadog, each line of a stack trace is typically a separate log entry. A single exception with a 10-line stack trace produces 10 log entries. This means: +- Raw log counts are inflated (one error = many log lines) +- Pattern discovery helps group these related lines +- To find the actual error message, filter for the line containing the exception class name (e.g., `ExceptionsHandler`) + +### 4. ANSI escape codes appear in log messages + +Production NestJS logs contain ANSI color codes like `[31m`, `[39m`, `[38;5;3m`. These appear in raw log output. When searching, ignore these codes -- they won't affect keyword matching but make messages harder to read visually. + +### 5. The `extra_fields: ["*"]` option returns extensive tag metadata + +Using `["*"]` returns all Kubernetes/cloud metadata (pod name, node, container, cluster, etc.). This is useful for: +- Identifying which pods are affected +- Determining if errors are node-specific +- Cross-referencing with infrastructure incidents + +But it significantly increases response size -- only use when needed. + +### 6. Time range format + +- Relative: Must start with `now-` (e.g., `now-5d`, `now-24h`, `now-30m`) +- Absolute: ISO 8601 (e.g., `2026-04-10T00:00:00Z`) +- Default is `now-1h` which is too short for most analyses -- always set explicitly + +### 7. Token limits and pagination + +- Default `max_tokens` is 5000 for search and 10000 for analyze +- If results are truncated, the response includes `is_truncated: true` and a `truncation_message` with the next `start_at` offset +- For pattern discovery, 10000 tokens is usually sufficient +- For raw log fetching with `extra_fields`, reduce the number of results or increase max_tokens + +### 8. DDSQL GROUP BY alias trap + +This fails: +```sql +SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt +FROM logs GROUP BY day -- ERROR: 'day' alias not recognized +``` + +This works: +```sql +SELECT DATE_TRUNC('day', timestamp) as day, count(*) as cnt +FROM logs GROUP BY DATE_TRUNC('day', timestamp) -- Repeat full expression +``` + +### 9. Storage tier for older logs + +For logs older than the default retention period, use `storage_tier: "flex_and_indexes"` to also search Flex storage. This is only needed for the analyze tool; the search tool supports both `flex_and_indexes` and `online_archives_and_indexes`. diff --git a/.opencode/skills/datadog-analysis/references/known_patterns.md b/.opencode/skills/datadog-analysis/references/known_patterns.md new file mode 100644 index 000000000..18d908a7d --- /dev/null +++ b/.opencode/skills/datadog-analysis/references/known_patterns.md @@ -0,0 +1,23 @@ +# Known Error Patterns + +Pre-catalogued recurring error patterns and their codebase entry points. Consult during Phase 3 when an error matches a known signature — jump straight to the listed entry point and skip ad-hoc grepping. + +## API (`api-proprietary`) + +| Pattern | Datadog query | Codebase entry point | +|---------|--------------|---------------------| +| Redis connection failure | `service:api-proprietary status:error ETIMEDOUT OR ECONNREFUSED` | `ioredis` client, infra-level | +| Space membership check | `service:api-proprietary status:error SpaceMembershipRequiredError` | `packages/node-utils/src/application/AbstractSpaceMemberUseCase.ts` | +| Recipe not found | `service:api-proprietary status:error "Recipe" "not found"` | `packages/recipes/src/application/services/RecipeService.ts` | +| Artefact not found | `service:api-proprietary status:error "Artefact" "not found"` | `packages/playbook-change-management/src/application/services/validateArtefactInSpace.ts` | +| Sign-in failure | `service:api-proprietary status:error "Failed to sign in"` | `apps/api/src/app/auth/auth.controller.ts` | +| Onboarding status failure | `service:api-proprietary status:error "onboarding status"` | `apps/api/src/app/auth/auth.service.ts` | +| Amplitude tracking failure | `service:api-proprietary status:error Amplitude` | Amplitude Node.js SDK (external) | +| PG concurrent query | `service:api-proprietary status:error "client.query() when the client is already executing"` | `pg` driver through TypeORM | + +## Frontend (`frontend-proprietary`) + +| Pattern | Datadog query | Codebase entry point | +|---------|--------------|---------------------| +| Nginx PID unlink permission denied | `service:frontend-proprietary "unlink" "nginx.pid"` | `dockerfile/Dockerfile.frontend:17` + `dockerfile/nginx.*.conf:3` | +| Nginx notice logs misclassified | `service:frontend-proprietary status:error "[notice]"` | Datadog log pipeline config (not code) | diff --git a/.opencode/skills/datadog-analysis/references/report-template.md b/.opencode/skills/datadog-analysis/references/report-template.md new file mode 100644 index 000000000..2e18b42f6 --- /dev/null +++ b/.opencode/skills/datadog-analysis/references/report-template.md @@ -0,0 +1,101 @@ +# Datadog Report Template + +Output path, scaffold, ordering, and labels for the Phase 4 report. + +## Output path + +Write the report to `datadog_{YYYY_MM_DD}.md` at the project root, where the date is today's date. + +## Report structure + +The report is organized with **one top-level section per application**, each containing its own issues: + +````markdown +# Datadog Error Report + +**Period**: {start_date} to {end_date} ({N} days) + +--- + +# API (`api-proprietary`) + +**Total error log lines**: ~{count} + +| Day | Error count | +|-----------|------------| +| {date} | {count} | + +## 1. {Issue Title} + +**Occurrences**: {frequency description} + +**Datadog search pattern**: +``` +service:api-proprietary status:error {specific pattern keywords} +``` + +**Description**: {What the error is and how it manifests} + +**Root cause**: {Analysis with source file paths and line numbers from the codebase.} + +**Source**: `{file_path}:{line_number}` + +--- + +## 2. {Next Issue} +... + +--- + +# MCP Server (`mcp-proprietary`) + +**Total error log lines**: ~{count} + +| Day | Error count | +|-----------|------------| +| {date} | {count} | + +## 1. {Issue Title} +... + +--- + +# Frontend (`frontend-proprietary`) + +**Total error log lines**: ~{count} (excluding Nginx [notice] noise) + +| Day | Error count | +|-----------|------------| +| {date} | {count} | + +## 1. {Issue Title} +... +```` + +## Ordering (within each section) + +Sort issues by severity: +1. Infrastructure errors (Redis, database connectivity, Nginx permission errors) +2. Application bugs (unhandled exceptions returning 500, missing static assets) +3. External service failures (Amplitude, third-party APIs) +4. Deprecation warnings (Node.js, library deprecations) +5. Expected user errors logged at wrong level (failed logins) + +## Occurrence labels + +Use these labels based on the pattern: +- **ONCE** -- single occurrence in the period +- **{N} TIMES** -- N occurrences total, no clear daily pattern +- **{N} DAYS THIS WEEK** -- recurring across N distinct days +- **ALL {N} DAYS** -- present every day in the period +- **{N} lines, {M} day(s)** -- for high-volume infra errors, specify raw log line count and day spread + +## Summary table + +After writing the report, print a summary table covering all services: + +| Service | # | Issue | Occurrences | Severity | +|---------|---|-------|-------------|----------| +| API | 1 | ... | ... | High/Medium/Low | +| MCP | 1 | ... | ... | High/Medium/Low | +| Frontend | 1 | ... | ... | High/Medium/Low | diff --git a/.opencode/skills/doc-audit/SKILL.md b/.opencode/skills/doc-audit/SKILL.md new file mode 100644 index 000000000..c5ffcc215 --- /dev/null +++ b/.opencode/skills/doc-audit/SKILL.md @@ -0,0 +1,140 @@ +--- +name: 'doc-audit' +description: 'Audit Packmind end-user documentation (apps/doc/) for broken links, outdated CLI references, non-existent concepts, misleading information, and missing coverage. Produces a structured markdown report at project root. Use when docs may have drifted from the codebase, before a release, or on a regular cadence.' +--- + +# Documentation Audit + +Detect outdated, broken, or misleading documentation by cross-referencing MDX pages against the actual codebase. Produces a structured `doc-audit-report.md` at the project root. + +**This skill only detects issues — it does not fix them.** + +## Phase 1: Build Ground Truth + +Before launching any sub-agents, build a concise ground truth summary by gathering these four data sources: + +1. **Navigation structure** — Read `apps/doc/docs.json` and extract all navigation groups with their page lists +2. **CLI commands** — List files in `apps/cli/src/infra/commands/` to get current command files +3. **Domain packages** — List directories in `packages/` to get current package names +4. **Doc MDX files** — Glob `apps/doc/**/*.mdx` to get all actual pages on disk + +Compile these into a **ground truth summary** string formatted as: + +``` +## Ground Truth + +### Navigation Groups (from docs.json) +- Getting Started: index, getting-started/gs-install-cloud, ... +- Concepts: concepts/standards-management, ... +[list all groups] + +### CLI Commands (from apps/cli/src/infra/commands/) +[list all *Command.ts and *Handler.ts files] + +### Domain Packages (from packages/) +[list all package directory names] + +### MDX Files on Disk (from apps/doc/**/*.mdx) +[list all .mdx file paths relative to apps/doc/] + +### Current Date +{today's date} +``` + +## Phase 2: Launch Parallel Sub-Agents + +Launch **5 Explore sub-agents** in parallel (`subagent_type: Explore`), one per section group. Each agent receives: +- The ground truth summary from Phase 1 +- The full contents of `references/section-audit-instructions.md` (read this file and include its contents in each prompt) +- Its assigned section and list of MDX files to audit + +### Agent Assignments + +| Agent | Sections | Pages to Audit | +|-------|----------|----------------| +| 1 | Getting Started + root pages | `index.mdx`, `home.mdx` + all `getting-started/*.mdx` | +| 2 | Concepts | All `concepts/*.mdx` + `tools/import-from-knowledge-base.mdx` | +| 3 | Tools & Integrations | `tools/cli.mdx` | +| 4 | Governance + Playbook Maintenance + Linter | All `governance/*.mdx` + `playbook-maintenance/*.mdx` + `linter/*.mdx` | +| 5 | Administration + Security | All `administration/*.mdx` + `security/*.mdx` | + +### Agent Prompt Template + +Each agent's prompt should follow this structure: + +``` +You are auditing the {section_name} section of the Packmind documentation. + +## Your Assigned Pages +{list of MDX file paths to read and audit} + +## Ground Truth +{ground truth summary from Phase 1} + +## Audit Instructions +{full contents of references/section-audit-instructions.md} + +Read each assigned MDX page completely and apply all detection categories. Return your findings in the exact format specified in the instructions. +``` + +### Sequential Fallback + +If the Agent tool is unavailable, perform the audit sequentially: read each section's pages one by one and apply the same checks from `references/section-audit-instructions.md` directly. + +## Phase 3: Consolidate Report + +After all sub-agents complete: + +1. **Collect** all findings from the 5 agents +2. **Deduplicate** — remove exact duplicates (same page, same line, same issue) +3. **Sort** by severity: ERROR first, then WARNING, then INFO +4. **Group** by category within each severity level +5. **Write** the report to `doc-audit-report.md` at the project root + +### Report Format + +```markdown +# Documentation Audit Report +Generated: {date} | Pages audited: {count} + +## Summary +| Severity | Count | +|----------|-------| +| ERROR | N | +| WARNING | N | +| INFO | N | + +## Errors + +### [A] Broken Internal Links +- **{page}** (line ~{N}): Link to `{target}` — no matching MDX file exists +[... more findings] + +### [B] Outdated CLI Commands +- **{page}** (line ~{N}): References `packmind-cli {cmd}` — command not found in CLI source +[... more findings] + +### [C] Non-Existent Concepts +- **{page}** (line ~{N}): References `{concept}` — not found in codebase +[... more findings] + +## Warnings + +### [D] Misleading Information +- **{page}** (line ~{N}): "{quoted text}" — {reason} +[... more findings] + +## Info + +### [E] Missing Documentation Coverage +- CLI command `{cmd}` has no documentation +- Package `{pkg}` has no documentation page +[... more findings] +``` + +**Omit any category section that has zero findings.** Only include sections with actual results. + +After writing the report, print a brief summary: +- Total issues found per severity +- Top 3 most problematic pages (by issue count) +- The report file path \ No newline at end of file diff --git a/.opencode/skills/doc-audit/references/section-audit-instructions.md b/.opencode/skills/doc-audit/references/section-audit-instructions.md new file mode 100644 index 000000000..61ee255f0 --- /dev/null +++ b/.opencode/skills/doc-audit/references/section-audit-instructions.md @@ -0,0 +1,122 @@ +# Section Audit Instructions + +You are auditing a section of the Packmind end-user documentation. Your job is to cross-reference claims in the MDX pages against the actual codebase and ground truth data to find **concrete, verifiable issues only**. + +## Golden Rule + +**Only flag issues where you can point to a specific codebase artifact that contradicts the documentation.** Do not flag stylistic issues, subjective concerns, or things that "might" be wrong. Every finding must be backed by evidence. + +## Detection Categories + +### Category A: Broken Internal Links (ERROR) + +**What to check:** Links in MDX files that reference other doc pages (e.g., `/concepts/standards-management`, `getting-started/gs-cli-setup`). + +**How to verify:** Check if the link target resolves to an actual MDX file in the ground truth MDX file list. Mintlify resolves links relative to the `apps/doc/` root — a link to `/concepts/foo` should map to `apps/doc/concepts/foo.mdx`. + +**Valid finding:** +- Page links to `/concepts/workflow-management` but no `apps/doc/concepts/workflow-management.mdx` exists + +**Not a finding (false positive):** +- Links to external URLs (https://...) — do not check these +- Links to anchors within the same page (#section) +- Links listed in the `redirects` section of `docs.json` — these are handled by Mintlify + +### Category B: Outdated CLI Commands (ERROR) + +**What to check:** References to `packmind-cli <command>` or CLI command names in the documentation. + +**How to verify:** Check if the referenced command exists in the CLI commands ground truth (file list from `apps/cli/src/infra/commands/`). Command files follow the pattern `*Command.ts` or `*Handler.ts`. + +**Valid finding:** +- Doc references `packmind-cli migrate` but no `MigrateCommand.ts` or `migrateHandler.ts` exists in CLI source + +**Not a finding (false positive):** +- CLI flags or options (e.g., `--verbose`) — only check command names +- Generic references to "the CLI" without a specific command name + +### Category C: Non-Existent Concepts (ERROR) + +**What to check:** References to specific Packmind features, domain packages, or integrations that should exist in the codebase. + +**How to verify:** Check against the ground truth package list and codebase. Look for references to packages (e.g., `@packmind/some-package`), specific integration names, or feature names that imply codebase support. + +**Valid finding:** +- Doc describes a "Bitbucket integration" but no bitbucket-related package or code exists +- Doc references `@packmind/analytics` but that package doesn't exist in `packages/` + +**Not a finding (false positive):** +- High-level conceptual descriptions (e.g., "Packmind helps teams share knowledge") +- References to third-party tools that Packmind integrates with via configuration (not code) +- UI features that exist in the frontend but not as standalone packages +- Do not infer feature availability per edition (OSS vs. Enterprise vs. Cloud) from package paths. Edition gating is controlled by TypeScript path aliases in `tsconfig.paths.oss.json` vs `tsconfig.paths.proprietary.json`, not by directory structure — see Category D for the full explanation + +### Category D: Misleading Information (WARNING) + +**What to check:** +- Dates in the past presented as future events (e.g., "coming in Q2 2024" when it's 2026) +- Direct contradictions between two doc pages about the same feature +- References to deprecated or removed features still presented as current + +**How to verify:** Compare dates against the current date (provided in your context). Compare claims across pages for consistency. + +**Valid finding:** +- "This feature will be available in Q3 2024" — that date has passed +- Page A says "standards support Markdown only" while page B says "standards support Markdown and YAML" + +**Not a finding (false positive):** +- Vague future references ("we plan to add...") +- Minor wording differences between pages about the same concept +- Claims about feature availability per edition (e.g., "Enterprise only", "OSS only", "available in the Cloud version") cannot be verified by inspecting package directories. Edition gating in this repo is done at build time via TypeScript path aliases — `tsconfig.paths.oss.json` redirects `@packmind/<feature>` imports to `packages/editions/src/index.ts` (a **stubs aggregator** for the OSS build), while `tsconfig.paths.proprietary.json` redirects the same imports to the real top-level package such as `packages/linter/src/index.ts`. Consequences: (a) the presence of `packages/editions/src/oss/<feature>/` only means an OSS-build stub exists — it is **not evidence** the feature works in OSS; (b) the existence of a top-level `packages/<feature>/` package does **not** mean the feature ships in OSS, because the OSS tsconfig points elsewhere. Do not flag edition-availability claims based on either of these paths — verify against the two `tsconfig.paths.*.json` files if a check is unavoidable, otherwise skip. + +### Category E: Missing Documentation Coverage (INFO) + +**What to check:** CLI commands or domain packages that exist in the codebase but have no corresponding documentation page or section. + +**How to verify:** Compare the ground truth CLI commands and packages against what the documentation covers. A command is "covered" if it's mentioned on any doc page (typically `tools/cli.mdx`). A package is "covered" if its functionality is described somewhere in the docs. + +**Valid finding:** +- CLI has `SyncCommand.ts` but no doc page mentions the `sync` command +- Package `packages/linter` exists but no linter documentation page exists + +**Not a finding (false positive):** +- Internal/infrastructure packages (e.g., `node-utils`, `test-helpers`) — these are not user-facing +- Commands that are clearly internal or development-only + +## Expected Output Format + +Return your findings as a structured list, one per line, in this exact format: + +``` +[SEVERITY] [CATEGORY] **{page-path}** (line ~{N}): {description} +``` + +Where: +- `SEVERITY` is one of: `ERROR`, `WARNING`, `INFO` +- `CATEGORY` is one of: `A`, `B`, `C`, `D`, `E` +- `page-path` is the relative path from `apps/doc/` (e.g., `tools/cli.mdx`) +- `line ~{N}` is the approximate line number (use `~` since MDX rendering may shift lines) +- `description` is a concise explanation of the issue with evidence + +**Example output:** + +``` +[ERROR] [A] **getting-started/gs-onboarding.mdx** (line ~45): Link to `/concepts/workflow-management` — no matching MDX file exists in apps/doc/concepts/ +[ERROR] [B] **tools/cli.mdx** (line ~120): References `packmind-cli migrate` — no MigrateCommand.ts found in CLI source +[WARNING] [D] **concepts/standards-management.mdx** (line ~30): "Coming in Q2 2024" — date has passed (current date: 2026-03-12) +[INFO] [E] **N/A**: CLI command `SyncCommand.ts` has no documentation coverage +``` + +If you find **no issues** in your assigned section, return: + +``` +NO_ISSUES_FOUND +``` + +## Important Reminders + +- Read each MDX file **completely** — don't skip content +- Be thorough but precise — false positives waste time +- Include approximate line numbers to help locate issues +- For Category E, you only need to check commands/packages relevant to your assigned section +- Do NOT suggest improvements or rewrites — this is detection only diff --git a/.opencode/skills/feature-spec/SKILL.md b/.opencode/skills/feature-spec/SKILL.md new file mode 100644 index 000000000..7ee6ce9eb --- /dev/null +++ b/.opencode/skills/feature-spec/SKILL.md @@ -0,0 +1,135 @@ +--- +name: 'feature-spec' +description: 'Generate a Packmind feature specification from a GitHub issue, file, URL, or direct description. Breaks Phase 3 into 4 fine-grained approval gates (domain data structures, ports/use cases/routes, frontend components, implementation plan) to catch problems early. Routes work between the OSS sibling (../packmind) and the proprietary repo (cwd). Outputs spec artifacts under tmp/feature-specs/{slug}/ for use with /feature-sprint.' +--- + +# Feature Spec Skill + +Generate a comprehensive feature specification grounded in Packmind's hexagonal architecture, frontend gateway/PM-UI conventions, and the OSS/proprietary fork boundary. + +## When to Use + +Use this skill when the user wants to: +- Plan a new feature, fix, or refactor that spans Packmind's stack +- Convert a GitHub issue, design doc, or rough idea into a structured spec +- Get fine-grained validation of data structures, routes, and components before committing to a plan + +**Do NOT use** for trivial one-line fixes or pure dependency bumps. + +## Packmind Repo Layout (essential context) + +- Closed-source fork (`packmind-proprietary`): the current working directory when this skill runs. +- Open-source repo (`packmind`): the sibling directory at `../packmind` (cloned next to the proprietary repo). Resolve to an absolute path at runtime with `realpath ../packmind`. +- **Most feature work happens on the OSS repo and auto-merges into the proprietary fork.** The proprietary fork only contains paid/closed features (e.g. `packages/editions`, certain `packages/deployments` extensions). After an OSS merge, you typically pull on the proprietary side. +- Architecture: hexagonal (`packages/{domain}/{domain,application,infra}`), NestJS API at `apps/api`, React+`@packmind/ui` frontend at `apps/frontend`, TypeORM, Jest+@swc/jest. + +## Input Sources + +| Source | Examples | Detection | +|--------|----------|-----------| +| GitHub | `#123`, `owner/repo#123`, full issue URL | Pattern `#\d+` or `github.com/.../issues/` | +| File | `file:spec.md`, `path/to/spec.md` | Prefix `file:` or readable file | +| URL | `https://...` | HTTP(S) URL (non-GitHub) | +| Prompt | Any other text | Default | + +## Workflow Phases + +Execute phases **sequentially**. Phases 1 and 4 run seamlessly (no approval gate). Phases 2 and 3 require user approval before proceeding. + +| Phase | File | Purpose | Gate | +|-------|------|---------|------| +| 1 | `phase-1-resolve-source.md` | Detect source type, fetch content, decide OSS-vs-proprietary routing, create state | seamless | +| 2 | `phase-2-discovery-and-spec.md` | Research Packmind patterns (reference domain, hex layers, frontend conventions), draft acceptance criteria, generate functional spec | approval | +| 3 | `phase-3-implementation.md` | Technical approach + implementation plan (4 subtasks below) | 4 approvals | +| 3A | ↳ Subtask 3A | Validate domain data structures (entities, value objects, events, migrations) | approval | +| 3B | ↳ Subtask 3B | Validate ports, contracts, use cases, services, adapters, NestJS routes | approval | +| 3C | ↳ Subtask 3C | Validate frontend components, gateways, query hooks, PM-UI usage | approval | +| 3D | ↳ Subtask 3D | Generate full implementation plan (task breakdown, grouping, coverage) | approval | +| 4 | `phase-4-finalize.md` | Generate `context.md`, mark spec complete, report (no commit — artifacts live in `tmp/`) | seamless | + +## State Management + +State is persisted to markdown files under a git-ignored `tmp/` directory for cross-session continuity. Phase progress is inferred from which output files exist — no separate state file needed. + +``` +tmp/feature-specs/{slug}/ +├── {slug}.md # Main spec (status: DRAFT → COMPLETE in frontmatter) +├── discovery.md # Phase 2 output (YAML frontmatter + markdown) +├── functional-spec.md # Phase 2 output (informed by discovery) +├── implementation-plan.md # Phase 3 output (includes parallel groups section) +└── context.md # Phase 4 output (YAML frontmatter + markdown) +``` + +### Phase Detection (Resume Logic) + +| Files Present | Completed Phase | Resume At | +|---------------|----------------|-----------| +| `{slug}.md` only | Phase 1 | Phase 2 | +| + `discovery.md`, `functional-spec.md` | Phase 2 | Phase 3 | +| + `implementation-plan.md` | Phase 3 | Phase 4 | +| + `context.md` (status: COMPLETE) | Phase 4 | Done | + +## Invocation + +### Auto-Detection (Resuming) + +Before starting, check for an existing task directory at `tmp/feature-specs/{slug}/`: + +1. Check which output files exist to determine current phase (see Phase Detection table above) +2. Read the main spec file's YAML frontmatter for `status: DRAFT|COMPLETE` +3. If resuming, display: + ``` + **Resuming Feature Spec**: {slug} + **Current Phase**: {detected_phase} + **Status**: {status from frontmatter} + + Continue from Phase {detected_phase}? (Y/n) + ``` +4. If user confirms, proceed to the appropriate phase file +5. If no task directory found, start a new feature spec + +### Starting New + +To start a new feature spec: Read `phase-1-resolve-source.md` and follow its instructions. + +<feature-spec-rules> + <rule>Execute phases SEQUENTIALLY — never skip phases</rule> + <rule>Phases 1 and 4 proceed automatically — no approval gate</rule> + <rule>Phases 2 and 3 require USER APPROVAL via AskUserQuestion before proceeding</rule> + <rule>Save all artifacts under tmp/feature-specs/{slug}/ — never under tracked folders</rule> + <rule>Use Task tool with Explore subagent for codebase research; never read 50 files yourself</rule> + <rule>Agent NEVER implements code in this skill — output ONLY specification documents</rule> + <rule>Main spec file has status DRAFT until Phase 4 completes</rule> + <rule>Always record target_repo (oss | proprietary | both) decided in Phase 1; consumed by /feature-sprint</rule> + <rule>Never invent OSS/proprietary boundaries — if unsure, ask the user</rule> +</feature-spec-rules> + +## Output + +Final deliverable: `tmp/feature-specs/{slug}/{slug}.md` + sibling artifacts. + +Contains: +- Functional specification with acceptance criteria +- Technical approach grounded in Packmind reference patterns +- Implementation plan with phased tasks (each task has reference `file:line` patterns) +- Parallel Groups section (for parallel execution via `/feature-sprint`) +- `target_repo`: oss | proprietary | both + +## Parallel Execution Support + +Phase 3 includes a grouping step that analyzes task dependencies and creates parallel execution groups: + +**How grouping works:** +1. After generating the implementation plan, a Plan subagent analyzes tasks +2. Tasks are grouped by file dependencies (tasks sharing files go together) +3. Groups are non-overlapping (no file belongs to multiple groups) +4. Results are stored in `implementation-plan.md` (Parallel Groups section) and `context.md` (YAML frontmatter) + +**When grouping is skipped:** +- Tasks have no clear file dependencies +- Single-file or trivial implementations +- User opts out during Phase 3 review + +## Handoff + +When the spec is complete (Phase 4), the next step is `/feature-sprint {slug}` — the companion skill that executes the implementation plan. \ No newline at end of file diff --git a/.opencode/skills/feature-spec/phase-1-resolve-source.md b/.opencode/skills/feature-spec/phase-1-resolve-source.md new file mode 100644 index 000000000..6f21a1240 --- /dev/null +++ b/.opencode/skills/feature-spec/phase-1-resolve-source.md @@ -0,0 +1,171 @@ +# Phase 1: Resolve Source & Decide Fork Routing + +Detect the source type, fetch content, decide where the work will land (OSS fork or proprietary), and create the initial state directory. + +## Input + +User provides `task_input` — one of: +- GitHub issue: `#123`, `owner/repo#123`, or `https://github.com/.../issues/123` +- File path: `file:spec.md`, `path/to/spec.md` +- URL: `https://example.com/...` +- Direct prompt: any other text + +## Steps + +### 1.1 Detect Source Type + +| Pattern | Source Type | +|---------|-------------| +| `#\d+` or `owner/repo#\d+` or `github.com/.../issues/` | github | +| `file:` prefix or readable file path with `.md`/`.txt`/`.json` extension | file | +| `https?://` (non-GitHub) | url | +| Anything else | prompt | + +### 1.2 Generate Task Slug + +Create `task_slug` from the user-provided title or first meaningful phrase: +- Convert to lowercase +- Replace spaces and special characters with hyphens +- Strip leading/trailing hyphens, collapse consecutive hyphens +- Keep it under 60 characters + +### 1.3 Create State Directory + +```bash +mkdir -p tmp/feature-specs/{task_slug}/ +``` + +`tmp/` is git-ignored — these files never get committed. + +### 1.4 Create Initial Spec File (DRAFT) + +Write `tmp/feature-specs/{task_slug}/{task_slug}.md`: + +```markdown +--- +status: DRAFT +task_slug: {task_slug} +created: {ISO-8601 timestamp} +finished: null +--- + +# {task_name} + +_Specification in progress. See state directory for current phase._ +``` + +### 1.5 Fetch Content + +Use the appropriate tool based on source type: + +**GitHub:** +- If `gh` is available, use `gh issue view {issue_ref} --json title,body,labels,url,author` +- Otherwise use WebFetch on the issue URL +- Extract: `task_id` (e.g. `GH-{number}`), `task_name`, `task_description`, `task_url`, `labels` + +**File:** +- Read the file with the Read tool +- `task_name` = first heading or filename; `task_description` = full body + +**URL:** +- Use WebFetch +- Extract: `task_name`, `task_description`, `task_url` + +**Prompt:** +- `task_description` = the raw input +- `task_name` = first line (truncated to ~80 chars) +- `task_id` = `PROMPT-{timestamp}` + +### 1.6 Decide Fork Routing (Required) + +Packmind ships from two repos: +- **OSS** (`../packmind`): public packages and features. **Most code changes go here.** After merge, the proprietary fork pulls from upstream automatically. +- **Proprietary** (this repo): closed-source extensions. Examples: `packages/editions` (forbidden import elsewhere), some `packages/deployments` proprietary flows, billing, enterprise auth. + +Before continuing, classify the work using these heuristics: + +| Signal | Likely target | +|--------|---------------| +| Touches `packages/editions/`, billing, license, enterprise auth, paid deploy flows | proprietary | +| Touches `apps/api`, `apps/frontend`, `apps/cli`, `apps/mcp-server`, or any package present in BOTH repos | oss | +| Mentions enterprise-only features or paid customers in the issue | likely proprietary | +| Public bug, generic feature, OSS-visible UI | oss | +| Unsure | ask the user | + +Use `AskUserQuestion` to confirm: + +```json +{ + "questions": [{ + "question": "Where should this feature be implemented?", + "header": "Fork routing", + "multiSelect": false, + "options": [ + {"label": "OSS (../packmind) (Recommended)", "description": "Default for most work. Auto-merges into proprietary; you pull afterward."}, + {"label": "Proprietary (this repo)", "description": "Closed-source only: editions, paid deployments, enterprise features."}, + {"label": "Both (split)", "description": "Some tasks land on OSS, others on proprietary. Spec will split them in Phase 3."} + ] + }] +} +``` + +Record the answer as `target_repo` in `oss | proprietary | both`. + +**If `target_repo == "oss"`**: verify the OSS repo exists at `../packmind`. If missing, warn the user and ask whether to proceed targeting proprietary instead. + +### 1.7 Check for Existing Documentation + +Look for prior work on the same topic: +- Glob: `tmp/feature-specs/*{task_slug_keyword}*/` +- Glob: `.claude/specs/*{task_slug_keyword}*.md` (existing design specs) +- Glob: `.claude/plans/*{task_slug_keyword}*.md` (existing plans) + +If matches are found, read them and surface them in the summary so the user can decide whether to extend or supersede. + +### 1.8 Update Spec Frontmatter + +Update `tmp/feature-specs/{task_slug}/{task_slug}.md` with full metadata: + +```markdown +--- +status: DRAFT +task_slug: {task_slug} +task_id: {task_id} +source_type: {github | file | url | prompt} +source_url: {url or null} +task_name: {task_name} +target_repo: {oss | proprietary | both} +created: {ISO-8601 timestamp} +finished: null +--- + +# {task_name} + +{task_description} + +## Related Prior Work + +{Optional: list of existing specs/plans found in step 1.7, or "None found."} +``` + +This frontmatter is the source of truth for task metadata. Phase progress is inferred from which output files exist in the directory — no separate state file needed. + +## Task Summary + +Display before proceeding: + +``` +**Phase 1 complete.** Source resolved. + +**Task:** {task_name} +**ID:** {task_id} +**Source:** {source_type} +**Target repo:** {target_repo} +**Prior work:** {none | list of matched files} + +{task_description (first 500 chars)} +``` + +## Next Phase + +Proceed automatically to `phase-2-discovery-and-spec.md`. diff --git a/.opencode/skills/feature-spec/phase-2-discovery-and-spec.md b/.opencode/skills/feature-spec/phase-2-discovery-and-spec.md new file mode 100644 index 000000000..ec9a0dd34 --- /dev/null +++ b/.opencode/skills/feature-spec/phase-2-discovery-and-spec.md @@ -0,0 +1,443 @@ +# Phase 2: Discovery & Functional Spec + +Discover how Packmind actually does this kind of thing, then draft requirements grounded in that reality. + +## Purpose + +Two goals in one phase: +1. Find **the closest existing implementation** in Packmind — which domain package, which use case, which frontend component — and trace its full hex stack +2. Draft **functional requirements** informed by that discovery, so acceptance criteria match what Packmind's architecture can actually support + +## Prerequisites + +- Phase 1 completed (source resolved, `target_repo` decided) +- `tmp/feature-specs/{task_slug}/{task_slug}.md` exists with `status: DRAFT` + +## Steps + +### 2.1 Load Source Context + +Read `tmp/feature-specs/{task_slug}/{task_slug}.md`: +- YAML frontmatter: `task_name`, `source_type`, `target_repo` +- Body: `task_description` + +Extract key terms from `task_description`: +- **Domain entities** mentioned (e.g., "standard", "recipe", "space", "deployment", "user") +- **Actions/verbs** (e.g., "create", "import", "deploy", "rename", "preview") +- **Cross-cutting concerns** (e.g., "event", "migration", "permission", "rate limit") + +### 2.2 Pick the Discovery Root + +Based on `target_repo`: +- `oss` → run discovery in `../packmind` (most patterns live there) +- `proprietary` → run discovery in `.` (this repo) +- `both` → discovery in `../packmind` first; if a needed reference doesn't exist there, also search `.` + +Subagents should be told explicitly which root(s) to search. + +### 2.3 Launch Research Subagents (PARALLEL) + +Launch **TWO** subagents in parallel using the Task tool. Both should run concurrently in the same message. + +#### Subagent A: Packmind Pattern Finder + +Use `Agent` tool with `subagent_type="Explore"`, `description="Find Packmind reference patterns"`. Run "very thorough" search: + +``` +Find Packmind reference patterns for: {task_name} + +Search root: {discovery_root absolute path} +Key terms: {extracted_terms} +Domain entities mentioned: {entities} +Task description: {task_description} + +## What to find + +Trace ACTUAL code paths for the closest similar feature in Packmind. Don't just list files — explain HOW the feature flows through the hex layers. + +### 1. Reference domain package +Find the closest existing `packages/{domain}/` whose problem matches. Example domains: accounts, deployments, spaces, standards, recipes, skills, coding-agent, playbook-change-management. + +For the chosen reference domain, document: +- **Domain layer** (`packages/{domain}/src/domain/`): + - Entities used: file path + key fields + - Repository interfaces: `IFooRepository` file path + - Events: any domain events emitted + - Errors: domain error classes +- **Application layer** (`packages/{domain}/src/application/`): + - Closest use case: `useCases/{name}/{name}.usecase.ts` with the abstract base it extends (AbstractMemberUseCase, AbstractSpaceMemberUseCase, AbstractAdminUseCase) + - Services involved + - Adapter: `adapter/{Domain}Adapter.ts` and which ports it exposes +- **Infrastructure layer** (`packages/{domain}/src/infra/`): + - Repository impl + TypeORM Schema + - Migration pattern reference (under `packages/migrations/`) +- **Types package** (`packages/types/src/{domain}/`): + - Port interface + - Contract for the use case (request/response shapes) +- **Hexa facade** (`{Domain}Hexa.ts`) and `index.ts` exports + +Capture every `file:line` reference. + +### 2. Reference API route +Search `apps/api/src/` for the closest NestJS controller method. Document: +- Controller file + method name + decorators +- DTO file (if any) under `apps/api/src/` or types package +- How it resolves the use case (`HexaRegistry.getAdapter<IFooPort>(...)`) + +### 3. Reference frontend feature +Search `apps/frontend/` for the closest existing component that does this kind of thing. Document: +- **Gateway** (`apps/frontend/src/.../gateways/{Name}Gateway.ts`) — how it wraps the API call +- **Query/mutation hook** (TanStack Query or local equivalent) +- **Page / container component** that orchestrates queries + UI +- **UI components** built from `@packmind/ui` (PM-prefixed wrappers around Chakra) +- Form/validation library, if any (Zod, react-hook-form, etc.) + +### 4. Test patterns +For each layer touched, find the matching test file pattern (e.g. `*.usecase.spec.ts`, `*.repository.spec.ts`, `*.tsx` with React Testing Library). Note where integration tests live (`packages/integration-tests/`). + +### 5. Conventions +Cross-reference 2-3 similar features and note: +- Naming conventions for use cases, ports, contracts +- Authorization base class typically used +- Error-mapping approach (domain errors → HTTP) +- How events propagate cross-domain +- Frontend data-flow shape (gateway → query hook → component) + +### 6. Constraints +Note any: +- Files under `packages/editions/` (forbidden to import from elsewhere when on proprietary) +- Feature flags involved +- Existing migrations that overlap + +## Output Format + +Return JSON: +{ + "discovery_root": "absolute path searched", + "reference_domain": { + "package": "packages/standards", + "domain": [{"file": "", "line": 0, "role": ""}], + "application": [{"file": "", "line": 0, "role": ""}], + "infra": [{"file": "", "line": 0, "role": ""}], + "types": [{"file": "", "line": 0, "role": ""}], + "hexa_facade": {"file": "", "line": 0} + }, + "reference_route": {"controller": "", "method": "", "file": "", "line": 0, "uses_port": ""}, + "reference_frontend": { + "gateway": {"file": "", "line": 0}, + "queries": [{"file": "", "line": 0}], + "page": {"file": "", "line": 0}, + "ui_components": [{"name": "", "file": "", "line": 0}] + }, + "test_patterns": {"usecase_spec": "", "repository_spec": "", "frontend_component_spec": "", "integration_tests_dir": ""}, + "conventions": { + "naming": "", + "authorization": "", + "error_mapping": "", + "events": "", + "frontend_data_flow": "" + }, + "constraints": ["..."] +} +``` + +#### Subagent B: Documentation Researcher + +Use `Agent` tool with `subagent_type="Explore"`, `description="Search Packmind docs for context"`, "medium" thoroughness: + +``` +Search documentation for context on: {task_name} + +Search roots: {discovery_root}, and also the proprietary repo at {proprietary_root} if different + +## Where to look + +1. `apps/doc/` — public Mintlify end-user docs +2. `AGENTS.md`, `CLAUDE.md`, root `README.md` +3. `.claude/specs/` — prior design specs in this repo +4. `.claude/plans/` — prior implementation plans in this repo +5. `.claude/rules/packmind/*.md` — coding standards (frontmatter `paths` shows which paths each governs) +6. `tmp/feature-specs/` — earlier feature specs (any directory matching the task keywords) +7. Package-level `*.md` files (e.g., `packages/standards/README.md`) + +## Find + +- Related specifications, RFCs, or design docs +- Coding standards whose `paths` glob will match the files this feature touches +- Planned work that might overlap or conflict +- Historical context or decisions + +## Output Format + +Return JSON: +{ + "related_docs": [{"path": "", "relevance": "high|medium|low", "summary": ""}], + "applicable_standards": [{"path": ".claude/rules/packmind/foo.md", "paths_glob": "", "summary": ""}], + "planned_work": [{"path": "", "description": "", "potential_overlap": ""}], + "historical_context": [""] +} +``` + +Wait for BOTH subagents to complete before proceeding. + +### 2.4 Synthesize & Present Discovery + +Combine subagent results and present to the user (informational — no approval gate here): + +``` +## Discovery Summary + +### Target repo +{target_repo} (discovery root: {discovery_root}) + +### Reference Domain Package: `packages/{name}/` +Full hex stack trace: + +**Domain layer** +- `{file}:{line}` — {role} + +**Application layer** +- `{file}:{line}` — {role} + +**Infrastructure layer** +- `{file}:{line}` — {role} + +**Types package** (`packages/types/src/{domain}/`) +- `{file}:{line}` — {role} + +**Hexa facade** +- `{file}:{line}` + +### Reference API Route +- `{controller_file}:{line}` — `{METHOD} {path}` → uses port `{port_name}` + +### Reference Frontend Feature +- Gateway: `{file}:{line}` +- Queries: `{file}:{line}` +- Page: `{file}:{line}` +- UI components: {list of PM-* components used} + +### Test Patterns +- Use case spec: `{file_pattern}` +- Repository spec: `{file_pattern}` +- Frontend component spec: `{file_pattern}` +- Integration tests: `{dir}` + +### Conventions Found +- Naming: {conventions.naming} +- Authorization: {conventions.authorization} +- Error mapping: {conventions.error_mapping} +- Events: {conventions.events} +- Frontend data flow: {conventions.frontend_data_flow} + +### Applicable Coding Standards +{For each from documentation researcher:} +- `{path}` (governs: `{paths_glob}`) — {summary} + +### Related Documentation +{For each:} +- `{path}` ({relevance}) — {summary} + +### Constraints +{constraints list, including OSS/proprietary boundary callouts if relevant} +``` + +### 2.5 Draft Acceptance Criteria + +Using the discovery context, analyze `task_description` for: +- Existing acceptance criteria (checkboxes, numbered lists) +- User stories or personas mentioned +- Technical constraints or requirements +- Scope boundaries (what's included/excluded) + +Validate patterns against the task: + +1. **Relevance check** — Does the reference domain cover the layers this task needs? +2. **Consistency check** — Do similar features follow the same conventions? +3. **Gap check** — Does the task require something no existing feature does (e.g., a new port type, a new event)? + +Then draft acceptance criteria and present everything for approval: + +``` +### Pattern Validation + +**Reference template:** `packages/{name}` — {applicable | partially applicable | not applicable} +**Convention consistency:** {consistent across N features | divergences noted: ...} +**Gaps:** {none | list of things no existing feature covers} + +**Derived approach:** Follow `packages/{name}` pattern across {layers}. {Gap handling, if any.} + +## Draft Acceptance Criteria + +Based on the source description and discovery analysis: + +- [ ] {Criterion 1 — extracted from source} +- [ ] {Criterion 2 — extracted from source} +- [ ] {Criterion N — inferred from discovery patterns, e.g., "Soft delete supported following standard repository pattern"} + +### Potential Gaps +{List anything the source doesn't cover but the reference feature handles, e.g., authorization, events, error mapping} + +### Potential Over-scope +{List anything in the source that may be too broad or vague} +``` + +### 2.6 Approval Gate + +Use the `AskUserQuestion` tool: + +```json +{ + "questions": [{ + "question": "Do the discovered patterns and draft acceptance criteria look correct?", + "header": "Phase 2", + "multiSelect": false, + "options": [ + {"label": "Yes, proceed", "description": "Patterns and criteria are accurate, continue to generate the full functional spec"}, + {"label": "Needs adjustment", "description": "I'll clarify what's wrong or missing"} + ] + }] +} +``` + +If the user needs adjustment, incorporate their feedback and re-present step 2.5. + +### 2.7 Generate Functional Specification + +Create `tmp/feature-specs/{task_slug}/functional-spec.md`: + +```markdown +## Functional Specification + +### Overview +{1-2 paragraphs: what the feature does and why} +{Reference the derived approach from pattern validation} + +### Target Repo +{target_repo} — most changes land in {discovery_root}. Note any tasks that must live in the proprietary fork (e.g., editions, paid deployments). + +### Business Requirements +1. {Requirement 1} +2. {Requirement 2} +3. {Requirement 3} + +### User Stories +- As a {role}, I want to {action}, so that {benefit} +- As a {role}, I want to {action}, so that {benefit} + +### Acceptance Criteria +- [ ] {Criterion 1 — specific, measurable} +- [ ] {Criterion 2 — specific, measurable} +- [ ] {Criterion 3 — specific, measurable} + +### Constraints +- {Technical constraint, e.g., "Must follow AbstractSpaceMemberUseCase authorization pattern"} +- {Performance requirement} +- {Compatibility requirement} +- {OSS/proprietary boundary — e.g., "No imports from @packmind/editions in OSS tasks"} +{Include constraints from discovery if relevant} + +### Out of Scope +- {Explicitly excluded item} + +### Dependencies +{List known dependencies — other packages, ports, planned work that must precede this} +``` + +### 2.8 Save Discovery + +Write `tmp/feature-specs/{task_slug}/discovery.md` with YAML frontmatter capturing everything the implementation plan subagent will need: + +```markdown +--- +target_repo: {oss | proprietary | both} +discovery_root: "{absolute path}" + +reference_domain: + package: "packages/{name}" + domain_files: + - file: "packages/{name}/src/domain/entities/{Entity}.ts" + line: 1 + role: "Main entity" + application_files: + - file: "packages/{name}/src/application/useCases/{name}/{name}.usecase.ts" + line: 1 + role: "Closest existing use case" + infra_files: + - file: "packages/{name}/src/infra/repositories/{Entity}Repository.ts" + line: 1 + role: "Repository implementation" + types_files: + - file: "packages/types/src/{name}/ports/I{Name}Port.ts" + line: 1 + role: "Port interface" + hexa_facade: + file: "packages/{name}/src/{Name}Hexa.ts" + line: 1 + +reference_route: + controller: "apps/api/src/controllers/{Name}Controller.ts" + method: "create" + line: 42 + http: "POST /api/{path}" + uses_port: "I{Name}Port" + +reference_frontend: + gateway: + file: "apps/frontend/src/.../{Name}Gateway.ts" + line: 1 + queries: + - file: "apps/frontend/src/.../use{Name}.ts" + line: 1 + page: + file: "apps/frontend/src/.../{Name}Page.tsx" + line: 1 + ui_components: + - name: "PMButton" + file: "packages/ui/src/PMButton.tsx" + +test_patterns: + usecase_spec: "packages/{name}/src/application/useCases/{name}/{name}.usecase.spec.ts" + repository_spec: "packages/{name}/src/infra/repositories/{Entity}Repository.spec.ts" + frontend_component_spec: "apps/frontend/src/.../{Name}Page.spec.tsx" + integration_tests_dir: "packages/integration-tests" + +conventions: + naming: "{from subagent}" + authorization: "AbstractSpaceMemberUseCase | AbstractMemberUseCase | AbstractAdminUseCase" + error_mapping: "{from subagent}" + events: "{from subagent}" + frontend_data_flow: "gateway → query hook → component, PM-prefixed UI from @packmind/ui" + +applicable_standards: + - path: ".claude/rules/packmind/packmind-proprietary.md" + paths_glob: "**/*" + summary: "Forbid imports from @packmind/editions in proprietary code" + +constraints: + - "{e.g., must use soft-delete-aware AbstractRepository}" + +pattern_validation: + reference_applicable: true + convention_consistency: "consistent across N features" + gaps: [] + derived_approach: "Follow packages/{name} pattern across {layers}" + +related_documentation: + - path: "{path}" + relevance: "high" + summary: "..." +--- + +## Discovery Summary + +{Mirror the human-readable view from step 2.4} + +## Pattern Validation + +{Mirror the pattern-validation block from step 2.5} +``` + +## Next Phase + +Proceed automatically to `phase-3-implementation.md`. diff --git a/.opencode/skills/feature-spec/phase-3-implementation.md b/.opencode/skills/feature-spec/phase-3-implementation.md new file mode 100644 index 000000000..290349702 --- /dev/null +++ b/.opencode/skills/feature-spec/phase-3-implementation.md @@ -0,0 +1,403 @@ +# Phase 3: Implementation Plan + +Generate the technical approach and detailed task breakdown, with Packmind-specific reference patterns and file targets. + +Phase 3 is split into **four subtasks**, each with its own approval gate. Foundational decisions are validated before generating the full plan, so problems get caught early. + +| Subtask | Purpose | Gate | +|---------|---------|------| +| 3A | Validate **domain data structures** (entities, value objects, events, repositories, schemas, migrations) | approval | +| 3B | Validate **ports, contracts, use cases, services, adapters, NestJS routes** | approval | +| 3C | Validate **frontend components** (page/container, gateway, query hooks, PM-UI usage, forms) | approval | +| 3D | Generate full **implementation plan** (task breakdown, grouping, coverage) | approval | + +## Prerequisites + +- Phase 2 completed +- If resuming a fresh session, read `tmp/feature-specs/{task_slug}/functional-spec.md` and `discovery.md` + +## Steps + +### 3.1 Present Technical Approach + +Using functional spec + discovery, present a brief technical approach to the user: + +``` +## Technical Approach + +### Target Repo +{target_repo} — implementation root: `{discovery_root}`. Tasks landing in this fork (proprietary) will be tagged; otherwise default to OSS. + +### Stack +- API: NestJS (`apps/api`) +- Domain packages: hexagonal layout under `packages/{domain}/src/{domain,application,infra}` +- Types/ports/contracts: `packages/types/src/{domain}` +- Frontend: React + `@packmind/ui` (PM-prefixed Chakra wrappers) (`apps/frontend`) +- Tests: Jest + `@swc/jest`; integration tests in `packages/integration-tests` +- DB: TypeORM + PostgreSQL; migrations under `packages/migrations` + +### Reference Patterns (from discovery) +- Reference domain: `{reference_domain.package}` +- Reference use case: `{reference_domain.application_files[0].file}:{line}` +- Reference route: `{reference_route.controller}:{line}` (`{reference_route.http}`) +- Reference frontend page: `{reference_frontend.page.file}:{line}` + +### Architecture Decisions +- {derived_approach from pattern_validation} +- Authorization: {convention chosen, e.g. AbstractSpaceMemberUseCase} +- Error mapping: {convention} +- Cross-domain: {via injected port or via domain event} + +### New Patterns Required +{If any:} +- {e.g., "New port `IFooPort` — no existing port covers this responsibility"} +- {e.g., "New domain event `FooDeletedEvent` — to notify deployments domain"} + +{Else: "None — fully covered by existing patterns."} +``` + +If new patterns are identified, use `AskUserQuestion` for each decision (one question per decision): + +```json +{ + "questions": [{ + "question": "{Describe the specific new pattern decision}", + "header": "New pattern", + "multiSelect": false, + "options": [ + {"label": "{Option A}", "description": "{What this means concretely}"}, + {"label": "{Option B}", "description": "{What this means concretely}"} + ] + }] +} +``` + +If no new patterns are needed, proceed directly. + +### 3.2 Append Technical Approach to discovery.md + +Append the finalized technical approach to `tmp/feature-specs/{task_slug}/discovery.md` after the YAML frontmatter: + +```markdown +## Technical Approach + +### Target Repo +... + +### Stack +... + +### Reference Patterns +... + +### Architecture Decisions +... +``` + +--- + +## Subtask 3A: Validate Domain Data Structures + +### 3A.1 Extract Data Structure Changes + +From functional spec + discovery, identify ALL data-structure impacts in the **domain** and **infra** layers: + +**New entities** (`packages/{domain}/src/domain/entities/`) — domain objects that don't exist yet: +- Name, purpose, key fields with TS types +- Relationships to existing entities (1:1, 1:N, N:M) +- Whether it carries an ID type (`{Entity}Id` branded type in `packages/types`) + +**Entity modifications** — changes to existing domain objects: +- New fields (name, type, nullable, default) +- Modified fields (what changes and why) +- Removed fields (what + migration impact) + +**Value objects / branded ID types** (`packages/types/src/{domain}/`): +- Name, shape + +**Domain events** (`packages/types/src/events/{EventName}.ts`): +- Event name, payload shape +- Which domain emits it, which listeners consume it +- Reference the `event.md` component guide + +**Repository interfaces** (`packages/{domain}/src/domain/repositories/I{Entity}Repository.ts`): +- Methods needed (findById, findByX, etc.) +- Whether `AbstractRepository<T>` (soft-delete) is appropriate + +**TypeORM schemas** (`packages/{domain}/src/infra/schemas/{Entity}Schema.ts`): +- Columns + types + indexes + foreign keys + +**Migrations** (`packages/migrations/src/migrations/`): +- New tables, altered columns, new indexes +- Reference the `how-to-write-typeorm-migrations-in-packmind` skill +- Down-migration plan + +**Domain errors** (`packages/{domain}/src/domain/errors/`): +- New error classes for invariant violations + +Use `discovery.md` reference paths to ground every naming and structure decision. + +### 3A.2 Present Data Structures for Validation + +Render the **Subtask 3A** presentation template from `references/spec-templates.md`, substituting the items extracted in 3A.1. Do not paraphrase the section headings — they keep 3A → 3D consistent. + +### 3A.3 Approval Gate — Data Structures + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Are the proposed domain data structures correct? (entities, events, repositories, schemas, migrations)", + "header": "Subtask 3A", + "multiSelect": false, + "options": [ + {"label": "Approved", "description": "Data structures are correct, proceed to ports/use cases/routes"}, + {"label": "Needs changes", "description": "I'll specify what to adjust"} + ] + }] +} +``` + +If "Needs changes", incorporate feedback and re-present 3A.2. + +If there are no data-structure changes for this task, state so briefly and skip the approval gate — proceed directly to Subtask 3B. + +--- + +## Subtask 3B: Validate Ports, Contracts, Use Cases, Services, Adapters, Routes + +### 3B.1 Extract Application + API Changes + +From functional spec + discovery, identify ALL impacts in the **application** layer and **API** layer: + +**New ports** (`packages/types/src/{domain}/ports/I{Domain}Port.ts`): +- Port name, methods exposed +- Which adapter implements it +- Justify: why a new port (vs. extending an existing one) + +**New use case contracts** (`packages/types/src/{domain}/contracts/{UseCaseName}.ts`): +- Request shape, Response shape +- Which port method returns these + +**New use cases** (`packages/{domain}/src/application/useCases/{name}/{name}.usecase.ts`): +- Name, purpose +- Which `Abstract*UseCase` base class (`AbstractMemberUseCase`, `AbstractSpaceMemberUseCase`, `AbstractAdminUseCase`) +- Authorization rules (who can call) +- Which services / repos it uses +- Which events it emits + +**Modified use cases** — changes to existing use cases: +- What changes, backward-compatibility notes + +**New services** (`packages/{domain}/src/application/services/{Name}Service.ts`): +- Name, methods, callers (use cases) + +**New adapter methods** (`packages/{domain}/src/application/adapter/{Domain}Adapter.ts`): +- Which port methods are now implemented + +**New listeners** (`packages/{domain}/src/application/listeners/{Domain}Listener.ts`): +- Which events listened to, what it does + +**New / modified NestJS routes** (`apps/api/src/`): +- Method + path (e.g., `POST /api/standards/{id}/preview`) +- Request/response DTOs (referencing contracts from above) +- Controller file + method +- How it resolves the port: `HexaRegistry.getAdapter<IFooPort>('foo')` + +**Removed routes**: +- Path + reason + +Use `discovery.md` references for path conventions, base classes, and adapter patterns. + +### 3B.2 Present for Validation + +Render the **Subtask 3B** presentation template from `references/spec-templates.md`, substituting the items extracted in 3B.1. + +### 3B.3 Approval Gate — Application + Routes + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Are the proposed ports, contracts, use cases, services, adapters, and routes correct?", + "header": "Subtask 3B", + "multiSelect": false, + "options": [ + {"label": "Approved", "description": "Application + API layer is correct, proceed to frontend"}, + {"label": "Needs changes", "description": "I'll specify what to adjust"} + ] + }] +} +``` + +If "Needs changes", incorporate feedback and re-present 3B.2. + +If nothing changes in this layer, state so and skip the gate — proceed to Subtask 3C. + +--- + +## Subtask 3C: Validate Frontend Components + +### 3C.1 Extract Frontend Changes + +From functional spec + discovery, identify ALL frontend impacts in `apps/frontend/`: + +**New page / route components** — top-level orchestrators: +- File path under `apps/frontend/src/` +- Route it mounts on (React Router or app router) +- Which gateway queries/mutations it owns +- Which container/domain components it renders + +**New domain / container components** — feature-scoped UI: +- File path, props interface (TypeScript) +- Pure-display vs. stateful (owns queries?) +- Form components: validation lib + schema, submit callback shape +- List components: item shape, empty state, loading skeleton +- Detail components: data shown, actions + +**Modified domain components**: +- File + line, what changes, props added/removed + +**New gateways** (`apps/frontend/src/.../gateways/{Name}Gateway.ts`): +- Method signatures wrapping API calls +- Reference the existing gateway pattern (see `gateway-pattern-implementation-in-packmind-frontend` command) +- Type mapping (contract → frontend type) + +**New query / mutation hooks**: +- Hook name, query key, return shape +- Cache invalidation rules + +**`@packmind/ui` usage** (PM-prefixed Chakra wrappers): +- Which existing PM-* components are used (PMButton, PMDialog, PMField, etc.) +- Whether any new wrapper is needed (see `wrapping-chakra-ui-with-slot-components` command and `working-with-pm-design-kit` skill) + +**Layout / navigation changes**: +- New nav links, sidebar items, page tabs + +**Microcopy**: +- New user-facing strings — flag for `ux-microcopy` skill if there are non-trivial messages (errors, empty states, dialogs) + +Use `discovery.md`'s reference frontend feature to ground naming and structure decisions. + +### 3C.2 Present for Validation + +Render the **Subtask 3C** presentation template from `references/spec-templates.md`, substituting the items extracted in 3C.1. + +### 3C.3 Approval Gate — Frontend Components + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Are the proposed frontend components correct? (pages, containers, gateways, queries, PM-UI usage)", + "header": "Subtask 3C", + "multiSelect": false, + "options": [ + {"label": "Approved", "description": "Frontend is correct, proceed to full implementation plan"}, + {"label": "Needs changes", "description": "I'll specify what to adjust"} + ] + }] +} +``` + +If "Needs changes", incorporate feedback and re-present 3C.2. + +If no frontend changes, state so and skip — proceed to Subtask 3D. + +--- + +## Subtask 3D: Generate Implementation Plan + +### 3D.1 Skill Loading (conditional) + +Load Packmind skills based on which layers the task touches: + +**Backend (domain / application / infra) is touched:** +1. Read `.claude/skills/hexagonal-architecture/SKILL.md` +2. Read `.claude/skills/hexagonal-architecture/components/usecase.md` +3. Read `.claude/skills/hexagonal-architecture/components/repository.md` +4. Read `.claude/skills/repository-implementation-and-testing-pattern/SKILL.md` (if it exists) + +**Migrations are touched:** +5. Read `.claude/skills/how-to-write-typeorm-migrations-in-packmind/SKILL.md` +6. Read `.claude/skills/create-or-update-model-and-typeorm-schemas/SKILL.md` + +**Frontend is touched:** +7. Read `.claude/skills/working-with-pm-design-kit/SKILL.md` +8. Read `.claude/commands/gateway-pattern-implementation-in-packmind-frontend.md` +9. Read `.claude/commands/wrapping-chakra-ui-with-slot-components.md` (only if new PM wrappers needed) + +**CLI tests are touched:** +10. Read `.claude/skills/cli-e2e-test-authoring/SKILL.md` + +Skip a category entirely if no task in that layer. + +### 3D.2 Launch Implementation Plan Subagent + +Read the bundled prompt template at `.claude/skills/feature-spec/references/implementation-plan-prompt.md`. Substitute its placeholders (`{task_name}`, `{task_slug}`, `{target_repo}`, and the three `{APPROVED_3*_BLOCK}` blocks pasted verbatim from the prior approval gates). Include or omit the Backend / Frontend / Migration constraint sections based on which layers the prior subtasks produced work for — guidance is at the top of the reference file. + +Invoke `Agent` with `subagent_type="general-purpose"` and the substituted prompt. Wait for the subagent to complete. + +Wait for the subagent to complete. + +### 3D.3 Validate Plan Output + +Read `tmp/feature-specs/{task_slug}/implementation-plan.md` and verify: +- Every acceptance criterion is covered in the Coverage Matrix +- Every task has a reference pattern (`file:line`) +- Every task has target files and a `repo` tag +- Plan is consistent with 3A, 3B, 3C decisions (no extra entities, no missing routes) +- Parallel Groups section exists with at least 1 group +- All tasks belong to exactly one group; no file appears in multiple groups + +If validation fails: tell the user what's missing and ask whether to refine manually or relaunch the subagent. + +### 3D.4 Approval Gate — Implementation Plan + +Display the summary: + +``` +**Subtask 3D complete.** Implementation plan generated. + +**Phases:** {count} +**Total tasks:** {count} +**Parallel groups:** {group_count} +**Repos used:** {oss only | proprietary only | both, with count} + +Review the plan in `tmp/feature-specs/{task_slug}/implementation-plan.md` + +Questions to consider: +- Does each task follow the identified patterns? +- Are file references specific enough? +- Are inter-group dependencies clear? +- Are OSS-vs-proprietary tags correct? +``` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Is the implementation plan approved? Proceed to finalize?", + "header": "Subtask 3D", + "multiSelect": false, + "options": [ + {"label": "Yes, proceed", "description": "Approve plan and continue to Phase 4: Finalize"}, + {"label": "Need changes", "description": "I need to modify the implementation plan first"} + ] + }] +} +``` + +Do not continue until user confirms via AskUserQuestion response. + +### 3.7 Phase Complete + +The existence of `implementation-plan.md` marks Phase 3 as complete. No separate state update needed. + +## Next Phase + +After approval, proceed automatically to `phase-4-finalize.md`. diff --git a/.opencode/skills/feature-spec/phase-4-finalize.md b/.opencode/skills/feature-spec/phase-4-finalize.md new file mode 100644 index 000000000..842d1d6da --- /dev/null +++ b/.opencode/skills/feature-spec/phase-4-finalize.md @@ -0,0 +1,89 @@ +# Phase 4: Finalize + +Generate the implementation context, mark the spec as complete, and report. This phase runs seamlessly — no approval gate. + +Artifacts live in `tmp/feature-specs/{task_slug}/` which is git-ignored, so **no commit step** here. The user commits implementation code via `/feature-sprint`. + +## Prerequisites + +- Phases 1-3 completed +- If resuming a fresh session, read `discovery.md`, `functional-spec.md`, and `implementation-plan.md` from `tmp/feature-specs/{task_slug}/` + +## Steps + +### 4.1 Generate Implementation Context + +**Purpose**: produce a structured `context.md` that `/feature-sprint` will read to set up parallel execution. + +Read `.claude/skills/feature-spec/references/context-schema.md` — it contains the full YAML template, the body template, and the ordered extraction process (sources: `discovery.md` frontmatter, main spec frontmatter, `functional-spec.md` body, `implementation-plan.md` Parallel Groups table). + +Write the substituted result to `tmp/feature-specs/{task_slug}/context.md`. + +### 4.2 Mark Spec Complete + +Update the main spec file's YAML frontmatter: + +```markdown +--- +status: COMPLETE +... +finished: {ISO-8601 timestamp} +--- +``` + +The body of the main spec should now include a brief summary (one paragraph) and links to the sibling artifacts: + +```markdown +# {task_name} + +{1-paragraph summary of the feature} + +## Artifacts + +- [Discovery](./discovery.md) — reference patterns and conventions +- [Functional Spec](./functional-spec.md) — acceptance criteria and scope +- [Implementation Plan](./implementation-plan.md) — task breakdown and parallel groups +- [Context](./context.md) — machine-readable handoff for `/feature-sprint` + +## Next Step + +``` +/feature-sprint {task_slug} +``` +``` + +### 4.3 Completion Report + +Output to the user: + +``` +## ✅ FEATURE SPEC COMPLETE + +**Task**: {task_name} +**Slug**: {task_slug} +**Target repo**: {target_repo} + +### Summary +- Acceptance criteria: {count} items +- Implementation phases: {phase_count} +- Total tasks: {task_count} +- Parallel groups: {group_count} + +### Artifacts +- `tmp/feature-specs/{task_slug}/{task_slug}.md` +- `tmp/feature-specs/{task_slug}/discovery.md` +- `tmp/feature-specs/{task_slug}/functional-spec.md` +- `tmp/feature-specs/{task_slug}/implementation-plan.md` +- `tmp/feature-specs/{task_slug}/context.md` + +### Reminder +Artifacts live in `tmp/` (git-ignored). They will NOT be committed automatically — they're scratch state for `/feature-sprint`. + +### Next Steps +1. Review the artifacts above +2. Execute: `/feature-sprint {task_slug}` +``` + +## End of Workflow + +The feature spec is ready. The companion skill `/feature-sprint` will pick it up and execute the implementation plan. diff --git a/.opencode/skills/feature-spec/references/context-schema.md b/.opencode/skills/feature-spec/references/context-schema.md new file mode 100644 index 000000000..a3175418c --- /dev/null +++ b/.opencode/skills/feature-spec/references/context-schema.md @@ -0,0 +1,141 @@ +# context.md Schema + +Loaded by `phase-4-finalize.md` step 4.1. Defines the structured artifact +that `/feature-sprint` consumes to set up parallel execution. + +## Extraction process (in order) + +1. Read `discovery.md` YAML frontmatter → `target_repo`, `discovery_root`, + `reference_domain.*`, `reference_route.*`, `reference_frontend.*`, + `applicable_standards`. +2. Read `{task_slug}.md` YAML frontmatter → `task_id`, `task_name`, + `source_type`, `source_url`. +3. Parse `functional-spec.md` body → scope (`in_scope`, `out_of_scope`, + `constraints`). These need light prose-to-list conversion; keep items + short and verbatim where possible. +4. Parse `implementation-plan.md` "Parallel Groups" table → populate + `parallel_groups[]`. Each row maps to one entry; `task_ids` is the + comma-split task IDs column. +5. Set `version: "1.1"` if `parallel_groups[]` is non-empty, else `"1.0"`. + +## Template + +Write to `tmp/feature-specs/{task_slug}/context.md`: + +```markdown +--- +version: "1.0" # bump to "1.1" if parallel_groups non-empty +task_slug: "{task_slug}" +task_id: "{from main spec frontmatter}" +task_name: "{task_name}" + +source: + type: "{source_type from main spec}" + url: "{source_url from main spec}" + +target_repo: "{oss | proprietary | both}" +discovery_root: "{absolute path from discovery.md}" +proprietary_root: "{absolute path of the proprietary repo — i.e. `realpath .` when running this skill from the proprietary repo root}" +oss_root: "{absolute path of the OSS sibling — i.e. `realpath ../packmind`}" + +stack: + api: "NestJS (apps/api)" + domain_packages: "packages/{domain}/src/{domain,application,infra}" + types: "packages/types" + frontend: "React + @packmind/ui (apps/frontend)" + tests: "Jest + @swc/jest" + integration_tests: "packages/integration-tests" + db: "TypeORM + PostgreSQL" + migrations: "packages/migrations" + +reference: + domain_package: "{from discovery.reference_domain.package}" + usecase_file: "{from discovery}" + api_controller: "{from discovery}" + frontend_page: "{from discovery}" + +discovery_summary: + patterns: "{1-2 sentence summary of the derived approach}" + applicable_standards: + - path: ".claude/rules/packmind/packmind-proprietary.md" + summary: "Forbid imports from @packmind/editions" + +scope_definition: + in_scope: + - "{extracted from functional-spec.md}" + out_of_scope: + - "{extracted from functional-spec.md}" + constraints: + - "{extracted from functional-spec.md}" + +quality_gates: + # Nx targets to run for the projects this feature touches. + # /feature-sprint will resolve which projects need each target. + lint: true + test: true + build: true + extra: [] + +parallel_groups: # Populate from implementation-plan.md "Parallel Groups" section + - group_id: "A" + group_name: "backend-{domain}" + task_ids: ["1.1", "1.2", "1.3"] + target_files: ["packages/{domain}/..."] + repo: "oss" + blocked_by: [] + - group_id: "B" + group_name: "frontend-{domain}" + task_ids: ["2.1", "2.2"] + target_files: ["apps/frontend/..."] + repo: "oss" + blocked_by: [] + - group_id: "C" + group_name: "tests" + task_ids: ["3.1", "3.2"] + target_files: ["packages/integration-tests/..."] + repo: "oss" + blocked_by: ["A", "B"] +--- + +## Implementation Context + +**Task:** {task_name} +**Slug:** {task_slug} +**Target repo:** {target_repo} + +### Discovery summary +- Reference domain: `{reference.domain_package}` +- Reference use case: `{reference.usecase_file}` +- Reference API: `{reference.api_controller}` +- Reference frontend: `{reference.frontend_page}` + +### Scope +**In scope:** +- {items} + +**Out of scope:** +- {items} + +**Constraints:** +- {items} + +### Quality gates +- Lint: nx lint on affected projects +- Test: nx test on affected projects +- Build: nx build on affected projects + +### Parallel groups +| Group | Name | Tasks | Repo | Blocked by | +|-------|------|-------|------|------------| +| A | {name} | 1.1, 1.2, 1.3 | oss | — | +| B | {name} | 2.1, 2.2 | oss | — | +| C | {name} | 3.1, 3.2 | oss | A, B | +``` + +## Notes + +- `parallel_groups[*].repo` is independent of the top-level `target_repo`: + one feature can have OSS and proprietary groups in `both` mode. +- `blocked_by` may be empty `[]`. Don't omit the key. +- If a group has no obvious file area (e.g., cross-cutting tests), name it + by purpose (`tests`, `wiring`) rather than by file path. diff --git a/.opencode/skills/feature-spec/references/implementation-plan-prompt.md b/.opencode/skills/feature-spec/references/implementation-plan-prompt.md new file mode 100644 index 000000000..96254d2fd --- /dev/null +++ b/.opencode/skills/feature-spec/references/implementation-plan-prompt.md @@ -0,0 +1,138 @@ +# Implementation Plan Subagent Prompt Template + +Loaded by `phase-3-implementation.md` step 3D.2 to generate +`tmp/feature-specs/{task_slug}/implementation-plan.md`. The orchestrator +substitutes every `{placeholder}` and then passes the result as the `prompt` +to `Agent` with `subagent_type="general-purpose"`. + +## Placeholders + +| Placeholder | Source | +|-------------|--------| +| `{task_name}` | main spec frontmatter | +| `{task_slug}` | spec directory name | +| `{target_repo}` | discovery.md frontmatter (`oss \| proprietary \| both`) | +| `{APPROVED_3A_BLOCK}` | the approved 3A.2 summary, pasted verbatim | +| `{APPROVED_3B_BLOCK}` | the approved 3B.2 summary, pasted verbatim | +| `{APPROVED_3C_BLOCK}` | the approved 3C.2 summary, pasted verbatim | + +## Conditional sections + +The template includes layer-specific constraint sections. Include or skip each +based on whether any task in that layer exists: + +- Backend section → include if 3A or 3B produced any work +- Frontend section → include if 3C produced any work +- Migration section → include if 3A produced any migration entries + +**Do not** paste the contents of referenced skills inline. The subagent has +access to the project's `.claude/skills/` directory and should `Read` them +directly. Inline pastes would force the orchestrator to grow with every +upstream skill change. + +## Template + +``` +Design implementation tasks for: {task_name} + +IMPORTANT: Do NOT use EnterPlanMode. Write the plan directly to the file path in the Output section below. + +## Context files (Read these first) +- tmp/feature-specs/{task_slug}/functional-spec.md (requirements, acceptance criteria) +- tmp/feature-specs/{task_slug}/discovery.md (patterns, reference paths in YAML frontmatter + Technical Approach) + +## Validated decisions (from earlier subtasks — treat as LOCKED) + +The following have been reviewed and approved by the user. The implementation plan MUST be consistent — do not add, remove, or rename anything listed here. + +### Domain Data Structures (Subtask 3A) +{APPROVED_3A_BLOCK} + +### Ports, Contracts, Use Cases, Routes (Subtask 3B) +{APPROVED_3B_BLOCK} + +### Frontend Components (Subtask 3C) +{APPROVED_3C_BLOCK} + +## Architecture Constraints — Backend (include only if backend touched) + +Read these skills before producing backend tasks. Do NOT paste their contents into this prompt — they are loaded conditionally and may evolve: + +- `.claude/skills/hexagonal-architecture/SKILL.md` +- `.claude/skills/hexagonal-architecture/components/usecase.md` +- `.claude/skills/hexagonal-architecture/components/repository.md` +- `.claude/skills/repository-implementation-and-testing-pattern/SKILL.md` (if it exists) + +For each backend task ensure: +- Layer is explicit (domain, application, infra) +- Cross-domain communication goes through a port in `packages/types/` or a domain event — NEVER direct cross-package imports +- The right `Abstract*UseCase` base class is used for authorization +- Domain errors are mapped at the API layer, not raised as HTTP exceptions inside the domain +- New repositories use `AbstractRepository<T>` with soft-delete support unless explicitly justified otherwise + +## Testing Constraints + +For each backend task involving logic, plan a sibling `*.spec.ts` task using Jest + `@swc/jest`. For repositories, follow the test pattern from `repository-implementation-and-testing-pattern` skill (factory-driven tests). For end-to-end behavior, add an `integration-tests` task in `packages/integration-tests`. + +## Frontend Constraints (include only if frontend touched) + +Read these skills before producing frontend tasks: + +- `.claude/skills/working-with-pm-design-kit/SKILL.md` +- `.claude/commands/gateway-pattern-implementation-in-packmind-frontend.md` + +For each frontend task ensure: +- UI is built from `@packmind/ui` PM-prefixed components, NOT raw Chakra primitives (unless wrapping a new slot component — then plan a slot-wrapping task referencing `.claude/commands/wrapping-chakra-ui-with-slot-components.md`) +- API calls go through a Gateway, not directly from components or hooks +- TanStack Query (or the project's equivalent) is used via the existing hook pattern from discovery +- Tests use React Testing Library at the component layer; integration scenarios go to e2e + +## Migration Constraints (include only if migrations touched) + +Read: `.claude/skills/how-to-write-typeorm-migrations-in-packmind/SKILL.md` + +Each migration task must: +- Create both `up` and `down` methods +- Use the standard logger +- Land under `packages/migrations/src/migrations/{timestamp}-{name}.ts` + +## OSS / Proprietary Constraints + +`target_repo` for this spec: `{target_repo}`. + +- If `target_repo == "oss"`, all tasks must be implementable in `../packmind` (the OSS sibling next to the proprietary repo). Verify no task references `packages/editions/` or paid-only paths. +- If `target_repo == "proprietary"`, mark each task with its repo. Never import from `@packmind/editions` outside files that already live in the editions package (see `.claude/rules/packmind/packmind-proprietary.md`). +- If `target_repo == "both"`, tag every task with `repo: oss | proprietary`. Place OSS tasks first (they unblock proprietary ones after auto-merge). + +## Requirements + +Generate a phased implementation plan that: +1. Maps each acceptance criterion to specific tasks (Coverage Matrix at end) +2. Groups tasks logically: domain entities/events → repositories/migrations → use cases → API routes → frontend gateway/queries → frontend components → tests +3. Uses reference patterns from discovery.md for every task (`file:line`) +4. Specifies target files (existing or new) for every task +5. Includes test tasks for every behavioral change +6. Groups tasks into parallel execution groups by file dependencies + +## Task format + +See `.claude/skills/feature-spec/references/spec-templates.md` for the canonical task block shape and section structure. The format must match exactly so `/feature-sprint`'s parse_implementation_plan.py can read it. + +## Parallel grouping rules + +After defining all tasks, append a "Parallel Groups" section. + +- Tasks sharing the SAME target files MUST be in the same group +- Tasks where A writes a file B reads MUST be in the same group +- Backend domain/application tasks usually share `packages/{domain}/`, so they often go in one group +- Frontend tasks under `apps/frontend/` often share the gateway file and form a second group +- Migration tasks usually stand alone +- Name groups by dominant file area (e.g. `backend-{domain}`, `frontend-{domain}`, `migrations`) +- Maximize parallelism while respecting file conflicts + +## Output + +Write to: `tmp/feature-specs/{task_slug}/implementation-plan.md` + +Use the section structure documented in `.claude/skills/feature-spec/references/spec-templates.md` (Implementation Plan section). Then return a summary: `{phase_count}` phases, `{task_count}` tasks, `{group_count}` parallel groups, coverage complete/incomplete. +``` diff --git a/.opencode/skills/feature-spec/references/spec-templates.md b/.opencode/skills/feature-spec/references/spec-templates.md new file mode 100644 index 000000000..77b7530a9 --- /dev/null +++ b/.opencode/skills/feature-spec/references/spec-templates.md @@ -0,0 +1,253 @@ +# Spec Presentation & Plan Templates + +Reusable markdown skeletons for Phase 3 (Subtasks 3A/3B/3C) and Phase 3D +(implementation plan + task format). Keeps `phase-3-implementation.md` lean +and procedural; this file holds the bulky presentation structure. + +The templates are fill-in-the-blank — substitute the `{placeholder}` tokens +from `discovery.md` + the prior subtask's approved decisions. Always preserve +the exact section ordering: `/feature-sprint` parses the resulting +`implementation-plan.md` and the order matters for it. + +## Subtask 3A: Domain Data Structures (presentation template) + +``` +## Subtask 3A: Domain Data Structures + +### Target Repo +{target_repo} — these changes live in {oss | proprietary}. + +### New Entities +{For each:} +- **`{EntityName}`** — {purpose} + - File: `packages/{domain}/src/domain/entities/{EntityName}.ts` + - Fields: + - `id: {EntityName}Id` (branded type in `packages/types/src/{domain}/`) + - `{field}: {type}` {constraints} + - Relationships: {description} + - **Reference**: `{discovery.reference_domain.domain_files[*].file}:{line}` — follows pattern + +### Entity Modifications +{For each:} +- **`{EntityName}`** (`{existing_file}:{line}`) + - Add: `{field}: {type}` — {reason} + - Modify: `{field}: {old}` → `{new}` — {reason + migration impact} + - Remove: `{field}` — {reason + migration note} + +### New Value Objects / Branded IDs +{For each:} +- **`{Name}`** (`packages/types/src/{domain}/{Name}.ts`) + +### New Domain Events +{For each:} +- **`{EventName}`** (`packages/types/src/events/{EventName}.ts`) + - Payload: `{shape}` + - Emitted by: `{Domain}` (use case `{name}`) + - Listened by: `{ListenerDomain}` — `application/listeners/{Name}Listener.ts` + +### New Repository Interfaces +{For each:} +- **`I{Entity}Repository`** (`packages/{domain}/src/domain/repositories/`) + - Methods: `{methods}` + - Extends `AbstractRepository<{Entity}>`: yes/no + +### TypeORM Schemas +{For each:} +- **`{Entity}Schema`** (`packages/{domain}/src/infra/schemas/`) + - Table: `{table_name}` + - Columns: {list} + - Indexes: {list} + - FKs: {list} + +### Migrations +{For each:} +- **`{TimestampMigrationName}`** under `packages/migrations/src/migrations/` + - Up: {description} + - Down: {description} + - Reference pattern: `{existing_migration_file}` + +### New Domain Errors +{For each:} +- **`{ErrorClass}`** — thrown when {invariant} + +{If no data-structure changes: "No domain data-structure changes — this task only touches application/UI behavior."} +``` + +## Subtask 3B: Ports, Contracts, Use Cases, Routes (presentation template) + +``` +## Subtask 3B: Ports, Contracts, Use Cases, Routes + +### Target Repo +{target_repo} + +### New Ports +{For each:} +- **`I{Name}Port`** (`packages/types/src/{domain}/ports/`) + - Methods: `{signatures}` + - Implemented by: `{Domain}Adapter` + - Why new: {justification} + +### New Contracts +{For each:} +- **`{UseCaseName}`** (`packages/types/src/{domain}/contracts/`) + - Request: `{shape}` + - Response: `{shape}` + +### New Use Cases +{For each:} +- **`{name}.usecase.ts`** (`packages/{domain}/src/application/useCases/{name}/`) — {purpose} + - Base class: `AbstractSpaceMemberUseCase` (etc.) + - Authorization: {who can call} + - Uses repos: {list} + - Uses services: {list} + - Emits events: {list, referencing 3A} + - **Reference**: `{discovery.reference_domain.application_files[*].file}:{line}` + +### Modified Use Cases +{For each:} +- **`{file}:{line}`** — {change description} + +### New Services +{For each:} +- **`{Name}Service`** (`packages/{domain}/src/application/services/`) + - Methods: {signatures} + - Used by: {use cases} + +### New Adapter Methods +{For each:} +- **`{Domain}Adapter.{method}()`** (`packages/{domain}/src/application/adapter/`) + - Implements port: `I{Name}Port.{method}` + +### New Listeners +{For each:} +- **`{Domain}Listener.handle{Event}()`** — reacts to `{EventName}` + +### New API Routes +{For each:} +- **`{METHOD} {path}`** — {purpose} + - Controller: `apps/api/src/controllers/{Name}Controller.ts` (new or extends existing) + - Request DTO: `{shape or file}` + - Response DTO: `{shape or file}` + - Resolves port: `IFooPort.{method}` via `HexaRegistry` + - **Reference**: `{discovery.reference_route.controller}:{line}` + +### Modified API Routes +{For each:} +- **`{METHOD} {path}`** (`{file}:{line}`) + - Change: {description} + - Backward compatible: yes/no + detail + +### Removed Routes +{For each:} +- **`{METHOD} {path}`** — {reason + deprecation plan} + +{If no application/API changes: "No application or API changes — this task only modifies data or UI."} +``` + +## Subtask 3C: Frontend Components (presentation template) + +``` +## Subtask 3C: Frontend Components + +### Target Repo +{target_repo} + +### New Page / Route Components +{For each:} +- **`{ComponentName}`** (`apps/frontend/src/.../{ComponentName}.tsx`) — {purpose} + - Route: `{path}` + - Queries/mutations owned: {list} + - Renders: {list of child components} + - **Reference**: `{discovery.reference_frontend.page.file}:{line}` + +### New Domain / Container Components +{For each:} +- **`{ComponentName}`** (`apps/frontend/src/.../{ComponentName}.tsx`) — {purpose} + - Props: `{prop}: {type}` + - Behavior: pure display | form (validation: {schema}) | stateful with queries + - **Reference**: `{similar_component_file}:{line}` + +### Modified Domain Components +{For each:} +- **`{ComponentName}`** (`{file}:{line}`) — {change} + +### New Gateways +{For each:} +- **`{Name}Gateway`** (`apps/frontend/src/.../gateways/{Name}Gateway.ts`) + - Methods: `{signatures}` wrapping `{contract from 3B}` + - **Reference**: `{discovery.reference_frontend.gateway.file}:{line}` + +### New Query / Mutation Hooks +{For each:} +- **`use{Name}`** (`apps/frontend/src/.../{name}.ts`) + - Query key: `{key}` + - Returns: `{shape}` + - Invalidates: `{keys}` + +### `@packmind/ui` Usage +- Reused PM components: {list} +- New PM wrapper components needed: {list or "none"} +- **If new wrappers**: see `wrapping-chakra-ui-with-slot-components` command before implementing + +### Layout / Navigation +{For each change:} +- **`{file}`** — {description} + +### Microcopy +- {Flag any non-trivial user-facing strings that should be reviewed with the `ux-microcopy` skill} + +{If no frontend changes: "No frontend changes — this task only touches backend/data."} +``` + +## 3D: Task & Implementation Plan Format + +The implementation-plan.md generated by 3D must use this exact shape so that +`/feature-sprint`'s `parse_implementation_plan.py` can read it. + +### Task block + +``` +- [ ] **{PHASE}.{TASK}: {short description}** + - Repo: `oss | proprietary` + - Layer: `domain | application | infra | api | frontend | tests | migrations` + - Files: `{paths}` + - Reference: `{file}:{line}` — {pattern_role} + - Notes: {short implementation notes} +``` + +Phases are numbered (1, 2, 3...). Tasks within a phase use decimals (1.1, +1.2, 2.1). Indentation is exactly two spaces for the metadata lines — the +parser depends on it. + +### Plan-level structure + +``` +# Implementation Plan: {task_name} + +## Phase 1: {short label} +- [ ] **1.1: ...** + - Repo, Layer, Files, Reference, Notes +- [ ] **1.2: ...** + ... + +## Phase 2: ... + +## Coverage Matrix + +| Acceptance Criterion | Task IDs | +|----------------------|----------| +| {criterion} | 1.1, 2.3 | + +## Parallel Groups + +| group_id | group_name | task_ids | target_files | rationale | +|----------|-----------|----------|--------------|-----------| +| A | backend-standards | 1.1, 1.2, 1.3 | packages/standards/... | shared domain package | +| B | frontend-standards | 2.1, 2.2 | apps/frontend/src/standards/... | shared gateway + page | +| C | tests | 3.1, 3.2 | varies | after A and B | + +## Dependency Notes +- Group C blocked by Groups A and B +- ... +``` diff --git a/.opencode/skills/feature-sprint/SKILL.md b/.opencode/skills/feature-sprint/SKILL.md new file mode 100644 index 000000000..06e1aa8a6 --- /dev/null +++ b/.opencode/skills/feature-sprint/SKILL.md @@ -0,0 +1,165 @@ +--- +name: 'feature-sprint' +description: 'Execute the implementation plan produced by /feature-spec. Loads tasks from tmp/feature-specs/{slug}/implementation-plan.md, hydrates them as Claude Tasks, runs parallel groups via subagents in the correct repo (OSS sibling at ../packmind or the proprietary repo in cwd), syncs progress back to checkboxes, commits per group, and runs Nx quality gates. Resumable across sessions via checkbox state.' +--- + +# Feature Sprint Skill + +Execute a Packmind feature implementation plan via subagents with automatic parallel execution and sync-back. Companion to `/feature-spec`. + +## When to Use + +| Scenario | Use feature-sprint | Use plan + architect-executor | +|----------|--------------------|-----------------------------------| +| Fast execution of an already-specified feature | ✅ | ❌ | +| Parallel group execution | ✅ | partial | +| Multi-repo (OSS + proprietary) execution | ✅ | ❌ | +| Heavy TDD enforcement, per-task escalation | ❌ | ✅ | +| Specs in `tmp/feature-specs/` (this flow) | ✅ | ❌ | +| Specs in `.claude/specs/` and `.claude/plans/` | ❌ | ✅ | + +**Rule of thumb**: `/feature-sprint` is the executor for `/feature-spec`. For more careful, single-task-at-a-time execution with the global `plan` skill, use `architect-executor` instead. + +## Prerequisites + +A completed feature spec at `tmp/feature-specs/{slug}/`: +- `{slug}.md` with `status: COMPLETE` in frontmatter +- `context.md` with YAML frontmatter (version 1.0+) +- `implementation-plan.md` with task checkboxes + +If missing or DRAFT: tell the user to run `/feature-spec {source}` first. + +## Architecture: Orchestration + Subagents + Sync-Back + +**Core Principle**: The main session **orchestrates only**. All implementation happens in Task subagents — that keeps the main context clean and lets agents do heavy reading without poisoning your conversation. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Persistent Layer (tmp/, git-ignored) │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ implementation-plan.md │ │ +│ │ Source of truth: task checkboxes [ ] / [x] │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ ▲ + │ Hydrate (session start) │ Sync-back (per group) + ▼ │ +┌─────────────────────────────────────────────────────────────────┐ +│ Main Session (ORCHESTRATION ONLY) │ +│ • Load spec & state • Spawn subagents (one per group) │ +│ • Parse agent output • Sync checkboxes │ +│ • Run Nx quality gates • Commit per group │ +│ • NEVER implement tasks directly │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ Spawn (parallel or sequential) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Subagent Layer (IMPLEMENTATION) │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────┐ │ +│ │ Group A agent │ │ Group B agent │ │ Group C agent │ │ +│ │ Backend tasks │ │ Frontend tasks │ │ Tests / integration│ │ +│ │ cwd: OSS repo │ │ cwd: OSS repo │ │ cwd: depends │ │ +│ └────────────────┘ └────────────────┘ └────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Hydration (session start) +1. Read `implementation-plan.md` → extract tasks with checkbox state (`[ ]` pending, `[x]` done) +2. Read `context.md` → extract `parallel_groups`, `target_repo`, `quality_gates` +3. Use `TaskCreate` for each pending task (visual tracking) +4. Wire `addBlockedBy` from `parallel_groups[*].blocked_by` + +### Subagent delegation +Every group runs in a Task subagent — parallel or sequential. The main session never touches code, never reads source files, never runs Nx commands itself. + +### Sync-back (automatic) +After each group's subagent completes: +1. Parse output for `TASK_COMPLETE: {id}` markers +2. Update `implementation-plan.md` checkboxes: `[ ]` → `[x]` +3. Update Claude Tasks status + +Progress is durable: kill the session, restart `/feature-sprint {slug}`, and it resumes from where checkboxes left off. + +## Workflow Phases + +| Phase | File | Purpose | +|-------|------|---------| +| 1 | `phase-1-init.md` | Load spec, hydrate tasks, get approval | +| 2 | `phase-2-execute.md` | Parallel execution via subagents with auto sync-back + per-group commits | +| 3 | `phase-3-finalize.md` | Run Nx quality gates, final report, optional archive | + +## State Management + +``` +tmp/feature-specs/{slug}/ +├── {slug}.md # status: COMPLETE +├── context.md # parallel_groups, target_repo, quality_gates +├── implementation-plan.md # tasks with checkboxes — SOURCE OF TRUTH for progress +├── discovery.md # reference patterns +└── functional-spec.md # acceptance criteria +``` + +Progress is tracked entirely through `implementation-plan.md` checkboxes — no separate state file. + +## OSS / Proprietary Routing + +`context.md` declares `target_repo` and each parallel group declares its `repo` field. The main session sets `cwd` for each subagent accordingly: + +- `repo: oss` → `cwd: ../packmind` (the OSS sibling cloned next to the proprietary repo) +- `repo: proprietary` → `cwd: .` (this repo) + +After all OSS work is committed and merged upstream, remind the user to `git pull` here so the proprietary fork picks up the auto-merge before any proprietary tasks run. + +## Commit Strategy + +**Each parallel group gets its own commit** when its subagent reports success (one commit per group, not per task). This matches the Packmind CLAUDE.md rule "Each sub-task should have its own commit" — we treat a parallel group as a coherent sub-task unit. + +Commits follow the `git-commit-guidelines` skill format (gitmoji + Conventional Commits, never auto-closing issues). The main session always shows the proposed message and asks for approval before running `git commit`. + +## Invocation + +### New sprint + +``` +/feature-sprint {task_slug} +``` + +If `{task_slug}` is omitted, the skill lists available specs in `tmp/feature-specs/` and asks the user to pick one. + +### Resume + +Same command. The skill detects existing checkbox state and offers to continue. + +## Status + +Show progress across active sprints: + +``` +/feature-sprint status +``` + +(Implemented by listing `tmp/feature-specs/*/implementation-plan.md` and counting checkbox state in each.) + +## Error Handling + +| Situation | Action | +|-----------|--------| +| Spec not found | Error: "Run /feature-spec first" | +| Spec DRAFT | Error: "Complete the spec first — `status` is DRAFT" | +| No parallel groups | Fall back to single-group sequential execution | +| `repo: oss` but `../packmind` missing | Ask user — switch to proprietary or abort | +| Agent fails | Sync completed tasks, pause sprint, report error | +| Quality gate fails | Sync progress, report failures, prompt for fix-and-retry | + +## Integration with /feature-spec + +``` +/feature-spec #123 # produces tmp/feature-specs/{slug}/ +/feature-sprint {slug} # executes the plan +``` + +Sprint reads: +- `context.md` → `parallel_groups`, `target_repo`, `quality_gates` +- `implementation-plan.md` → task list with checkboxes (source of truth) +- `{slug}.md` → verify `status: COMPLETE` \ No newline at end of file diff --git a/.opencode/skills/feature-sprint/phase-1-init.md b/.opencode/skills/feature-sprint/phase-1-init.md new file mode 100644 index 000000000..4cdd6225c --- /dev/null +++ b/.opencode/skills/feature-sprint/phase-1-init.md @@ -0,0 +1,267 @@ +# Phase 1: Initialize Sprint + +Load the feature spec, hydrate Claude Tasks, detect parallel groups, decide OSS-vs-proprietary execution roots, get user approval. + +## Prerequisites + +- Feature spec exists at `tmp/feature-specs/{task_slug}/` +- Spec `status: COMPLETE` + +## Steps + +### 1.1 Resolve Task Slug + +Extract `{task_slug}` from user input. If not provided: + +```bash +ls tmp/feature-specs/ +``` + +For each candidate directory, read `{slug}.md` YAML frontmatter: +- Skip directories without `status: COMPLETE` +- Capture `task_name`, `source_type`, `target_repo` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Which feature spec do you want to sprint?", + "header": "Task", + "multiSelect": false, + "options": [ + {"label": "{slug-1}", "description": "[{target_repo}] {task_name}"}, + {"label": "{slug-2}", "description": "[{target_repo}] {task_name}"} + ] + }] +} +``` + +If `tmp/feature-specs/` has no COMPLETE specs, tell the user to run `/feature-spec` first. + +### 1.2 Load Specification Files + +Read from `tmp/feature-specs/{task_slug}/`: + +1. **`{task_slug}.md`** — Read YAML frontmatter, verify `status: COMPLETE`. If DRAFT or missing, error and stop. + +2. **`context.md`** — Extract YAML frontmatter fields: + - `version` (≥ 1.0) + - `target_repo` (`oss | proprietary | both`) + - `oss_root`, `proprietary_root`, `discovery_root` + - `parallel_groups[]` (may be empty for trivial features) + - `quality_gates` (`lint`, `test`, `build`, `extra`) + +3. **`implementation-plan.md`** — Parse via the bundled script (do NOT hand-roll the regex; it must handle the `_(BLOCKED: …)_` annotation and indented metadata blocks): + + ```bash + python3 .claude/skills/feature-sprint/scripts/parse_implementation_plan.py \ + tmp/feature-specs/{task_slug}/implementation-plan.md + ``` + + Output is JSON: `{tasks: [{id, completed, description, metadata, blocked_reason, raw_block}], summary}`. Keep each task's `raw_block` — Phase 2 pastes it verbatim into the subagent prompt. + +### 1.3 Verify Repos Exist + +For each parallel group, check the repo it targets exists: + +- `repo: oss` → confirm `oss_root` directory exists (the OSS sibling, typically `realpath ../packmind` from the proprietary repo) +- `repo: proprietary` → confirm `proprietary_root` exists (the current working directory when /feature-spec was run) + +If a required repo is missing, ask the user: + +```json +{ + "questions": [{ + "question": "OSS repo at `{oss_root}` is missing. How to proceed?", + "header": "Missing repo", + "multiSelect": false, + "options": [ + {"label": "Abort", "description": "Cancel sprint; user will set up the repo"}, + {"label": "Switch to proprietary", "description": "Run all groups in this repo regardless of repo tag"}, + {"label": "Skip OSS groups", "description": "Only execute groups tagged proprietary"} + ] + }] +} +``` + +### 1.4 Map Tasks to Groups + +If `context.md` has non-empty `parallel_groups`: + +```javascript +const groupedTasks = {}; +for (const group of parallel_groups) { + groupedTasks[group.group_id] = { + name: group.group_name, + tasks: group.task_ids, + target_files: group.target_files, + repo: group.repo, + blocked_by: group.blocked_by ?? [], + status: 'pending', + }; +} +``` + +If `parallel_groups` is empty, create a single sequential group covering all tasks, using `target_repo` from context for `repo`. + +### 1.5 Detect Resume State + +Use the plan-wide totals (`completed`, `pending`, `total`, `percent`) from the `summary` field already returned by `parse_implementation_plan.py`. + +For the **per-group** breakdown table below, also run the readiness resolver and use each group's `completed_ids`/`task_ids` lengths: + +```bash +python3 .claude/skills/feature-sprint/scripts/resolve_ready_groups.py \ + tmp/feature-specs/{task_slug}/context.md \ + tmp/feature-specs/{task_slug}/implementation-plan.md +``` + +Map each `groups[*]` entry to a row — `Tasks = len(task_ids)`, `Completed = len(completed_ids)`, `Status` from `status` (`ready` → ✅ Ready / 🔄 Partial / ⏳ Blocked / ✅ Done). + +If any tasks are already completed: + +``` +**Resuming Sprint: {task_slug}** + +Previous progress: +- Completed: {n}/{total} ({percent}%) +- Pending: {pending} + +| Group | Repo | Tasks | Completed | Status | +|-------|------|-------|-----------|--------| +| A: {name} | oss | 3 | 3 | ✅ Done | +| B: {name} | oss | 2 | 1 | 🔄 Partial | +| C: {name} | oss | 2 | 0 | ⏳ Blocked (after A, B) | +``` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Resume sprint from previous progress?", + "header": "Resume", + "multiSelect": false, + "options": [ + {"label": "Continue (Recommended)", "description": "Resume from {completed}/{total} done"}, + {"label": "Restart", "description": "Reset all checkboxes to [ ] and start over"} + ] + }] +} +``` + +If "Restart": rewrite `implementation-plan.md` replacing every `- [x]` with `- [ ]` and stripping any `_(BLOCKED: …)_` annotations (a single `sed` pass is fine since this is destructive on purpose). + +### 1.6 Pull-Before-Sprint (proprietary only) + +If `target_repo` is `proprietary` or `both`: + +Remind the user (informational, no gate): +``` +Heads up: most OSS work auto-merges into this proprietary fork. +Before starting, you may want to run `git pull` here so the fork is up to date. +``` + +If `target_repo` is `oss` only, skip this reminder. + +### 1.7 Hydrate Claude Tasks + +For visual feedback, use `TaskCreate` for each pending task: + +``` +TaskCreate({ + subject: `${task.id}: ${task.description}`, + description: `Repo: ${task.repo} | Layer: ${task.layer} | Files: ${task.files}`, + activeForm: `Implementing ${task.id}...`, +}) +``` + +Capture each returned task ID into a `sessionTasks` map keyed by `task.id`. + +### 1.8 Set Up Task Dependencies + +For each group with `blocked_by`: + +``` +for blockerGroupId in group.blocked_by: + for taskId in group.tasks: + TaskUpdate( + taskId: sessionTasks[taskId], + addBlockedBy: blockerGroup.tasks.map(t => sessionTasks[t]) + ) +``` + +This is purely visual — the actual execution gating is enforced in Phase 2 by ready-group selection. + +### 1.9 Display Execution Plan + +``` +**Sprint Ready: {task_slug}** + +**Target repo:** {target_repo} +**Tasks:** {pending}/{total} pending ({completed} already done) + +**Parallel Execution Plan:** +| Group | Repo | Tasks | Status | Dependencies | +|-------|------|-------|--------|--------------| +| A: {name} | oss | {ids} | ✅ Ready | None | +| B: {name} | oss | {ids} | ✅ Ready | None | +| C: {name} | oss | {ids} | ⏳ Blocked | After A, B | + +**Quality gates (final phase):** Nx lint / test / build on affected projects +**Commit strategy:** one commit per group via git-commit-guidelines +``` + +### 1.10 Authorize Subagent File Edits + +Phase 2 spawns Agent subagents that call Edit / Write / Bash on files in the target repo. **Subagents inherit the parent session's permission mode** — in default mode every tool call pauses for approval, which serializes parallel execution and stalls the sprint. + +**Before continuing, the user must enable auto-accept mode in the Claude Code TUI** (`Shift+Tab` cycles through modes; pick "accept edits" or "bypass permissions"). The orchestrator cannot toggle this on the user's behalf. + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Confirm auto-accept (Shift+Tab) is enabled so subagents can edit files without prompts?", + "header": "Authorize", + "multiSelect": false, + "options": [ + {"label": "Yes, ready to sprint", "description": "Auto-accept is on; subagents will edit files without per-call prompts"}, + {"label": "Not yet — cancel", "description": "Stop the sprint; I'll toggle auto-accept and re-run /feature-sprint"} + ] + }] +} +``` + +If "Not yet — cancel": stop and leave state untouched. Otherwise continue to §1.11. + +### 1.11 Get Approval + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Start sprint execution?", + "header": "Sprint", + "multiSelect": false, + "options": [ + {"label": "Start parallel (Recommended)", "description": "Execute all ready groups in parallel via subagents"}, + {"label": "Sequential only", "description": "One group at a time — slower but easier to debug"}, + {"label": "Cancel", "description": "Don't start"} + ] + }] +} +``` + +Do not continue until the user confirms. + +- "Start parallel" → set `execution_mode = "parallel"` +- "Sequential only" → set `execution_mode = "sequential"` +- "Cancel" → stop, leave state untouched + +### 1.12 Proceed + +Read `phase-2-execute.md` and continue. diff --git a/.opencode/skills/feature-sprint/phase-2-execute.md b/.opencode/skills/feature-sprint/phase-2-execute.md new file mode 100644 index 000000000..5507d67c5 --- /dev/null +++ b/.opencode/skills/feature-sprint/phase-2-execute.md @@ -0,0 +1,222 @@ +# Phase 2: Execute Sprint + +Execute tasks via subagents with automatic sync-back and per-group commits. Runs autonomously after Phase 1 approval. + +## Critical Rule: Subagent-Only Execution + +**The main session MUST NOT implement tasks directly.** All implementation lives in Task subagents. + +- Main session = orchestration (spawn agents, parse output, sync state, run commits) +- Subagents = implementation (read specs, write code, run small validation commands) + +This keeps the main context clean and lets agents read heavily without poisoning your conversation. + +## Prerequisites + +- Phase 1 completed +- `execution_mode` chosen (parallel or sequential) +- `sessionTasks` map populated +- `groupedTasks` populated from `parallel_groups` + +## Execution Modes + +### Parallel Mode (Default) + +Spawn Task subagents for ALL ready groups **simultaneously in a single message**. Agents run concurrently. + +### Sequential Mode + +Spawn Task subagents **one at a time**. Wait for each to complete before spawning the next. Used when: +- No `parallel_groups` defined (single sequential group) +- User chose "Sequential only" in Phase 1 +- Only one ready group exists + +## Steps + +### 2.1 Identify Ready Groups + +Call the bundled script — it reads `context.md` + `implementation-plan.md` and classifies every group as `ready | completed | partial | blocked`: + +```bash +python3 .claude/skills/feature-sprint/scripts/resolve_ready_groups.py \ + tmp/feature-specs/{task_slug}/context.md \ + tmp/feature-specs/{task_slug}/implementation-plan.md +``` + +Output JSON contains `ready_group_ids[]` and per-group `status`/`completed_ids`/`blocked_ids`. Use this every iteration of the loop in step 2.10 — never hand-compute the readiness check. + +### 2.2 Pick Working Directory per Group + +For each ready group: + +| group.repo | cwd | +|------------|-----| +| `oss` | `oss_root` from context (the OSS sibling at `../packmind`, resolved to absolute path at spec time) | +| `proprietary` | `proprietary_root` from context (the proprietary repo, the cwd when /feature-spec ran) | + +This `cwd` is passed to the subagent's prompt as the *implementation root*. The subagent must Bash with absolute paths so it works regardless of the agent's own cwd. + +### 2.3 Build Subagent Prompt + +Read the bundled template once: `.claude/skills/feature-sprint/references/group-prompt-template.md`. Substitute the placeholders documented at the top of that file. For `{TASKS_BLOCK}`, concatenate the `raw_block` of each task in `group.task_ids` (from the Phase 1 `parse_implementation_plan.py` output) separated by blank lines. + +Do not paraphrase the template — it is the authoritative contract subagents respond to (`TASK_COMPLETE`, `GROUP_COMPLETE`, `BLOCKED`, `FILES_MODIFIED` markers are parsed in step 2.5). + +### 2.4 Spawn Subagents + +**Parallel mode**: send a single message containing one `Agent` tool call per ready group. They run concurrently. + +**Sequential mode**: send one `Agent` call, wait for it to return, then move to the next. + +In both cases, set `subagent_type="general-purpose"`. + +### 2.5 Parse Subagent Output + +For each agent that returns, parse: + +- `TASK_COMPLETE: {id}` markers — collect into `completed[]` +- `GROUP_COMPLETE: {id}` marker — group finished cleanly +- `BLOCKED: {id} - {reason}` markers — collect into `blocked[]` +- `FILES_MODIFIED: ...` — capture for the commit step + +### 2.6 Sync Checkboxes to implementation-plan.md + +Apply both completed and blocked updates in one pass via the bundled script: + +```bash +python3 .claude/skills/feature-sprint/scripts/sync_checkboxes.py \ + tmp/feature-specs/{task_slug}/implementation-plan.md \ + --complete {comma-separated ids from completed[]} \ + --blocked "{id1}:{reason1}" "{id2}:{reason2}" +``` + +`--blocked` takes one `id:reason` per argument (so reasons may contain commas freely). `--complete` is a single comma-joined ID list. + +**Omit a flag entirely when its list is empty** — do not pass an unquoted empty value (`--complete `) since argparse will treat the next flag as the value. If `completed[]` and `blocked[]` are both empty, skip this step (nothing to sync). + +The script returns JSON with `applied_complete`, `applied_blocked`, and `missing` (any task ID that didn't match — surface these as a warning; don't retry the agent). Exit code is `1` if anything was missing, `0` otherwise. + +### 2.7 Update Claude Tasks + +For every task in `completed[]`: + +``` +TaskUpdate({ taskId: sessionTasks[task_id], status: "completed" }) +``` + +For tasks currently being executed but not yet complete (shouldn't normally happen here since groups are atomic), mark them `in_progress`. + +### 2.8 Commit the Group + +After the agent finishes (whether all tasks completed or some blocked), commit the changes if anything was modified: + +1. Switch to the group's working repo (use absolute path): + ``` + git -C {repo_root} status --short + ``` + If nothing changed, skip the commit step for this group. + +2. Stage only the files listed in `FILES_MODIFIED` (don't `git add -A`): + ``` + git -C {repo_root} add {files...} + ``` + +3. Build the commit message using the `git-commit-guidelines` skill conventions: + - Gitmoji prefix (✨ for new features, 🐛 for fixes, ♻️ for refactor, ✅ for tests, etc.) + - Conventional Commits format: `<type>(<scope>): <subject>` + - NEVER use `Close`/`Fix #` keywords (no auto-closing of issues) + - Body lists completed task IDs and references the spec slug + - Include `Co-Authored-By: Claude <noreply@anthropic.com>` + + Template: + ``` + {emoji} {type}({scope}): {one-line subject derived from group_name} + + Sprint group {group_id} ({group_name}) for feature `{task_slug}`. + + Completed tasks: + - {1.1}: {description} + - {1.2}: {description} + + Refs: tmp/feature-specs/{task_slug}/ + + Co-Authored-By: Claude <noreply@anthropic.com> + ``` + +4. Show the message to the user and ask for approval: + + ```json + { + "questions": [{ + "question": "Commit group {group_id} ({group_name})?", + "header": "Commit", + "multiSelect": false, + "options": [ + {"label": "Commit (Recommended)", "description": "Apply the proposed message"}, + {"label": "Edit message", "description": "Provide a new commit message"}, + {"label": "Skip commit", "description": "Leave changes staged for manual commit later"} + ] + }] + } + ``` + +5. On approval, run: + ``` + git -C {repo_root} commit -m "$(cat <<'EOF' + {message} + EOF + )" + ``` + + If a pre-commit hook fails, do NOT use `--no-verify`. Fix the underlying issue or pass control back to the user. + +### 2.9 Refresh Group Status + +Group status is derived from checkbox state on the next call to `resolve_ready_groups.py` — no in-memory bookkeeping required. The sync step in 2.6 is the only state mutation. + +### 2.10 Loop Until No Ready Groups Remain + +After each round, re-run `resolve_ready_groups.py` (it reads from disk, so it picks up the sync from 2.6): + +1. If `ready_group_ids[]` is non-empty → repeat steps 2.3–2.8 for the new ready groups +2. If `ready_group_ids[]` is empty AND `all_completed` is false → every remaining group is blocked (status `blocked` or `partial`). Report blockers and exit to Phase 3. +3. If `all_completed` is true → proceed to Phase 3. + +### 2.11 Final Sync Summary + +Before handing off to Phase 3, print a summary: + +``` +**Execution Phase Complete** + +| Group | Repo | Tasks | Status | Commit | +|-------|------|-------|--------|--------| +| A: backend-{domain} | oss | 3/3 | ✅ Complete | abc123 | +| B: frontend-{domain} | oss | 2/2 | ✅ Complete | def456 | +| C: tests | oss | 1/2 | 🔄 Partial | ghi789 | + +Total: {completed}/{total} tasks done +Blocked: {list of blocked task IDs with reasons} +``` + +## Error Recovery + +| Scenario | Action | +|----------|--------| +| Agent times out | Sync completed tasks, mark group `partial`, continue | +| Agent crashes | Sync completed tasks, log the error, pause sprint and ask user | +| Lint/test fails inside subagent | Subagent should self-correct; if it can't, it emits `BLOCKED` | +| File conflict (group A writes while group B is reading) | Shouldn't happen if Phase 3D grouping was correct. If it does: pause, report, ask user to resolve | +| All ready groups are blocked | Pause sprint, report blockers, suggest fixes | +| `git commit` fails | Surface the error verbatim; do NOT retry with `--no-verify` | + +## Handling Interruption + +If the session is killed mid-sprint: +- Completed tasks already have `[x]` in `implementation-plan.md` +- Blocked tasks have the `_(BLOCKED: ...)_` annotation +- Re-running `/feature-sprint {task_slug}` resumes from those checkboxes + +## Next Phase + +After all reachable groups are completed or blocked, read `phase-3-finalize.md`. diff --git a/.opencode/skills/feature-sprint/phase-3-finalize.md b/.opencode/skills/feature-sprint/phase-3-finalize.md new file mode 100644 index 000000000..ac7f3f426 --- /dev/null +++ b/.opencode/skills/feature-sprint/phase-3-finalize.md @@ -0,0 +1,227 @@ +# Phase 3: Finalize Sprint + +Run Nx quality gates on affected projects, produce the final report, optionally archive the spec. + +## Prerequisites + +- Phase 2 completed (all reachable groups completed or blocked) +- Per-group commits already created (or skipped) in each working repo + +## Steps + +### 3.1 Load Final State + +Read `tmp/feature-specs/{task_slug}/implementation-plan.md`: +- Count `[x]` vs `[ ]` +- Find any `_(BLOCKED: ...)_` annotations + +Read `tmp/feature-specs/{task_slug}/context.md`: +- `quality_gates` (lint/test/build flags + extras) +- `parallel_groups[]` to derive which Nx projects were touched + +### 3.2 Resolve Affected Nx Projects + +For each completed group, look at `target_files`: +- A path under `packages/{name}/` → Nx project `{name}` +- A path under `apps/{name}/` → Nx project `{name}` + +Build a deduplicated set of `affected_projects[]`. Also note which **repo** each project belongs to (`oss` or `proprietary`) — gates run in the right repo. + +If unsure or you want a broader gate, the Nx convention is: +``` +./node_modules/.bin/nx affected -t lint +./node_modules/.bin/nx affected -t test +./node_modules/.bin/nx affected -t build +``` +But narrowly targeting the specific projects is faster and clearer when you know them. + +### 3.3 Run Quality Gates + +For each `affected_project` × each gate flag in `quality_gates`: + +```bash +# Always cd to the repo via -C; use absolute paths. +{ensure node version per .nvmrc, e.g. via nvm if available} + +# Lint +./node_modules/.bin/nx lint {project} + +# Test +./node_modules/.bin/nx test {project} + +# Build (only if quality_gates.build is true) +./node_modules/.bin/nx build {project} +``` + +Set `PACKMIND_EDITION=proprietary` in the environment when running gates against the proprietary repo (per `CLAUDE.md`). + +Capture for each: `passed: bool`, `duration`, truncated output. + +If any extras are listed in `quality_gates.extra`, run them too (e.g., a project-specific e2e command). + +### 3.4 Handle Gate Failures + +If any gate fails, display the failure: + +``` +**Quality Gate Failed: {project} / {gate}** + +Repo: {repo} +Command: {command} +Exit: {code} + +{Last 30 lines of output} +``` + +Then use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Quality gate '{project}/{gate}' failed. How to proceed?", + "header": "Gate Failed", + "multiSelect": false, + "options": [ + {"label": "Fix and retry (Recommended)", "description": "I'll fix the issue, then retry the gate"}, + {"label": "Spawn fixer subagent", "description": "Delegate the fix to a fresh general-purpose subagent in the right repo"}, + {"label": "Skip gate", "description": "Continue without this gate passing (risky)"}, + {"label": "Pause sprint", "description": "Stop here, investigate manually"} + ] + }] +} +``` + +- "Fix and retry" → wait for the user to make changes, then re-run the gate +- "Spawn fixer subagent" → launch an `Agent` with `subagent_type="general-purpose"`. Prompt: working repo, the failed command and output, the relevant files. Instruct it to make the smallest fix and re-run the gate. On success, commit the fix as a follow-up commit (with user approval, per the per-group commit policy). +- "Skip gate" → record skipped, continue +- "Pause sprint" → exit; progress is preserved in checkboxes + commits + +### 3.5 Verify Final Checkbox State + +Re-read `implementation-plan.md`: +- Count completed (`[x]`) +- Count remaining (`[ ]`) — these should all be annotated `_(BLOCKED: ...)_` or the sprint is incomplete + +If any unblocked `[ ]` tasks remain, the sprint is not finished. Tell the user and offer to resume Phase 2. + +### 3.6 Offer to Archive + +``` +**Sprint Complete: {task_slug}** + +All reachable tasks executed, gates run, commits created. + +Move spec artifacts to an archive folder? (Still in tmp/ — git-ignored.) +- From: tmp/feature-specs/{task_slug}/ +- To: tmp/feature-specs/done/{task_slug}/ +``` + +Use `AskUserQuestion`: + +```json +{ + "questions": [{ + "question": "Move completed spec to tmp/feature-specs/done/?", + "header": "Archive", + "multiSelect": false, + "options": [ + {"label": "Yes, archive", "description": "Move under tmp/feature-specs/done/ so /feature-spec sees a clean pending list"}, + {"label": "Keep in place (Recommended)", "description": "Leave it; you may want to revisit it"} + ] + }] +} +``` + +If "Yes, archive": +```bash +mkdir -p tmp/feature-specs/done/ +mv tmp/feature-specs/{task_slug} tmp/feature-specs/done/{task_slug} +``` + +(No commit needed — `tmp/` is git-ignored.) + +### 3.7 Pull Reminder (OSS → proprietary) + +If any commits landed in the OSS repo (`oss_root`), remind the user: + +``` +✅ OSS commits created in {oss_root}. + +When upstream auto-merges into the proprietary fork, run: + git -C {proprietary_root} pull +to pick up the changes here. + +If this feature has proprietary-only tasks pending (e.g., editions wiring), do that pull before resuming /feature-sprint {task_slug}. +``` + +Skip this section if `target_repo` was `proprietary`. + +### 3.8 Final Report + +``` +## ✅ SPRINT COMPLETE + +**Task**: {task_name} +**Slug**: {task_slug} +**Target repo**: {target_repo} + +### Execution Summary + +| Metric | Value | +|--------|-------| +| Tasks completed | {n}/{total} | +| Parallel groups | {completed_groups}/{total_groups} | +| Commits created | {commit_count} | +| Files modified | {file_count} | +| Quality gates | {passed}/{total} passed{, X skipped if any} | + +### Commits + +| Repo | Group | SHA | Message | +|------|-------|-----|---------| +| oss | A: backend-... | abc123 | ✨ feat(...): ... | +| oss | B: frontend-... | def456 | ✨ feat(...): ... | + +### Quality Gates + +| Project | Gate | Status | Duration | +|---------|------|--------|----------| +| standards | lint | ✅ | 4s | +| standards | test | ✅ | 22s | +| frontend | lint | ✅ | 8s | +| frontend | test | ✅ | 31s | + +### Blocked Tasks + +{if any:} +| ID | Reason | +|----|--------| +| 3.2 | {reason} | + +{else: "None."} + +### Files Modified + +{abbreviated git status -s output per repo} + +--- + +**Spec**: `tmp/feature-specs/{pending|done}/{task_slug}/` +**Plan**: `tmp/feature-specs/{pending|done}/{task_slug}/implementation-plan.md` + +Sprint complete. Changes committed locally (not pushed). +``` + +### 3.9 Cleanup Session Tasks + +``` +for (taskId, sessionTaskId) in sessionTasks: + TaskUpdate({ taskId: sessionTaskId, status: "completed" }) +``` + +`sessionTasks` is ephemeral — nothing else to clean up. + +## End of Sprint + +To run another: `/feature-sprint {another_task_slug}` +To check overall progress: `/feature-sprint status` diff --git a/.opencode/skills/feature-sprint/references/group-prompt-template.md b/.opencode/skills/feature-sprint/references/group-prompt-template.md new file mode 100644 index 000000000..d928f20ba --- /dev/null +++ b/.opencode/skills/feature-sprint/references/group-prompt-template.md @@ -0,0 +1,84 @@ +# Per-Group Subagent Prompt Template + +Loaded by `phase-2-execute.md` step 2.3. The orchestrator substitutes every +`{placeholder}` (and the `{TASKS_BLOCK}` section) with concrete values, then +passes the result as the `prompt` argument to `Agent` with +`subagent_type="general-purpose"`. + +## Placeholders + +| Placeholder | Source | +|-------------|--------| +| `{group_id}` | `context.md` → `parallel_groups[*].group_id` | +| `{group_name}` | `context.md` → `parallel_groups[*].group_name` | +| `{task_slug}` | spec directory name (`tmp/feature-specs/{slug}/`) | +| `{repo}` | `parallel_groups[*].repo` (`oss` or `proprietary`) | +| `{repo_root}` | absolute path: `oss_root` or `proprietary_root` from `context.md` | +| `{proprietary_root}` | always `context.md` → `proprietary_root` (spec artifacts live here) | +| `{TASKS_BLOCK}` | concatenation of each task's `raw_block` from `parse_implementation_plan.py` output | + +## Template + +``` +Execute sprint group {group_id}: {group_name} for feature {task_slug}. + +## Working repo +- Repo: {repo} +- Root (absolute path): {repo_root} +- All file paths in tasks are relative to this root unless otherwise noted. + +## Context files (read in this exact order) +1. {proprietary_root}/tmp/feature-specs/{task_slug}/context.md +2. {proprietary_root}/tmp/feature-specs/{task_slug}/functional-spec.md +3. {proprietary_root}/tmp/feature-specs/{task_slug}/discovery.md +4. {proprietary_root}/tmp/feature-specs/{task_slug}/implementation-plan.md + +Note: spec artifacts live in the PROPRIETARY repo's tmp/ even when implementation +targets OSS. Always read from `{proprietary_root}/tmp/feature-specs/{task_slug}/`. + +## Your tasks (in this exact order) + +{TASKS_BLOCK} + +## Rules + +1. For each task in order: + a. Re-read the task block in implementation-plan.md for full detail + b. Open the Reference file:line — that is your pattern template + c. Implement the task following the Packmind conventions established in discovery.md + d. If the task is a backend domain/application/infra task, respect the hexagonal-architecture skill at `{proprietary_root}/.claude/skills/hexagonal-architecture/` + e. If the task is a frontend task, respect `working-with-pm-design-kit` and `gateway-pattern-implementation-in-packmind-frontend` + f. If the task is a migration, respect `how-to-write-typeorm-migrations-in-packmind` + g. After finishing the task, output exactly: `TASK_COMPLETE: {task_id}` + +2. NEVER import from `@packmind/editions` unless the file you're editing already lives inside `packages/editions/` (this is enforced by `.claude/rules/packmind/packmind-proprietary.md`). + +3. Run `./node_modules/.bin/nx lint <project>` and `./node_modules/.bin/nx test <project>` after finishing each Nx project's tasks within the group. If anything fails, fix it before moving on. + +4. After ALL tasks in this group are complete, output exactly: + ``` + GROUP_COMPLETE: {group_id} + COMPLETED_TASKS: <comma-separated task_ids> + FILES_MODIFIED: <list of absolute file paths you changed> + ``` + +5. If you encounter a blocker: + - Output: `BLOCKED: <task_id> - <short reason>` + - Continue with later tasks in this group only if they don't depend on the blocked task + - At the end, list blocked tasks in your group summary + +## Hard constraints + +- Do NOT commit changes — the main session handles commits per group +- Do NOT modify `implementation-plan.md` checkboxes — the main session handles sync-back +- Do NOT modify any file in `{proprietary_root}/tmp/feature-specs/{task_slug}/` +- Focus only on implementing the listed task IDs; do not "improve" unrelated files +- Use absolute paths in your Bash calls so they work regardless of your cwd + +## Tools you should rely on + +- Read, Edit, Write for code changes +- Bash with absolute paths for `nx lint`, `nx test`, `nx build` in the working repo +- Glob/Grep within the working repo for navigation +- Avoid spawning further subagents (`Agent`) — flatten everything in this one +``` diff --git a/.opencode/skills/feature-sprint/scripts/parse_implementation_plan.py b/.opencode/skills/feature-sprint/scripts/parse_implementation_plan.py new file mode 100644 index 000000000..b8e6068c2 --- /dev/null +++ b/.opencode/skills/feature-sprint/scripts/parse_implementation_plan.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Parse a feature-sprint implementation-plan.md into structured JSON. + +Reads checkbox tasks of the form: + + - [ ] **1.2: short description** + - Repo: oss + - Layer: application + - Files: packages/foo/... + - Reference: packages/bar/baz.ts:42 + - Notes: free text + +and emits JSON to stdout: + + { + "tasks": [ + { + "id": "1.2", + "completed": false, + "description": "short description", + "metadata": { + "repo": "oss", + "layer": "application", + "files": "packages/foo/...", + "reference": "packages/bar/baz.ts:42", + "notes": "free text" + }, + "blocked_reason": null, + "raw_block": "..." + } + ], + "summary": {"total": 1, "completed": 0, "pending": 1, "blocked": 0} + } + +Tasks annotated with `_(BLOCKED: reason)_` after the description carry that +reason on `blocked_reason`. The full markdown block (including indented +metadata lines) is preserved on `raw_block` so the orchestrator can paste it +verbatim into subagent prompts without re-reading the file. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +TASK_LINE = re.compile( + r"""^-\s\[(?P<state>[ x])\]\s\*\* + (?P<id>\d+(?:\.\d+)+):\s + (?P<desc>.+?)\*\* + (?:\s+_\(BLOCKED:\s(?P<blocked>.+?)\)_)? + \s*$""", + re.VERBOSE, +) +META_LINE = re.compile(r"^\s+-\s(?P<key>[A-Za-z]+):\s*(?P<value>.*)$") + + +def parse(content: str) -> dict: + tasks: list[dict] = [] + current: dict | None = None + raw_lines: list[str] = [] + + def flush() -> None: + if current is None: + return + current["raw_block"] = "\n".join(raw_lines).rstrip() + tasks.append(current) + + for line in content.splitlines(): + task_match = TASK_LINE.match(line) + if task_match: + flush() + raw_lines = [line] + current = { + "id": task_match.group("id"), + "completed": task_match.group("state") == "x", + "description": task_match.group("desc").strip(), + "metadata": {}, + "blocked_reason": task_match.group("blocked"), + "raw_block": "", + } + continue + + if current is None: + continue + + meta_match = META_LINE.match(line) + if meta_match: + raw_lines.append(line) + key = meta_match.group("key").lower() + value = meta_match.group("value").strip() + current["metadata"][key] = value + continue + + if line.startswith("- [") or (line.strip() == "" and len(raw_lines) > 1): + flush() + current = None + raw_lines = [] + continue + + if line.strip(): + raw_lines.append(line) + + flush() + + completed = sum(1 for t in tasks if t["completed"]) + blocked = sum(1 for t in tasks if t["blocked_reason"]) + return { + "tasks": tasks, + "summary": { + "total": len(tasks), + "completed": completed, + "pending": len(tasks) - completed, + "blocked": blocked, + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("plan", help="Path to implementation-plan.md") + parser.add_argument( + "--ids-only", + action="store_true", + help="Print only the task IDs (one per line) instead of JSON", + ) + parser.add_argument( + "--pending-only", + action="store_true", + help="Filter to non-completed tasks before printing", + ) + args = parser.parse_args() + + path = Path(args.plan) + if not path.exists(): + print(f"Error: plan file not found: {path}", file=sys.stderr) + return 1 + + result = parse(path.read_text(encoding="utf-8")) + + if args.pending_only: + result["tasks"] = [t for t in result["tasks"] if not t["completed"]] + + if args.ids_only: + for task in result["tasks"]: + print(task["id"]) + return 0 + + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.opencode/skills/feature-sprint/scripts/resolve_ready_groups.py b/.opencode/skills/feature-sprint/scripts/resolve_ready_groups.py new file mode 100644 index 000000000..b6da2306a --- /dev/null +++ b/.opencode/skills/feature-sprint/scripts/resolve_ready_groups.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Compute which parallel groups are ready to execute next. + +Reads: +- context.md (YAML frontmatter with `parallel_groups[]`) +- implementation-plan.md (checkbox state per task) + +A group is **completed** when every task in `task_ids` is checked `[x]` AND +not annotated `_(BLOCKED: ...)_`. A group is **partial** when some tasks are +done and some are blocked. A group is **ready** when its status is not yet +`completed` and every group it depends on (`blocked_by`) has status +`completed`. + +Output (stdout, JSON): + + { + "groups": [ + { + "group_id": "A", + "group_name": "backend-foo", + "repo": "oss", + "status": "ready" | "completed" | "partial" | "blocked", + "task_ids": ["1.1", "1.2"], + "completed_ids": ["1.1"], + "blocked_ids": [], + "blocked_by": [] + } + ], + "ready_group_ids": ["A"], + "all_completed": false + } + +Requires PyYAML. Exit 1 on missing files or malformed YAML. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +try: + import yaml +except ImportError: + print( + "Error: PyYAML is required (pip install pyyaml)", + file=sys.stderr, + ) + sys.exit(1) + + +FRONTMATTER = re.compile(r"^---\n(.*?)\n---", re.DOTALL) +TASK_STATE = re.compile( + r"^-\s\[(?P<state>[ x])\]\s\*\*(?P<id>\d+(?:\.\d+)+):\s.+?\*\*" + r"(?P<blocked>\s+_\(BLOCKED:.+?\)_)?\s*$", + re.MULTILINE, +) + + +def load_frontmatter(path: Path) -> dict: + text = path.read_text(encoding="utf-8") + match = FRONTMATTER.match(text) + if not match: + print(f"Error: no YAML frontmatter in {path}", file=sys.stderr) + sys.exit(1) + try: + data = yaml.safe_load(match.group(1)) + except yaml.YAMLError as exc: + print(f"Error: invalid YAML in {path}: {exc}", file=sys.stderr) + sys.exit(1) + if not isinstance(data, dict): + print(f"Error: frontmatter must be a mapping in {path}", file=sys.stderr) + sys.exit(1) + return data + + +def extract_task_state(plan_path: Path) -> dict[str, dict]: + states: dict[str, dict] = {} + for match in TASK_STATE.finditer(plan_path.read_text(encoding="utf-8")): + states[match.group("id")] = { + "completed": match.group("state") == "x", + "blocked": bool(match.group("blocked")), + } + return states + + +def classify_group(group: dict, task_state: dict[str, dict]) -> dict: + task_ids = group.get("task_ids", []) or [] + completed: list[str] = [] + blocked: list[str] = [] + for task_id in task_ids: + info = task_state.get(task_id) + if info is None: + continue + if info["completed"]: + completed.append(task_id) + elif info["blocked"]: + blocked.append(task_id) + + if task_ids and len(completed) == len(task_ids): + status = "completed" + elif blocked and (len(completed) + len(blocked)) == len(task_ids): + status = "partial" + else: + status = "pending" # may become "ready" after dep check + + return { + "group_id": group.get("group_id"), + "group_name": group.get("group_name"), + "repo": group.get("repo"), + "task_ids": task_ids, + "blocked_by": group.get("blocked_by", []) or [], + "target_files": group.get("target_files", []) or [], + "status": status, + "completed_ids": completed, + "blocked_ids": blocked, + } + + +def resolve_readiness(groups: list[dict]) -> None: + by_id = {g["group_id"]: g for g in groups} + for group in groups: + if group["status"] in ("completed", "partial"): + continue + deps_done = all( + by_id.get(dep, {}).get("status") == "completed" + for dep in group["blocked_by"] + ) + group["status"] = "ready" if deps_done else "blocked" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("context", help="Path to context.md") + parser.add_argument("plan", help="Path to implementation-plan.md") + args = parser.parse_args() + + context_path = Path(args.context) + plan_path = Path(args.plan) + for path, label in [(context_path, "context"), (plan_path, "plan")]: + if not path.exists(): + print(f"Error: {label} file not found: {path}", file=sys.stderr) + return 1 + + frontmatter = load_frontmatter(context_path) + parallel_groups = frontmatter.get("parallel_groups") or [] + if not isinstance(parallel_groups, list): + print("Error: parallel_groups must be a list", file=sys.stderr) + return 1 + + task_state = extract_task_state(plan_path) + groups = [classify_group(g, task_state) for g in parallel_groups] + resolve_readiness(groups) + + ready_ids = [g["group_id"] for g in groups if g["status"] == "ready"] + all_completed = bool(groups) and all(g["status"] == "completed" for g in groups) + + result = { + "groups": groups, + "ready_group_ids": ready_ids, + "all_completed": all_completed, + } + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.opencode/skills/feature-sprint/scripts/sync_checkboxes.py b/.opencode/skills/feature-sprint/scripts/sync_checkboxes.py new file mode 100644 index 000000000..0253477fa --- /dev/null +++ b/.opencode/skills/feature-sprint/scripts/sync_checkboxes.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Batch-update task checkboxes in a feature-sprint implementation-plan.md. + +Supports two operations in a single pass: + + --complete 1.1,1.2,2.3 Flip `- [ ] **{id}:` to `- [x] **{id}:` + --blocked '3.1:reason' '3.2:r2' Annotate `- [ ] **{id}: desc**` with + `_(BLOCKED: reason)_` (keeps the checkbox + unchecked so resume picks it up). + +`--blocked` accepts one pair per argument so reasons can contain commas +freely (e.g. `'3.1:waiting on API, see #123'`). Pass either flag without +values, or omit it entirely, when the corresponding list is empty. + +Already-completed tasks (`[x]`) are left alone. Tasks already annotated +BLOCKED have their annotation replaced if a new reason is supplied. Any task +ID passed but not found in the file is reported on stderr; exit code is 1 if +any IDs were not applied, 0 otherwise. + +Output (stdout, JSON): + + { + "applied_complete": ["1.1", "1.2"], + "applied_blocked": [{"id": "3.1", "reason": "..."}], + "missing": ["9.9"] + } +""" + +import argparse +import json +import re +import sys +from pathlib import Path + + +def parse_id_list(raw: str | None) -> list[str]: + if not raw: + return [] + return [item.strip() for item in raw.split(",") if item.strip()] + + +def parse_blocked_list(items: list[str] | None) -> list[tuple[str, str]]: + if not items: + return [] + pairs = [] + for item in items: + item = item.strip() + if not item: + continue + if ":" not in item: + print( + f"Error: --blocked entry '{item}' missing ':reason'", + file=sys.stderr, + ) + sys.exit(2) + task_id, reason = item.split(":", 1) + pairs.append((task_id.strip(), reason.strip())) + return pairs + + +def task_line_pattern(task_id: str) -> re.Pattern[str]: + # Match the exact task line, preserving the description and any trailing + # markup. Captures: 1=state ([ ] or [x]), 2=description, 3=optional + # BLOCKED annotation already present. + return re.compile( + rf"^-\s\[(?P<state>[ x])\]\s\*\*{re.escape(task_id)}:\s(?P<desc>.+?)\*\*" + r"(?P<blocked>\s+_\(BLOCKED:.+?\)_)?\s*$", + re.MULTILINE, + ) + + +def apply_complete(content: str, ids: list[str]) -> tuple[str, list[str], list[str]]: + applied: list[str] = [] + missing: list[str] = [] + for task_id in ids: + pattern = task_line_pattern(task_id) + match = pattern.search(content) + if not match: + missing.append(task_id) + continue + if match.group("state") == "x": + applied.append(task_id) # idempotent — already complete + continue + # Replace the matched line: flip state, strip any BLOCKED annotation + new_line = f"- [x] **{task_id}: {match.group('desc')}**" + content = content[: match.start()] + new_line + content[match.end():] + applied.append(task_id) + return content, applied, missing + + +def apply_blocked( + content: str, pairs: list[tuple[str, str]] +) -> tuple[str, list[dict], list[str]]: + applied: list[dict] = [] + missing: list[str] = [] + for task_id, reason in pairs: + pattern = task_line_pattern(task_id) + match = pattern.search(content) + if not match: + missing.append(task_id) + continue + if match.group("state") == "x": + # Already complete — don't re-annotate + continue + new_line = ( + f"- [ ] **{task_id}: {match.group('desc')}** " + f"_(BLOCKED: {reason})_" + ) + content = content[: match.start()] + new_line + content[match.end():] + applied.append({"id": task_id, "reason": reason}) + return content, applied, missing + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("plan", help="Path to implementation-plan.md") + parser.add_argument( + "--complete", + help="Comma-separated task IDs to mark complete (e.g. 1.1,1.2)", + ) + parser.add_argument( + "--blocked", + nargs="*", + default=[], + help=( + "One or more 'id:reason' pairs, each as a single argument so " + "reasons may contain commas " + "(e.g. --blocked '3.1:waiting on API, see #123' '3.2:other')" + ), + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would change without writing the file", + ) + args = parser.parse_args() + + path = Path(args.plan) + if not path.exists(): + print(f"Error: plan file not found: {path}", file=sys.stderr) + return 1 + + completes = parse_id_list(args.complete) + blocks = parse_blocked_list(args.blocked) + + if not completes and not blocks: + print( + "Error: at least one of --complete or --blocked is required", + file=sys.stderr, + ) + return 2 + + content = path.read_text(encoding="utf-8") + content, applied_complete, missing_complete = apply_complete(content, completes) + content, applied_blocked, missing_blocked = apply_blocked(content, blocks) + + if not args.dry_run: + path.write_text(content, encoding="utf-8") + + result = { + "applied_complete": applied_complete, + "applied_blocked": applied_blocked, + "missing": sorted(set(missing_complete + missing_blocked)), + "dry_run": args.dry_run, + } + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + + return 1 if result["missing"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.opencode/skills/packmind-onboard/LICENSE.txt b/.opencode/skills/packmind-onboard/LICENSE.txt new file mode 100644 index 000000000..f433b1a53 --- /dev/null +++ b/.opencode/skills/packmind-onboard/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/.opencode/skills/packmind-onboard/README.md b/.opencode/skills/packmind-onboard/README.md new file mode 100644 index 000000000..a67977da6 --- /dev/null +++ b/.opencode/skills/packmind-onboard/README.md @@ -0,0 +1,46 @@ +# Packmind Onboarding Skill + +Read-only codebase analysis skill that identifies non-linter architectural patterns and generates draft Packmind Standards and Commands. + +## What It Does + +1. **Detects existing configuration** - Shows what's already configured (standards, commands, agent docs) +2. **Detects your stack** - Language, monorepo structure, architecture markers +3. **Analyzes for non-linter patterns** - 4 architectural analyses across code organization, workflows, and testing +4. **Generates draft artifacts** - Max 5 Standards and 5 Commands per run +5. **Applies on your choice** - Nothing written without explicit confirmation + +**Works with any language** - JavaScript, TypeScript, Python, Go, Ruby, Java, and more. + +## Available Analyses + +| Category | Analyses | +|----------|----------| +| **Infrastructure** | CI/Local Workflow Parity | +| **Code Organization** | File Template Consistency, Role Taxonomy Drift | +| **Testing** | Test Data Construction | + +## Usage + +Ask your AI agent to onboard: +- "Onboard this project to Packmind" +- "Analyze this codebase for standards" +- "Generate coding standards for this project" + +## What You'll Discover + +- **Test data patterns**: "23 factories with 1166 usages across test files" +- **File boilerplate**: "All UseCases extend AbstractMemberUseCase with same structure" +- **Workflow gaps**: "CI runs security scan, no local equivalent" +- **Role drift**: "3 role definitions with inconsistent naming across modules" + +## What It Skips (Linter-Enforceable) + +- ESLint disable counts +- TypeScript strict violations +- Formatting issues +- Import ordering + +## License + +Apache 2.0 - See LICENSE.txt for details. diff --git a/.opencode/skills/packmind-onboard/SKILL.md b/.opencode/skills/packmind-onboard/SKILL.md new file mode 100644 index 000000000..97aba3c8e --- /dev/null +++ b/.opencode/skills/packmind-onboard/SKILL.md @@ -0,0 +1,328 @@ +--- +name: 'packmind-onboard' +description: 'Complete automated onboarding: analyzes codebase, creates package, and generates standards & commands via CLI. Automatic package creation when none exist, user selection when packages are available.' +license: 'Complete terms in LICENSE.txt' +--- + +# packmind-onboard +Run "packmind-cli --version" to get the current cli installation. + +Find the highest version at or below the cli version in this list: +- 0.16.0 +- 0.23.0 +Remember this value as $PACKMIND_CLI_VERSION for the rest of the skill. + + +Action skill. Provides **complete automated onboarding** for Packmind: +1. Creates or selects a package +2. Analyzes codebase for patterns +3. Generates draft Standards and Commands +4. Creates items via CLI + +Automatic package creation when none exist, user selection when packages are available. + +## Guarantees + +- **Read-only analysis.** Analysis phase does not modify any project files. +- **Drafts before creation.** All items are written as drafts first, allowing review before creation. +- **Preserve existing.** Never overwrite existing artifacts. If a slug already exists, create `-2`, `-3`, etc. +- **Evidence required.** Every reported insight must include file-path evidence (and line ranges when feasible). +- **Focused output.** Max **5 Standards** and **5 Commands** generated per run. +- **Graceful failure.** Partial failures don't lose successful work; failed drafts are preserved. +- **User control.** When packages exist, users confirm package selection before creation. + +## Definitions + +- **Pattern (non-linter):** a convention a linter cannot reliably enforce (module boundaries, cross-domain communication, workflow parity, error semantics, etc). +- **Evidence:** `path[:line-line]` entries; omit line ranges only when the file isn't text-searchable. + +--- + +## Step 0 — Introduction + +Print exactly: + +``` +I'll start the Packmind onboarding process. I'll create your first standards and commands and send them to your Packmind organization. This usually takes ~3 minutes. +``` + + +--- + +## Step 1 — Get Repository Name + +Get the repository name for package naming: + +```bash +basename "$(git rev-parse --show-toplevel)" +``` + +Remember this as the repository name for package creation in Step 2. + +Also run `packmind-cli whoami` and extract the `Host:` value from the output. Remember this URL for the completion summary. + + +--- + +## Step 2 — Package Handling + +Handle package creation or selection. + +### Check existing packages + +List available packages by following [`packmind-versions/$PACKMIND_CLI_VERSION/list-packages.md`](packmind-versions/$PACKMIND_CLI_VERSION/list-packages.md). + +Parse the output to get package names and slugs. + +### No packages exist + +Auto-create a package using the repository name. Follow [`packmind-versions/$PACKMIND_CLI_VERSION/create-package.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-package.md) using `${REPO_NAME}-standards` as the package name. + +The create-package step will determine the space. Capture the chosen space slug as `$SPACE_SLUG` and the new package slug as `$PACKAGE_SLUG`. + +Print: +``` +No existing packages found — created a new one: ${REPO_NAME}-standards +``` + +### One package exists + +Ask via AskUserQuestion: +- "Add to `{package-name}`?" +- "Create new package instead" + +### Multiple packages exist + +Ask via AskUserQuestion: +- List each existing package as an option +- Include "Create new package" option + +### If "Create new package" is selected + +- Ask for package name (suggest `${REPO_NAME}-standards` as default) +- Follow [`packmind-versions/$PACKMIND_CLI_VERSION/create-package.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-package.md) using the chosen name. +- The create-package step will determine the space. Capture the chosen space slug as `$SPACE_SLUG` and the new package slug as `$PACKAGE_SLUG`. + +### If an existing package is selected + +Follow [`packmind-versions/$PACKMIND_CLI_VERSION/select-package.md`](packmind-versions/$PACKMIND_CLI_VERSION/select-package.md). + +### After this step + +Remember `$PACKAGE_SLUG` (the slug of the selected/created package) and `$SPACE_SLUG` for later reference — they will be used together in Step 9 to ensure items are added to the correct package in the correct space (as `@$SPACE_SLUG/$PACKAGE_SLUG`). + + +--- + +## Step 3 — Announce + +Print exactly: + +``` +packmind-onboard: analyzing codebase (read-only) +Target package: [package-name] +``` + + +--- + +## Step 4 — Detect Existing Packmind and Agent Configuration + +Before analyzing, detect and preserve any existing Packmind/agent configuration. + +### Glob (broad, future-proof) +Glob for markdown in these roots (recursive): +- `.packmind/**/*.md` +- `.claude/**/*.md` +- `.agents/**/*.md` +- `**/skills/**/*.md` +- `**/rules/**/*.md` + +### Classify +Classify found files into counts: +- **standards**: `.packmind/standards/**/*.md` +- **commands**: `.packmind/commands/**/*.md` +- **other_docs**: any markdown under `.claude/`, `.agents/`, or any `skills/` or `rules/` directory outside `.packmind` + +If any exist, print exactly: + +``` +Existing Packmind/agent docs detected: + + Standards: [N] + + Commands: [M] + + Other docs: [P] +``` + +No overwrites. New files (if you Export) will be added next to the existing ones. + + +--- + +## Step 5 — Detect Project Stack (Minimal, Evidence-Based) + +### Language markers (check presence) +- JS/TS: `package.json`, `pnpm-lock.yaml`, `yarn.lock`, `tsconfig.json` +- Python: `pyproject.toml`, `requirements.txt`, `setup.py` +- Go: `go.mod` +- Rust: `Cargo.toml` +- Ruby: `Gemfile` +- JVM: `pom.xml`, `build.gradle`, `build.gradle.kts` +- .NET: `*.csproj`, `*.sln` +- PHP: `composer.json` + +### Architecture markers (check directories) +- Hexagonal/DDD: `src/application/`, `src/domain/`, `src/infra/` +- Layered/MVC: `src/controllers/`, `src/services/` +- Monorepo: `packages/`, `apps/` + +Print exactly: + +``` +Stack detected (heuristic): + + Languages: [..] + + Repo shape: [monorepo|single] + + Architecture markers: [..|none] +``` + + +--- + +## Step 6 — Run Analyses + +Read each reference file for detailed search patterns, thresholds, and insight templates. + +| Analysis | Reference File | Output focus | +|----------|----------------|--------------| +| File Template Consistency | `references/file-template-consistency.md` | Commands | +| CI/Local Workflow Parity | `references/ci-local-workflow-parity.md` | Commands | +| Role Taxonomy Drift | `references/role-taxonomy-drift.md` | Standards | +| Test Data Construction | `references/test-data-construction.md` | Standards | + +### Output schema (internal; do not print as-is to user) +For every finding, keep an internal record: + +``` +INSIGHT: +title: ... +why_it_matters: ... +confidence: [high|medium|low] +evidence: +- path[:line-line] +where_it_doesnt_apply: +- path[:line-line] +``` + + +--- + +## Step 7 — Generate All Drafts + +Generate all draft files in one batch, using the format defined for your CLI version. + +Read the **Draft Format** section in [`packmind-versions/$PACKMIND_CLI_VERSION/create-items.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-items.md) and create draft files accordingly. + +### Generation Rules (all versions) + +- Generate drafts **only from discovered insights** (no invention) +- Use evidence from analysis to populate rules/steps +- Cap output: max **5 Standards** + **5 Commands** +- Never overwrite existing files; append `-2`, `-3`, etc. if slug exists + + +--- + +## Step 8 — Present Summary & Confirm + +Present the generated draft files and ask for confirmation: + +``` +============================================================ + ANALYSIS COMPLETE +============================================================ + +Target package: [package-name] +Stack detected: [languages], [monorepo?], [architecture markers] +Analyses run: [N] checks + +DRAFTS CREATED: + +Standards ([N]): + 1. [Name] → .packmind/standards/_drafts/[slug].draft.md + 2. ... + +Commands ([M]): + 1. [Name] → .packmind/commands/_drafts/[slug].draft.md + 2. ... + +Drafts are saved in .packmind/*/_drafts/ — you can review or edit them before creating. +============================================================ +``` + +Then ask via AskUserQuestion with three options: + +- **Create all now** — Proceed with creating all standards and commands +- **Let me review drafts first** — Pause to allow editing, re-run skill when ready +- **Cancel** — Exit without creating anything + + +--- + +## Step 9 — Create Items + +Follow [`packmind-versions/$PACKMIND_CLI_VERSION/create-items.md`](packmind-versions/$PACKMIND_CLI_VERSION/create-items.md). + + +--- + +## Step 10 — Completion Summary + +Follow [`packmind-versions/$PACKMIND_CLI_VERSION/completion-summary.md`](packmind-versions/$PACKMIND_CLI_VERSION/completion-summary.md). + + +--- + +## Edge Cases + +### Package creation fails + +If `packmind-cli packages create` fails: + +``` +❌ Failed to create package: [error message] + +Please check: + - You are logged in: `packmind-cli login` + - Your network connection is working + - The package name is valid + +Cannot proceed with onboarding until package is created. +``` + +Exit the skill. Do not proceed to analysis. + +### Not logged in + +If CLI commands fail with authentication errors: + +``` +❌ Not logged in to Packmind + +Please run: + packmind-cli login + +Then re-run this skill. +``` + +### No packages available + +If the package listing command returns no packages: + +Auto-create a package using the repository name. + + diff --git a/.opencode/skills/packmind-onboard/packmind-versions/0.16.0/completion-summary.md b/.opencode/skills/packmind-onboard/packmind-versions/0.16.0/completion-summary.md new file mode 100644 index 000000000..c41fd4869 --- /dev/null +++ b/.opencode/skills/packmind-onboard/packmind-versions/0.16.0/completion-summary.md @@ -0,0 +1,64 @@ +## Completion Summary (CLI 0.16.0) + +### All items created successfully + +``` +============================================================ + ✅ ONBOARDING COMPLETE +============================================================ + +Package: [package-name] +Created: [N] standards, [M] commands + +Your standards and commands have been created and deployed locally. + +Next steps: + - Reload your AI coding assistant to start using them + - Visit [host from packmind-cli whoami] to manage your standards and commands + - Run `packmind-cli install [package-slug]` in other repos to distribute them +============================================================ +``` + +Clean up successful draft files after creation. + +### Partial success (some items failed) + +``` +============================================================ + ⚠️ ONBOARDING COMPLETED WITH ERRORS +============================================================ + +Package: [package-name] +Created: [N] standards, [M] commands +Failed: [X] items + +Failed items: + • [item-name]: [error message] + +Failed drafts remain in .packmind/*/_drafts/ for review. +You can fix and re-run, or create manually with: + packmind-cli standards create .packmind/standards/_drafts/<slug>.json + packmind-cli packages add --to <package-slug> --standard <slug> + packmind-cli commands create .packmind/commands/_drafts/<slug>.json + packmind-cli packages add --to <package-slug> --command <slug> +============================================================ +``` + +Keep failed draft files for user to fix and retry. + +### No patterns discovered + +If analysis found no patterns: + +``` +============================================================ + ℹ️ NO PATTERNS DISCOVERED +============================================================ + +The analysis didn't find enough recurring patterns to generate standards or commands. + +This can happen with smaller codebases or projects with very diverse coding styles. +You can try again later as the codebase grows, or create standards manually with: + packmind-cli standards create <file> +============================================================ +``` diff --git a/.opencode/skills/packmind-onboard/packmind-versions/0.16.0/create-items.md b/.opencode/skills/packmind-onboard/packmind-versions/0.16.0/create-items.md new file mode 100644 index 000000000..e2e02e0fc --- /dev/null +++ b/.opencode/skills/packmind-onboard/packmind-versions/0.16.0/create-items.md @@ -0,0 +1,240 @@ +## Draft Format (CLI 0.16.0) + +### Standard Draft Format + +For each Standard insight, create a Markdown file at `.packmind/standards/_drafts/<slug>.draft.md`: + +```markdown +# Standard Name + +What the standard covers and why. + +## Scope + +Where this standard applies (e.g., 'TypeScript files', 'React components'). + +## Rules + +### Rule starting with action verb + +Another rule can follow... + +## Examples + +### Good + +```typescript +// Valid code example +``` + +### Bad + +```typescript +// Invalid code example +``` +``` + +### Command Draft Format + +For each Command insight, create a Markdown file at `.packmind/commands/_drafts/<slug>.draft.md`: + +```markdown +# Command Name + +What the command does, why it's useful, and when it's relevant. + +## When to Use + +- Scenario when this command applies +- Another scenario... + +## Checkpoints + +- Question to validate before proceeding? + +## Steps + +### 1. Step Name + +What this step does and how to implement it. + +```typescript +// Optional code example +``` + +### 2. Another Step + +Description of next step... +``` + +--- + +## Create Items (CLI 0.16.0) + +### If user selected "Create all now" + +**IMPORTANT:** The CLI only accepts JSON playbook files, not markdown. Before calling the CLI, convert each `.draft.md` file to a `.json` file. + +#### Standard JSON Schema + +Convert the markdown draft to this JSON format: + +```json +{ + "name": "Standard name (from # heading)", + "description": "What the standard covers (from intro paragraph)", + "scope": "Where it applies (from ## Scope section)", + "rules": [ + { + "content": "Rule starting with action verb (from ### Rule headings under ## Rules)", + "examples": { + "positive": "Valid code example (from ### Good section)", + "negative": "Invalid code example (from ### Bad section)", + "language": "TYPESCRIPT" + } + } + ] +} +``` + +#### Command JSON Schema + +Convert the markdown draft to this JSON format: + +```json +{ + "name": "Command name (from # heading)", + "summary": "What it does and when (from intro paragraph)", + "whenToUse": ["Scenario 1", "Scenario 2 (from ## When to Use bullets)"], + "contextValidationCheckpoints": ["Question 1? (from ## Checkpoints bullets)"], + "steps": [ + { + "name": "Step name (from ### N. Step Name)", + "description": "Step description (from step content)", + "codeSnippet": "Optional code fence content" + } + ] +} +``` + +#### Conversion and Creation Process + +**For each standard draft:** + +1. Read the `.draft.md` file +2. Convert to JSON matching the schema above +3. Write the JSON to `.packmind/standards/_drafts/<slug>.json` +4. Run CLI command to create: +```bash +packmind-cli standards create .packmind/standards/_drafts/<slug>.json +``` +5. If creation succeeded, add to package: +```bash +packmind-cli packages add --to <package-slug> --standard <slug> +``` +6. Track result (success/failure) + +**For each command draft:** + +1. Read the `.draft.md` file +2. Convert to JSON matching the schema above +3. Write the JSON to `.packmind/commands/_drafts/<slug>.json` +4. Run CLI command to create: +```bash +packmind-cli commands create .packmind/commands/_drafts/<slug>.json +``` +5. If creation succeeded, add to package: +```bash +packmind-cli packages add --to <package-slug> --command <slug> +``` +6. Track result (success/failure) + +**Show progress:** +``` +Sending standards and commands to your Packmind organization... +✓ error-handling-pattern +✓ naming-conventions +✗ test-factory-patterns (error: duplicate name exists) +✓ run-full-test-suite + +Done: 3 created, 1 failed +``` + +### If user selected "Let me review drafts first" + +Print: +``` +Draft files ready for review at: + - .packmind/standards/_drafts/ + - .packmind/commands/_drafts/ + +Edit them as needed, then re-run this skill to continue. +``` + +Exit the skill. + +### If user selected "Cancel" + +Print: +``` +Onboarding cancelled. +Draft files remain at .packmind/*/_drafts/ if you want to review them later. +``` + +Exit the skill. + +--- + +### 9.1 Deploy Locally (after successful creation) + +Since the onboard skill is present, the user has configured an AI agent. Deploy the created artifacts locally using the package selected/created in Step 2: + +```bash +packmind-cli install <package-slug> +``` + +This deploys to agent-specific folders: + +| Agent | Standards | Commands | +|-------|-----------|----------| +| Claude | `.claude/rules/packmind/standard-[slug].md` | `.claude/commands/packmind/[slug].md` | +| Cursor | `.cursor/rules/packmind/standard-[slug].mdc` | `.cursor/commands/packmind/[slug].mdc` | +| Copilot | `.github/instructions/packmind-standard-[slug].instructions.md` | `.github/prompts/packmind-[slug].prompt.md` | + +### 9.2 Cleanup and Summary + +Delete the draft files, then print final summary: + +``` +============================================================ + PUBLISHED & DEPLOYED +============================================================ + +Standards and commands have been sent to your Packmind organization +and deployed to your AI coding assistant's configuration files. + +Standards: [N] + - [Name] (slug: [slug]) + → .packmind/standards/[slug].md + → [agent-specific path] + +Commands: [M] + - [Name] (slug: [slug]) + → .packmind/commands/[slug].md + → [agent-specific path] + +Draft files cleaned up. +============================================================ +``` + +**If user declines (N):** + +Print: + +``` +Draft files ready for review at: + - .packmind/standards/_drafts/ + - .packmind/commands/_drafts/ + +Edit them as needed, then re-run this skill to create them. +``` diff --git a/.opencode/skills/packmind-onboard/packmind-versions/0.16.0/create-package.md b/.opencode/skills/packmind-onboard/packmind-versions/0.16.0/create-package.md new file mode 100644 index 000000000..259e57a27 --- /dev/null +++ b/.opencode/skills/packmind-onboard/packmind-versions/0.16.0/create-package.md @@ -0,0 +1,5 @@ +## Create Package (CLI 0.16.0) + +```bash +packmind-cli packages create "<package-name>" +``` diff --git a/.opencode/skills/packmind-onboard/packmind-versions/0.16.0/list-packages.md b/.opencode/skills/packmind-onboard/packmind-versions/0.16.0/list-packages.md new file mode 100644 index 000000000..1384951ba --- /dev/null +++ b/.opencode/skills/packmind-onboard/packmind-versions/0.16.0/list-packages.md @@ -0,0 +1,5 @@ +## List Packages (CLI 0.16.0) + +```bash +packmind-cli install --list +``` diff --git a/.opencode/skills/packmind-onboard/packmind-versions/0.16.0/select-package.md b/.opencode/skills/packmind-onboard/packmind-versions/0.16.0/select-package.md new file mode 100644 index 000000000..9a3cfa0f4 --- /dev/null +++ b/.opencode/skills/packmind-onboard/packmind-versions/0.16.0/select-package.md @@ -0,0 +1,3 @@ +## Select Existing Package (CLI 0.16.0) + +Spaces are not supported in this CLI version. Use the selected package slug directly as `$PACKAGE_SLUG`. diff --git a/.opencode/skills/packmind-onboard/packmind-versions/0.23.0/completion-summary.md b/.opencode/skills/packmind-onboard/packmind-versions/0.23.0/completion-summary.md new file mode 100644 index 000000000..34ba8b828 --- /dev/null +++ b/.opencode/skills/packmind-onboard/packmind-versions/0.23.0/completion-summary.md @@ -0,0 +1,69 @@ +## Completion Summary (CLI 0.23.0) + +### All items created successfully + +``` +============================================================ + ✅ ONBOARDING COMPLETE +============================================================ + +Package: [package-name] +Created: [N] standards, [M] commands + +Your standards and commands have been created and deployed locally. + +Next steps: + - Reload your AI coding assistant to start using them + - Visit [host from packmind-cli whoami] to manage your standards and commands + - Run `packmind-cli install @$SPACE_SLUG/[package-slug]` in other repos to distribute them +============================================================ +``` + +Clean up successful draft files after creation. + +### Partial success (some items failed) + +``` +============================================================ + ⚠️ ONBOARDING COMPLETED WITH ERRORS +============================================================ + +Package: [package-name] +Created: [N] standards, [M] commands +Failed: [X] items + +Failed items: + • [item-name]: [error message] + +Failed drafts remain in .packmind/*/_drafts/ for review. +You can fix and re-run, or create manually with: + cp .packmind/standards/_drafts/<slug>.draft.md .packmind/standards/_drafts/<slug>.md + packmind-cli playbook add --space <space-slug> .packmind/standards/_drafts/<slug>.md + cp .packmind/commands/_drafts/<slug>.draft.md .packmind/commands/_drafts/<slug>.md + packmind-cli playbook add --space <space-slug> .packmind/commands/_drafts/<slug>.md + packmind-cli playbook submit --no-review + packmind-cli packages add --to @<space-slug>/<package-slug> --standard <slug> + packmind-cli packages add --to @<space-slug>/<package-slug> --command <slug> +============================================================ +``` + +Keep failed draft files for user to fix and retry. + +### No patterns discovered + +If analysis found no patterns: + +``` +============================================================ + ℹ️ NO PATTERNS DISCOVERED +============================================================ + +The analysis didn't find enough recurring patterns to generate standards or commands. + +This can happen with smaller codebases or projects with very diverse coding styles. +You can try again later as the codebase grows, or create standards manually with: + cp .packmind/standards/_drafts/<slug>.draft.md .packmind/standards/_drafts/<slug>.md + packmind-cli playbook add --space <space-slug> .packmind/standards/_drafts/<slug>.md + packmind-cli playbook submit --no-review +============================================================ +``` diff --git a/.opencode/skills/packmind-onboard/packmind-versions/0.23.0/create-items.md b/.opencode/skills/packmind-onboard/packmind-versions/0.23.0/create-items.md new file mode 100644 index 000000000..4fba56c7b --- /dev/null +++ b/.opencode/skills/packmind-onboard/packmind-versions/0.23.0/create-items.md @@ -0,0 +1,185 @@ +## Draft Format (CLI 0.23.0) + +### Standard Draft Format + +For each Standard insight, create a Markdown file at `.packmind/standards/_drafts/<slug>.draft.md`: + +```markdown +# Standard Name + +What the standard covers and why. + +## Scope + +Where this standard applies (e.g., 'TypeScript files', 'React components'). + +## Rules + +- Rule starting with an action verb +- Another rule... +``` + +No code examples required for this version. + +### Command Draft Format + +For each Command insight, create a Markdown file at `.packmind/commands/_drafts/<slug>.draft.md`: + +```markdown +# Command Name + +What the command does, why it's useful, and when it's relevant. + +## When to Use + +- Scenario when this command applies +- Another scenario... + +## Checkpoints + +- Question to validate before proceeding? + +## Steps + +### 1. Step Name + +What this step does and how to implement it. + +### 2. Another Step + +Description of next step... +``` + +--- + +## Create Items (CLI 0.23.0) + +### If user selected "Create all now" + +The CLI accepts markdown files directly — no JSON conversion needed. + +**IMPORTANT:** The CLI derives the artifact name and slug from the filename. Strip the `.draft` suffix before adding by copying to a temporary file without `.draft`: + +**For each standard draft:** + +```bash +cp .packmind/standards/_drafts/<slug>.draft.md .packmind/standards/_drafts/<slug>.md +packmind-cli playbook add --space $SPACE_SLUG .packmind/standards/_drafts/<slug>.md +rm .packmind/standards/_drafts/<slug>.md +``` + +**For each command draft:** + +```bash +cp .packmind/commands/_drafts/<slug>.draft.md .packmind/commands/_drafts/<slug>.md +packmind-cli playbook add --space $SPACE_SLUG .packmind/commands/_drafts/<slug>.md +rm .packmind/commands/_drafts/<slug>.md +``` + +**After adding all items, submit:** + +```bash +packmind-cli playbook submit --no-review +``` + +**Show progress:** +``` +Sending standards and commands to your Packmind organization... ++ error-handling-pattern ++ naming-conventions ++ run-full-test-suite + +Submitting... +Done: 3 submitted +``` + +**After a successful submit, add each item to the package:** + +For each standard: +```bash +packmind-cli packages add --to @$SPACE_SLUG/$PACKAGE_SLUG --standard <slug> +``` + +For each command: +```bash +packmind-cli packages add --to @$SPACE_SLUG/$PACKAGE_SLUG --command <slug> +``` + +### If user selected "Let me review drafts first" + +Print: +``` +Draft files ready for review at: + - .packmind/standards/_drafts/ + - .packmind/commands/_drafts/ + +Edit them as needed, then re-run this skill to continue. +``` + +Exit the skill. + +### If user selected "Cancel" + +Print: +``` +Onboarding cancelled. +Draft files remain at .packmind/*/_drafts/ if you want to review them later. +``` + +Exit the skill. + +--- + +### 9.1 Deploy Locally (after successful creation) + +Since the onboard skill is present, the user has configured an AI agent. Deploy the created artifacts locally using the package selected/created in Step 2: + +```bash +packmind-cli install @$SPACE_SLUG/$PACKAGE_SLUG +``` + +This deploys to agent-specific folders: + +| Agent | Standards | Commands | +|-------|-----------|----------| +| Claude | `.claude/rules/packmind/standard-[slug].md` | `.claude/commands/packmind/[slug].md` | +| Cursor | `.cursor/rules/packmind/standard-[slug].mdc` | `.cursor/commands/packmind/[slug].mdc` | +| Copilot | `.github/instructions/packmind-standard-[slug].instructions.md` | `.github/prompts/packmind-[slug].prompt.md` | + +### 9.2 Cleanup and Summary + +Delete the draft files, then print final summary: + +``` +============================================================ + PUBLISHED & DEPLOYED +============================================================ + +Standards and commands have been sent to your Packmind organization +and deployed to your AI coding assistant's configuration files. + +Standards: [N] + - [Name] (slug: [slug]) + → .packmind/standards/[slug].md + → [agent-specific path] + +Commands: [M] + - [Name] (slug: [slug]) + → .packmind/commands/[slug].md + → [agent-specific path] + +Draft files cleaned up. +============================================================ +``` + +**If user declines (N):** + +Print: + +``` +Draft files ready for review at: + - .packmind/standards/_drafts/ + - .packmind/commands/_drafts/ + +Edit them as needed, then re-run this skill to create them. +``` diff --git a/.opencode/skills/packmind-onboard/packmind-versions/0.23.0/create-package.md b/.opencode/skills/packmind-onboard/packmind-versions/0.23.0/create-package.md new file mode 100644 index 000000000..5eea4ceb0 --- /dev/null +++ b/.opencode/skills/packmind-onboard/packmind-versions/0.23.0/create-package.md @@ -0,0 +1,18 @@ +## Create Package (CLI 0.23.0) + +### Select a space + +List available spaces: + +```bash +packmind-cli spaces list +``` + +- **Multiple spaces**: ask the user which space to use via AskUserQuestion, then remember the chosen slug as `<space-slug>`. +- **Single space**: use it directly — no user prompt needed. + +### Create the package + +```bash +packmind-cli packages create "<package-name>" --space <space-slug> +``` diff --git a/.opencode/skills/packmind-onboard/packmind-versions/0.23.0/list-packages.md b/.opencode/skills/packmind-onboard/packmind-versions/0.23.0/list-packages.md new file mode 100644 index 000000000..b9796aaba --- /dev/null +++ b/.opencode/skills/packmind-onboard/packmind-versions/0.23.0/list-packages.md @@ -0,0 +1,5 @@ +## List Packages (CLI 0.23.0) + +```bash +packmind-cli packages list +``` diff --git a/.opencode/skills/packmind-onboard/packmind-versions/0.23.0/select-package.md b/.opencode/skills/packmind-onboard/packmind-versions/0.23.0/select-package.md new file mode 100644 index 000000000..aec8f309d --- /dev/null +++ b/.opencode/skills/packmind-onboard/packmind-versions/0.23.0/select-package.md @@ -0,0 +1,10 @@ +## Select Existing Package (CLI 0.23.0) + +Determine the space of the selected package: + +```bash +packmind-cli spaces list +``` + +- **Single space**: use it directly — set `$SPACE_SLUG` to its slug, no user prompt needed. +- **Multiple spaces**: ask the user via AskUserQuestion which space the selected package belongs to, then set `$SPACE_SLUG` to the chosen slug. diff --git a/.opencode/skills/packmind-onboard/references/ci-local-workflow-parity.md b/.opencode/skills/packmind-onboard/references/ci-local-workflow-parity.md new file mode 100644 index 000000000..188e6572d --- /dev/null +++ b/.opencode/skills/packmind-onboard/references/ci-local-workflow-parity.md @@ -0,0 +1,221 @@ +# CI / Local Workflow Parity + +Identify CI steps that cannot be run locally; propose "Pre-PR Quality Check" command. + +## Search Patterns + +### CI Configuration Files + +``` +# GitHub Actions +.github/workflows/*.yml +.github/workflows/*.yaml + +# GitLab CI +.gitlab-ci.yml +.gitlab-ci.yaml + +# CircleCI +.circleci/config.yml + +# Azure Pipelines +azure-pipelines.yml +azure-pipelines.yaml + +# Jenkins +Jenkinsfile + +# Travis CI +.travis.yml + +# Bitbucket Pipelines +bitbucket-pipelines.yml + +# Generic +ci.yml +ci.yaml +pipeline.yml +``` + +### Local Script Definitions + +``` +# Node.js +package.json (scripts section) +pnpm-workspace.yaml + +# Make +Makefile +makefile +GNUmakefile + +# Task runners +Taskfile.yml +Taskfile.yaml +Justfile +justfile + +# Python +pyproject.toml ([tool.poetry.scripts], [project.scripts]) +setup.py (entry_points) +tox.ini +noxfile.py + +# Ruby +Rakefile +bin/* + +# Go +Makefile +mage.go + +# Nx / Monorepo +nx.json +project.json +``` + +### Pre-commit Hooks + +``` +.husky/ +.husky/pre-commit +.husky/pre-push +.pre-commit-config.yaml +lefthook.yml +lint-staged.config.js +``` + +### Common CI Steps to Check + +``` +# Testing +npm test +npm run test +yarn test +pnpm test +pytest +go test +cargo test +dotnet test +mvn test +gradle test + +# Linting +npm run lint +eslint +prettier +pylint +flake8 +golangci-lint +cargo clippy +rubocop + +# Type checking +tsc --noEmit +mypy +pyright + +# Building +npm run build +yarn build +go build +cargo build +dotnet build +mvn package + +# Security scanning +npm audit +snyk +trivy +safety check +cargo audit + +# Code coverage +coverage +nyc +jest --coverage +codecov + +# E2E tests +cypress +playwright +selenium + +# Docker operations +docker build +docker-compose +``` + +## Analysis Method + +1. Parse local script definitions (package.json scripts, Makefile targets, etc.) +2. Parse CI config files (extract `run:` commands) +3. For each CI command: + - Check if equivalent exists locally + - Flag if no local entrypoint +4. Identify Docker-dependent steps that assume CI environment + +## Reporting Threshold + +Report only if: +- ≥1 meaningful CI step lacks local entrypoint + +## Insight Template + +``` +INSIGHT: + id: CILOCAL-[n] + title: "WORKFLOW GAP: [N] CI steps lack local entrypoints" + summary: "CI runs [steps] that cannot be easily reproduced locally." + confidence: [high|medium|low] + evidence: + ci_only_steps: + - step: "[command]" + ci_file: path[:line] + local_equivalent: "none" | "[partial match]" + local_scripts: + - path — defines [N] scripts +``` + +## Command Template + +When workflow gaps exist, propose: + +```yaml +name: "pre-pr-quality-check" +summary: "Run all CI checks locally before creating a PR" +whenToUse: + - "Before creating a pull request" + - "After completing a feature to verify CI will pass" + - "When CI fails and you want to debug locally" +contextValidationCheckpoints: + - "Are all dependencies installed?" + - "Is the development environment configured?" +steps: + - name: "Run linting" + description: "Execute lint checks" + codeSnippet: | + [extracted from CI or local scripts] + - name: "Run type checking" + description: "Verify type correctness" + codeSnippet: | + [extracted from CI or local scripts] + - name: "Run tests" + description: "Execute test suite" + codeSnippet: | + [extracted from CI or local scripts] + - name: "Run build" + description: "Verify build succeeds" + codeSnippet: | + [extracted from CI or local scripts] +``` + +## Gap Categories + +| Category | CI Example | Local Gap | +|----------|------------|-----------| +| **Security** | `npm audit --audit-level=high` | Often not in package.json scripts | +| **Coverage** | `--coverage --coverageThreshold` | Thresholds may differ locally | +| **E2E** | `cypress run` | May require specific env setup | +| **Docker** | `docker build` | Requires Docker daemon | +| **Secrets** | env var checks | Secrets not available locally | diff --git a/.opencode/skills/packmind-onboard/references/file-template-consistency.md b/.opencode/skills/packmind-onboard/references/file-template-consistency.md new file mode 100644 index 000000000..5ef3cefa6 --- /dev/null +++ b/.opencode/skills/packmind-onboard/references/file-template-consistency.md @@ -0,0 +1,180 @@ +# File Template Consistency + +Detect repeatable file templates and propose "Create X" commands for scaffolding. + +## Search Patterns + +### Common File Roles + +``` +# Controllers / Handlers +*Controller.ts +*Controller.js +*Handler.ts +*Handler.js +*controller.py +*_controller.rb +*Controller.java + +# Services +*Service.ts +*Service.js +*service.py +*_service.rb +*Service.java + +# Use Cases +*UseCase.ts +*UseCase.js +*Interactor.ts +*usecase.go +*_use_case.rb + +# Repositories +*Repository.ts +*Repository.js +*Repo.ts +*repository.py +*_repository.rb +*Repository.java + +# DTOs / Value Objects +*DTO.ts +*Dto.ts +*Request.ts +*Response.ts +*VO.ts +*ValueObject.ts + +# Mappers / Adapters +*Mapper.ts +*Adapter.ts +*Converter.ts + +# Components (Frontend) +*.component.tsx +*.component.ts +*Component.tsx +*Component.vue + +# Hooks (React) +use*.ts +use*.tsx +``` + +### Structure Markers to Extract + +``` +# Base classes / interfaces +extends Abstract +extends Base +implements I +implements Interface + +# Decorators / annotations +@Controller +@Injectable +@Service +@Repository +@Component +@Module +@Entity +@UseCase + +# Constructor injection +constructor( + private readonly + private final + @Inject + @Autowired + +# Standard methods +async execute( +async handle( +async run( +async invoke( +def execute( +def handle( +def call( + +# Required exports +export class +export default +export const +module.exports +``` + +### Directory Conventions + +``` +# Check for consistent placement +src/controllers/ +src/services/ +src/useCases/ +src/use-cases/ +src/application/ +src/domain/ +src/infra/ +src/infrastructure/ +src/repositories/ +src/handlers/ +``` + +## Analysis Method + +1. Identify file categories with ≥5 instances (by naming + directory) +2. Read 3-5 representative files per category +3. Extract shared structure: + - Base class/interface + - Required decorators + - Constructor pattern + - Standard method signatures + - Export pattern + +## Reporting Threshold + +Report only if: +- ≥5 files in category AND +- ≥3 share ≥2 structure markers + +## Insight Template + +``` +INSIGHT: + id: TMPL-[n] + title: "FILE PATTERN: [FileType] follows consistent template" + summary: "[N] [FileType] files share [markers]. Scaffolding can be automated." + confidence: [high|medium|low] + evidence: + - path[:line-line] — shows [marker] + template_markers: + - base_class: [name or none] + - decorators: [list] + - constructor_pattern: [description] + - required_methods: [list] + - export_pattern: [description] +``` + +## Command Template + +When a file pattern is detected, propose a command: + +```yaml +name: "create-[file-type]" +summary: "Scaffold a new [FileType] with standard structure" +whenToUse: + - "Adding a new [file-type] to the codebase" + - "Need consistent [file-type] structure" +contextValidationCheckpoints: + - "What is the name of the new [file-type]?" + - "Which module/domain does it belong to?" +steps: + - name: "Create file" + description: "Create [file-type] with standard template" + codeSnippet: | + [extracted template] + - name: "Add to index" + description: "Export from module index if pattern requires" + - name: "Create test file" + description: "Create corresponding test file" +``` diff --git a/.opencode/skills/packmind-onboard/references/role-taxonomy-drift.md b/.opencode/skills/packmind-onboard/references/role-taxonomy-drift.md new file mode 100644 index 000000000..0629128ab --- /dev/null +++ b/.opencode/skills/packmind-onboard/references/role-taxonomy-drift.md @@ -0,0 +1,199 @@ +# Role Taxonomy Drift + +Infer what Service/Handler/UseCase/Controller/Repository mean in practice and surface misnamed or mixed-responsibility hotspots. + +## Search Patterns + +### Common Role Names + +``` +# Controllers (HTTP/presentation) +*Controller.ts +*Controller.js +*controller.py +*_controller.rb +*Controller.java +*Controller.go + +# Handlers (event/command) +*Handler.ts +*Handler.js +*handler.py +*_handler.rb +*Handler.java + +# Services (business logic) +*Service.ts +*Service.js +*service.py +*_service.rb +*Service.java + +# Use Cases (application layer) +*UseCase.ts +*UseCase.js +*Interactor.ts +*use_case.py +*_use_case.rb + +# Repositories (data access) +*Repository.ts +*Repository.js +*Repo.ts +*repository.py +*_repository.rb +*Repository.java + +# Managers (ambiguous) +*Manager.ts +*Manager.js +*manager.py + +# Helpers/Utils (utility) +*Helper.ts +*Utils.ts +*helper.py +*_helper.rb +``` + +### Responsibility Indicators + +``` +# IO operations (should be in repos/adapters) +.save( +.find( +.delete( +.update( +fetch( +axios. +http. +database. +query( +.execute( + +# Business logic (should be in services/use cases) +validate +calculate +process +transform +apply +execute business rule + +# Presentation concerns (should be in controllers) +@Get( +@Post( +@Put( +@Delete( +res.json( +res.send( +response. +request. +@Query( +@Body( +@Param( + +# Event handling (should be in handlers) +@OnEvent( +@Subscribe( +.handle( +.process( +eventEmitter +``` + +### Mixed Responsibility Indicators + +``` +# Controller doing business logic +Controller.*{ + .*validate.* + .*calculate.* + .*process.* + +# Service doing IO directly +Service.*{ + .*\.save\( + .*\.find\( + .*fetch\( + .*database\. + +# Repository doing business logic +Repository.*{ + .*validate.* + .*calculate.* + .*transform.* + +# Handler doing presentation +Handler.*{ + .*res\.json\( + .*response\. +``` + +## Analysis Method + +1. **Enumerate files by role name**: Group by Controller/Service/UseCase/etc. +2. **Sample and analyze**: Read 3-5 files per role +3. **Classify actual responsibilities**: + - Presentation: HTTP handling, request/response + - Business logic: Validation, calculation, rules + - Data access: Persistence, external calls + - Orchestration: Coordinating other components +4. **Compare name vs actual**: Does "Service" do service things? +5. **Find mixed responsibilities**: Single file doing multiple concerns + +## Expected Responsibilities + +| Role | Expected | Red Flags | +|------|----------|-----------| +| **Controller** | HTTP handling, request mapping | Business logic, direct DB access | +| **Service** | Business logic, orchestration | HTTP concerns, raw queries | +| **UseCase** | Single business operation | Multiple concerns, infrastructure | +| **Handler** | Event/command processing | HTTP responses | +| **Repository** | Data access abstraction | Business rules, validation | +| **Manager** | Ambiguous - investigate | Often a code smell | + +## Drift Categories + +| Drift Type | Example | Impact | +|------------|---------|--------| +| **Bloated controller** | Controller with business logic | Hard to test, coupled | +| **Anemic service** | Service just delegates | Unnecessary layer | +| **Fat repository** | Repo with business rules | Logic in wrong layer | +| **Confused handler** | Handler doing everything | Unclear boundaries | +| **God manager** | Manager with all concerns | Unmaintainable | + +## Reporting Threshold + +Report only if: +- ≥3 files with same role name AND +- (Inconsistent responsibilities OR mixed concerns detected) + +## Insight Template + +``` +INSIGHT: + id: ROLE-[n] + title: "ROLE TAXONOMY: [role] has inconsistent meaning across codebase" + summary: "[role] files show [N] different responsibility patterns." + confidence: [high|medium|low] + evidence: + role_analysis: + - role: "Service" + expected: "business logic" + actual_patterns: + - "business logic" — [N] files + - "data access" — [N] files (drift) + - "HTTP handling" — [N] files (drift) + mixed_responsibility_hotspots: + - path[:line] — [role] doing [unexpected concern] + naming_inconsistencies: + - "UserService vs UserManager" — same responsibility + recommendations: + - "[file] should be renamed to [better name]" +``` + +## Standard/Command Suggestions + +- **Standard**: "Controllers handle HTTP only, delegate to use cases" +- **Standard**: "Services contain business logic, no IO" +- **Standard**: "Repositories abstract data access only" +- **Command**: "Extract business logic from controller" diff --git a/.opencode/skills/packmind-onboard/references/test-data-construction.md b/.opencode/skills/packmind-onboard/references/test-data-construction.md new file mode 100644 index 000000000..55691efc8 --- /dev/null +++ b/.opencode/skills/packmind-onboard/references/test-data-construction.md @@ -0,0 +1,136 @@ +# Test Data Construction Patterns + +Determine how tests construct data: helpers/builders, inline literals, fixtures, or mixed. + +## Search Patterns + +### Test File Locations + +``` +# Directories +test/ +tests/ +__tests__/ +spec/ +specs/ + +# File patterns +*.test.ts +*.test.js +*.test.tsx +*.test.jsx +*.spec.ts +*.spec.js +*.spec.tsx +*.spec.jsx +*_test.go +*_test.py +test_*.py +*Test.java +*Spec.scala +``` + +### Helper/Builder Patterns + +``` +# Factory functions +Factory +factory( +createMock +buildMock +make( +build( +generate( +fake( +stub( + +# Builder patterns +Builder +.build() +.create() +.with( +.having( + +# Test data libraries +faker +Faker +@faker-js +factory-girl +fishery +test-data-bot +FactoryBot +factory_bot +Fabricator +``` + +### Fixture/Seed Patterns + +``` +# Fixture files +fixtures/ +__fixtures__/ +seeds/ +mocks/ +stubs/ + +# Fixture loading +loadFixture +readFixture +importFixture +fixture( +seed( +``` + +### Inline Construction Indicators + +``` +# Direct object creation in tests +const mock = { +let testData = { +const input = { +new TestEntity( +Object.assign( +{ ...baseData, +``` + +## Classification Criteria + +| Pattern | Indicators | +|---------|------------| +| **Helpers/builders** | Dedicated factory functions reused across ≥3 test files | +| **Inline** | Objects created directly in test bodies; no shared helpers | +| **Fixtures** | External JSON/YAML files or fixture directories | +| **Mixed** | Multiple patterns without dominant approach | + +## Sampling Method + +1. List test files by path, sort ascending +2. Sample first 10 files (or all if <10) +3. For each file, classify primary data construction method +4. Detect shared builders: same helper imported in ≥3 tests + +## Reporting Threshold + +Report only if: +- ≥2 patterns appear in sample, OR +- Shared builder exists but many tests still use inline (inconsistency) + +## Insight Template + +``` +INSIGHT: + id: TEST-[n] + title: "TEST DATA: construction patterns vary ([X]% helpers, [Y]% inline, [Z]% fixtures)" + summary: "Test data is constructed using [dominant pattern]. [inconsistencies if any]" + confidence: [high|medium|low] + evidence: + - path[:line-line] — uses [pattern] + exceptions: + - path[:line-line] — diverges from dominant pattern +``` + +## Standard/Command Suggestions + +- **Standard**: "Use test factories for domain entities" (if inline dominant but factories exist) +- **Standard**: "Prefer builder pattern for complex test data" (if mixed patterns) +- **Command**: "Create test factory" (if helpers pattern established) diff --git a/.opencode/skills/packmind-update-playbook/LICENSE.txt b/.opencode/skills/packmind-update-playbook/LICENSE.txt new file mode 100644 index 000000000..f433b1a53 --- /dev/null +++ b/.opencode/skills/packmind-update-playbook/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/.opencode/skills/packmind-update-playbook/SKILL.md b/.opencode/skills/packmind-update-playbook/SKILL.md new file mode 100644 index 000000000..e9efc2323 --- /dev/null +++ b/.opencode/skills/packmind-update-playbook/SKILL.md @@ -0,0 +1,143 @@ +--- +name: 'packmind-update-playbook' +description: 'Use when updating, adding, fixing, changing, or deprecating Packmind playbook artifacts (standards, commands, skills). Triggers on explicit phrases like "update packmind standard", "add a packmind skill", "fix packmind command", "change packmind playbook", "deprecate a standard". Also triggers — even without an explicit request — whenever the conversation reveals an opportunity: a new coding convention was just agreed on, a recurring pattern emerged, a workflow changed, a rule was found outdated, or the user says things like "we always do X", "let us remember to Y", "that is the pattern we use". If there is any chance the conversation established a convention or exposed a gap, invoke this skill proactively. This skill defines a mandatory workflow: do NOT edit artifact files directly — follow all phases regardless of change size.' +--- + +# Update Playbook +Run "packmind-cli --version" to get the current cli installation. + +Find the highest version at or below the cli version in this list: +- 0.21.0 +- 0.23.0 +Remember this value as $PACKMIND_CLI_VERSION for the rest of the skill. + +Evaluate the user's intent against existing Packmind artifacts (standards, commands, skills) to identify what needs creating or updating. Produce a structured change report, then apply approved changes using the `playbook` CLI workflow. + +**⚠️ MANDATORY WORKFLOW — This skill defines a strict sequence: Understanding Your Request → Summarizing Changes → Analyzing Playbook → Change Report → Applying Changes. Do NOT skip steps or edit artifact files directly. Even for a single-line change, follow every step. The workflow ensures changes are reviewed, approved, submitted, and propagated correctly.** + +## **Understanding Your Request** + +**STOP. This phase runs FIRST, before anything else. No file reads, no CLI commands, no subagents until this gate passes.** + +Analyze the user's input and conversation context to determine intent: + +#### Case A: No prior conversation / empty input + +The skill was invoked standalone with no context. Ask: + +"What Packmind artifact do you want to modify? For example: a **standard** (coding rule/convention), a **command** (multi-step workflow), or a **skill** (specialized capability). Please describe what you'd like to change." + +**BLOCK** — do not proceed until the user responds. + +#### Case B: Explicit intent found + +The user explicitly asked to update, add, fix, or change a Packmind artifact. Extract an **intent summary**: +- **Target artifact(s)**: which standard(s), command(s), or skill(s) to modify (or "new") +- **Kind of change**: create or update +- **Specifics**: any details the user provided about the change + +Proceed to Summarizing Changes with this validated intent. + +#### Case C: Opportunity detected from conversation + +The conversation reveals a playbook update opportunity — e.g., a convention was established, a pattern emerged, a workflow was changed, or a known artifact is now stale — but the user did not explicitly ask for a playbook update. Summarize the opportunity and ask: + +"I noticed an opportunity to update the Packmind playbook: **<brief description>**. Would you like me to run the update workflow?" + +**BLOCK** — do not proceed until the user confirms. + +#### Case D: No intent and no opportunity + +If the conversation contains no references to modifying Packmind artifacts and no detectable update opportunity, tell the user: + +"I didn't detect any intent or opportunity to modify the Packmind playbook. What artifact would you like to update — a standard, command, or skill? Please describe the change." + +**BLOCK** — do not proceed until the user responds. + +### Summarizing Changes + +> Only proceed after Understanding Your Request validates intent (explicit request or confirmed opportunity). + +Summarize the validated intent before launching any subagents. Extract: +- Which artifact(s) the user wants to modify and what kind of change +- Any specifics the user provided about the desired change +- If prior conversation exists, relevant context that supports the intent (patterns observed, decisions made, problems encountered) + +This intent summary is passed as input to all subagents. + +### Analyzing Playbook + +> **CLI health check**: Before launching subagents, run `packmind-cli --version`. If it fails, stop immediately and tell the user: "The Packmind CLI is not available or not working. Please check your installation before proceeding." Do not continue. + +> **No subagent support?** If the `Task` tool is unavailable, perform all three domain analyses sequentially in the current session — run each `steps/analyze-*.md` analysis one after another before proceeding to Change Report. + +Launch all three as `Task(general-purpose)` subagents **simultaneously** — do not wait for one before starting the others. Each subagent handles its own listing, filtering, and deep analysis in one pass. + +Construct each prompt as: + +``` +## Validated Intent + +<the intent summary from Summarizing Changes> + +## Analysis Task + +<full contents of the corresponding steps/analyze-*.md file> +``` + +| Subagent | Step File | Output | +|----------|-----------|--------| +| Standards | `steps/analyze-standards.md` | Standards change report | +| Commands | `steps/analyze-commands.md` | Commands change report | +| Skills | `steps/analyze-skills.md` | Skills change report | + +For each domain, decide whether to launch or skip based on the validated intent's **target artifact type**: +- **Launch** if the intent mentions or affects that artifact type (standard, command, or skill) +- **Always launch skills** — skill accuracy must be checked against any behavioral change +- **Limit scope** to the targeted artifact type when the intent is explicit and narrow (e.g., "update standard X" → standards only, no commands or unrelated skills) + +### Change Report + +After all subagents complete, consolidate their reports. **Before numbering, deduplicate**: if multiple subagents propose modifying the same artifact, merge those into one entry combining both rationales — do not list the same artifact twice. **Number every change sequentially** so the user can selectively approve: + +``` +## Playbook Change Report + +<!-- Only include sections that have changes. Omit empty sections entirely. --> +<!-- Ordering reflects priority: skill accuracy first, then standards, then commands. --> +<!-- New commands have a high bar — see domain-commands.md for criteria. --> + +### Skill Updates +1. [skill] <name>: <what changed and why> + +### New Skills +2. [skill] <name>: <reason> + +### Standard Updates +3. [standard] <name>: <what changed and why> + +### New Standards +4. [standard] <name>: <reason> + +### New Commands +5. [command] <name>: <reason> + +### Command Updates +6. [command] <name>: <what changed and why> +``` + +**Only include sections that have actual changes** — omit empty sections entirely. Order by priority: skills first, then standards, then commands. + +Present this report and ask the user for approval: +- **Single change**: ask "Do you accept this change?" +- **Multiple changes**: ask "Which changes to apply?" and accept: + - **All**: apply every numbered change + - **Inclusion list**: "1, 3, 5" or "only 2 and 6" + - **Exclusion list**: "all but 4" or "everything except 2, 7" + +### Applying Changes + +Follow the procedure in [`packmind-versions/<$PACKMIND_CLI_VERSION>/apply-changes.md`](packmind-versions/<$PACKMIND_CLI_VERSION>/apply-changes.md). +Pass it the list of approved changes (with their numbers and details) from the Change Report above. + + diff --git a/.opencode/skills/packmind-update-playbook/packmind-versions/0.21.0/apply-changes.md b/.opencode/skills/packmind-update-playbook/packmind-versions/0.21.0/apply-changes.md new file mode 100644 index 000000000..7de5104a2 --- /dev/null +++ b/.opencode/skills/packmind-update-playbook/packmind-versions/0.21.0/apply-changes.md @@ -0,0 +1,50 @@ +# Applying Changes + +> **Prerequisite**: Run `packmind-cli --version`. If it fails, stop immediately and tell the user: "The Packmind CLI is not available or not working. Please check your installation before proceeding." Do not continue. + +## Step 1: Write new artifacts + +For each approved **new** artifact, read the corresponding creation procedure from `references/`, then write the file(s) at the specified location: + +| Artifact Type | Creation Procedure | Write Path | +|---|---|---| +| Standard | [create-standard-procedure.md](../references/create-standard-procedure.md) | `.packmind/standards/<slug>.md` | +| Command | [create-command-procedure.md](../references/create-command-procedure.md) | `.packmind/commands/<slug>.md` | +| Skill | [create-skill-procedure.md](../references/create-skill-procedure.md) | `<agent-skills-dir>/<skill-name>/SKILL.md` | + +For skills: check which agent skills directory exists at the project root (`.claude/skills/`, `.cursor/skills/`, `.github/skills/`) — pick the first found in that priority order. If none exist, create `.claude/skills/`. + +After writing each new artifact, run `packmind-cli diff add <path> -m "<description>"` to submit it as a change proposal. This auto-submits the new artifact. The message must be non-empty and max 1024 characters. If this command fails, show the full error output, stop, and ask the user how to proceed — do not retry silently. + +## Step 2: Preview updates + +For each approved **update** to an existing artifact, edit the local installed files directly. Search the project root **and all subdirectories** (e.g. `src/backend/.cursor/skills/`, `packages/api/.packmind/standards/`): + +- **Standards**: `**/.packmind/standards/<slug>.md` (source of truth). Installed copies also exist in: + - Claude Code: `**/.claude/rules/packmind/` + - Cursor: `**/.cursor/rules/packmind/` + - GitHub Copilot: `**/.github/instructions/packmind-*` +- **Commands**: `**/.packmind/commands/<slug>.md` (source of truth). Installed copies also exist in: + - Claude Code: `**/.claude/commands/` + - Cursor: `**/.cursor/commands/` + - GitHub Copilot: `**/.github/prompts/` +- **Skills**: no `.packmind/` source — skills live directly in agent directories: + - Claude Code: `**/.claude/skills/<skill-name>/` + - Cursor: `**/.cursor/skills/<skill-name>/` + - GitHub Copilot: `**/.github/skills/<skill-name>/` + +If the same artifact exists in multiple agent directories, edit the one matching the current session context: Claude Code → `.claude/`, Cursor → `.cursor/`, GitHub Copilot → `.github/`. If the context is unclear and multiple directories exist, list them and ask the user which agent directory to update. + +Run `packmind-cli diff` and present the output. List all artifacts included in the diff. Since it is not possible to select individual changes, **all detected modifications will be submitted together**. + +- If the diff contains only the intended changes, proceed to Step 3. +- If the diff contains **additional or unrelated artifacts**, inform the user by listing them and clarifying that they will be included in the submission as well. + +## Step 3: Submit updates + +Run `packmind-cli diff --submit -m "<concise summary of all changes>"` to submit the changes as proposals for human review on Packmind. If this command fails, show the full error output, stop, and ask the user how to proceed — do not retry silently. + +Once submitted, run `packmind-cli whoami` and extract the `Organization:` field from the output. Construct the review URL as `https://app.packmind.ai/org/<organization>/space/global/review-changes/`. + +Tell the user: **"✅ Successfully sent to Packmind for review!"** +Then add in italics: *"Review and accept your change proposals at <constructed-url> — once accepted, changes will be propagated and will replace all local copies."* diff --git a/.cursor/skills/packmind-update-playbook-v2/packmind-versions/0.24.0/apply-changes.md b/.opencode/skills/packmind-update-playbook/packmind-versions/0.23.0/apply-changes.md similarity index 93% rename from .cursor/skills/packmind-update-playbook-v2/packmind-versions/0.24.0/apply-changes.md rename to .opencode/skills/packmind-update-playbook/packmind-versions/0.23.0/apply-changes.md index 6eeb30147..2f00ba50d 100644 --- a/.cursor/skills/packmind-update-playbook-v2/packmind-versions/0.24.0/apply-changes.md +++ b/.opencode/skills/packmind-update-playbook/packmind-versions/0.23.0/apply-changes.md @@ -42,6 +42,11 @@ Determine which agent context you are running in. The agent directories are: **Important**: Packmind packages can be installed in subdirectories, not just the repo root. Search for `**/packmind-lock.json` across the entire project tree to find all installed locations. Each lock file lists all files per artifact with their agent — use the path matching your agent. +Before writing or editing any artifact, read the corresponding creation procedure for content format and structure guidance: +- Standard: [create-standard-procedure.md](../../references/create-standard-procedure.md) +- Command: [create-command-procedure.md](../../references/create-command-procedure.md) +- Skill: [create-skill-procedure.md](../../references/create-skill-procedure.md) + **For updated artifacts**, find and edit the file at your agent's path. The lock file tells you the exact relative path. Remember that artifacts may live in nested project directories (e.g. `packages/api/.claude/rules/packmind/`, `apps/backend/.claude/commands/`). **For new artifacts**, write files at the agent-specific location within the directory that contains the relevant `packmind-lock.json`: diff --git a/.opencode/skills/packmind-update-playbook/references/agent-skills-specification.md b/.opencode/skills/packmind-update-playbook/references/agent-skills-specification.md new file mode 100644 index 000000000..142fbbc38 --- /dev/null +++ b/.opencode/skills/packmind-update-playbook/references/agent-skills-specification.md @@ -0,0 +1,255 @@ + +> ## Documentation Index +> Fetch the complete documentation index at: https://agentskills.io/llms.txt +> Use this file to discover all available pages before exploring further. + +# Specification + +> The complete format specification for Agent Skills. + +This document defines the Agent Skills format. + +## Directory structure + +A skill is a directory containing at minimum a `SKILL.md` file: + +``` +skill-name/ +└── SKILL.md # Required +``` + +<Tip> + You can optionally include [additional directories](#optional-directories) such as `scripts/`, `references/`, and `assets/` to support your skill. +</Tip> + +## SKILL.md format + +The `SKILL.md` file must contain YAML frontmatter followed by Markdown content. + +### Frontmatter (required) + +```yaml theme={null} +--- +name: skill-name +description: A description of what this skill does and when to use it. +--- +``` + +With optional fields: + +```yaml theme={null} +--- +name: pdf-processing +description: Extract text and tables from PDF files, fill forms, merge documents. +license: Apache-2.0 +metadata: + author: example-org + version: "1.0" +--- +``` + +| Field | Required | Constraints | +| --------------- | -------- | ----------------------------------------------------------------------------------------------------------------- | +| `name` | Yes | Max 64 characters. Lowercase letters, numbers, and hyphens only. Must not start or end with a hyphen. | +| `description` | Yes | Max 1024 characters. Non-empty. Describes what the skill does and when to use it. | +| `license` | No | License name or reference to a bundled license file. | +| `compatibility` | No | Max 500 characters. Indicates environment requirements (intended product, system packages, network access, etc.). | +| `metadata` | No | Arbitrary key-value mapping for additional metadata. | +| `allowed-tools` | No | Space-delimited list of pre-approved tools the skill may use. (Experimental) | + +#### `name` field + +The required `name` field: + +* Must be 1-64 characters +* May only contain unicode lowercase alphanumeric characters and hyphens (`a-z` and `-`) +* Must not start or end with `-` +* Must not contain consecutive hyphens (`--`) +* Must match the parent directory name + +Valid examples: + +```yaml theme={null} +name: pdf-processing +``` + +```yaml theme={null} +name: data-analysis +``` + +```yaml theme={null} +name: code-review +``` + +Invalid examples: + +```yaml theme={null} +name: PDF-Processing # uppercase not allowed +``` + +```yaml theme={null} +name: -pdf # cannot start with hyphen +``` + +```yaml theme={null} +name: pdf--processing # consecutive hyphens not allowed +``` + +#### `description` field + +The required `description` field: + +* Must be 1-1024 characters +* Should describe both what the skill does and when to use it +* Should include specific keywords that help agents identify relevant tasks + +Good example: + +```yaml theme={null} +description: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs. Use when working with PDF documents or when the user mentions PDFs, forms, or document extraction. +``` + +Poor example: + +```yaml theme={null} +description: Helps with PDFs. +``` + +#### `license` field + +The optional `license` field: + +* Specifies the license applied to the skill +* We recommend keeping it short (either the name of a license or the name of a bundled license file) + +Example: + +```yaml theme={null} +license: Proprietary. LICENSE.txt has complete terms +``` + +#### `compatibility` field + +The optional `compatibility` field: + +* Must be 1-500 characters if provided +* Should only be included if your skill has specific environment requirements +* Can indicate intended product, required system packages, network access needs, etc. + +Examples: + +```yaml theme={null} +compatibility: Designed for Claude Code (or similar products) +``` + +```yaml theme={null} +compatibility: Requires git, docker, jq, and access to the internet +``` + +<Note> + Most skills do not need the `compatibility` field. +</Note> + +#### `metadata` field + +The optional `metadata` field: + +* A map from string keys to string values +* Clients can use this to store additional properties not defined by the Agent Skills spec +* We recommend making your key names reasonably unique to avoid accidental conflicts + +Example: + +```yaml theme={null} +metadata: + author: example-org + version: "1.0" +``` + +#### `allowed-tools` field + +The optional `allowed-tools` field: + +* A space-delimited list of tools that are pre-approved to run +* Experimental. Support for this field may vary between agent implementations + +Example: + +```yaml theme={null} +allowed-tools: Bash(git:*) Bash(jq:*) Read +``` + +### Body content + +The Markdown body after the frontmatter contains the skill instructions. There are no format restrictions. Write whatever helps agents perform the task effectively. + +Recommended sections: + +* Step-by-step instructions +* Examples of inputs and outputs +* Common edge cases + +Note that the agent will load this entire file once it's decided to activate a skill. Consider splitting longer `SKILL.md` content into referenced files. + +## Optional directories + +### scripts/ + +Contains executable code that agents can run. Scripts should: + +* Be self-contained or clearly document dependencies +* Include helpful error messages +* Handle edge cases gracefully + +Supported languages depend on the agent implementation. Common options include Python, Bash, and JavaScript. + +### references/ + +Contains additional documentation that agents can read when needed: + +* `REFERENCE.md` - Detailed technical reference +* `FORMS.md` - Form templates or structured data formats +* Domain-specific files (`finance.md`, `legal.md`, etc.) + +Keep individual [reference files](#file-references) focused. Agents load these on demand, so smaller files mean less use of context. + +### assets/ + +Contains static resources: + +* Templates (document templates, configuration templates) +* Images (diagrams, examples) +* Data files (lookup tables, schemas) + +## Progressive disclosure + +Skills should be structured for efficient use of context: + +1. **Metadata** (\~100 tokens): The `name` and `description` fields are loaded at startup for all skills +2. **Instructions** (\< 5000 tokens recommended): The full `SKILL.md` body is loaded when the skill is activated +3. **Resources** (as needed): Files (e.g. those in `scripts/`, `references/`, or `assets/`) are loaded only when required + +Keep your main `SKILL.md` under 500 lines. Move detailed reference material to separate files. + +## File references + +When referencing other files in your skill, use relative paths from the skill root: + +```markdown theme={null} +See [the reference guide](references/REFERENCE.md) for details. + +Run the extraction script: +scripts/extract.py +``` + +Keep file references one level deep from `SKILL.md`. Avoid deeply nested reference chains. + +## Validation + +Use the [skills-ref](https://github.com/agentskills/agentskills/tree/main/skills-ref) reference library to validate your skills: + +```bash theme={null} +skills-ref validate ./my-skill +``` + +This checks that your `SKILL.md` frontmatter is valid and follows all naming conventions. diff --git a/.opencode/skills/packmind-update-playbook/references/create-command-procedure.md b/.opencode/skills/packmind-update-playbook/references/create-command-procedure.md new file mode 100644 index 000000000..e891b9a2a --- /dev/null +++ b/.opencode/skills/packmind-update-playbook/references/create-command-procedure.md @@ -0,0 +1,102 @@ +# Create Command Procedure + +Write a new command file locally at `.packmind/commands/<slug>.md`. + +## Slug Derivation + +Derive the slug from the command title: lowercase, replace spaces with hyphens, remove special characters. + +**Example**: "Create API Endpoint" → `create-api-endpoint` + +**Write path**: `.packmind/commands/<slug>.md` + +**Directory check**: If `.packmind/commands/` does not exist, create it before writing the file. + +## File Format + +Use YAML frontmatter with a `description:` field, followed by the command body: + +```markdown +--- +description: 'One-sentence description of the command purpose and when to use it.' +--- + +Opening paragraph describing what this command does and its scope. + +## When to Use + +* When scenario 1 + +* When scenario 2 + +## Context Validation Checkpoints + +* [ ] What is the key input or configuration needed? + +* [ ] Does the target directory or file already exist? + +* [ ] Are there any prerequisites that must be met? + +* [ ] Is the current state clean enough to proceed? + +## Steps + +### Step 1: Verify prerequisites + +Explanatory text describing what this step does and why. + +\`\`\`language +// code example showing exactly what to do +\`\`\` + +### Step 2: Create core implementation + +Explanatory text describing what this step does and why. + +\`\`\`language +// code example showing exactly what to do +\`\`\` +``` + +### Critical Format Constraints + +- **File must be non-empty** — the parser rejects empty files +- **Without frontmatter**, the display name is auto-derived from the filename (only the first character is capitalized, e.g. `create-api-endpoint` → "Create-api-endpoint") +- **Use frontmatter `description:`** to provide a one-sentence summary of the command's purpose and when to use it +- The command body content is stored as-is — the parser is permissive about structure + +## Command Writing Guidelines + +### Body Structure + +1. **Opening paragraph** (required) — one paragraph directly after frontmatter, no `# H1` heading. Describes what the command does and its scope. +2. **"When to Use"** section (optional for procedural commands) — 2-6 bullet points using the "When..." pattern (e.g., `* When adding support for a new AI coding assistant`). +3. **"Context Validation Checkpoints"** section (optional for procedural commands) — 4-6 `* [ ]` checkbox questions the agent should verify before executing. +4. **"Steps"** section (required) — the core of the command. + +### Step Writing + +- Use `### Step N: Verb Object` format (e.g., `### Step 1: Add RenderMode enum value`, `### Step 5: Register deployer in registry`) +- Always start the step title with a verb: Add, Create, Implement, Write, Register, Update, Verify, Export +- Target 6-16 steps for complex commands +- Every step includes explanatory text followed by a code example showing exactly what to do +- End with a verification or quality gate step when applicable (e.g., "Verify link paths", "Run integration tests") + +### Template Variables + +- Use `{{variable}}` syntax for user-provided values (e.g., `{{version}}`, `{{today_date}}`) +- Use sparingly — only for values that change per invocation + +### Cross-References + +- Reference existing files and patterns as concrete examples to follow +- Use actual file paths from the codebase (e.g., `packages/types/src/deployments/RenderMode.ts`) +- Point to existing implementations as models (e.g., "Follow the pattern of existing integration tests like `cursor-deployment.spec.ts`") + +### Variant: Procedural Commands + +Simpler structure for release-style or operational commands: +- Skip "When to Use" and "Context Validation Checkpoints" sections +- Use numbered list steps (`1. **Step name**: description`) instead of `###` headers +- Include code blocks inline within list items where needed +- See `release-app.md` as an example of this pattern diff --git a/.opencode/skills/packmind-update-playbook/references/create-skill-procedure.md b/.opencode/skills/packmind-update-playbook/references/create-skill-procedure.md new file mode 100644 index 000000000..b1b2b84f2 --- /dev/null +++ b/.opencode/skills/packmind-update-playbook/references/create-skill-procedure.md @@ -0,0 +1,97 @@ +# Create Skill Procedure + +Write a new skill directory locally in an agent skills directory. + +## Write Path Selection + +Skills are stored in agent-specific directories: +- `.claude/skills/` (agent: claude) +- `.cursor/skills/` (agent: cursor) +- `.github/skills/` (agent: copilot) + +**Selection logic**: check which directories exist at the project root. Pick the first one found in priority order: +1. `.claude/skills/` +2. `.cursor/skills/` +3. `.github/skills/` + +If none exist, create `.claude/skills/` as the default. + +**Write path**: `<agent-skills-dir>/<skill-name>/SKILL.md` + +## Slug Derivation + +Derive the skill name from the title: lowercase, replace spaces with hyphens, remove special characters. + +**Example**: "PDF Processing" → `pdf-processing` + +**Naming rules**: +- 1-64 characters +- Lowercase letters, numbers, and hyphens only +- Must not start or end with a hyphen +- Must not contain consecutive hyphens (`--`) +- Directory name MUST match the `name` field in SKILL.md frontmatter + +## Directory Structure + +``` +<skill-name>/ +├── SKILL.md # Required +├── references/ # Optional — detailed docs, specs, lookup tables +├── scripts/ # Optional — executable code agents can run +└── assets/ # Optional — templates, images, data files +``` + +**When to use each resource type:** +- **scripts/**: Executable code for tasks requiring deterministic reliability or repeatedly rewritten code (e.g. `scripts/rotate_pdf.py`). Token-efficient — may be executed without loading into context. +- **references/**: Documentation loaded into context as needed (schemas, API docs, domain knowledge). Keeps SKILL.md lean — avoid duplicating content between SKILL.md and references. +- **assets/**: Files used in output (templates, images, boilerplate) — not loaded into context, copied or modified by Claude during execution. + +## SKILL.md Format + +```markdown +--- +name: skill-name +description: 'What the skill does and when to use it. Third-person form. Include trigger keywords for auto-load.' +--- + +# Skill Title + +Body content with instructions, examples, and edge cases. +``` + +### Critical Format Constraints + +- **Directory name must match `name` field** — e.g. directory `pdf-processing/` requires `name: pdf-processing` +- **`name`**: 1-64 chars, lowercase + hyphens only, no leading/trailing/consecutive hyphens +- **`description`**: 1-800 chars (Packmind cap, tighter than the 1024 spec maximum), include specific keywords that help agents identify when to activate +- **Keep SKILL.md under 500 lines** — use `references/` for detailed content overflow +- **Both the directory path and the `SKILL.md` path** are valid references to a skill (they resolve to the same directory) + +### Description Writing Guidelines + +The description determines when the skill auto-loads. Write it to: +- **Stay within 800 characters.** This is a Packmind cap (the upstream spec allows 1024). The description is always loaded into agent context, so keep it lean. If you need more room to explain the skill, move detail into the SKILL.md body or a `references/` file. +- Describe what the skill does in **third-person form** (e.g. "This skill should be used when..." not "Use this skill when...") +- Include trigger phrases the user might say (e.g. "extract text from PDF", "fill PDF forms") +- Mention relevant keywords for discoverability + +**Good**: `'Extract text and tables from PDF files, fill PDF forms, and merge multiple PDFs. Use when working with PDF documents or when the user mentions PDFs, forms, or document extraction.'` + +**Poor**: `'Helps with PDFs.'` + +### Writing Style + +- Use **imperative/infinitive form** (verb-first instructions), not second person (e.g. "To accomplish X, do Y" not "You should do X") +- Write for another Claude instance — focus on non-obvious procedural knowledge, not general capabilities +- **File placement rule**: create files directly in their target subdirectory (`references/`, `scripts/`, `assets/`), never at skill root then move + +### Body Content Guidelines + +- Provide step-by-step instructions for the main workflow +- Include examples of inputs and outputs +- Document common edge cases +- **Progressive disclosure**: metadata (~100 words, always in context) → SKILL.md body (<5k words, loaded on trigger) → bundled resources (loaded as needed). Structure content accordingly. +- **No duplication**: information should live in either SKILL.md or references, not both. Keep SKILL.md lean with essential workflow guidance; move detailed schemas, specs, and examples to `references/`. +- **Large references**: if reference files exceed ~10k words, include grep search patterns in SKILL.md so Claude can locate relevant sections without loading the entire file + +For the complete format specification, see [agent-skills-specification.md](agent-skills-specification.md). diff --git a/.opencode/skills/packmind-update-playbook/references/create-standard-procedure.md b/.opencode/skills/packmind-update-playbook/references/create-standard-procedure.md new file mode 100644 index 000000000..70362d93a --- /dev/null +++ b/.opencode/skills/packmind-update-playbook/references/create-standard-procedure.md @@ -0,0 +1,62 @@ +# Create Standard Procedure + +Write a new standard file locally at `.packmind/standards/<slug>.md`. + +## Slug Derivation + +Derive the slug from the standard title: lowercase, replace spaces with hyphens, remove special characters. + +**Example**: "TypeScript Testing Conventions" → `typescript-testing-conventions` + +**Write path**: `.packmind/standards/<slug>.md` + +**Directory check**: If `.packmind/standards/` does not exist, create it before writing the file. + +## File Format + +The parser expects this exact structure: + +```markdown +# Standard Name + +Description text here. Explain what this standard covers and why it matters. + +## Rules + +* Rule 1 +* Rule 2 +* Rule 3 +``` + +### Critical Format Constraints + +- **No YAML frontmatter** — the file is pure markdown +- **Only `* ` or `- ` bullet rules are parsed** — `### Rule` subsections are NOT supported by the parser +- **`## Scope` is NOT parsed** (always returns empty string) — do not include it +- **Must have at least one real rule** — the parser rejects empty rules and silently filters out the placeholder text "No rules defined yet." +- The `## Rules` heading is recommended for clarity but technically optional — the parser finds rules from the first `* `/`- ` bullet even without it + +## Rule Writing Guidelines + +Each rule should: +- **Start with a verb** (imperative mood): "Use", "Prefer", "Avoid", "Return", "Define", "Extract" +- **Express one concept per rule** — split compound rules into separate bullets +- **Be concise** (~25 words max) — no rationale phrases like "because..." or "to ensure..." +- **Be actionable** — the developer should know exactly what to do +- **Avoid vague terms** — replace "appropriate", "properly", "correctly" with specifics + +### Good Rules + +```markdown +* Use `readonly` for properties that should not be reassigned after initialization +* Prefer named exports over default exports for better refactoring support +* Return early from functions to avoid deep nesting +``` + +### Bad Rules + +```markdown +* Make sure to properly handle errors (vague, no verb-first) +* Use TypeScript because it's safer and better (contains rationale) +* Functions should be small, well-named, and follow single responsibility (multiple concepts) +``` diff --git a/.opencode/skills/packmind-update-playbook/steps/analyze-commands.md b/.opencode/skills/packmind-update-playbook/steps/analyze-commands.md new file mode 100644 index 000000000..eaa338fba --- /dev/null +++ b/.opencode/skills/packmind-update-playbook/steps/analyze-commands.md @@ -0,0 +1,57 @@ +# Commands Domain Analysis + +Scan existing commands, identify which are relevant to the user's validated intent, then perform deep analysis on those in one pass. + +## What Commands Are + +Commands are reusable multi-step workflows distributed to AI coding agents. Each command has a name, summary, "when to use" list, context validation checkpoints, and numbered steps. Source files live in `**/.packmind/commands/<slug>.md`. Installed copies also exist in agent directories: +- Claude Code: `**/.claude/commands/` +- Cursor: `**/.cursor/commands/` +- GitHub Copilot: `**/.github/prompt/` +Search the project root and all subdirectories. + +## Instructions + +### Step 1: List Commands + +Run `packmind-cli commands list` to get slugs and names. Do NOT read individual command files yet. + +### Step 2: Filter Relevant Commands + +For each command in the list, ask: **Does the user's intent involve updating this command?** + +Relevant means: the intent explicitly targets this command, describes changes to its workflow steps, or references issues with its current behavior. Match by workflow topic using slug and name — no deep reading yet. + +Do NOT propose new commands — command creation is a deliberate, user-driven process. + +### Step 3: Deep Analyze Flagged Commands + +For each relevant command, read `**/.packmind/commands/<slug>.md`. Evaluate the command against the user's requested changes: +- Intent requests adding steps → propose adding them at the specified location +- Intent requests modifying steps → propose the specific modifications +- Intent requests removing steps → propose removal with rationale +- Intent requests updating "When to use" → propose the new triggers +- If conversation context exists, use it as supporting evidence for the evaluation + +Apply a HIGH BAR — only propose updates when there is strong evidence: +- The user's intent clearly describes a needed change +- A step references a tool, path, or API that no longer exists +- A critical gap is identified that the intent highlights + +Do NOT propose updates for minor wording or changes not supported by the user's intent. + +## Output Format + +```markdown +## Commands Change Report + +### Command Updates +(If none: "No updates needed.") + +#### Command Name (`<slug>`) +- **Reason**: what changed or what's missing +- **Steps to add**: step name — description (insert after step N) +- **Steps to modify**: Step N: old → new +- **Steps to remove**: Step N: "step name" — reason +- **Checkpoints to add**: checkpoint question? +``` diff --git a/.opencode/skills/packmind-update-playbook/steps/analyze-skills.md b/.opencode/skills/packmind-update-playbook/steps/analyze-skills.md new file mode 100644 index 000000000..524bcc3f9 --- /dev/null +++ b/.opencode/skills/packmind-update-playbook/steps/analyze-skills.md @@ -0,0 +1,77 @@ +# Skills Domain Analysis + +Scan existing skills, identify which are relevant to the user's validated intent, then perform deep analysis on those in one pass. + +## What Skills Are + +Skills are modular packages providing specialized knowledge and workflows. Each skill has a `SKILL.md` with YAML frontmatter (`name`, `description`) and markdown body, plus optional `references/`, `scripts/`, and `assets/`. The `description` field determines when the skill auto-loads. Skills live in a platform-specific agent directory, at the project root or in any subdirectory (e.g. `src/backend/.cursor/skills/`): +- Claude Code: `**/.claude/skills/<skill-name>/` +- Cursor: `**/.cursor/skills/<skill-name>/` +- GitHub Copilot: `**/.github/skills/<skill-name>/` +Search recursively for these directories. If multiple agent directories exist, pick one. + +For the complete format specification (frontmatter fields, naming rules, directory structure, progressive disclosure), see [agent-skills-specification.md](../references/agent-skills-specification.md). + +## Instructions + +### Step 1: List Skills + +Run `packmind-cli skills list` to get slugs, names, and descriptions. Do NOT read SKILL.md bodies or reference files yet. + +**Exclude default artifacts**: skip any skill whose slug starts with `packmind-`. These are managed by Packmind and must not be analyzed or modified by this workflow. + +### Step 2: Filter Relevant Skills + +For each skill in the list, ask: **Does the user's intent relate to this skill?** + +Relevant means: the intent explicitly targets this skill, describes changes to its domain or behavior, or highlights issues with its current content. + +Be GENEROUS in flagging existing skills for review — it's cheap to check and expensive to let skills go stale. (This intentionally differs from the HIGH BAR applied to standards: a stale skill actively misleads agents at runtime, so the cost of a false negative is high. Standards are noise-sensitive; skills are accuracy-sensitive.) + +Also identify **new skill ideas** if the user's intent suggests creating one. A new skill is warranted if: +- The intent describes specialized knowledge that no existing skill covers +- A decision table or heuristic is needed that a general model wouldn't know +- The user explicitly requests a new skill for a specific domain + +### Step 3: Deep Analyze Flagged Skills + +For each relevant skill, read the full `SKILL.md` and note its reference files. Keeping skills accurate is critical — stale skills actively mislead agents. Evaluate against the user's intent: +- Does the intent request changes to decision tables, patterns, or workflows? +- Does the intent highlight APIs, paths, patterns, or versions that changed? (update immediately) +- Does the intent describe domain knowledge the skill should provide but doesn't? (add content or reference) +- Does the intent flag anti-patterns that aren't documented? +- Does the intent suggest the skill's auto-load triggers need adjustment? (description may need tuning) +- If conversation context exists, use it as supporting evidence for the evaluation + +Flag even small inaccuracies — a skill that gives wrong guidance is worse than no skill. + +For each new skill idea, verify: +- This fits at least one skill archetype: team-specific conventions/domain logic not in public docs, a multi-step workflow with project-specific tools or paths, or a curated bundle of resources/knowledge that provides task-relevant context to agents +- It will be needed in future sessions (not a one-off) +- SKILL.md would stay under 5k words (plan references for overflow) + +For each new skill that passes validation, follow the procedure in [create-skill-procedure.md](../references/create-skill-procedure.md) to write the skill directory. + +## Output Format + +```markdown +## Skills Change Report + +### New Skills +<!-- Omit this section if none --> + +#### skill-name +- **Reason**: why this skill is needed +- **Auto-load triggers**: when it should activate +- **Key contents**: decision tables, anti-patterns, references needed + +### Skill Updates +<!-- Omit this section if none --> + +#### skill-name +- **Reason**: what changed or what's missing +- **SKILL.md changes**: sections to add/modify/remove +- **Reference file changes**: files to add/update/remove +- **Description update**: new description if auto-load triggers need adjustment + +``` diff --git a/.opencode/skills/packmind-update-playbook/steps/analyze-standards.md b/.opencode/skills/packmind-update-playbook/steps/analyze-standards.md new file mode 100644 index 000000000..9f28cf2dd --- /dev/null +++ b/.opencode/skills/packmind-update-playbook/steps/analyze-standards.md @@ -0,0 +1,76 @@ +# Standards Domain Analysis + +Scan existing standards, identify which are relevant to the user's validated intent, then perform deep analysis on those in one pass. + +## What Standards Are + +Standards are coding conventions distributed to AI coding agents. Each standard has a name (with `[TAG]` prefix), description, rules (imperative, ~25 words max each), and scope (file glob). Source files live in `**/.packmind/standards/<slug>.md`. Installed copies also exist in agent directories: +- Claude Code: `**/.claude/rules/packmind/` +- Cursor: `**/.cursor/rules/packmind/` +- GitHub Copilot: `**/.github/instructions/packmind-*` +Search the project root and all subdirectories. + +## Instructions + +### Step 1: List Standards + +Run `packmind-cli standards list` to get slugs, names, and descriptions. Do NOT read individual standard files yet. + +### Step 2: Filter Relevant Standards + +For each standard in the list, ask: **Does the user's stated intent relate to the domain this standard covers?** + +Relevant means: the intent targets this standard directly, involves changes to files matching the standard's scope, or describes modifications that could add, change, or invalidate a rule. Match by topic using slug, name, and description — no deep reading yet. + +Also identify **new standard ideas** if the user's intent suggests capturing a new convention. A new standard must meet ALL of: +- **Lintable**: mechanically verifiable by reading code (not subjective judgment) +- **Recurring**: pattern applies broadly or is a hard constraint (not a one-off) +- **Uncovered**: no existing standard already addresses it + +Skip general best practices any competent developer already knows. + +### Step 3: Deep Analyze Flagged Standards + +For each relevant existing standard, read `**/.packmind/standards/<slug>.md` and evaluate every rule against the user's intent: +- Rule aligns with the requested change → apply the modification +- Rule conflicts with the intent → update or remove as requested +- Intent describes a pattern this standard should cover but doesn't → gap, propose adding a rule +- If conversation context exists, use it as supporting evidence for the evaluation + +For each new standard idea, draft concrete rules and apply the lintability gate: +- **Mechanically verifiable**: can an agent check compliance by reading code? +- **Clear scope**: does it have a file glob where it applies? +- **Actionable**: does it say exactly what to do (not "prefer X" or "consider Y")? +- **Non-obvious**: would a senior developer NOT already do this without the rule? + +Prefer fewer, sharper rules. When in doubt, leave it out. + +## Output Format + +```markdown +## Standards Change Report + +### New Standards +(If none: "No new standards needed.") + +#### [TAG] Standard Name +- **Scope**: `file/glob/pattern` +- **Reason**: why this pattern warrants a standard +- **Rules**: + - Rule in imperative form (~25 words max) + +### Standard Updates +(If none: "No updates needed.") + +#### [TAG] Standard Name (`<slug>`) +- **Reason**: what changed or what's missing +- **Rules to add**: new rule text +- **Rules to modify**: "old text" → "new text" +- **Rules to remove**: "rule text" — reason + +### Standards to Deprecate +(If none: "No deprecations needed.") + +#### [TAG] Standard Name (`<slug>`) +- **Reason**: why no longer relevant +``` diff --git a/.opencode/skills/playbook-cli-audit/SKILL.md b/.opencode/skills/playbook-cli-audit/SKILL.md new file mode 100644 index 000000000..aa57be232 --- /dev/null +++ b/.opencode/skills/playbook-cli-audit/SKILL.md @@ -0,0 +1,203 @@ +--- +name: 'playbook-cli-audit' +description: 'Audit all packmind-* skills for CLI compatibility issues: deprecated commands, invalid options, wrong file formats, missing flags, and version mismatches against the locally installed Packmind CLI. Use when you want to check that skills referencing packmind-cli are up to date, after a CLI upgrade, before a release, or whenever you suspect skills may reference outdated CLI commands. Also triggers on phrases like "audit CLI usage", "check skills for CLI issues", "are my skills up to date with the CLI", or "CLI compatibility check".' +--- + +# Playbook CLI Audit + +Detect incompatibilities between the packmind-* skills deployed in `.claude/skills/` and the current Packmind CLI. Skills evolve independently from the CLI — when the CLI deprecates or removes commands, renames flags, or changes accepted file formats, skills can silently break. This audit catches those issues before users hit them at runtime. + +**This skill only detects issues — it does not fix them.** + +## Phase 1: Establish CLI Surface + +Build the authoritative map of what the CLI currently supports. + +### 1.1 Get CLI version + +```bash +node ./dist/apps/cli/main.cjs --version +``` + +Remember this as `$CLI_VERSION` (e.g., `0.26.0`). If the command fails, try `packmind-cli --version` as a fallback. If both fail, stop and tell the user the CLI is not available. + +### 1.2 Build the command map from CLI source code + +The CLI command definitions live in `apps/cli/src/infra/commands/`. Read the command registration files to build a complete map of: + +- **Available top-level commands** and their subcommands +- **Accepted options/flags** for each command (names, types, whether required) +- **Accepted argument types** (file paths, strings, etc.) and any format constraints +- **Deprecated commands** — look for `[Deprecated]` in descriptions and `has been removed` in handler code +- **Migration paths** — what the deprecated command's error message suggests instead + +Structure this internally as a reference table. Here is the expected shape — adapt if the source reveals more or fewer commands: + +| Command | Status | Accepted Inputs | Key Flags | Migration | +|---|---|---|---|---| +| `standards create` | REMOVED | JSON file/stdin | `--space`, `--from-skill` | `playbook add <path>` | +| `commands create` | REMOVED | JSON file/stdin | `--space`, `--from-skill` | `playbook add <path>` | +| `skills add` | REMOVED | directory path | `--space`, `--from-skill` | `playbook add <path>` | +| `install --list` | REMOVED | — | — | `packages list` | +| `install --show` | REMOVED | — | — | `packages show <slug>` | +| `diff` | DEPRECATED | — | `--submit`, `-m` | `playbook diff`, `playbook submit` | +| `diff add` | DEPRECATED | file path | `-m` | `playbook add` | +| `lint --diff` | DEPRECATED | — | — | `--changed-files` / `--changed-lines` | +| `playbook add` | ACTIVE | `.md`, `.mdc` files | `--space` | — | +| `playbook submit` | ACTIVE | — | `-m` (needed in non-TTY) | — | +| `packages add` | ACTIVE | — | `--to`, `--standard`/`--command`/`--skill` (one type per call) | — | +| ... | ... | ... | ... | ... | + +Do not hardcode this table — **read the actual source** in `apps/cli/src/infra/commands/` to build it. The table above is a starting point to orient your search, not the source of truth. + +### 1.3 Print summary + +``` +CLI version: $CLI_VERSION +Commands mapped: N active, M deprecated/removed +``` + +## Phase 2: Discover and Scan Skills + +### 2.1 Find all packmind-* skills + +Glob `.claude/skills/packmind-*/` to find all skill directories. For each, collect: +- `SKILL.md` (the main instruction file) +- All files in subdirectories (`references/`, `steps/`, `scripts/`, `packmind-versions/`, etc.) + +### 2.2 Handle version-specific subdirectories + +Some skills contain `packmind-versions/<semver>/` directories with version-specific instructions. The version resolution logic works like this: + +1. List all version directories (e.g., `0.21.0/`, `0.23.0/`) +2. Compare each against `$CLI_VERSION` +3. A version directory is **in scope** if it is the highest version that is `<=` `$CLI_VERSION` +4. Version directories that are **not in scope** (lower versions superseded by a higher one that is still `<= $CLI_VERSION`) should be flagged as INFO-level findings ("version `0.21.0` is superseded by `0.23.0` for CLI `$CLI_VERSION`") but their CLI invocations still need to be checked — they may still be reachable by users with older CLI versions + +**Example**: If `$CLI_VERSION` is `0.25.0` and the skill has `0.21.0/` and `0.23.0/`: +- `0.23.0/` is the **active version** (highest `<= 0.25.0`) +- `0.21.0/` is **superseded** but still scanned + +### 2.3 Extract CLI invocations + +For every file in scope, extract all lines that invoke the CLI. Look for these patterns: +- `packmind-cli <anything>` — the primary pattern +- `node ./dist/apps/cli/main.cjs <anything>` — local dev invocation +- Code blocks containing CLI commands (inside ` ``` ` fences) +- Inline code references (inside `` ` `` backticks) +- Plain-text references in prose (e.g., "Run `packmind-cli standards create`") + +For each invocation, parse: +- **Command**: the top-level command (e.g., `standards`) +- **Subcommand**: if any (e.g., `create`) +- **Arguments**: positional args (e.g., file paths) +- **Flags/options**: named options (e.g., `--space`, `-m`) +- **Source location**: file path and line number + +## Phase 3: Cross-Reference and Detect Issues + +For each extracted CLI invocation, check it against the command map from Phase 1. Detect these categories of issues: + +### CRITICAL — Will fail at runtime + +| Check | Description | Example | +|---|---|---| +| **Removed command** | Invocation uses a command the CLI has removed | `standards create` → removed, use `playbook add` | +| **Non-existent command** | Command or subcommand does not exist in the CLI | `packmind-cli list standards` (correct: `standards list`) | +| **Invalid file format** | File type passed to a command that doesn't accept it | `.json` file to `playbook add` (accepts only `.md`/`.mdc`) | +| **Mixed artifact types** | `packages add` called with multiple artifact type flags | `--standard X --command Y` in one call | + +### WARNING — May fail or produce unexpected behavior + +| Check | Description | Example | +|---|---|---| +| **Deprecated command** | Command works but shows deprecation warning | `diff add` → use `playbook add` | +| **Missing required flag in agent context** | Command lacks a flag needed in non-interactive/CI contexts | `playbook submit` without `-m` opens an editor | +| **Deprecated flag** | A specific flag is deprecated | `lint --diff` → use `--changed-files` | +| **Incorrect install syntax** | `install --list` or `install --show` used instead of `packages list`/`packages show` | `packmind-cli install --list` | + +### INFO — Worth noting + +| Check | Description | Example | +|---|---|---| +| **Superseded version directory** | A version-specific directory is no longer the active one | `0.21.0/` when `0.23.0/` exists and CLI is `>= 0.23.0` | +| **CLI version metadata mismatch** | Skill frontmatter declares `packmind-cli-version` that conflicts with installed version | `packmind-cli-version: "< 0.25.0"` when CLI is `0.26.0` | +| **Undocumented flag usage** | A flag is used that doesn't appear in the command definition | May indicate a removed or renamed flag | + +## Phase 4: Generate Report + +Write the report to `playbook-cli-audit-report.md` at the project root. + +### Report Structure + +```markdown +# Playbook CLI Audit Report + +**Generated**: {date} +**CLI version**: {$CLI_VERSION} +**Skills scanned**: {count} +**Files analyzed**: {count} + +## Summary + +| Severity | Count | +|---|---| +| CRITICAL | {n} | +| WARNING | {n} | +| INFO | {n} | + +## Findings + +### CRITICAL + +#### {n}. [{skill-name}] {short description} + +- **File**: `{path}:{line}` +- **Invocation**: `{the CLI command as written in the skill}` +- **Issue**: {what's wrong} +- **Fix**: {what the command should be replaced with} + +### WARNING + +...same structure... + +### INFO + +...same structure... + +## Skills Scanned + +| Skill | Files | Invocations | Issues | +|---|---|---|---| +| packmind-onboard | 19 | 12 | 5 CRITICAL, 1 WARNING | +| packmind-update-playbook | 8 | 15 | 0 | +| ... | ... | ... | ... | + +## CLI Command Surface Reference + +List of all active commands with their accepted inputs, for quick cross-reference. +``` + +### If no issues are found + +```markdown +# Playbook CLI Audit Report + +**Generated**: {date} +**CLI version**: {$CLI_VERSION} +**Skills scanned**: {count} + +All packmind-* skills are compatible with the installed CLI version. No issues found. +``` + +## Phase 5: Present to User + +After writing the report: + +1. Print a summary to the conversation: + - Total CRITICAL / WARNING / INFO counts + - Top 3 most affected skills + - The report file path +2. If there are CRITICAL findings, highlight them explicitly — these are commands that **will fail** when users invoke the skill + +Do not suggest fixes automatically. The user decides how to address each finding. \ No newline at end of file diff --git a/.opencode/skills/product-map/SKILL.md b/.opencode/skills/product-map/SKILL.md new file mode 100644 index 000000000..0a47422f8 --- /dev/null +++ b/.opencode/skills/product-map/SKILL.md @@ -0,0 +1,190 @@ +--- +name: 'product-map' +description: 'Produces a functional cartography of the Packmind application (domains × features × usage signals) as `product-map.md` at the project root. Manual-only skill — invoke ONLY when the user explicitly runs `/product-map` or asks to "map the application", "cartographier l''application", "list every feature", or "find decommission candidates". Do NOT auto-load on generic mentions of features, domains, spaces, or standards.' +--- + +# Product Map + +Build a point-in-time functional map of the Packmind application by scanning the backend (use cases + endpoints), the frontend (routes + pages), and the CLI (commands). Aggregate features by functional domain, compute a usage signal per feature, and write a single Markdown report at `product-map.md` (project root). The report supports decommission decisions: features with weak signals (no frontend entry, no recent activity, no tests) become candidates for removal. + +**Manual-only.** Run only when the user explicitly invokes `/product-map` or asks for a functional cartography. The description above intentionally avoids generic trigger keywords. + +**This skill only maps — it does not delete anything.** Decommission decisions stay with the user. + +## Phase 0: Confirm Scope + +Before scanning, confirm with the user: + +- **Target output path** — default `product-map.md` at project root; offer to override. +- **Scope override** — default scans `apps/api`, `apps/frontend`, `apps/cli`, and `packages/*`. Ask if any package should be excluded (e.g., infra-only packages like `migrations`, `logger`, `node-utils`). +- **Decommission tolerance** — confirm signal heuristics (see Phase 4); user may want stricter or looser flagging. + +If the user invoked the skill with explicit instructions, skip confirmation and proceed with stated parameters. + +## Phase 1: Seed Domain Taxonomy + +Before launching subagents, read `packages/` and `apps/` to discover the domain taxonomy. Use folder names as the seed list — do NOT invent domains. + +The seed domains in this codebase (verify by `ls packages/` at run time, this list may drift): + +- **Spaces** — `packages/spaces`, `packages/spaces-management` +- **Standards** — `packages/standards`, `packages/linter`, `packages/linter-ast`, `packages/linter-execution` +- **Skills** — `packages/skills` +- **Recipes / Playbooks** — `packages/recipes` +- **Change Proposals** — `packages/playbook-change-applier`, `packages/playbook-change-management` +- **Users / Auth / Organizations** — `packages/accounts` +- **AI Agents / Coding Agent** — `packages/coding-agent` +- **Git Integration** — `packages/git` +- **Analytics** — `packages/amplitude` +- **Support / Crisp** — `packages/crisp` +- **Deployments** — `packages/deployments` +- **LLM Infrastructure** — `packages/llm` +- **Legacy Import** — `packages/import-practices-legacy` + +Cross-cutting infra packages (`logger`, `node-utils`, `migrations`, `types`, `ui`, `frontend`, `editions`, `test-utils`, `integration-tests`, `plugins`) are NOT user-facing domains — exclude from the map unless they expose user-facing features (e.g., `editions` may expose subscription/edition selection). + +Pass this seed taxonomy to all subagents so they classify consistently. + +## Phase 2: Launch 3 Parallel Source Subagents + +Launch three `Agent(general-purpose)` subagents simultaneously. Each scans one source and returns a structured feature list classified by domain. + +| Subagent | Source | Scans | +|----------|--------|-------| +| 1. Backend | Use cases + API endpoints | `packages/*/src/application/useCases/**/*.ts`, NestJS controllers in `apps/api/src/**/*.controller.ts` | +| 2. Frontend | Routes + pages | `apps/frontend/src` router config + page components | +| 3. CLI | Commander commands | `apps/cli/src` command definitions | + +### Subagent Prompt Template + +Each subagent receives: +1. The seed domain taxonomy from Phase 1 +2. The source-specific instructions below +3. A request for structured output + +``` +You are mapping the Packmind application. Your scope: <SOURCE_NAME>. + +# Domain taxonomy (use these labels — do not invent new domains unless you find a clear standalone area): +<seed taxonomy> + +# Your task +Scan <PATHS> and produce a list of every user-facing feature exposed through this source. +Group features under their domain. A "feature" is a user-meaningful capability (e.g. +"create a space", "invite a member", "archive a standard"), not a technical implementation +detail (e.g. "TypeORM repository method"). + +For each feature, return: +- domain: <one of the seed labels, or "Other (justify)"> +- feature: short imperative phrase (≤ 8 words) +- evidence: file path(s) + relevant export/route/command name +- visibility: "user-facing" or "admin-only" or "internal" + +Skip: +- Pure infra plumbing (logging, config loading, health checks) +- Cross-cutting middleware (auth guards, rate limiters) — list them once under "Auth" +- Test utilities + +# Output format +Markdown list grouped by domain. One bullet per feature. End with a 2-line summary +(total features, anything unclassifiable). +``` + +### Source-Specific Instructions + +**Backend subagent**: scan use cases first (they map 1:1 to features), then controllers to confirm the endpoint exists. A use case with no controller binding is an "orphan" — flag it as `visibility: internal`. + +**Frontend subagent**: scan the router config first to enumerate routes, then read each route's page component to determine what the user actually does there. A route that only renders a placeholder or 404 is a candidate for decommission — flag it. + +**CLI subagent**: enumerate every Commander `.command(...)` registration. Subcommands count as separate features (e.g. `playbook add` and `playbook submit` are two features under "Change Proposals"). + +### Sequential Fallback + +If the Agent tool is unavailable, perform the three scans sequentially in the current session using `Grep` + `Read`. Maintain the same output structure. + +## Phase 3: Merge Sources Per Feature + +After subagents return, merge their lists into a single feature table. Two source entries belong to the same feature when: +- Same domain, AND +- Feature phrases describe the same capability (e.g. "create a space" backend ↔ "Create Space" frontend route ↔ `space create` CLI command) + +For each unified feature row, record which sources cover it: + +| Domain | Feature | Backend | Frontend | CLI | Evidence | +|--------|---------|:-:|:-:|:-:|----------| +| Spaces | Create a space | ✓ | ✓ | ✓ | `packages/spaces/src/.../createSpace.ts`, `/spaces/new`, `space create` | + +If a feature exists in only one source, that is a signal — keep the row, leave the missing sources blank. + +## Phase 4: Compute Usage Signal + +For each feature row, compute additional signal columns. These power the decommission section. + +| Signal | How to compute | +|--------|----------------| +| **Tests** | Grep for the use case class or controller path in `**/*.spec.ts` and `**/*.test.ts`. Mark ✓ if at least one test references it. | +| **Amplitude** | Grep for the feature's domain prefix in `packages/amplitude/src/application/AmplitudeEventListener.ts`. Mark ✓ if any event for this domain is subscribed AND the event name plausibly matches the feature. | +| **Recent activity** | `git log --since="6 months ago" --pretty=format:%H -- <evidence path>`. Mark ✓ if at least one commit. | + +A feature is a **decommission candidate** when ANY of the following holds: +- No frontend coverage AND no CLI coverage (= backend-only, possibly orphaned) +- No tests AND no recent activity (= cold code) +- Frontend route exists but backend use case missing (= dead UI) +- Backend use case exists but no entry point in any client (= dead endpoint) + +Do NOT auto-delete — only flag. + +## Phase 5: Write Report + +Write `product-map.md` at the project root with this structure: + +```markdown +# Packmind Product Map — <YYYY-MM-DD> + +> Snapshot of every user-facing feature, grouped by functional domain. +> Generated by the `product-map` skill. Manual invocation only. + +## Scope + +- Sources scanned: backend use cases & endpoints, frontend routes & pages, CLI commands +- Packages excluded: <list> +- Git revision: <short SHA> + +## Functional Map + +| Domain | Feature | Backend | Frontend | CLI | Tests | Amplitude | Recent | Notes | +|--------|---------|:-:|:-:|:-:|:-:|:-:|:-:|-------| +| Spaces | Create a space | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| Spaces | Archive a space | ✓ | – | – | – | – | – | backend-only orphan | +| ... + +## Decommission Candidates + +Features matching at least one decommission heuristic (see methodology). Sorted by +weakest signal first. + +| Feature | Reason | Evidence | +|---------|--------|----------| +| Archive a space | backend-only, no tests, no commits in 6 months | `packages/spaces/src/.../archiveSpace.ts` | +| ... + +## Methodology + +- A feature = a user-meaningful capability, not a technical implementation detail. +- Sources merged when same domain + same capability across backend/frontend/CLI. +- Signal heuristics: see SKILL.md Phase 4. +- This is a point-in-time snapshot — re-run to refresh. +``` + +End by printing to the user: +- Path of the generated report +- Total features mapped +- Total decommission candidates +- Top 3 domains by feature count + +## Operating Rules + +- **Never delete code based on the report.** Decommission decisions belong to the user. +- **Do not invent domains.** If a feature does not fit the seed taxonomy, place it under "Other" with a one-line justification — the user will refine the taxonomy on re-run. +- **Stay factual.** Every row must cite an evidence path. No inferred features. +- **Re-runnable.** The skill produces a fresh snapshot each run; overwrite the previous report after confirming with the user. \ No newline at end of file diff --git a/.opencode/skills/qa-review/SKILL.md b/.opencode/skills/qa-review/SKILL.md new file mode 100644 index 000000000..ca218bd8b --- /dev/null +++ b/.opencode/skills/qa-review/SKILL.md @@ -0,0 +1,192 @@ +--- +name: 'qa-review' +description: 'Review a user story implementation against its Example Mapping (EM) specification.' +--- + +# QA Review + +Audit a user story implementation against its Example Mapping specification. Reads the EM +markdown, finds all implementing code in the codebase, then runs two parallel agents — one for +functional coverage, one for code review. Produces a single compact report. + +**This skill only detects issues — it does not fix them.** + +## Reference + +A ready-to-fill EM template is available at `em_template.md` (in this skill's directory). Share it with the user when they need to write a new spec from scratch. + +## 1. Parse the EM Spec + +Read the markdown file at the provided path. Extract a structured summary with these sections: + +### What to extract + +1. **User Story title** — the first line or heading describing the US +2. **Rules** — each `# Rule N: <title>` block. For each rule, extract: + - Rule number and title + - Each `## Example N` with its setup/action/outcome narrative (preserve the full text) +3. **Technical Rules** — bullet points under a `# Technical rules` heading (implementation-focused constraints) +4. **User Events** — content under `# User Events` heading: event names, properties, schemas +5. **Check Also items** — bullet points after "Check also" markers (additional rules/constraints, often separated by dashes) +6. **Code References** — all backtick-quoted terms across the entire spec (class names, field names, event names like `ConflictDetector`, `space_created`, `decision`) +7. **Domain Keywords** — key nouns and verbs from rule titles and examples (e.g., "space", "create", "slug", "rename", "conflict") + +### Handling ambiguity + +If a spec item is ambiguous or explicitly deferred (e.g., "TBD", "on verra plus tard", "later"), flag it in the Parsed Spec Summary but exclude it from coverage assessment. Note it in the report as "Deferred — not assessed." + +Compile everything into a **Parsed Spec Summary** formatted as below. The "Full Examples" section preserves the complete raw text of every example — sub-agents need this full context to accurately assess coverage. + +``` +## Parsed Spec Summary + +### User Story +{title} + +### Rules and Examples +Rule 1: {title} + Example 1: {one-line summary of scenario} + Example 2: {one-line summary of scenario} +Rule 2: {title} + Example 1: {one-line summary of scenario} +[...] + +### Full Examples (raw text) +{Copy the complete text of every example verbatim from the spec, preserving setup/action/outcome narratives. Do not summarize here — this section is passed to sub-agents so they can assess nuanced behaviors.} + +### Technical Rules +- {rule text} +[...] + +### Deferred Items +- {item text} — Deferred, not assessed +[...] + +### User Events +- {event_name}: {properties} +[...] + +### Check Also +- {constraint text} +[...] + +### Code References (from backticks) +{list of all backtick-quoted terms} + +### Domain Keywords +{list of distinctive nouns/verbs extracted from rules} +``` + +## 2. Select Target Domains + +Ask the user which domains this user story touches. Use **AskUserQuestion** with `multiSelect: true`: + +| Option | Directories | +|--------|-------------| +| CLI (apps/cli) | `apps/cli/src/**` | +| API (apps/api) | `apps/api/src/**` | +| Packages (packages/*) | `packages/*/src/**`, `packages/types/src/**` | +| Frontend (apps/frontend) | `apps/frontend/app/**`, `apps/frontend/src/**` | +| MCP (apps/mcp-server) | `apps/mcp-server/src/**` | + +**Packages is always included**, even if the user does not select it — it contains domain logic, contracts, events, and infra that all other layers depend on. + +Store the user's selection as `{target_domains}` — a list of domain labels and their directory patterns. All subsequent steps use this to scope their searches. + +## 3. Validate Implementation Exists + +Grep 2–3 of the most distinctive backtick-quoted terms from the spec, **scoped to the `{target_domains}` directories only**. If fewer than 3 implementing files are found, **stop and ask the user** to confirm that the US has been fully implemented. Do not proceed with a full review on a partially-implemented or not-yet-started US — the report would be misleading. + +## 4. Build Code Map + +Launch a **Code Map Agent** (`subagent_type: general-purpose`) using the prompt from `agents/code-map-agent.md`. Replace: +- `{parsed_spec_summary}` with the Parsed Spec Summary from step 1 +- `{target_domains}` with the selected domains and their directory patterns from step 2 + +The agent will search only within the target domain directories, then return a structured Code Map organized by architectural layer. Only layers matching the selected domains will appear in the output. + +Wait for the agent to complete before proceeding to step 5. + +## 5. Pre-filter Packmind Standards + +Before launching the review agents, collect the applicable Packmind coding standards: + +1. Glob for `**/.claude/rules/packmind/*.md` across the repository +2. Read the YAML frontmatter of each file to extract its `paths` glob patterns +3. Match the Code Map file paths against each standard's `paths` patterns +4. For each standard that matches at least one Code Map file, read its full content + +Compile the applicable standards into `{applicable_standards}` — the full text of each matching standard, prefixed with its name. If no standards match, set `{applicable_standards}` to "None". + +## 6. Launch Parallel Sub-Agents + +Launch **two sub-agents in parallel** (same turn), each receiving the Parsed Spec Summary (including the Full Examples section with raw text), Code Map, and target domains as context: + +1. **Functional Coverage Agent** (`subagent_type: general-purpose`) — prompt built from `agents/functional-coverage-agent.md`. Replace `{parsed_spec_summary}`, `{code_map}`, and `{target_domains}`. +2. **Code Review Agent** (`subagent_type: general-purpose`) — prompt built from `agents/code-review-agent.md`. Replace `{parsed_spec_summary}`, `{code_map}`, `{target_domains}`, and `{applicable_standards}`. + +Launch both agents simultaneously. The full raw example text is critical — sub-agents need the complete setup/action/outcome narratives to assess nuanced behaviors, not just one-line summaries. + +### Sequential Fallback + +If the Agent tool is unavailable, perform both reviews sequentially yourself, following the instructions from each agent prompt file. + +## 7. Combine & Write Report + +Once both agents complete, merge their outputs into a single report. + +### Output path + +Derive from the input path: if input is `path/to/my-spec.md`, output is `path/to/my-spec-report.md`. + +### Report template + +```markdown +# QA Review Report + +**Spec**: {filename} | **Date**: {date} | **Branch**: {branch} | **Commit**: {short-sha} +**Rules**: {N} | **Examples**: {N} | **Tech Rules**: {N} | **Events**: {N} + +## Summary + +| Metric | Count | +|--------|-------| +| Covered | N | +| Partially Covered | N | +| Not Covered | N | +| Code Findings | N (Critical: X, High: Y, Medium: Z) | +| Standards Violations | N | + +## Functional Coverage + +### Coverage Matrix + +{coverage matrix table from functional coverage agent} + +### Gaps + +{reproduction steps from functional coverage agent — omit this subsection if all items are Covered} + +## Code Review + +### Findings + +{findings from code review agent — omit this section if no issues found} + +## Deferred Items + +{list of items marked as deferred/TBD in the spec — not assessed in this review} + +--- +*Static analysis only. No code was executed during this review.* +``` + +**Omit any section that has zero content.** Only include sections with actual results. + +### Print Summary + +After writing the report, print a brief summary to the console: +- Total rules/examples in the spec +- Coverage stats (Covered / Partially / Not Covered counts) +- Code review findings count by severity +- The report file path \ No newline at end of file diff --git a/.opencode/skills/qa-review/agents/code-map-agent.md b/.opencode/skills/qa-review/agents/code-map-agent.md new file mode 100644 index 000000000..fb083cef1 --- /dev/null +++ b/.opencode/skills/qa-review/agents/code-map-agent.md @@ -0,0 +1,133 @@ +You are a codebase navigator building a Code Map for a user story implementation. Your job is to find all files involved in implementing the feature described in the Parsed Spec Summary below, **scoped to the target domains only**. + +## Target Domains + +{target_domains} + +**Only search within the directories listed above.** Do not search domains that are not listed. + +## Parsed Spec Summary + +{parsed_spec_summary} + +## Available Tools + +You have access to **Glob**, **Grep**, and **Read** (read-only). Use them to systematically search the codebase. + +## Search Strategy + +Using the code references (backtick-quoted terms) and domain keywords from the spec, follow this funnel (precise → broad). **All searches must be scoped to the target domain directories above.** + +### Step 1: Backtick terms first (highest signal) + +Grep each backtick-quoted term, but only within the target domain directories. These are author-curated pointers into the codebase and almost always resolve to exact matches. + +### Step 2: Domain keyword search + +Grep the 3–5 most distinctive domain keywords against the target domain directories. Here is the mapping of domains to their search patterns — **only use patterns for selected domains**: + +**Backend (packages/*):** +- `packages/*/src/application/useCases/**/*.ts` and `packages/*/src/application/usecases/**/*.ts` (use cases — both casing variants exist) +- `packages/*/src/infra/**/*.ts` (repository implementations, TypeORM schemas, BullMQ job factories) +- `packages/types/src/*/events/**/*.ts` (domain events — centralized in @packmind/types) +- `packages/types/src/*/contracts/**/*.ts` (use case contracts — Command, Response, IUseCase interfaces) + +**API (apps/api):** +- `apps/api/src/app/**/*.ts` (NestJS controllers, guards, modules) + +**Frontend (apps/frontend):** +- `apps/frontend/app/routes/**/*.tsx` (file-based route components with clientLoaders) +- `apps/frontend/src/domain/**/*.ts` (gateways extending PackmindGateway, TanStack Query hooks) + +**CLI (apps/cli):** +- `apps/cli/src/**/*.ts` (CLI commands and handlers) + +**MCP (apps/mcp-server):** +- `apps/mcp-server/src/**/*.ts` (MCP server tools and prompts) + +### Step 3: Package-level scan (Backend only) + +Skip if Backend is not in the target domains. Once a relevant package is identified (e.g., `packages/spaces/`), list its `application/useCases/` directory (or `application/usecases/` — both casing variants exist) to find all related use cases. + +### Step 4: Hexagonal registry check (Backend only) + +Skip if Backend is not in the target domains. Once a relevant package is identified, read its `{PackageName}Hexa.ts` file (e.g., `packages/standards/src/StandardsHexa.ts`) to understand which ports, adapters, and use cases are registered. This reveals the full wiring of the feature. + +### Step 5: Contract discovery (Backend only) + +Skip if Backend is not in the target domains. For each use case found, check `packages/types/src/{domain}/contracts/` for the corresponding contract file, which defines `{Name}Command`, `{Name}Response`, and `I{Name}UseCase`. + +### Step 6: Test file discovery + +For each source file found within target domain directories, check for a sibling `.spec.ts` equivalent. + +### Step 7: Event tracking (Backend only) + +Skip if Backend is not in the target domains. Grep event names from the "User Events" section. Look for event class definitions in `packages/types/src/{domain}/events/`, emission via `eventEmitterService.emit(new {EventName}Event(`, and consumption via `PackmindListener` classes with `this.subscribe({EventName}Event, ...)`. + +### Step 8: Cross-US dependencies + +When a rule depends on behavior from another feature (e.g., conflict detection logic that belongs to a different US), include those files in the Code Map but mark them as `[DEPENDENCY]`. Only follow dependencies within the target domain directories. The functional agent should verify the integration point works, not re-audit the dependency itself. + +## Output Format + +Compile results into a **Code Map** organized by layer: + +``` +## Code Map + +### Hexagonal Registry +- packages/{pkg}/src/{Pkg}Hexa.ts (port/adapter wiring) + +### Contracts (@packmind/types) +- packages/types/src/{domain}/contracts/I{UseCaseName}UseCase.ts + ({Name}Command, {Name}Response, I{Name}UseCase) + +### Backend Domain +- packages/{pkg}/src/application/useCases/{useCaseName}/{UseCaseName}UseCase.ts + Test: {path}.spec.ts [EXISTS | NOT FOUND] +- packages/{pkg}/src/domain/{Entity}.ts + Test: [EXISTS | NOT FOUND] + +### Infra (repositories, schemas, jobs) +- packages/{pkg}/src/infra/repositories/{Repository}.ts + Test: [EXISTS | NOT FOUND] +- packages/{pkg}/src/infra/schemas/{Schema}.ts +- packages/{pkg}/src/infra/jobs/{JobFactory}.ts (BullMQ background jobs) + +### API Layer (NestJS) +- apps/api/src/app/{...}/{controller}.ts + Guards: [OrganizationAccessGuard | SpaceAccessGuard | None] + Adapter injection: [@Inject{Pkg}Adapter()] + Test: [EXISTS | NOT FOUND] + +### Frontend +- apps/frontend/app/routes/{...}.tsx (route component + clientLoader) +- apps/frontend/src/domain/{entity}/api/gateways/{Entity}GatewayApi.ts (extends PackmindGateway) +- apps/frontend/src/domain/{entity}/api/queries/{Entity}Queries.ts (TanStack Query v5 hooks) + Test: [EXISTS | NOT FOUND] + +### CLI +- apps/cli/src/{...}/{command|handler}.ts + Test: [EXISTS | NOT FOUND] + +### MCP Server +- apps/mcp-server/src/app/tools/{toolName}/{toolName}.tool.ts + Test: [EXISTS | NOT FOUND] + +### Domain Events (@packmind/types) +- packages/types/src/{domain}/events/{EventName}Event.ts (extends UserEvent/SystemEvent) +- {files containing eventEmitterService.emit()} +- {files containing PackmindListener / this.subscribe()} + +### Background Jobs (BullMQ) +- packages/{pkg}/src/infra/jobs/{JobFactory}.ts (WorkerQueue registration) + +### Dependencies (from other USs — verify integration only) +- {files that implement behavior from another feature but are used by this US} [DEPENDENCY] + +### Other Relevant Files +- {any other files found via keyword search} +``` + +Only include sections that have actual files **and** match the target domains. Omit empty sections and sections for domains not in the target list. diff --git a/.opencode/skills/qa-review/agents/code-review-agent.md b/.opencode/skills/qa-review/agents/code-review-agent.md new file mode 100644 index 000000000..4d4563ddd --- /dev/null +++ b/.opencode/skills/qa-review/agents/code-review-agent.md @@ -0,0 +1,183 @@ +You are a senior engineer performing a technical code review on a user story implementation. Your focus is exclusively on actual bugs, edge cases, and inconsistencies — not style, naming, formatting, or minor improvements. Report findings at **Medium** severity or above. If no Critical, High, or Medium issues are found, include **Low** severity findings instead. + +## Target Domains + +{target_domains} + +**Only review code within the target domains listed above.** Skip layers not in scope. + +## Spec Context + +{parsed_spec_summary} + +Understanding the spec helps you know what the code is *supposed* to do, which makes it easier to spot where it does something wrong or incomplete. + +## Code Map + +{code_map} + +## Available Tools + +You have access to **Glob**, **Grep**, and **Read** (read-only). Use them to read every file in the Code Map and trace the logic across target layers only. + +## Your Process + +### Step 1: Read All Implementing Files (within target domains) + +Read every file listed in the Code Map that belongs to the target domains. Build a mental model of the relevant flows — **only for selected domains**: + +**Backend (packages/*):** +- The hexagonal data flow: use case → domain entity → infra repository → TypeORM schema → database +- How contracts in `@packmind/types` define the shared interface (`{Name}Command`/`{Name}Response`/`I{Name}UseCase`) +- How domain events are emitted (`eventEmitterService.emit()`) and consumed (`PackmindListener` with `this.subscribe()`) +- How background jobs are registered and processed (BullMQ `WorkerQueue`, `JobsService`) +- How the Hexa registry (`{Pkg}Hexa.ts`) wires ports to adapters + +**API (apps/api):** +- NestJS controller → adapter (via `@Inject{Pkg}Adapter()`) → use case +- Where validations and guards are applied (NestJS guards like `OrganizationAccessGuard`) + +**Frontend (apps/frontend):** +- Route `clientLoader` → `queryClient.ensureQueryData()` → TanStack Query hook → gateway (extends `PackmindGateway`) → API call + +**CLI (apps/cli):** +- CLI command structure, output formatting, error handling + +**MCP (apps/mcp-server):** +- MCP tool implementation, use case invocation, result formatting + +### Step 2: Check for These Issue Categories + +#### A. Actual Bugs (Critical / High) + +- **Logic errors**: wrong conditions, inverted checks, off-by-one, missing null checks on required paths +- **Data integrity**: unique constraints that can be bypassed (e.g., race conditions on concurrent create), missing database constraints that the spec requires +- **Missing error handling**: operations that can throw but have no try/catch or return no meaningful error to the caller +- **Incorrect queries**: TypeORM queries that don't match the intended behavior (wrong where clause, missing relations, incorrect join) +- **Security gaps**: missing authorization checks the spec explicitly requires (e.g., "only admins can...") + +#### B. Edge Cases (Medium / High) + +- **Boundary values**: empty strings, max length violations, special characters (accents, unicode), whitespace handling +- **Concurrent operations**: two users performing the same action simultaneously (e.g., creating a resource with the same name at the same time) +- **State transitions**: operations that can leave data inconsistent if they fail partway through (e.g., entity created but event not emitted) + +#### C. Cross-File Inconsistencies (Medium / High) + +Look specifically for mismatches **between** files within the target domains. Only check mismatches between layers that are both in scope: +- **Gateway-to-contract mismatch** (Frontend + Backend): Frontend gateway method sends a field name or shape that differs from the `@packmind/types` contract (`{Name}Command`/`{Name}Response`) +- **Contract-to-API mismatch** (API + Backend): NestJS controller params or response types don't match the `@packmind/types` contract +- **Validation layer inconsistency** (any two selected layers): API validates a constraint that the domain use case does not enforce (or vice versa — validation only in one layer) +- **Event payload drift** (Backend): Event class in `packages/types/src/{domain}/events/` defines one payload shape, but `eventEmitterService.emit()` passes different properties, or `PackmindListener` handler expects different fields +- **Entity-to-schema drift** (Backend): Domain entity field names don't match TypeORM schema column names in `infra/schemas/` +- **Duplicate logic** (any two selected layers): Same logic implemented differently across layers (e.g., slug generation in both frontend gateway and backend use case with different rules) +- **Hexa wiring gap** (Backend): Adapter registered in `{Pkg}Hexa.ts` but the corresponding use case not wired, or a new use case not added to the Hexa registry + +#### D. Missing Validations (Medium) + +Constraints explicitly mentioned in the spec but not enforced in code: +- Max length for a field (spec says 64, no validation in DTO or entity) +- Authorization checks (spec says "only admins", no guard or role check) +- Uniqueness constraints (spec says "cannot have the same name", no unique check before insert) +- Required fields that accept null/undefined + +#### E. Technical Rules Violations (Medium / High) + +Systematically verify **every** technical rule from the "Technical Rules" section of the Parsed Spec Summary. For each technical rule: + +1. Identify which files in the Code Map are relevant — technical rules can apply to **any layer** (backend domain, infra, API, frontend, CLI, MCP server, background jobs, etc.) +2. Read those files and check whether the constraint is enforced +3. Report a finding if a technical rule is not implemented or is implemented incorrectly + +Common technical rule patterns to look for — **only check layers within the target domains**: +- **Backend**: missing domain validations, incorrect repository queries, missing event emissions, wrong error types, missing guards or authorization checks +- **Frontend**: missing form validations, incorrect API call parameters, missing loading/error states, wrong route guards, missing optimistic updates or cache invalidation +- **Cross-cutting**: naming conventions not followed, missing logging, incorrect feature flag checks, missing analytics events, wrong data transformations + +If a technical rule is ambiguous about which layer should enforce it, check only within the target domains. + +#### F. Hexagonal Architecture & NestJS Issues (Medium / High) + +- **Missing Hexa registration**: A new use case or port exists but is not registered in the package's `{Pkg}Hexa.ts` registry +- **Missing adapter injection**: NestJS controller uses a port but doesn't inject it via `@Inject{Pkg}Adapter()` decorator +- **Missing guard**: Controller endpoint modifies data but doesn't use `@UseGuards(OrganizationAccessGuard)` or appropriate guard +- **Event listener not registered**: A `PackmindListener` class exists but isn't wired to receive the expected events via `this.subscribe()` +- **Background job not registered**: A `WorkerQueue` is defined but `JobsService` doesn't register or submit it +- **Contract not exported**: A new use case contract exists in `@packmind/types` but isn't exported from the package's barrel file + +### Step 3: Check Test Quality + +If test files exist for the implementing code: +- Are the tests testing the **right behavior** or just mocking everything away? A test that mocks the repository and only checks the mock was called does not catch real bugs. +- Are there assertions that would catch regressions on the spec's key behaviors? +- Are error paths and edge cases tested, or only the happy path? + +Only flag test quality issues if they represent a **Medium+ risk** — e.g., a critical validation has no test at all, or tests mock away the exact layer where a bug exists. + +### Step 4: Check Pre-loaded Packmind Standards + +The following Packmind standards have been pre-filtered by the orchestrator and apply to files in the Code Map: + +{applicable_standards} + +If "None" is shown above, skip this step entirely. + +**Verification:** +- Read each applicable standard's rules carefully +- Check every file in the Code Map that matches the standard's scope +- Report violations at the same severity levels as other findings (Critical/High/Medium depending on impact) +- In the **Spec Reference** field, reference the standard name (e.g., "Standard: Testing Good Practices") + +## Severity Definitions + +- **Critical**: Data loss, security vulnerability, or crash in a production code path. Would block a release. +- **High**: Incorrect behavior that users will encounter in normal usage. A bug that produces wrong results or breaks a flow. +- **Medium**: Edge case that could cause issues under specific conditions. Missing validation that the spec explicitly requires. Cross-file inconsistency that could cause subtle bugs. +- **Low**: Minor issues — small inconsistencies, non-critical missing validations, minor edge cases unlikely to affect normal usage, slight contract mismatches that don't break functionality. + +**Do NOT report**: Informational, cosmetic, style preferences, missing comments, naming suggestions, or "nice to have" improvements. If you find fewer than 2 issues, that is perfectly fine — do not manufacture findings to fill the report. + +## Output Format + +Return findings in this exact format: + +``` +### Findings + +#### [CRITICAL] {Short descriptive title} +- **Category**: Bug | Edge Case | Inconsistency | Missing Validation | Technical Rule Violation +- **File**: `{file-path}:{line}` +- **Description**: {What is wrong, why it matters, and what could happen} +- **Spec Reference**: {Which rule/example/technical rule this relates to, e.g., "Rule 2 / Example 1", "Check Also: max length 64", or "Technical Rule: ..."} +- **Suggested Fix**: {Brief direction — not full code, just the approach} + +--- + +#### [HIGH] {Short descriptive title} +- **Category**: ... +[same format] + +--- + +#### [MEDIUM] {Short descriptive title} +- **Category**: ... +[same format] +``` + +**Order findings by severity**: Critical first, then High, then Medium. + +**Low-severity fallback**: If you found **no** Critical, High, or Medium issues, include any Low findings using this format: + +``` +#### [LOW] {Short descriptive title} +- **Category**: ... +[same format] +``` + +If no issues are found at any severity, return exactly: + +``` +### Findings + +No significant issues found. The implementation appears sound for the behaviors described in the spec. +``` diff --git a/.opencode/skills/qa-review/agents/functional-coverage-agent.md b/.opencode/skills/qa-review/agents/functional-coverage-agent.md new file mode 100644 index 000000000..d82534b83 --- /dev/null +++ b/.opencode/skills/qa-review/agents/functional-coverage-agent.md @@ -0,0 +1,116 @@ +You are a QA analyst reviewing whether a user story implementation fully covers its Example Mapping specification. Your job is evidence-based: every assessment must cite specific files and code patterns you found (or did not find) in the codebase. + +## Target Domains + +{target_domains} + +**Only trace rules through the layers listed above.** Skip layers not in the target domains — they are out of scope for this review. + +## Spec to Review + +{parsed_spec_summary} + +## Code Map + +{code_map} + +## Available Tools + +You have access to **Glob**, **Grep**, and **Read** (read-only). Use them to: +- Read source files identified in the Code Map +- Search for specific behavior implementations (Grep for method names, conditions, error messages) +- Check test file existence and content (Glob for `*.spec.ts` patterns) + +The Code Map is a starting index — always verify by reading the actual source files. + +## Your Process + +### Step 1: Trace Each Rule and Example Across Target Layers + +For every Rule + Example pair in the spec, trace the behavior through **only the target domain layers listed above**. A rule is only fully covered when all target layers that should implement it actually do. + +1. **Identify the expected behavior** from the example's setup/action/outcome +2. **Search for the implementation across target layers only**. Below are the checks for each domain — **only perform checks for domains in the target list**: + + **Backend (packages/*):** + - **Contract layer** (`@packmind/types`): Read the use case contract (`I{UseCaseName}UseCase.ts` in `packages/types/src/{domain}/contracts/`). Does it define the correct `{Name}Command` input shape and `{Name}Response` output shape for this behavior? + - **Backend domain**: Trace the hexagonal flow: NestJS controller → adapter (via `@Inject{Pkg}Adapter()`) → use case → domain entity. Does the use case handle the precondition, perform the action, and produce the expected outcome? Is the Hexa registry (`{Pkg}Hexa.ts`) wiring the correct adapter? + - **Infra layer**: Do the repository implementations in `infra/` correctly persist or query the data? Are TypeORM schemas aligned with domain entities? Are BullMQ jobs (`WorkerQueue`) registered for async operations? + + **API (apps/api):** + - **API layer** (NestJS): Does the controller expose this behavior at the correct route under `/organizations/:orgId`? Is `@UseGuards(OrganizationAccessGuard)` (or appropriate guard) present? Are the request/response types aligned with the `@packmind/types` contract? + + **Frontend (apps/frontend):** + - **Frontend**: Trace the data flow: route `clientLoader` → `queryClient.ensureQueryData()` → TanStack Query hook → gateway (extends `PackmindGateway`) → API call. Does each layer handle this scenario? Does the gateway method signature match the API endpoint and `@packmind/types` contract? + + **CLI (apps/cli):** + - **CLI**: Does the CLI command implement the expected behavior for this rule? Correct output, error handling, flags? + + **MCP (apps/mcp-server):** + - **MCP Server**: Does the tool in `apps/mcp-server/src/app/tools/` correctly invoke the use case and return the expected result? + + **Cross-layer consistency** (check only between selected domains): Do field names match between `@packmind/types` contracts, NestJS controller params, frontend gateway methods, and CLI handlers? Are validations applied consistently across selected layers (not just in one)? +3. **Check for test coverage** — look for test cases that exercise this specific scenario. Not just that test files exist, but that a test covers this particular case (look for describe/it blocks, test data, assertions matching the example). +4. **Assess coverage level**: + - **Covered**: Implementation code exists AND handles this specific case across all target layers. Cite the file:line where the behavior is implemented. + - **Partially Covered**: Implementation exists but is incomplete — e.g., backend handles it but frontend doesn't (when both are in scope), happy path only, missing error case, missing edge case from the example. Cite what exists and what is missing. + - **Not Covered**: No implementation found for this behavior in any target layer. Describe what you searched for and where. + +### Step 2: Check Technical Rules + +For each bullet under "Technical rules": +- Search for the implementation of the described technical constraint +- Verify the code matches what the spec says (e.g., if the spec says "ConflictDetector uses the `decision` field if available, payload otherwise", read the ConflictDetector and verify this logic) + +### Step 3: Check User Events + +For each event described: +- Check the event class definition in `packages/types/src/{domain}/events/{EventName}Event.ts`. Verify it extends `UserEvent<TPayload>` or `SystemEvent<TPayload>` with the correct payload type. +- Grep for `eventEmitterService.emit(new {EventName}Event(` to find where it is emitted. Verify it is emitted in the correct use case after the action succeeds. +- Grep for `PackmindListener` classes that `this.subscribe({EventName}Event, ...)` to verify the event is consumed where expected. +- Check the event payload properties match the spec (property names, types, optional/required flags). + +### Step 4: Check "Check Also" Items + +For each bullet under "Check also": +- Search for the constraint or validation in the code +- Assess coverage the same way as rules (Covered / Partially Covered / Not Covered) + +## Output Format + +Return **two sections**: + +### Coverage Matrix + +Use this exact table format, one row per rule+example, technical rule, user event, and check-also item. The **Layer** column indicates where the behavior is implemented (Contract, Backend Domain, Infra, API, Frontend (Route/Gateway/Query), CLI, MCP Server, Event, Background Job, or Cross-layer) — this helps route fixes to the right team/area: + +``` +| ID | Rule / Item | Layer | Status | Evidence | Test Coverage | +|----|-------------|-------|--------|----------|---------------| +| R1-E1 | Rule 1: {title} / Example 1: {summary} | Backend Domain | Covered | `file.ts:45` - {what the code does} | `file.spec.ts:23` - {test description} | +| R1-E2 | Rule 1: {title} / Example 2: {summary} | Frontend | Not Covered | No uniqueness check found in {file} | None | +| R2-E1 | Rule 2: {title} / Example 1: {summary} | Backend Domain | Partially Covered | `file.ts:80` - slug generated but not immutable | None | +| T1 | Tech: {description} | Cross-layer | Covered | `ConflictDetector.ts:12` - uses decision field | `ConflictDetector.spec.ts:5` | +| EV1 | Event: {event_name} | Event | Not Covered | No emit found for this event | None | +| C1 | Check: {description} | API | Covered | `guard.ts:15` - admin role check | None | +``` + +**ID format**: `R{rule}-E{example}` for rules, `T{n}` for technical rules, `EV{n}` for events, `C{n}` for check-also items. + +### Reproduction Steps for Gaps + +For each item marked **Not Covered** or **Partially Covered**, provide: + +``` +#### [{ID}] {Rule title} / {Example summary} +**Status**: Not Covered | Partially Covered +**What is missing**: {specific behavior not implemented} +**Where to look**: {file paths where this should be implemented} +**How to reproduce**: +1. {step-by-step reproduction of the scenario from the example} +2. {what happens vs what should happen} +``` + +Keep reproduction steps concise — focus on the minimum steps to demonstrate the gap. + +If everything is fully covered, state: "All rules and examples are fully covered." diff --git a/.opencode/skills/qa-review/em_template.md b/.opencode/skills/qa-review/em_template.md new file mode 100644 index 000000000..a2a483b55 --- /dev/null +++ b/.opencode/skills/qa-review/em_template.md @@ -0,0 +1,50 @@ +User Story Review: {Title of the user story} + +# Rule 1: {Short rule title describing the expected behavior} + +## Example 1 + +{Setup: describe the initial state} + +{Action: describe what the user does} + +{Outcome: describe the expected result} + +## Example 2 + +{Setup} + +{Action} + +{Outcome} + +# Rule 2: {Short rule title} + +## Example 1 + +{Setup} + +{Action} + +{Outcome} + +# Technical rules + +- {Implementation constraint that applies across frontend and backend, e.g., "Slug generation must be deterministic and locale-independent"} +- {Performance or infrastructure constraint, e.g., "The operation must be idempotent"} +- {Security or authorization constraint, e.g., "Only organization admins can perform this action"} +- {Data constraint, e.g., "Max length for the name field: 64 characters"} +- {Integration constraint, e.g., "Must work on both Linux and Windows (normalize paths)"} + +# User Events + +- `{event_name}`: emitted when {trigger description}. Properties: `{property1}`, `{property2}` +- `{event_name}`: emitted when {trigger description}. Properties: `{property1}` + +--- + +Check also the following rules are applied: + +- {Additional business rule or constraint not covered by the rules above} +- {Edge case to verify, e.g., "Two items in the same scope cannot have the same name"} +- {Default behavior, e.g., "By default, the user lands on the default space"} diff --git a/.opencode/skills/release-proprietary/SKILL.md b/.opencode/skills/release-proprietary/SKILL.md new file mode 100644 index 000000000..e20ce1386 --- /dev/null +++ b/.opencode/skills/release-proprietary/SKILL.md @@ -0,0 +1,207 @@ +--- +name: 'release-proprietary' +description: 'Orchestrate a full Packmind proprietary release: verify Main CI/CD Pipeline is green on both OSS and proprietary main, drive the release on the OSS sibling repo (version bumps, CHANGELOG, tag, push), wait for the oss-sync workflow to land the release commit on the proprietary fork, then tag and push `release/{{version}}` on the proprietary repo. Invoke from the proprietary repo.' +--- + +Create a full Packmind proprietary release with version `{{version}}`. + +This skill orchestrates a cross-repository release that spans the OSS sibling (`../packmind`, cloned next to the proprietary repo) and the proprietary repo itself (the current working directory). All real release content (version bumps, CHANGELOG) lives on OSS; the proprietary side receives the OSS commit through the `oss-sync` workflow and gets its own `release/{{version}}` tag pointing at a **different SHA** than the OSS tag — see below. + +**Invocation requirement:** run this skill from the root of the proprietary repo, with the OSS sibling checked out at `../packmind`. All commands below assume that layout. + +**Dual-SHA model — important:** + +* On **OSS**, the `release/{{version}}` tag points at the release commit itself (subject `chore: release {{version}}`). That commit's tree contains OSS code, which is what OSS deployments need. +* On **proprietary**, the same tag points at the **sync-merge commit** that brought the OSS release into proprietary `main`. The OSS release commit has no proprietary-only files in its tree, so tagging it on proprietary would break deployments (the build can't find `@packmind/proprietary/*` modules). The sync-merge commit's tree combines OSS-at-release with the proprietary-only files, which is what proprietary deployments need. + +The wait/tag scripts handle this for you — Phase 2 emits the proprietary merge SHA, and Phase 3 tags that SHA. + +Repository layout assumed by every command in this file: + +* OSS repo: `../packmind` (remote `PackmindHub/packmind`) +* Proprietary repo: `.` — the current working directory (remote `PackmindHub/packmind-proprietary`) + +Scripts live next to this file under `./scripts/`. From the proprietary repo root, paths are `.claude/skills/release-proprietary/scripts/<name>`. + +## Phase 0 — Pre-flight (proprietary repo) + +### 0.1 Feature flags audit gate (MANDATORY) + +Before doing anything, stop and ask the user: + +> Has the `feature-flags-audit` skill been run on the **proprietary** repo for this release? (yes / no) + +If the answer is anything other than an explicit `yes`, abort and tell the user to run `feature-flags-audit` on the proprietary repo first. + +If the audit surfaced any loose / stale flags that should have been removed, instruct the user to: + +1. Check whether each loose flag also exists in the OSS sibling at `../packmind`. Most code flows from OSS → proprietary via `oss-sync`, so flags should generally be cleaned up on the OSS side. +2. Remove them in OSS first, let the oss-sync workflow land the cleanup on proprietary, then re-invoke this skill. + +### 0.2 No open `oss-sync` PR + +```bash +./.claude/skills/release-proprietary/scripts/check-oss-sync-pr.sh PackmindHub/packmind-proprietary +``` + +When the auto-sync hits a merge conflict it opens a PR on branch `oss-sync` instead of fast-forwarding. If such a PR is open, Phase 2 polling cannot succeed. Abort and ask the user to resolve and merge the PR first. + +### 0.3 CI green on both repos + +Run the check script for both repositories: + +```bash +./.claude/skills/release-proprietary/scripts/check-ci.sh PackmindHub/packmind +./.claude/skills/release-proprietary/scripts/check-ci.sh PackmindHub/packmind-proprietary +``` + +Exit codes: + +* `0` — green, proceed. +* `1` — the run failed (or no run found, or auth missing). Abort, surface the URL. +* `2` — the run is still in progress / queued. Not a failure: ask the user whether to wait and retry, or abort. + +### 0.4 Clean working tree on proprietary and no local divergence + +```bash +git status --porcelain # must be empty +git fetch origin main +git merge-base --is-ancestor HEAD origin/main \ + || (echo "local main has diverged from origin/main — reconcile before releasing" && exit 1) +``` + +If the working tree is dirty, or local `HEAD` is not an ancestor of `origin/main`, abort and ask the user to reconcile. + +## Phase 1 — Drive the OSS release (`../packmind`) + +All commands in this phase target the OSS repo via `git -C` or `cd`. + +### 1.1 Verify clean OSS state and update + +```bash +git -C ../packmind status --porcelain # must be empty +git -C ../packmind checkout main +git -C ../packmind pull --ff-only origin main +``` + +If the working tree is dirty or the pull is not fast-forward, abort and ask the user to reconcile. + +### 1.2 Bump versions + +```bash +node ./.claude/skills/release-proprietary/scripts/bump-versions.mjs {{version}} ../packmind +( cd ../packmind && npm install ) +``` + +`npm install` regenerates `package-lock.json` with the new version. + +### 1.3 Rewrite CHANGELOG for release + +Resolve today's date in ISO 8601 (`YYYY-MM-DD`) — call it `{{today}}`. + +```bash +node ./.claude/skills/release-proprietary/scripts/changelog-release.mjs {{version}} {{today}} ../packmind +``` + +### 1.4 Commit the release and push main + +```bash +./.claude/skills/release-proprietary/scripts/commit-release.sh ../packmind {{version}} +``` + +This stages `package.json`, `apps/api/docker-package.json`, `package-lock.json`, and `CHANGELOG.MD`, commits with the exact subject `chore: release {{version}}` (Phase 2 polls for this fixed string), and pushes main. The script never uses `--no-verify`. + +### 1.5 Tag the release and push the tag + +```bash +./.claude/skills/release-proprietary/scripts/tag-release.sh ../packmind {{version}} +``` + +Creates `release/{{version}}` at HEAD (the release commit just pushed) and pushes the tag. If the tag already exists, the script verifies it points at HEAD before re-pushing. + +### 1.6 Prepare next dev cycle on OSS + +```bash +node ./.claude/skills/release-proprietary/scripts/changelog-next.mjs {{version}} ../packmind +./.claude/skills/release-proprietary/scripts/commit-next-cycle.sh ../packmind +``` + +## Phase 2 — Wait for oss-sync to land on proprietary + +The `sync-from-oss-repository.yml` workflow on the proprietary repo merges OSS commits into proprietary `main` automatically (usually under a minute). It will land BOTH the release commit AND the subsequent "prepare next development cycle" commit; each goes through its own sync-merge commit on proprietary `main`. + +```bash +PROP_TAG_SHA=$(./.claude/skills/release-proprietary/scripts/wait-for-oss-sync.sh \ + . {{version}}) +``` + +The script writes the **proprietary sync-merge SHA** to stdout (captured into `PROP_TAG_SHA`) and progress to stderr. Internally it: + +1. Polls `origin/main` every 5 seconds for the OSS release commit (subject exactly `chore: release {{version}}`), with a 10-minute timeout. +2. Once found, locates the proprietary merge commit whose **2nd parent** is that OSS commit — that's the upstream side of the sync merge. Requiring the OSS release to be the direct 2nd parent (not a grandparent) ensures the merge's tree is exactly "OSS at release + proprietary state at that moment" — i.e. version `{{version}}`, not `{{next}}-next`. + +This is the SHA you tag on proprietary. It is NOT the OSS release SHA — that one's tree contains only OSS code and would break proprietary deployments. See the "Dual-SHA model" note at the top. + +If it times out, abort and instruct the user to: + +* Check the `sync-from-oss-repository` workflow on `PackmindHub/packmind-proprietary`. +* Check whether an open `oss-sync` PR appeared after Phase 0 ran. + +If the script reports `OSS release commit … is on proprietary origin/main, but no merge commit has it as its direct upstream (2nd) parent`, the auto-sync either fast-forwarded or batched release + next-cycle into one merge. The operator must resolve manually before continuing — there is no merge commit with the right tree to tag. + +Once `PROP_TAG_SHA` is set, fast-forward the local proprietary checkout (purely to keep the working tree in sync — we do NOT rely on HEAD for the tag): + +```bash +git checkout main +git pull --ff-only origin main +``` + +## Phase 3 — Tag proprietary + +### 3.1 Re-check proprietary CI green + +The sync just pushed new commits to proprietary main; the Phase 0 CI gate is now stale. Re-run it (allowing exit 2 = still running) and either wait or abort: + +```bash +./.claude/skills/release-proprietary/scripts/check-ci.sh PackmindHub/packmind-proprietary +``` + +### 3.2 Tag the proprietary release commit + +```bash +./.claude/skills/release-proprietary/scripts/tag-release.sh \ + . {{version}} "${PROP_TAG_SHA}" +``` + +Passing the SHA explicitly is essential — tagging `HEAD` would tag a later sync-merge or the next-cycle commit. The script re-verifies that the target is either a direct release commit OR a merge commit whose 2nd parent is the release commit, and refuses to tag otherwise. It will not allow retagging an existing tag at a different commit. + +### 3.3 Done + +Report to the user: + +* OSS release commit URL: `https://github.com/PackmindHub/packmind/releases/tag/release/{{version}}` (or the compare link) +* Proprietary tag URL: `https://github.com/PackmindHub/packmind-proprietary/releases/tag/release/{{version}}` +* Reminder: any deployment workflow that watches `release/*` tags will trigger. + +## Important notes + +* Do NOT use `--no-verify` on any commit. If a hook fails, fix the root cause and create a new commit. +* The release commit subject MUST be exactly `chore: release {{version}}` — Phase 2 matches subjects by exact equality and `tag-release.sh` verifies subject equality before tagging. Any prefix/suffix (e.g. gitmoji) will break the flow. +* Date format MUST be ISO 8601 (`YYYY-MM-DD`). +* Verify each commit and push succeeded before proceeding to the next step. +* If anything fails mid-way after the OSS tag is pushed, do NOT delete the OSS tag — coordinate a follow-up patch release instead. + +## Recovery cheatsheet + +If the flow fails at a known phase, recover as follows: + +* **After 1.4, before 1.5** (release commit pushed, no OSS tag yet): re-run `tag-release.sh <oss-dir> {{version}}` from Phase 1.5. The script tags HEAD only if HEAD's subject is `chore: release {{version}}`. +* **After 1.5, before 1.6** (OSS is tagged, no next-cycle commit): re-run `changelog-next.mjs` + `commit-next-cycle.sh`. Then proceed to Phase 2. +* **After 1.6, Phase 2 timed out**: investigate the sync workflow / oss-sync PR. Once unstuck, re-run only Phase 2 + Phase 3. +* **After Phase 3 failure** (OSS tagged, proprietary not tagged): once any blocker is cleared, re-run only Phase 3, passing the same `PROP_TAG_SHA`. Recover it by re-running `wait-for-oss-sync.sh` (idempotent — it finds and emits the same merge SHA as before) or manually with: + ```bash + OSS_SHA=$(git log origin/main \ + --format='%H%x09%s' -500 | awk -F '\t' -v subj="chore: release {{version}}" '$2 == subj { print $1; exit }') + git log origin/main --merges \ + --format='%H %P' -500 | awk -v oss="${OSS_SHA}" '$3 == oss { print $1; exit }' + ``` \ No newline at end of file diff --git a/.opencode/skills/release-proprietary/scripts/bump-versions.mjs b/.opencode/skills/release-proprietary/scripts/bump-versions.mjs new file mode 100755 index 000000000..86f17a5ac --- /dev/null +++ b/.opencode/skills/release-proprietary/scripts/bump-versions.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// Bump version field in package.json and apps/api/docker-package.json to the +// provided version. Preserves 2-space indentation and trailing newline. +// +// Usage: node bump-versions.mjs <version> <repo-dir> + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const [, , version, repoDirArg] = process.argv; + +if (!version || !repoDirArg) { + console.error('usage: bump-versions.mjs <version> <repo-dir>'); + process.exit(1); +} + +const repoDir = resolve(repoDirArg); +const targets = ['package.json', 'apps/api/docker-package.json']; + +for (const rel of targets) { + const path = join(repoDir, rel); + const raw = readFileSync(path, 'utf8'); + const json = JSON.parse(raw); + const previous = json.version; + json.version = version; + const endsWithNewline = raw.endsWith('\n'); + writeFileSync(path, JSON.stringify(json, null, 2) + (endsWithNewline ? '\n' : '')); + console.log(`bumped ${rel}: ${previous} → ${version}`); +} diff --git a/.opencode/skills/release-proprietary/scripts/changelog-next.mjs b/.opencode/skills/release-proprietary/scripts/changelog-next.mjs new file mode 100755 index 000000000..1d74c49e5 --- /dev/null +++ b/.opencode/skills/release-proprietary/scripts/changelog-next.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// Mutate CHANGELOG.MD for the next development cycle: +// - Prepend a fresh `# [Unreleased]` section with empty Added/Changed/Fixed/Removed +// - Insert `[Unreleased]: ...compare/release/{version}...HEAD` link just above +// the `[{version}]: ...` link in the bottom link section +// +// Usage: node changelog-next.mjs <version> <repo-dir> + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const [, , version, repoDirArg] = process.argv; + +if (!version || !repoDirArg) { + console.error('usage: changelog-next.mjs <version> <repo-dir>'); + process.exit(1); +} + +const path = join(resolve(repoDirArg), 'CHANGELOG.MD'); +const original = readFileSync(path, 'utf8'); + +const firstHeadingMatch = original.match(/^# \[/m); +if (!firstHeadingMatch) { + console.error('CHANGELOG.MD: could not find any `# [` heading'); + process.exit(1); +} +const insertAt = firstHeadingMatch.index; + +const unreleasedBlock = + '# [Unreleased]\n\n## Added\n\n## Changed\n\n## Fixed\n\n## Removed\n\n'; + +let result = original.slice(0, insertAt) + unreleasedBlock + original.slice(insertAt); + +const versionLinkRegex = new RegExp( + `^\\[${version.replace(/\./g, '\\.')}\\]: (https://github\\.com/[^/]+/[^/]+)/compare/release/[0-9A-Za-z._-]+\\.\\.\\.release/${version.replace(/\./g, '\\.')}\\s*$`, + 'm', +); +const versionLinkMatch = result.match(versionLinkRegex); +if (!versionLinkMatch) { + console.error(`CHANGELOG.MD: could not find \`[${version}]: ...\` compare link`); + process.exit(1); +} +const repoUrl = versionLinkMatch[1]; +const unreleasedLink = `[Unreleased]: ${repoUrl}/compare/release/${version}...HEAD`; + +result = result.replace(versionLinkRegex, `${unreleasedLink}\n${versionLinkMatch[0]}`); + +writeFileSync(path, result); +console.log(`changelog: prepared next cycle after ${version}`); diff --git a/.opencode/skills/release-proprietary/scripts/changelog-release.mjs b/.opencode/skills/release-proprietary/scripts/changelog-release.mjs new file mode 100755 index 000000000..db97d5aea --- /dev/null +++ b/.opencode/skills/release-proprietary/scripts/changelog-release.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// Mutate CHANGELOG.MD for a release: +// - Rename `# [Unreleased]` heading to `# [{version}] - {date}` +// - Drop empty `## Added|Changed|Fixed|Removed` subsections inside that block +// - Swap the bottom compare link +// `[Unreleased]: ...compare/release/{prev}...HEAD` +// for +// `[{version}]: ...compare/release/{prev}...release/{version}` +// +// Usage: node changelog-release.mjs <version> <date> <repo-dir> + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const [, , version, date, repoDirArg] = process.argv; + +if (!version || !date || !repoDirArg) { + console.error('usage: changelog-release.mjs <version> <date> <repo-dir>'); + process.exit(1); +} + +const path = join(resolve(repoDirArg), 'CHANGELOG.MD'); +const original = readFileSync(path, 'utf8'); + +const unreleasedHeading = original.match(/^# \[Unreleased\][^\n]*$/m); +if (!unreleasedHeading) { + console.error('CHANGELOG.MD: could not find `# [Unreleased]` heading'); + process.exit(1); +} + +const start = unreleasedHeading.index; +const after = original.slice(start + unreleasedHeading[0].length); +const nextHeadingRel = after.match(/^# \[/m); +if (!nextHeadingRel) { + console.error('CHANGELOG.MD: could not find next `# [` heading after Unreleased'); + process.exit(1); +} +const blockEnd = start + unreleasedHeading[0].length + nextHeadingRel.index; +const block = original.slice(start, blockEnd); + +const lines = block.split('\n'); +const out = [`# [${version}] - ${date}`]; +let i = 1; +while (i < lines.length) { + const line = lines[i]; + if (line.startsWith('## ')) { + let j = i + 1; + while (j < lines.length && !lines[j].startsWith('## ')) j++; + const body = lines.slice(i + 1, j).join('\n').trim(); + if (body.length > 0) { + out.push(line); + for (let k = i + 1; k < j; k++) out.push(lines[k]); + } + i = j; + } else { + out.push(line); + i++; + } +} + +let newBlock = out.join('\n'); +if (!newBlock.endsWith('\n\n')) { + newBlock = newBlock.replace(/\n*$/, '\n\n'); +} + +let result = original.slice(0, start) + newBlock + original.slice(blockEnd); + +const linkRegex = /^\[Unreleased\]: (https:\/\/github\.com\/[^/]+\/[^/]+)\/compare\/release\/([0-9A-Za-z._-]+)\.\.\.HEAD\s*$/m; +const linkMatch = result.match(linkRegex); +if (!linkMatch) { + console.error('CHANGELOG.MD: could not find `[Unreleased]: .../compare/release/<prev>...HEAD` link'); + process.exit(1); +} +const repoUrl = linkMatch[1]; +const prev = linkMatch[2]; +const newLink = `[${version}]: ${repoUrl}/compare/release/${prev}...release/${version}`; +result = result.replace(linkRegex, newLink); + +writeFileSync(path, result); +console.log(`changelog: released ${version} (date ${date}, previous ${prev})`); diff --git a/.opencode/skills/release-proprietary/scripts/check-ci.sh b/.opencode/skills/release-proprietary/scripts/check-ci.sh new file mode 100755 index 000000000..f9cf727cc --- /dev/null +++ b/.opencode/skills/release-proprietary/scripts/check-ci.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Verify that the "Main CI/CD Pipeline" workflow on the latest commit of the +# main branch of a given GitHub repository is green. +# +# Usage: check-ci.sh <owner/repo> +# Exit codes: +# 0 - latest run on main concluded successfully (green) +# 1 - latest run failed / cancelled / no run found / gh auth missing +# 2 - latest run is still in progress or queued (not red, just not done) + +set -euo pipefail + +REPO="${1:?usage: check-ci.sh <owner/repo>}" + +if ! command -v gh >/dev/null 2>&1; then + echo "❌ gh CLI not found in PATH" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "❌ jq not found in PATH" >&2 + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "❌ gh is not authenticated — run \`gh auth login\` first" >&2 + exit 1 +fi + +HEAD_SHA=$(gh api "repos/${REPO}/commits/main" --jq .sha 2>/dev/null || true) +if [ -z "${HEAD_SHA}" ]; then + echo "❌ ${REPO}: could not resolve main HEAD SHA (does the repo exist and do you have access?)" >&2 + exit 1 +fi +SHORT_SHA="${HEAD_SHA:0:7}" + +RUN_JSON=$(gh run list \ + --repo "${REPO}" \ + --workflow=main.yml \ + --branch=main \ + --limit=20 \ + --json databaseId,status,conclusion,headSha,url,createdAt) + +MATCH=$(echo "${RUN_JSON}" | jq --arg sha "${HEAD_SHA}" ' + [ .[] | select(.headSha == $sha) ] | sort_by(.createdAt) | reverse | .[0] // empty +') + +if [ -z "${MATCH}" ] || [ "${MATCH}" = "null" ]; then + echo "❌ ${REPO}: no Main CI/CD Pipeline run found for current main HEAD (${SHORT_SHA})" >&2 + echo " Most recent runs on main:" >&2 + echo "${RUN_JSON}" | jq -r '.[] | " - sha=\(.headSha[0:7]) status=\(.status) conclusion=\(.conclusion) \(.url)"' >&2 + exit 1 +fi + +STATUS=$(echo "${MATCH}" | jq -r '.status') +CONCL=$(echo "${MATCH}" | jq -r '.conclusion') +URL=$(echo "${MATCH}" | jq -r '.url') + +case "${STATUS}" in + completed) + case "${CONCL}" in + success) + echo "✅ ${REPO}: Main CI/CD Pipeline green on main (${SHORT_SHA})" + echo " ${URL}" + exit 0 + ;; + *) + echo "❌ ${REPO}: Main CI/CD Pipeline concluded '${CONCL}' on main (${SHORT_SHA})" >&2 + echo " ${URL}" >&2 + exit 1 + ;; + esac + ;; + queued|in_progress|waiting|requested|pending) + echo "⏳ ${REPO}: Main CI/CD Pipeline still '${STATUS}' on main (${SHORT_SHA})" >&2 + echo " ${URL}" >&2 + echo " This is not a failure — re-run check-ci.sh after the run completes." >&2 + exit 2 + ;; + *) + echo "❌ ${REPO}: Main CI/CD Pipeline status '${STATUS}' on main (${SHORT_SHA}) — unexpected state" >&2 + echo " ${URL}" >&2 + exit 1 + ;; +esac diff --git a/.opencode/skills/release-proprietary/scripts/check-oss-sync-pr.sh b/.opencode/skills/release-proprietary/scripts/check-oss-sync-pr.sh new file mode 100755 index 000000000..20cac1bae --- /dev/null +++ b/.opencode/skills/release-proprietary/scripts/check-oss-sync-pr.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Verify there is no open `oss-sync` PR on the proprietary repository. +# +# Background: the sync-from-oss-repository workflow fast-forwards OSS commits +# onto proprietary main when there are no conflicts. When conflicts occur, it +# pushes branch `oss-sync` and opens a PR for manual review instead. If such +# a PR is open when we run a release, Phase 2 polling will never find the +# release commit on origin/main and will time out. +# +# Usage: check-oss-sync-pr.sh <owner/repo> +# Exit codes: +# 0 - no open oss-sync PR +# 1 - open oss-sync PR found (PR details printed to stderr) +# 2 - gh CLI / auth issue + +set -euo pipefail + +REPO="${1:?usage: check-oss-sync-pr.sh <owner/repo>}" + +if ! command -v gh >/dev/null 2>&1; then + echo "❌ gh CLI not found in PATH" >&2 + exit 2 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "❌ gh is not authenticated — run \`gh auth login\` first" >&2 + exit 2 +fi + +PR_JSON=$(gh pr list \ + --repo "${REPO}" \ + --head oss-sync \ + --state open \ + --json number,url,title) + +COUNT=$(echo "${PR_JSON}" | jq 'length') + +if [ "${COUNT}" -gt 0 ]; then + echo "❌ ${REPO}: an open oss-sync PR is blocking the auto-sync — resolve it before releasing." >&2 + echo "${PR_JSON}" | jq -r '.[] | " - PR #\(.number) \(.title) — \(.url)"' >&2 + exit 1 +fi + +echo "✅ ${REPO}: no open oss-sync PR" diff --git a/.opencode/skills/release-proprietary/scripts/commit-next-cycle.sh b/.opencode/skills/release-proprietary/scripts/commit-next-cycle.sh new file mode 100755 index 000000000..d03bd627c --- /dev/null +++ b/.opencode/skills/release-proprietary/scripts/commit-next-cycle.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Stage CHANGELOG.MD in <repo-dir>, commit with the exact subject +# `chore: prepare next development cycle`, and push main to origin. +# +# Usage: commit-next-cycle.sh <repo-dir> + +set -euo pipefail + +REPO_DIR="${1:?usage: commit-next-cycle.sh <repo-dir>}" + +cd "${REPO_DIR}" + +git add -- CHANGELOG.MD + +if git diff --cached --quiet; then + echo "❌ ${REPO_DIR}: nothing staged for next-cycle commit — did changelog-next.mjs run?" >&2 + exit 1 +fi + +git commit -m "chore: prepare next development cycle" +git push origin main + +echo "✅ ${REPO_DIR}: pushed \"chore: prepare next development cycle\"" diff --git a/.opencode/skills/release-proprietary/scripts/commit-release.sh b/.opencode/skills/release-proprietary/scripts/commit-release.sh new file mode 100755 index 000000000..166a11d3d --- /dev/null +++ b/.opencode/skills/release-proprietary/scripts/commit-release.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Stage the 4 release files in <repo-dir>, commit with the exact subject +# `chore: release <version>`, and push main to origin. +# +# Usage: commit-release.sh <repo-dir> <version> +# +# Never uses --no-verify. If a hook fails, fix the root cause and re-run. + +set -euo pipefail + +REPO_DIR="${1:?usage: commit-release.sh <repo-dir> <version>}" +VERSION="${2:?usage: commit-release.sh <repo-dir> <version>}" + +FILES=( + "package.json" + "apps/api/docker-package.json" + "package-lock.json" + "CHANGELOG.MD" +) + +cd "${REPO_DIR}" + +for f in "${FILES[@]}"; do + if [ ! -f "${f}" ]; then + echo "❌ ${REPO_DIR}: expected release file is missing: ${f}" >&2 + exit 1 + fi +done + +git add -- "${FILES[@]}" + +if git diff --cached --quiet; then + echo "❌ ${REPO_DIR}: nothing staged for release commit — did the bump/changelog scripts run?" >&2 + exit 1 +fi + +git commit -m "chore: release ${VERSION}" +git push origin main + +echo "✅ ${REPO_DIR}: pushed release commit \"chore: release ${VERSION}\"" diff --git a/.opencode/skills/release-proprietary/scripts/tag-release.sh b/.opencode/skills/release-proprietary/scripts/tag-release.sh new file mode 100755 index 000000000..56ffae1ff --- /dev/null +++ b/.opencode/skills/release-proprietary/scripts/tag-release.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Create the `release/<version>` tag in <repo-dir> and push it to origin. +# +# Usage: tag-release.sh <repo-dir> <version> [sha] +# +# If <sha> is provided, the tag is created at that exact commit. The +# commit's subject is verified before tagging — but for proprietary, the +# SHA we tag is a merge commit (subject "Merge remote-tracking branch +# 'upstream/main'") whose 2nd parent is the OSS release commit. So the +# check accepts EITHER: +# - a commit whose subject is `chore: release <version>` (OSS side, or +# a direct release commit), OR +# - a merge commit whose 2nd parent's subject is `chore: release <version>` +# (proprietary sync-merge of the OSS release). +# This safety net ensures we don't tag the wrong commit when the SHA is +# passed in by hand or by a partially recovered flow. +# +# If <sha> is omitted, the tag is created at HEAD (use on the OSS repo +# immediately after pushing the release commit, before any next-cycle commit). +# HEAD's subject must be exactly `chore: release <version>` in that case. + +set -euo pipefail + +REPO_DIR="${1:?usage: tag-release.sh <repo-dir> <version> [sha]}" +VERSION="${2:?usage: tag-release.sh <repo-dir> <version> [sha]}" +SHA_ARG="${3:-}" +TAG="release/${VERSION}" +EXPECTED_SUBJECT="chore: release ${VERSION}" + +cd "${REPO_DIR}" + +# Verify that <sha> is either the release commit itself OR a merge commit +# whose 2nd parent is the release commit. Returns 0 if OK, 1 otherwise, +# and prints a human-readable description of the match on success. +verify_release_target() { + local sha=$1 + local actual_subject + actual_subject=$(git log -1 --format='%s' "${sha}") + if [ "${actual_subject}" = "${EXPECTED_SUBJECT}" ]; then + echo "direct release commit" + return 0 + fi + # Maybe it's a merge commit whose 2nd parent is the release. + local second_parent + second_parent=$(git log -1 --format='%P' "${sha}" | awk '{print $2}') + if [ -n "${second_parent}" ]; then + local parent_subject + parent_subject=$(git log -1 --format='%s' "${second_parent}") + if [ "${parent_subject}" = "${EXPECTED_SUBJECT}" ]; then + echo "merge commit (2nd parent ${second_parent:0:7} is the release)" + return 0 + fi + fi + echo "neither subject \"${actual_subject}\" nor any merged parent matches \"${EXPECTED_SUBJECT}\"" + return 1 +} + +if [ -n "${SHA_ARG}" ]; then + TARGET_SHA=$(git rev-parse --verify "${SHA_ARG}" 2>/dev/null || true) + if [ -z "${TARGET_SHA}" ]; then + echo "❌ ${REPO_DIR}: provided SHA '${SHA_ARG}' is not a valid object" >&2 + exit 1 + fi + if ! MATCH_DESC=$(verify_release_target "${TARGET_SHA}"); then + echo "❌ ${REPO_DIR}: ${TARGET_SHA:0:7} ${MATCH_DESC}" >&2 + exit 1 + fi + echo "ℹ️ ${REPO_DIR}: ${TARGET_SHA:0:7} accepted — ${MATCH_DESC}" +else + TARGET_SHA=$(git rev-parse HEAD) + ACTUAL_SUBJECT=$(git log -1 --format='%s' HEAD) + if [ "${ACTUAL_SUBJECT}" != "${EXPECTED_SUBJECT}" ]; then + echo "❌ ${REPO_DIR}: HEAD subject is \"${ACTUAL_SUBJECT}\", expected \"${EXPECTED_SUBJECT}\"" >&2 + echo " Refusing to tag a commit that isn't the release commit." >&2 + exit 1 + fi +fi + +if git rev-parse --verify --quiet "refs/tags/${TAG}" >/dev/null; then + EXISTING_SHA=$(git rev-parse "refs/tags/${TAG}") + if [ "${EXISTING_SHA}" != "${TARGET_SHA}" ]; then + echo "❌ ${REPO_DIR}: tag ${TAG} already exists at ${EXISTING_SHA:0:7}, refusing to retag at ${TARGET_SHA:0:7}" >&2 + exit 1 + fi + echo "ℹ️ ${REPO_DIR}: tag ${TAG} already exists at target — skipping creation" +else + git tag "${TAG}" "${TARGET_SHA}" +fi + +git push origin "${TAG}" + +echo "✅ ${REPO_DIR}: pushed tag ${TAG} at ${TARGET_SHA:0:7}" diff --git a/.opencode/skills/release-proprietary/scripts/wait-for-oss-sync.sh b/.opencode/skills/release-proprietary/scripts/wait-for-oss-sync.sh new file mode 100755 index 000000000..f234dee23 --- /dev/null +++ b/.opencode/skills/release-proprietary/scripts/wait-for-oss-sync.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Poll the proprietary repo's origin/main until the OSS release commit has +# been merged in by the auto-sync workflow, then emit the SHA of the +# **proprietary merge commit** (NOT the OSS release commit itself). +# +# Why the merge commit and not the release commit: +# The OSS release commit (e.g. `chore: release 1.15.0`) is created on OSS, +# so its tree contains OSS-only files. When the proprietary auto-sync +# brings it into proprietary main, the resulting merge commit's tree +# combines the OSS release with the proprietary-only files. Tagging the +# merge commit is what we want — tagging the raw OSS commit would point +# `release/<version>` at an OSS-only tree, breaking proprietary +# deployments (the build can't find `@packmind/proprietary/*` modules). +# +# The script: +# 1. Polls origin/main for the OSS release commit (by exact subject). +# 2. Once found, locates the proprietary merge commit whose **2nd parent** +# is that OSS commit. The 2nd parent is the upstream side of the sync +# merge. Requiring the OSS release commit to be the *direct* upstream +# parent (not a grandparent) ensures the merge's tree is exactly +# "OSS at release + proprietary state at that moment" — i.e. version +# X.Y.Z, not X.Y.(Z+1)-next. +# 3. Emits the merge SHA on stdout. Progress messages go to stderr. +# +# Usage: wait-for-oss-sync.sh <prop-repo-dir> <version> [timeout-seconds] +# Defaults: timeout-seconds=600 (10 minutes) +# Exit codes: +# 0 - proprietary merge commit detected (SHA on stdout) +# 1 - timed out, or sync arrived but no suitable merge commit exists +# (e.g. fast-forward, or auto-sync batched release + next-cycle into +# one merge — in that case the operator must resolve manually). + +set -euo pipefail + +PROP_DIR="${1:?usage: wait-for-oss-sync.sh <prop-repo-dir> <version> [timeout-seconds]}" +VERSION="${2:?usage: wait-for-oss-sync.sh <prop-repo-dir> <version> [timeout-seconds]}" +TIMEOUT="${3:-600}" + +SUBJECT="chore: release ${VERSION}" +INTERVAL=5 +ELAPSED=0 + +echo "⏳ Polling ${PROP_DIR} origin/main for OSS commit \"${SUBJECT}\" and its proprietary merge (timeout ${TIMEOUT}s)…" >&2 + +while [ "${ELAPSED}" -lt "${TIMEOUT}" ]; do + git -C "${PROP_DIR}" fetch --quiet origin main + + # Step 1: find the OSS release commit on proprietary main (by exact subject) + OSS_SHA=$(git -C "${PROP_DIR}" log origin/main --format='%H%x09%s' -500 \ + | awk -F '\t' -v subj="${SUBJECT}" '$2 == subj { print $1; exit }') + + if [ -n "${OSS_SHA}" ]; then + # Step 2: find the proprietary merge commit whose 2nd parent is the OSS release. + # %P emits parents space-separated; on a merge commit, $2 is parent1 + # (prior proprietary tip) and $3 is parent2 (the upstream side). + MERGE_SHA=$(git -C "${PROP_DIR}" log origin/main --merges --format='%H %P' -500 \ + | awk -v oss="${OSS_SHA}" '$3 == oss { print $1; exit }') + + if [ -n "${MERGE_SHA}" ]; then + echo "✅ Found proprietary merge commit: ${MERGE_SHA:0:7} (merges OSS release ${OSS_SHA:0:7})" >&2 + echo "${MERGE_SHA}" + exit 0 + fi + + echo "⚠️ OSS release commit ${OSS_SHA:0:7} is on proprietary origin/main, but no merge commit has it as its direct upstream (2nd) parent." >&2 + echo " This can happen if the auto-sync fast-forwarded (unlikely if proprietary has local commits) or batched the release with later OSS commits into one merge." >&2 + echo " Proprietary cannot be tagged automatically — investigate the sync history before proceeding." >&2 + exit 1 + fi + + sleep "${INTERVAL}" + ELAPSED=$((ELAPSED + INTERVAL)) +done + +echo "❌ Timed out after ${TIMEOUT}s waiting for OSS release commit \"${SUBJECT}\" to land on proprietary origin/main" >&2 +echo " Check the sync-from-oss-repository workflow status and any pending oss-sync PR." >&2 +exit 1 diff --git a/.opencode/skills/upgrade-runtime-stack/SKILL.md b/.opencode/skills/upgrade-runtime-stack/SKILL.md new file mode 100644 index 000000000..04da3a3de --- /dev/null +++ b/.opencode/skills/upgrade-runtime-stack/SKILL.md @@ -0,0 +1,137 @@ +--- +name: 'upgrade-runtime-stack' +description: 'Check whether newer stable versions of Node.js (24.x line), Nx, or Vite are available and, if so, generate a detailed upgrade plan markdown file at the repo root. Use this skill whenever the user asks to "check for runtime upgrades", "upgrade Node/NX/Vite", "is our Node version current", "plan a Node 24 upgrade", "refresh our runtime stack", "monthly stack check", or anything along those lines — even if they don''t name a specific tool. Also use it when the user wants a recurring/cadence check of build-toolchain currency. Output is a plan only — does NOT mutate package.json, Dockerfiles, lockfiles, or any other repo file. CI/CD wrappers can invoke this skill to keep the runtime stack fresh.' +--- + +# Upgrade Runtime Stack + +Goal: detect available stable upgrades of **Node.js (24.x line only)**, **Nx**, and **Vite**, then emit an actionable, ready-to-execute upgrade plan as a markdown file at the repo root. Stop at the plan — never edit application files. + +## Why this skill exists + +Manual runtime upgrades drift months between attempts and produce avoidable risk. This skill captures the canonical file map (mined from the prior Node 22 → 24 migration on `emdash/migration-node24-cc0s9`) so each upgrade run reuses the same checklist instead of rediscovering it. The plan is the deliverable. A human or a CI bot decides whether to act on it. + +## Execution mode + +This skill **must run fully non-interactive**. It is invoked from CI/CD in headless mode where no human can answer prompts. + +- Never call `AskUserQuestion` or any other clarification tool. +- Never ask the user to confirm a choice. The skill makes deterministic decisions from the inputs (baked URLs + repo state) and produces the plan. +- Never wait on long-running interactive commands. Read-only file inspection, `WebFetch`, and `rg` scans are the only actions needed. +- All output goes to a single deterministic artifact: `upgrade-plan.md` at the repo root. The terminal print at the end (Phase 7) is informational only — CI can ignore stdout. + +If any input is missing or ambiguous, the skill records the gap **inside the plan** (e.g. "Vite changelog unreachable this run") and continues. It never blocks on input. + +## Inputs + +The skill takes **no arguments**. Version sources are baked in: + +| Tool | Source URL | What to extract | +|------|------------|-----------------| +| Node.js 24.x | `https://raw.githubusercontent.com/nodejs/node/refs/heads/main/doc/changelogs/CHANGELOG_V24.md` | Latest stable 24.x release | +| Nx | `https://nx.dev/changelog` | Latest stable Nx major.minor.patch | +| Vite | `https://raw.githubusercontent.com/vitejs/vite/refs/heads/main/packages/vite/CHANGELOG.md` | Latest stable Vite release | + +See `references/fetch-versions.md` for the exact parsing rules per source. + +## Workflow + +Execute phases in order. Each phase has a single clear deliverable. Do not skip steps; the value of this skill comes from the consistency of the output. + +### Phase 1 — Read current versions from the repo + +Read these exact locations and record what is currently pinned: + +- `.nvmrc` → exact Node version (e.g. `24.15.0`) +- `package.json` (root) → `engines.node`, `engines.npm`, `devDependencies.nx`, `devDependencies["@nx/*"]`, `devDependencies.vite` +- `apps/api/docker-package.json` → `engines.node`, `engines.npm` +- `dockerfile/Dockerfile.api` and `dockerfile/Dockerfile.mcp` → `FROM node:<version>-alpine<alpine-version>@sha256:<digest>` +- `docker-compose.yml` and `docker-compose.production.yml` → every `image: node:<version>-alpine<alpine-version>` occurrence +- `.github/workflows/*.yml` → default `node-version` inputs + +Record in a single in-memory table; this becomes the "Current versions" section of the plan. + +### Phase 2 — Fetch latest stable versions + +Use `WebFetch` against each source URL listed in **Inputs**. Follow the per-source parsing rules in `references/fetch-versions.md`. + +Filtering rules (apply to every source): + +- **Skip** anything tagged `next`, `beta`, `alpha`, `rc`, `canary`, `preview`, `dev`, or `pre`. +- **Node.js**: only consider `v24.x.y` entries. Ignore 22.x, 26.x, etc. — even if newer. +- **Nx**: take the highest `X.Y.Z` published as a stable release. +- **Vite**: take the highest `X.Y.Z` published as a stable release. + +For each tool, also extract the **published date** if visible and a short summary of headline changes / breaking changes from the changelog body covering the range *(current version, latest]*. This summary feeds the risk section of the plan. + +### Phase 3 — Compute delta and decide + +For each tool, compare current vs. latest stable: + +- `latest == current` → no upgrade needed for that tool. Record as "up to date". +- `latest > current` → upgrade candidate. Classify the bump as `patch`, `minor`, or `major` using semver rules. Note any breaking-change headlines from Phase 2. +- `latest < current` → unusual. Record but do not recommend a downgrade. + +If **all three** tools are up to date, still produce `upgrade-plan.md` but with a single "No upgrades available" section plus the timestamp. This keeps the CI integration deterministic. + +### Phase 4 — Resolve Docker image dependencies + +When Node is being upgraded, the Docker image pin (`node:<version>-alpine<X>@sha256:<digest>`) must change in lockstep. The plan must include: + +1. The exact new tag string to use (`node:<new-version>-alpine<X>`). +2. The current Alpine major (read from existing Dockerfiles) — keep it unless a Node breaking change requires a different base. +3. An explicit instruction line: *"Look up the sha256 digest for `node:<new-version>-alpine<X>` on Docker Hub before pinning."* Do not fabricate a digest. + +If Node is not being upgraded, the Docker section of the plan only lists which files **would** change in a future Node bump, for reference. + +### Phase 5 — Map files to modify + +Use `references/file-map.md` as the authoritative list of files that any Node / Nx / Vite upgrade must touch in this repo. The map is grouped per tool. Include in the plan only the file groups whose tool has a pending upgrade. + +After listing the bake-in files, run a quick scan to surface drift — files that match the relevant version pattern but are not yet in the map: + +- Node version drift: `rg -n "node:[0-9]+\.[0-9]+\.[0-9]+|node-version: ['\"]?[0-9]+" --hidden -g '!node_modules' -g '!dist'` +- Nx version drift: `rg -n '"nx": "[0-9]+\.[0-9]+\.[0-9]+"|"@nx/[a-z-]+": "[0-9]+\.[0-9]+\.[0-9]+"' --hidden -g '!node_modules' -g '!dist'` +- Vite version drift: `rg -n '"vite": "(\^|~)?[0-9]+\.[0-9]+\.[0-9]+"' --hidden -g '!node_modules' -g '!dist'` + +Add any hits that are not already covered to a "Drift detected" subsection of the plan so a human can decide whether to extend the file map. + +### Phase 6 — Validation harness + +Copy the validation steps from `references/validation.md` into the plan. The harness is the contract for "this upgrade did not break the repo" and must be runnable end-to-end after applying the plan. + +### Phase 7 — Write `upgrade-plan.md` + +Use the exact structure defined in `references/plan-template.md`. Write to the repo root as `upgrade-plan.md`. Overwrite any existing file at that path — the plan is always the latest snapshot. + +The first line of the plan **must** be a machine-readable status comment so CI can branch on it without parsing the body: + +``` +<!-- upgrade-status: available | none | partial-fetch-failure --> +``` + +- `available` — at least one tool has a stable upgrade. +- `none` — all three tools up to date. +- `partial-fetch-failure` — one or more source URLs could not be fetched this run. + +After writing, print to stdout (informational only — CI may discard): + +- One-line summary per tool (e.g. `Node 24.15.0 → 24.17.0 (patch)`). +- The absolute path of the generated `upgrade-plan.md`. +- Whether any drift was detected (so the file map may need updating). + +Do **not** apply edits. Stop here. + +## Hard rules + +- Never edit `package.json`, `package-lock.json`, `.nvmrc`, Dockerfiles, docker-compose files, CI workflows, or any other source file. The skill produces a plan only. +- Never invent version numbers, dates, or sha256 digests. If a source URL cannot be fetched, mark the tool as "unknown — fetch failed" in the plan and continue with the other tools. +- Never recommend Node majors other than 24. The team is on the 24.x line and a major bump is a separate, deliberate project. +- Never include `rc`, `beta`, `alpha`, `canary`, `next`, `preview`, `dev`, or `pre` versions in the recommendation, even if newer than current. + +## Reference files + +- `references/fetch-versions.md` — per-source parsing rules for the three changelog URLs. +- `references/file-map.md` — canonical list of files an upgrade of each tool touches in this repo. +- `references/plan-template.md` — exact markdown layout of `upgrade-plan.md`. +- `references/validation.md` — lint / test / build commands that act as the safety harness. \ No newline at end of file diff --git a/.opencode/skills/upgrade-runtime-stack/references/fetch-versions.md b/.opencode/skills/upgrade-runtime-stack/references/fetch-versions.md new file mode 100644 index 000000000..8ca96d4e3 --- /dev/null +++ b/.opencode/skills/upgrade-runtime-stack/references/fetch-versions.md @@ -0,0 +1,52 @@ +# Fetching latest stable versions + +Per-source parsing rules. All sources are fetched with `WebFetch`. Always strip pre-release tags (`rc`, `beta`, `alpha`, `canary`, `next`, `preview`, `dev`, `pre`). + +## Node.js (24.x line only) + +- URL: `https://raw.githubusercontent.com/nodejs/node/refs/heads/main/doc/changelogs/CHANGELOG_V24.md` +- The file is the changelog for the entire Node 24 line. Section headers look like: + ``` + <a id="24.15.0"></a> + ## 2025-XX-YY, Version 24.15.0 (Current), @<release-manager> + ``` +- Parse the topmost `## YYYY-MM-DD, Version 24.X.Y` heading whose tag is **not** marked `(Pre-release)`, `(RC)`, etc. +- Output: + - `latest`: e.g. `24.17.0` + - `released`: e.g. `2025-09-12` + - `highlights`: bullet headings between this version's heading and the next version heading. Limit to ~10 short bullets. Skip commit-only bullets. +- The "Current" / "LTS" annotation may be present — record it but do not gate the recommendation on it; the 24.x line moves from Current to LTS during its life. + +## Nx + +- URL: `https://nx.dev/changelog` +- The page lists release entries newest-first. Each entry looks like a section with a version (e.g. `Nx 22.7.2`) and a date. +- Parse the topmost entry whose version is **not** suffixed with `-rc.N`, `-beta.N`, `-alpha.N`, `-canary.N`, `-next.N`. +- Output: + - `latest`: e.g. `22.7.2` + - `released`: e.g. `2025-09-30` + - `highlights`: bullet section titles from that entry (e.g. "Breaking changes", "Features", "Bug Fixes"). Capture any text explicitly under "Breaking changes" verbatim — it drives the risk section. +- If the major changes between current and latest, also fetch the matching `https://nx.dev/changelog#vNN-migration` anchor or the linked migration guide and include the URL in the plan. + +## Vite + +- URL: `https://raw.githubusercontent.com/vitejs/vite/refs/heads/main/packages/vite/CHANGELOG.md` +- Standard `keep-a-changelog` style. Headings look like: + ``` + ## 8.0.3 (YYYY-MM-DD) + ``` +- Parse the topmost `## X.Y.Z` heading without a pre-release tag. +- Output: + - `latest`: e.g. `8.1.0` + - `released`: e.g. `2025-10-04` + - `highlights`: subsection titles (`### Features`, `### Bug Fixes`, `### BREAKING CHANGES`). Capture any `BREAKING CHANGES` section verbatim. + +## Fallback when a source cannot be fetched + +If `WebFetch` returns an error or an empty body, do not block the run. In the plan, record: + +``` +- <tool>: unable to fetch <URL> — skipped this run. +``` + +The two remaining tools are still processed normally. diff --git a/.opencode/skills/upgrade-runtime-stack/references/file-map.md b/.opencode/skills/upgrade-runtime-stack/references/file-map.md new file mode 100644 index 000000000..db47a227b --- /dev/null +++ b/.opencode/skills/upgrade-runtime-stack/references/file-map.md @@ -0,0 +1,62 @@ +# Canonical file map per tool + +These lists were mined from the Node 22 → 24 migration branch `emdash/migration-node24-cc0s9`. They represent the *minimum* set of files that an upgrade of each tool must touch. The skill's Phase 5 also scans for drift in case the repo gained a new file matching the relevant pattern. + +A file may appear under more than one tool when an upgrade of any of those tools requires editing it. + +## Node.js (24.x) + +When the Node line shifts to a new patch (or minor within 24.x), update every concrete pin so all environments agree. + +**Engine / runtime pins:** +- `.nvmrc` — full `X.Y.Z` Node version, no `v` prefix. +- `package.json` (root) — `engines.node` and `engines.npm`. +- `apps/api/docker-package.json` — `engines.node` and `engines.npm` (this file is shipped inside the API Docker image as `package.json`). + +**Docker images:** +- `dockerfile/Dockerfile.api` — `FROM node:<version>-alpine<X>@sha256:<digest>`. The sha256 digest must be looked up on Docker Hub for the new tag; do not invent it. +- `dockerfile/Dockerfile.mcp` — same pattern. +- `docker-compose.yml` — every `image: node:<version>-alpine<X>` line. There are several services. +- `docker-compose.production.yml` — every `image: node:<version>-alpine<X>` line. + +**CI workflows:** +- `.github/workflows/build.yml` — `node-version` workflow input default and every matrix entry. +- `.github/workflows/main.yml` — same. +- `.github/workflows/docker.yml` — same. +- `.github/workflows/publish-cli-release.yml` — same. +- `.github/workflows/tmp-cli-lint-windows.yml` — same. + +**Helper scripts (kept around from the prior migration; usually paired):** +- `migrate_node24.sh` — invoked by humans to switch local env after a bump. +- `downgrade_node22.sh` — escape hatch back to the previous major; only relevant on a *major* Node bump. + +When the Node bump is patch- or minor-only, the two helper scripts can be left as-is. On a major bump (e.g. 24.x → 26.x), the plan must explicitly call out rewriting/removing them. + +## Nx + +When Nx is bumped, every `@nx/*` package and `nx` itself must move to the same version. The Nx CLI's own `nx migrate` workflow handles most edits, but the plan must list: + +- `package.json` (root) — `devDependencies.nx` and every `devDependencies["@nx/*"]`. Currently used scopes: `@nx/devkit`, `@nx/esbuild`, `@nx/eslint`, `@nx/eslint-plugin`, `@nx/jest`, `@nx/js`, `@nx/nest`, `@nx/node`, `@nx/playwright`, `@nx/plugin`, `@nx/react`, `@nx/storybook`, `@nx/vite`, `@nx/vitest`, `@nx/web`, `@nx/webpack`. +- `tools/packmind-plugin/package.json` — `@nx/*` deps referenced by the local Nx plugin. +- `nx.json` — schema / plugin configuration sometimes shifts between minors. +- `package-lock.json` — regenerated by `npm install` after the version bumps. +- `migrations.json` — created by `nx migrate <version>` at the repo root. The plan must include the explicit command `npx nx migrate <new-version>` and a follow-up `npx nx migrate --run-migrations`. + +**ESLint coupling:** the Nx ESLint plugin sometimes lands new flat-config requirements that affect `eslint.config.mjs` and `packages/ui/eslint.config.mjs`. List them as "review only" in the plan unless the changelog explicitly calls them out. + +## Vite + +When Vite is bumped, the consumers of Vite in this repo are: + +- `package.json` (root) — `devDependencies.vite`. +- `apps/frontend/vite.config.ts` — config surface; major bumps often change plugin or build options. +- `packages/ui/vite.config.ts` — same. +- `apps/frontend/package.json` — peer / dev deps may need realignment if the major changes. +- `packages/ui/package.json` — same. +- `package-lock.json` — regenerated. + +**Coupling with Nx:** `@nx/vite` and `@nx/vitest` carry their own Vite peer-dep ranges. On a Vite major bump, the plan must check the Nx changelog for compatibility before recommending the move; if Nx doesn't yet support the new Vite major, the plan recommends *waiting* and notes the blocking constraint. + +## Drift scan + +Phase 5 of the skill also runs three ripgrep scans (see `SKILL.md`) and reports any additional matches. Anything reported there is a candidate for adding to this file map after the upgrade lands. diff --git a/.opencode/skills/upgrade-runtime-stack/references/plan-template.md b/.opencode/skills/upgrade-runtime-stack/references/plan-template.md new file mode 100644 index 000000000..2ec5bebd9 --- /dev/null +++ b/.opencode/skills/upgrade-runtime-stack/references/plan-template.md @@ -0,0 +1,103 @@ +# `upgrade-plan.md` layout + +Write the plan to the **repo root** as `upgrade-plan.md`. Overwrite any existing file at that path. The structure below is mandatory — downstream tooling (and the human reader's mental model) depends on it. + +When a tool has no pending upgrade, still emit its section but populate it with "Up to date — no action required" so the file's shape stays stable. + +```markdown +<!-- upgrade-status: available | none | partial-fetch-failure --> +# Runtime stack upgrade plan + +_Generated: <YYYY-MM-DD HH:MM TZ> by the `upgrade-runtime-stack` skill._ + +## Summary + +| Tool | Current | Latest stable | Bump | Action | +|------|---------|---------------|------|--------| +| Node.js 24.x | <X.Y.Z> | <X.Y.Z> | patch / minor / major / none | Upgrade / Skip | +| Nx | <X.Y.Z> | <X.Y.Z> | patch / minor / major / none | Upgrade / Skip | +| Vite | <X.Y.Z> | <X.Y.Z> | patch / minor / major / none | Upgrade / Skip | + +<one-paragraph recommendation: e.g. "All three tools have stable updates available. Node and Nx are patch-only and safe; Vite is a minor with one deprecation worth reviewing."> + +## Node.js + +- **Current**: <X.Y.Z> (from `.nvmrc`) +- **Latest stable**: <X.Y.Z>, released <YYYY-MM-DD> +- **Bump type**: patch / minor / major +- **Changelog highlights**: + - <bullet> + - <bullet> +- **Breaking changes**: <verbatim section from changelog, or "none documented"> + +### Files to modify + +<file map — group by category from `references/file-map.md`, with exact line patterns to change> + +### Docker image pin + +- New tag: `node:<new-version>-alpine<X>` +- Action: look up the sha256 digest for that tag on Docker Hub before editing `dockerfile/Dockerfile.api` and `dockerfile/Dockerfile.mcp`. Do **not** reuse the previous digest. +- Sample lookup: `docker manifest inspect node:<new-version>-alpine<X> | jq -r '.manifests[0].digest'` or use the Docker Hub UI. + +## Nx + +- **Current**: <X.Y.Z> +- **Latest stable**: <X.Y.Z>, released <YYYY-MM-DD> +- **Bump type**: patch / minor / major +- **Changelog highlights**: + - <bullet> +- **Breaking changes**: <verbatim> +- **Migration command**: + ``` + npx nx migrate <new-version> + npm install + npx nx migrate --run-migrations + ``` + +### Files to modify + +<from `references/file-map.md` — Nx section> + +## Vite + +- **Current**: <X.Y.Z> +- **Latest stable**: <X.Y.Z>, released <YYYY-MM-DD> +- **Bump type**: patch / minor / major +- **Changelog highlights**: + - <bullet> +- **Breaking changes**: <verbatim> +- **Nx / Vite compatibility note**: <only if Vite is a major bump — confirm `@nx/vite` and `@nx/vitest` support the new Vite major> + +### Files to modify + +<from `references/file-map.md` — Vite section> + +## Drift detected + +<empty list, or any extra hits the Phase 5 scans found that are not in the file map> + +## Validation harness + +<paste the steps from `references/validation.md` verbatim> + +## Risks + +- <pre-flagged risk: e.g. "Node 24.X.Y removes deprecated experimental flag `--foo`; the repo does not use it (verified via rg)"> +- <every breaking-change bullet from the changelogs goes here, paired with a quick repo check> + +## Rollback + +- Revert the upgrade commit and run `npm install` to regenerate the lockfile. +- For a Node major rollback, `downgrade_node22.sh` exists for the 22 ↔ 24 transition; on later majors a similar helper must be created before applying. +- Docker images are pinned by `@sha256:...` so previous deploys are reproducible. + +## Suggested commit split + +A single PR is fine for patch/minor bumps of all three tools combined. Split into separate PRs when: + +- Any tool has a **major** bump. +- The Nx and Vite bumps would interact (e.g. Vite major + `@nx/vite` major). + +Suggested split for this run: <one-paragraph proposal> +``` diff --git a/.opencode/skills/upgrade-runtime-stack/references/validation.md b/.opencode/skills/upgrade-runtime-stack/references/validation.md new file mode 100644 index 000000000..c26764221 --- /dev/null +++ b/.opencode/skills/upgrade-runtime-stack/references/validation.md @@ -0,0 +1,73 @@ +# Validation harness + +After the upgrade plan is applied, these steps must all succeed before merging. Copy this section verbatim into `upgrade-plan.md`. + +The order matters — fail fast on cheap checks before paying for full builds. + +## Local + +1. **Node + npm match the pins** + ``` + node --version # expect: vNEW_NODE + npm --version # expect: NEW_NPM + ``` + If `nvm` is in use: `nvm use` should read the new `.nvmrc` automatically. + +2. **Clean install** + ``` + rm -rf node_modules package-lock.json # only on major bumps; skip for patch/minor + npm install + ``` + For patch/minor bumps prefer `npm install` against the existing lockfile so the diff stays auditable. + +3. **Lint affected** + ``` + npm run lint:staged + ``` + +4. **Test affected** + ``` + npm run test:staged + ``` + +5. **Build the heaviest targets** + ``` + ./node_modules/.bin/nx build api + ./node_modules/.bin/nx build frontend + ./node_modules/.bin/nx build cli + ./node_modules/.bin/nx build mcp-server + ``` + +6. **Docker build smoke test** (only when Node is bumped) + ``` + docker build -f dockerfile/Dockerfile.api -t packmind-api:upgrade-check . + docker build -f dockerfile/Dockerfile.mcp -t packmind-mcp:upgrade-check . + docker compose -f docker-compose.yml up -d api frontend + docker compose -f docker-compose.yml ps + docker compose -f docker-compose.yml down + ``` + +## CI + +The GitHub Actions workflows already test against the `node-version` matrix entries. After the plan is applied, the **Main CI/CD Pipeline** must be green on the branch before merging: + +- `.github/workflows/build.yml` +- `.github/workflows/main.yml` +- `.github/workflows/docker.yml` + +If any workflow runs `nx affected`, it picks up the changed files automatically and runs the relevant projects. + +## Manual smoke (post-merge) + +- Spin up the local stack: `docker compose up -d`. +- Open the frontend on its dev URL and confirm the app loads. +- Hit the API health endpoint. +- Run one MCP server interaction end-to-end. + +## Failure handling + +If any harness step fails: + +1. Capture the exact error in the upgrade PR description. +2. Revert with `git revert <upgrade-commit>` rather than amending — keeps history auditable. +3. Re-run the skill on the reverted branch to regenerate a fresh plan once the upstream fix lands. diff --git a/.opencode/skills/working-with-playground-app/SKILL.md b/.opencode/skills/working-with-playground-app/SKILL.md new file mode 100644 index 000000000..52f0fcdb7 --- /dev/null +++ b/.opencode/skills/working-with-playground-app/SKILL.md @@ -0,0 +1,182 @@ +--- +name: 'working-with-playground-app' +description: 'This skill provides guidance for building UI/UX prototypes in the Packmind playground app. It should be used when creating a new prototype, iterating on an existing prototype, or working with files in apps/playground/. Triggers on mentions of "playground", "prototype", or direct work within the apps/playground/ directory.' +--- + +# Working With the Playground App + +## Overview + +The playground app (`apps/playground/`) is a standalone Vite + React environment for iterating on UI/UX of Packmind features — both new and existing. Prototypes built here are meant to be easily convertible into production-ready code. + +## UX Design Thinking + +Before writing any code, think like a UX designer. The playground exists to explore how a feature *feels* to use — not just how it looks. Apply these principles to every prototype: + +### Design All States First + +Never prototype only the happy path. Before building, identify and plan for every state the user might encounter: + +- **Empty state** — What does the user see before any data exists? Guide them toward the first action. +- **Loading state** — What appears while data is being fetched? Use skeletons or spinners to set expectations. +- **Populated state** — The standard view with data. Test with both minimal and full datasets. +- **Error state** — What happens when something fails? Show a clear message and a recovery path. +- **Edge cases** — What about a single item? Hundreds? An extremely long name that might overflow? + +Prototype each state explicitly — use local state toggles or tabs to let reviewers switch between them. + +### Use Realistic, Varied Data + +Mock data should stress-test the design, not just fill space: + +- Include **long names** that could wrap or overflow, **short names** that could look sparse, and **special characters**. +- Vary counts: 0 items, 1 item, a handful, and many (50+). Layouts that work for 5 items often break at 50. +- Include **missing optional fields** — not every record will be complete. +- Use **recognizable but realistic** content so reviewers can evaluate readability, not just layout. + +### Design for Interaction + +Prototypes should be clickable and stateful, not static mockups: + +- Wire up buttons, toggles, and form inputs with local state so reviewers experience the flow. +- Simulate async operations (e.g., a brief delay before showing a success toast) to prototype timing and feedback. +- Show what happens *after* an action — does a list update? Does a confirmation appear? Does the user navigate somewhere? + +### Establish Visual Hierarchy + +Guide the user's eye to what matters most: + +- **One primary action per view.** If everything is bold, nothing is. Use a primary button for the main action, secondary/ghost for the rest. +- **Group related information** with spacing and borders, not just proximity. +- **Size and weight signal importance.** Headings, subheadings, and body text should create a clear reading order. +- **Use whitespace deliberately** — it separates sections and reduces cognitive load. + +### Make Affordance Obvious + +Users should understand what they can do without instructions: + +- **Clickable elements should look clickable.** Buttons look like buttons, links look like links. +- **Destructive actions should look dangerous** — use red/warning styling and require confirmation. +- **Disabled states should be visually distinct** and ideally explain *why* they're disabled (tooltip or helper text). +- **Feedback should be immediate** — hover states, active states, and loading indicators tell the user the system responded. + +### Manage Information Density + +Show enough to be useful, hide enough to avoid overwhelm: + +- **Lead with a summary, offer detail on demand.** Use accordions, expandable rows, or drill-down views for secondary information. +- **Don't front-load every field.** Tables and lists should show the most important 3–5 columns; offer a detail view for the rest. +- **Use progressive disclosure** — reveal complexity as the user engages deeper (overview → detail → advanced settings). + +### Stay Consistent With the Product + +Before designing something new, check how the existing product handles similar patterns: + +- **Browse `apps/frontend/src/domain/`** for existing feature implementations. If a similar interaction exists, match its layout and behavior before inventing alternatives. +- **Reuse established patterns** — if the product uses a sidebar + main content layout for listings, don't prototype a card grid for the same type of data without good reason. +- **Follow the same information architecture** — similar actions in the same position, similar data in the same format. +- When diverging from existing patterns, call it out explicitly so reviewers can evaluate the change intentionally. + +## Running the Playground + +To start the playground dev server: + +```bash +nx dev playground +``` + +The app runs on **localhost:4300**. It is not part of the Docker Compose setup — it runs in isolation via Vite's dev server. + +## Creating a New Prototype + +### 1. Create the Prototype Directory + +Each prototype lives under `apps/playground/src/prototypes/`. For non-trivial prototypes, create a dedicated folder organized by domain — mirroring the frontend app structure. Consult `references/frontend-domain-structure.md` for the domain folder list. + +``` +apps/playground/src/prototypes/ +├── index.ts # Prototype registry +├── ButtonsPrototype.tsx # Simple single-file prototype +└── my-feature/ # Multi-component prototype + ├── MyFeaturePrototype.tsx # Root component (entry point) + ├── components/ + │ ├── domain-a/ + │ │ ├── SomeComponent.tsx + │ │ └── AnotherComponent.tsx + │ └── domain-b/ + │ └── SomethingElse.tsx + └── types.ts # Shared types for this prototype +``` + +For simple prototypes (e.g., showcasing a single component), a single file is acceptable. + +### 2. Build the Prototype Component + +Create a default-exported React component: + +```tsx +import { PMBox, PMHeading } from '@packmind/ui'; + +export default function MyFeaturePrototype() { + return ( + <PMBox padding="6"> + <PMHeading size="lg">My Feature</PMHeading> + {/* Prototype content */} + </PMBox> + ); +} +``` + +### 3. Register the Prototype + +Add the prototype to `apps/playground/src/prototypes/index.ts`: + +```tsx +import MyFeaturePrototype from './my-feature/MyFeaturePrototype'; + +export const prototypes: Prototype[] = [ + // ... existing prototypes + { + name: 'My Feature', + description: 'Optional description', + component: MyFeaturePrototype, + }, +]; +``` + +The prototype will appear in the dropdown selector in the playground nav bar. + +## Mandatory Rules + +### Data + +- **Always use stub/mock data.** Never make actual calls to the backend. Define realistic mock data inline or in a dedicated `data.ts` file within the prototype folder. + +### Dependencies + +- **Never install new dependencies** without explicit user approval. +- **Never modify files outside `apps/playground/`.** No changes to other apps or packages. + +### Reusing Existing Code + +- Prototypes **can** import presentational components and types from the frontend app (`apps/frontend/src/`) and shared type packages (`packages/`). +- Prototypes **must not** import services, gateways, hooks with side effects, or anything that calls the backend. + +### UI Components + +- Use `PM*` components from `@packmind/ui` (e.g., `PMBox`, `PMButton`, `PMHeading`, `PMVStack`, `PMHStack`, `PMText`). +- When a needed component is not available in the Packmind UI kit, browse [Chakra UI v3 components](https://www.chakra-ui.com/docs/components) for reference, then map to the corresponding `PM*` wrapper before using. If no wrapper exists, ask the user before using the raw Chakra component. +- Icons come from `react-icons/lu` (Lucide icons, prefixed with `Lu`). + +### Code Quality + +- Prototypes **must** be production-ready in structure. Follow React best practices: split responsibility, create as many components as needed, organize by domain. +- Use `useState`, `useCallback`, `useMemo` for local state and performance. +- Define TypeScript types for all data structures. +- Keep components focused — each component should have a single responsibility. + +## Resources + +### references/ + +- `frontend-domain-structure.md` — Domain folder structure from the frontend app, to guide component organization in prototypes. \ No newline at end of file diff --git a/.opencode/skills/working-with-playground-app/references/frontend-domain-structure.md b/.opencode/skills/working-with-playground-app/references/frontend-domain-structure.md new file mode 100644 index 000000000..5f2fa6b80 --- /dev/null +++ b/.opencode/skills/working-with-playground-app/references/frontend-domain-structure.md @@ -0,0 +1,33 @@ +# Frontend Domain Structure + +The frontend app (`apps/frontend/src/`) organizes code by domain. Prototypes should mirror this structure when splitting components. + +## Domain Folders + +``` +apps/frontend/src/domain/ +├── accounts/ +├── change-proposals/ +├── deployments/ +├── editions/ +├── git/ +├── llm/ +├── organizations/ +├── recipes/ +├── rules/ +├── setup/ +├── skills/ +├── spaces/ +├── sse/ +└── standards/ +``` + +## Shared Folder + +Cross-domain presentational components and utilities live in `apps/frontend/src/shared/`. + +## Component Conventions + +- Presentational components are reusable across domains +- Each domain folder groups components, hooks, and types related to that domain +- Prototypes should follow the same domain-based organization when splitting into multiple components diff --git a/.opencode/skills/working-with-pm-design-kit/SKILL.md b/.opencode/skills/working-with-pm-design-kit/SKILL.md new file mode 100644 index 000000000..ebc9a0472 --- /dev/null +++ b/.opencode/skills/working-with-pm-design-kit/SKILL.md @@ -0,0 +1,341 @@ +--- +name: 'working-with-pm-design-kit' +description: 'This skill provides guidance for using the Packmind UI component library (@packmind/ui). It should be used when building or modifying frontend UI with PM-prefixed components, working with Chakra UI in the Packmind codebase, or when questions arise about available components, theming, or layout patterns. Triggers on mentions of PM components, @packmind/ui, Chakra UI usage, design kit, or frontend component implementation.' +--- + +# Working With the PM Design Kit + +## Overview + +The Packmind design kit (`@packmind/ui`) is a component library built on top of Chakra UI v3. All components are prefixed with `PM` and provide a consistent, themed API across the application. The source lives in `packages/ui/`. + +**Import pattern**: Always import from `@packmind/ui`, never from Chakra UI directly. + +```tsx +import { PMButton, PMBox, PMHeading, PMText } from '@packmind/ui'; +``` + +## Component Selection Guide + +Before reaching for a raw `<div>` or Chakra primitive, check if a PM component exists. Consult `references/component-catalog.md` for the full inventory organized by category. + +### Decision Flow + +1. **Need a layout container?** Use `PMBox`, `PMVStack`, `PMHStack`, `PMFlex`, or `PMGrid`. +2. **Need text?** Use `PMHeading` (with `level` prop for semantic h1–h6) or `PMText` (with `variant` and `color`). +3. **Need a button?** Use `PMButton` with the appropriate `variant`: `primary` for main actions, `secondary`/`ghost` for secondary, `danger` for destructive. +4. **Need user input?** Use `PMInput`, `PMTextArea`, `PMSelect`, `PMCheckbox`, `PMSwitch`, or `PMRadioGroup`. +5. **Need feedback?** Use `pmToaster` for transient messages, `PMAlert` for inline messages, `PMConfirmationModal` for destructive confirmations. +6. **Need an overlay?** Use `PMDialog` for modals, `PMPopover` for contextual info, `PMDrawer` for side panels. +7. **Need to show nothing?** Use `PMEmptyState` with title, description, icon, and an action button. +8. **Need loading placeholders?** Use `PMSkeleton` for content areas, `PMSpinner` for inline indicators. +9. **Need a color indicator?** Use `PMColorSwatch` to display a color sample. +10. **No PM wrapper exists?** Check Chakra UI v3 docs, then ask the user before using a raw Chakra component. + +## Compound Component Patterns + +Several PM components use Chakra's compound pattern with dot notation. Always use the compound API — do not try to reconstruct these with standalone elements. + +```tsx +// Dialog +<PMDialog.Root open={isOpen} onOpenChange={setIsOpen}> + <PMDialog.Backdrop /> + <PMDialog.Positioner> + <PMDialog.Content> + <PMDialog.Header> + <PMDialog.Title>Title</PMDialog.Title> + <PMDialog.CloseTrigger /> + </PMDialog.Header> + {/* body */} + </PMDialog.Content> + </PMDialog.Positioner> +</PMDialog.Root> + +// Accordion +<PMAccordion.Root> + <PMAccordion.Item value="section-1"> + <PMAccordion.ItemTrigger>Section 1</PMAccordion.ItemTrigger> + <PMAccordion.ItemContent>Content here</PMAccordion.ItemContent> + </PMAccordion.Item> +</PMAccordion.Root> + +// Timeline +<PMTimeline.Root> + <PMTimeline.Item> + <PMTimeline.Separator> + <PMTimeline.Indicator /> + <PMTimeline.Connector /> + </PMTimeline.Separator> + <PMTimeline.Content> + <PMTimeline.Title>Event</PMTimeline.Title> + <PMTimeline.Description>Details</PMTimeline.Description> + </PMTimeline.Content> + </PMTimeline.Item> +</PMTimeline.Root> + +// Tabs (compound pattern — preferred for flexible tab layouts) +<PMTabsCompound.Root defaultValue="tab1"> + <PMTabsCompound.List> + <PMTabsCompound.Trigger value="tab1">First Tab</PMTabsCompound.Trigger> + <PMTabsCompound.Trigger value="tab2">Second Tab</PMTabsCompound.Trigger> + </PMTabsCompound.List> + <PMTabsCompound.Content value="tab1">First content</PMTabsCompound.Content> + <PMTabsCompound.Content value="tab2">Second content</PMTabsCompound.Content> +</PMTabsCompound.Root> +``` + +**Key compound components**: `PMDialog`, `PMAccordion`, `PMTimeline`, `PMCarousel`, `PMCopiable`, `PMSelect`, `PMMenu`, `PMTreeView`, `PMTabs`, `PMTabsCompound`. + +Always wrap overlays (dialogs, popovers, drawers) inside `PMPortal` to escape stacking context issues. + +## Layout Patterns + +### Spacing + +Use `gap` on stacks/grids for consistent spacing between children — never use margin on individual children to create gaps. + +```tsx +<PMVStack gap="4"> + <PMHeading level="h2">Title</PMHeading> + <PMText>Description</PMText> +</PMVStack> +``` + +### Full-Height Layouts + +For layouts that fill the viewport, use `height="100vh"` on the root, `flex="1"` on the expanding section, and `minHeight={0}` on flex children that need to scroll. + +### Grid Layouts + +Use `PMGrid` with `gridTemplateColumns` for multi-panel layouts: + +```tsx +<PMGrid gridTemplateColumns="minmax(240px, 270px) 1fr minmax(280px, 320px)"> + <PMBox>Sidebar</PMBox> + <PMBox>Main</PMBox> + <PMBox>Detail</PMBox> +</PMGrid> +``` + +### Page Structure + +Use `PMPage` for full-page layouts with title, breadcrumbs, actions, and optional sidebar. Use `PMPageSection` for collapsible content sections within a page. + +## Typography + +### Headings + +Use `PMHeading` with the `level` prop for semantic HTML (h1–h6) and `color` for emphasis: + +```tsx +<PMHeading level="h1" color="primary">Page Title</PMHeading> +<PMHeading level="h3" color="secondary">Section Title</PMHeading> +``` + +Available colors: `primary`, `secondary`, `tertiary`, `faded`, `primaryLight`, `secondaryLight`, `tertiaryLight`. + +### Body Text + +Use `PMText` with `variant` for size and `color` for emphasis: + +```tsx +<PMText variant="body" color="primary">Main content</PMText> +<PMText variant="small" color="secondary">Supporting text</PMText> +``` + +Variants: `body`, `body-important`, `small`, `small-important`. +Colors: `primary`, `secondary`, `tertiary`, `error`, `faded`, `warning`, `success`, `primaryLight`, `secondaryLight`, `tertiaryLight`. + +## Theming + +Use semantic tokens — never hardcode hex colors or raw Chakra palette values. + +### Semantic Token Categories + +| Category | Tokens | Usage | +|----------|--------|-------| +| Background | `background.primary`, `.secondary`, `.tertiary`, `.faded` | Surface colors (dark to light) | +| Text | `text.primary`, `.secondary`, `.tertiary`, `.faded`, `.error`, `.warning`, `.success` | Text contrast levels | +| Border | `border.primary`, `.secondary`, `.tertiary` | Border contrast levels | + +### Status Colors + +Use the semantic color names for status indicators: +- **Success**: `green` palette or `text.success` +- **Error/Danger**: `red` palette or `text.error` +- **Warning**: `orange` palette or `text.warning` +- **Info/Primary**: `blue` palette + +```tsx +<PMButton variant="danger">Delete</PMButton> +<PMText color="error">Validation failed</PMText> +<PMBadge colorPalette="green">Active</PMBadge> +``` + +## Form Patterns + +### Input Fields + +`PMInput` provides label, error state, and helper text out of the box: + +```tsx +<PMInput + label="Project name" + value={name} + onChange={(e) => setName(e.target.value)} + error={errors.name} + helperText="Must be unique within the organization" + maxLength={255} +/> +``` + +### Form Layout + +Group related fields with `PMFormContainer`: + +```tsx +<PMFormContainer maxWidth="400px" centered> + <PMInput label="Name" /> + <PMInput label="Email" /> + <PMButton variant="primary" type="submit">Save</PMButton> +</PMFormContainer> +``` + +### Validation + +Show errors directly on inputs via the `error` prop — this adds a red border and displays the message below the field. Disable submit buttons during async operations with `isLoading`. + +## Feedback Patterns + +### Toasts (Transient Notifications) + +```tsx +import { pmToaster } from '@packmind/ui'; + +pmToaster.create({ + type: 'success', // 'success' | 'error' | 'warning' | 'info' | 'loading' + title: 'Saved', + description: 'Your changes have been saved.', + closable: true, + action: { label: 'Undo', onClick: handleUndo }, // optional +}); +``` + +### Confirmation Modals (Destructive Actions) + +```tsx +<PMConfirmationModal + trigger={<PMButton variant="danger">Delete</PMButton>} + title="Delete project?" + message="This action cannot be undone." + confirmText="Delete" + confirmColorScheme="red" + onConfirm={handleDelete} + isLoading={isDeleting} +/> +``` + +### Inline Alerts + +```tsx +<PMAlert.Root status="warning"> + <PMAlert.Indicator /> + <PMAlert.Title>Attention</PMAlert.Title> + <PMAlert.Description>This feature is in beta.</PMAlert.Description> +</PMAlert.Root> +``` + +### Empty States + +```tsx +<PMEmptyState + title="No standards yet" + description="Create your first coding standard to get started." + icon={<LuInbox />} +> + <PMButton variant="primary">Create Standard</PMButton> +</PMEmptyState> +``` + +## Icons + +Icons come from `react-icons/lu` (Lucide icon set). Import with the `Lu` prefix: + +```tsx +import { LuTrash2, LuPlus, LuChevronDown } from 'react-icons/lu'; + +<PMButton><LuPlus /> Add Item</PMButton> +<PMIconButton variant="ghost"><LuTrash2 /></PMIconButton> +``` + +Control size via `fontSize` or `size` props on the icon element. + +## Responsive Design + +Use Chakra's responsive object syntax with breakpoints `base`, `sm`, `md`, `lg`, `xl`: + +```tsx +<PMBox + display={{ base: 'none', md: 'flex' }} + width={{ base: '100%', lg: '60%' }} + padding={{ base: '4', md: '6' }} +/> +``` + +Mobile-first approach: `base` styles apply to all sizes, then override at larger breakpoints. + +## Button Variant Guide + +| Variant | Usage | +|---------|-------| +| `primary` | Main action on the page (one per view) | +| `secondary` | Important but not primary actions | +| `tertiary` | Low-emphasis actions | +| `outline` | Alternative to secondary with border emphasis | +| `ghost` | Minimal actions (toolbar buttons, inline actions) | +| `success` | Positive confirmations | +| `warning` | Caution-required actions | +| `danger` | Destructive actions (delete, remove) | + +## Hooks + +`@packmind/ui` exports several hooks for common UI patterns: + +### useTableSort + +Manages sorting state for `PMTable`. Returns `sortKey`, `sortDirection`, `handleSort`, and `getSortDirection`: + +```tsx +import { useTableSort } from '@packmind/ui'; + +const { sortKey, sortDirection, handleSort, getSortDirection } = useTableSort({ + defaultSortKey: 'name', + defaultSortDirection: 'asc', +}); + +<PMTable columns={columns} data={data} onSort={handleSort} /> +``` + +### Chakra Re-exports + +- **pmUseFilter** — Chakra's `useFilter` for filtering collections +- **pmUseListCollection** — Chakra's `useListCollection` for managing list data (useful with `PMSelect`, `PMCombobox`) +- **pmUseToken** — Chakra's `useToken` for accessing design tokens programmatically + +```tsx +import { pmUseToken, pmUseListCollection } from '@packmind/ui'; +``` + +## Anti-Patterns + +- **Do not** import from `@chakra-ui/react` directly — always use `@packmind/ui` wrappers. +- **Do not** use inline styles or hardcoded colors — use semantic tokens and component props. +- **Do not** create custom modal/overlay implementations — use `PMDialog`, `PMDrawer`, or `PMPopover` with `PMPortal`. +- **Do not** build custom loading indicators — use `PMSkeleton` or `PMSpinner`. +- **Do not** use `as="h1"` on headings — use the `level` prop on `PMHeading` for semantic HTML. + +## Resources + +### references/ + +- `component-catalog.md` — Full inventory of all PM components and hooks with props, organized by category. \ No newline at end of file diff --git a/.opencode/skills/working-with-pm-design-kit/references/component-catalog.md b/.opencode/skills/working-with-pm-design-kit/references/component-catalog.md new file mode 100644 index 000000000..01caa5ae0 --- /dev/null +++ b/.opencode/skills/working-with-pm-design-kit/references/component-catalog.md @@ -0,0 +1,118 @@ +# PM Component Catalog + +Complete inventory of all PM-prefixed components in `@packmind/ui`, organized by category. Source: `packages/ui/src/lib/components/`. + +## Typography + +| Component | Key Props | Notes | +|-----------|-----------|-------| +| **PMHeading** | `level?: 'h1'–'h6'`, `color?: 'primary'\|'secondary'\|'tertiary'\|'faded'\|'primaryLight'\|'secondaryLight'\|'tertiaryLight'` | Use `level` for semantic HTML, not `as` | +| **PMText** | `variant?: 'body'\|'body-important'\|'small'\|'small-important'`, `color?: 'primary'\|'secondary'\|'tertiary'\|'error'\|'faded'\|'warning'\|'success'\|'primaryLight'\|'secondaryLight'\|'tertiaryLight'`, `as?: 'span'\|'p'\|'div'` | | +| **PMLink** | `variant?: 'plain'\|'navbar'\|'underline'\|'active'`, `to?: string` | Plain by default | +| **PMEm** | Chakra EmProps | Emphasis/italic text | +| **PMList** | Chakra ListProps | Ordered/unordered lists | + +## Form Controls + +| Component | Key Props | Notes | +|-----------|-----------|-------| +| **PMInput** | `label?: string`, `error?: string`, `helperText?: string`, `maxLength?: number` (default: 255) | Tertiary background, transparent border until error | +| **PMTextArea** | Chakra TextareaProps | Tertiary background | +| **PMLabel** | `required?: boolean`, `htmlFor?: string` | Renders `<label>` element | +| **PMButton** | `variant?: 'primary'\|'secondary'\|'tertiary'\|'outline'\|'ghost'\|'success'\|'warning'\|'danger'` | Primary by default | +| **PMIconButton** | `variant?: 'solid'\|'subtle'\|'surface'\|'outline'\|'ghost'\|'plain'\|'tertiary'` | Ghost by default | +| **PMButtonGroup** | Chakra ButtonGroupProps | Groups buttons together | +| **PMCheckbox** | `icon?: ReactNode`, `inputProps?`, `controlProps?`, `labelProps?` | Compound component | +| **PMCheckboxGroup** | Chakra CheckboxGroupProps | Groups checkboxes | +| **PMCheckboxCard** | Chakra CheckboxCardProps | Card-style checkbox | +| **PMRadioGroup** | Chakra RadioGroupProps | Radio button group | +| **PMRadioCard** | Chakra RadioCardProps | Card-style radio | +| **PMSelect** | Chakra Select + `.ItemGroupLabel`, `.CollapsibleItemGroup` | Use `pmCreateListCollection` helper | +| **PMNativeSelect** | Chakra NativeSelectProps | Standard HTML select | +| **PMCombobox** | Chakra ComboboxProps | Searchable select | +| **PMSwitch** | `inputProps?`, `rootRef?` | Toggle switch | +| **PMField** | Chakra FieldRootProps | Form field wrapper | +| **PMFieldset** | Chakra FieldsetProps | Fieldset grouping | +| **PMEditable** | Chakra EditableProps | Inline editable text | +| **PMInputGroup** | Chakra InputGroupProps | Input with addons | +| **PMSegmentGroup** | Chakra SegmentGroupProps | Segmented control | +| **PMFormContainer** | `maxWidth?: string` (default: '400px'), `spacing?: number` (default: 4), `padding?: string\|number` (default: 6), `centered?: boolean` (default: true) | VStack in Container | +| **PMEllipsisMenu** | `actions: {value, onClick, content}[]`, `title?: string`, `disabled?: boolean` | Three-dot menu button | +| **PMCodeMirror** | `language?: string` (20+ languages), ReactCodeMirrorProps | Dracula theme | + +## Layout + +| Component | Key Props | Notes | +|-----------|-----------|-------| +| **PMBox** | Chakra BoxProps | Basic container | +| **PMFlex** | Chakra FlexProps | Flex container | +| **PMVStack** | Chakra StackProps | Vertical stack with gap | +| **PMHStack** | Chakra StackProps | Horizontal stack with gap | +| **PMGrid** | Chakra GridProps | CSS Grid container | +| **PMGridItem** | Chakra GridItemProps | Grid child | +| **PMSeparator** | Chakra SeparatorProps | Horizontal/vertical divider | +| **PMPortal** | Chakra PortalProps | Portal for overlays | +| **PMHeader** | `color: 'primary'\|'secondary'\|'tertiary'` | Sticky nav bar | +| **PMVerticalNav** | `headerNav?`, `footerNav?`, `logo?: boolean`, `width?: string` (default: '220px') | Sidebar navigation | +| **PMVerticalNavSection** | `title?`, `titleExtra?`, `navEntries: ReactNode[]` | Section within nav | +| **PMTwoColumnsLayout** | `breadcrumbComponent`, `leftColumn`, `rightColumn` | 320px left, fluid right | + +## Content + +| Component | Key Props | Notes | +|-----------|-----------|-------| +| **PMPage** | `title?`, `titleLevel?`, `subtitle?`, `titleAction?`, `breadcrumbComponent?`, `actions?`, `sidebar?`, `maxWidth?` (default: '1200px'), `isFullWidth?` | Full-page layout | +| **PMPageSection** | `title?`, `titleComponent?`, `cta?`, `variant?: 'plain'\|'outline'`, `backgroundColor?: 'primary'\|'secondary'\|'tertiary'`, `collapsible?: boolean` | Collapsible section | +| **PMTable** | `columns: PMTableColumn[]`, `data: T[]`, `striped?`, `hoverable?`, `size?: 'sm'\|'md'\|'lg'`, `selectable?`, `onSort?` | Column config: `{key, header, width?, align?, grow?, sortable?}` | +| **PMBadge** | Chakra BadgeProps | Status indicators | +| **PMAvatar** | Chakra AvatarProps | User/entity avatar | +| **PMBreadcrumb** | `segments: ReactNode[]`, `interactive?: boolean` | "/" separator | +| **PMCard** | Chakra CardProps | Card container | +| **PMIcon** | Chakra IconProps | Icon wrapper | +| **PMImage** | Chakra ImageProps | Image element | +| **PMCloseButton** | Chakra CloseButtonProps | Close "X" button | +| **PMEmptyState** | `title: string`, `description?: string`, `icon?: ReactNode`, `children?: ReactNode` | Title as h3, children for actions | +| **PMMarkdownViewer** | `content?: string`, `htmlContent?: string`, `sanitize?: boolean` (default: true) | DOMPurify + marked | +| **PMDataList** | `items: {label, value}[]` | Key-value pair display | +| **PMColorSwatch** | Chakra ColorSwatchProps | Displays a color sample | +| **PMFeatureFlag** | `featureKeys: string[]`, `featureDomainMap`, `userEmail?` | Conditional rendering | +| **PMStat** | Chakra StatProps | Statistic display | + +## Feedback & Overlays + +| Component | Key Props | Notes | +|-----------|-----------|-------| +| **PMToaster** / **pmToaster** | `pmToaster.create({type, title, description, action?, closable?})` | Types: `success`, `error`, `warning`, `info`, `loading`. Placement: bottom-end | +| **PMAlert** | Chakra AlertProps | Compound: `.Root`, `.Indicator`, `.Title`, `.Description` | +| **PMConfirmationModal** | `trigger`, `title`, `message`, `confirmText?` ('Delete'), `cancelText?` ('Cancel'), `confirmColorScheme?` ('red'), `onConfirm`, `open?`, `onOpenChange?`, `isLoading?` | Slot components: `PMConfirmationModalHeader`, `PMConfirmationModalBody`, `PMConfirmationModalFooter` | +| **PMAlertDialog** | Same as PMConfirmationModal | Alternative confirmation pattern | +| **PMDialog** | Chakra DialogProps | Compound: `.Root`, `.Backdrop`, `.Positioner`, `.Content`, `.Header`, `.Title`, `.CloseTrigger` | +| **PMDrawer** | Chakra DrawerProps | Side panel overlay | +| **PMPopover** | Chakra PopoverProps | Contextual popup | +| **PMTooltip** | `label`, `placement?` ('top'), `disabled?`, `openDelay?` (500), `closeDelay?` (0), `showArrow?` (true) | Hover information | +| **PMSkeleton** | Chakra SkeletonProps | Loading placeholder | +| **PMSpinner** | Chakra SpinnerProps | Loading indicator | +| **PMProgress** | Chakra ProgressProps | Progress bar | +| **PMStatus** | Chakra StatusProps | Status indicator | +| **PMCopiable** | Compound: `.Root {value}`, `.Trigger`, `.Indicator` | Copy-to-clipboard | + +## Navigation + +| Component | Key Props | Notes | +|-----------|-----------|-------| +| **PMTabs** | `defaultValue`, `tabs: {value, triggerLabel, content?, disabled?}[]`, `scrollableContent?` | Sub-components: `PMTabsTrigger`, `PMTabsContent` | +| **PMAccordion** | Chakra AccordionProps | Compound: `.Root`, `.Item`, `.ItemTrigger`, `.ItemContent`, `.ItemIndicator` | +| **PMMenu** | Chakra MenuProps | Compound: `.Root`, `.Trigger` + all Chakra Menu members | +| **PMTreeView** | All Chakra TreeView members | Compound: `.Root`, `.Branch`, `.BranchContent`, `.Item`, `.ItemText`, etc. Helpers: `createTreeCollection`, `createFileTreeCollection` | +| **PMCarousel** | Chakra CarouselProps | Compound: `.Root`, `.Control`, `.NextTrigger`, `.PrevTrigger`, `.Item`, `.ItemGroup`, `.Indicator` | +| **PMTabsCompound** | `TabsRootProps` (Root), `value`, `children`, `disabled?` (Trigger), `value`, `children` (Content) | Compound: `.Root`, `.List`, `.Trigger`, `.Content`. Preferred over `PMTabs` for flexible tab layouts. Uses SlotComponent pattern | +| **PMTimeline** | Chakra TimelineProps | Compound: `.Root`, `.Item`, `.Content`, `.Separator`, `.Indicator`, `.Connector`, `.Title`, `.Description` | + +## Hooks + +| Hook | Key Props / Options | Notes | +|------|---------------------|-------| +| **useTableSort** | `options?: { defaultSortKey?, defaultSortDirection? }` | Returns `{ sortKey, sortDirection, handleSort, getSortDirection }`. Use with `PMTable`'s `onSort` prop | +| **pmUseFilter** | Chakra useFilter API | Re-export of Chakra's `useFilter` for filtering collections | +| **pmUseListCollection** | Chakra useListCollection API | Re-export of Chakra's `useListCollection`. Useful with `PMSelect`, `PMCombobox` | +| **pmUseToken** | Chakra useToken API | Re-export of Chakra's `useToken` for accessing design tokens programmatically | diff --git a/.packmind/standards-index.md b/.packmind/standards-index.md index e8fa4323b..23fda5533 100644 --- a/.packmind/standards-index.md +++ b/.packmind/standards-index.md @@ -4,10 +4,15 @@ This standards index contains all available coding standards that can be used by ## Available Standards -- [Backend Tests Redaction](./standards/backend-tests-redaction.md) : Enforce Jest backend test conventions in Packmind **/*.spec.ts (verb-first names, behavioral assertions, nested `describe('when...')`, one `expect`, `afterEach` cleanup with `datasource.destroy()` and `jest.clearAllMocks()`, `toEqual` for arrays, and `stubLogger()` for typed `PackmindLogger` stubs) to improve readability, consistency, and debuggability while preventing inter-test pollution. -- [Changelog](./standards/changelog.md) : Maintain CHANGELOG.MD using Keep a Changelog format with a top [Unreleased] section linked to HEAD, ISO 8601 dates (YYYY-MM-DD), and per-release comparison links like [X.Y.Z]: https://github.com/PackmindHub/packmind/compare/release/<previous>...release/X.Y.Z to ensure accurate, consistent release documentation and version links. -- [Compliance - Logging Personal Information](./standards/compliance-logging-personal-information.md) : Enforce masking of personal information in TypeScript logs, using a standard first-6-characters-plus-* format for emails and similar patterns for other identifiers, to protect user privacy, comply with data protection regulations, and reduce security risks when handling user-related log entries. -- [Typescript good practices](./standards/typescript-good-practices.md) : Enforce TypeScript error and DTO conventions by prohibiting Object.setPrototypeOf in custom errors and requiring intersection types (DomainType & { extraField: T }) for presentation DTO enrichment to improve reliability and catch domain-field drift at compile time. +- [Amplitude analytics usage](./standards/amplitude-analytics-usage.md) : * We use Amplitude to get insights about users' behavior when using our product with the different UI (CLI / MCP / web app). +* Only in "cloud" environment (no tracking in open-source deployments) +* Data hosted in EU +* No user related data tracked +- [Backend Tests Redaction](./standards/backend-tests-redaction.md) : This standard establishes best practices for writing backend tests using Jest in the Packmind monorepo. It focuses on clarity, maintainability, and consistency across test suites by emphasizing behavioral testing, proper test organization, and effective use of testing utilities. Apply these rules when writing or refactoring backend unit tests, integration tests, and service tests to ensure tests remain readable, reliable, and easy to debug. +- [Changelog](./standards/changelog.md) : Maintain a consistent and well-structured CHANGELOG.MD file following the Keep a Changelog format to ensure all releases are properly documented with accurate version links and dates. This standard applies whenever creating or updating the CHANGELOG.MD file in the repository root. +- [Compliance - Logging Personal Information](./standards/compliance-logging-personal-information.md) : This standard ensures personal information is not exposed in application logs across all environments (development, staging, and production). Logs are often forwarded to external processors such as Datadog, making it critical to prevent leaking sensitive user data. By masking personal information in logs, we maintain user privacy, comply with data protection regulations, and reduce security risks. This standard applies when logging user-related information, debugging authentication flows, tracking user actions, or handling error scenarios involving personal data. +- [Packmind Proprietary](./standards/packmind-proprietary.md) : . +- [Typescript good practices](./standards/typescript-good-practices.md) : Generic practices that can be applied for all TS code in our app --- diff --git a/.packmind/standards/amplitude-analytics-usage.md b/.packmind/standards/amplitude-analytics-usage.md new file mode 100644 index 000000000..a105245f0 --- /dev/null +++ b/.packmind/standards/amplitude-analytics-usage.md @@ -0,0 +1,12 @@ +# Amplitude analytics usage + +* We use Amplitude to get insights about users' behavior when using our product with the different UI (CLI / MCP / web app). +* Only in "cloud" environment (no tracking in open-source deployments) +* Data hosted in EU +* No user related data tracked + +## Rules + +* Tracked event name should be snake cased +* Event name ends with the verb (e.g 'standard_created', 'user_signed_up') +* Property name should be in lower camel case diff --git a/.packmind/standards/packmind-proprietary.md b/.packmind/standards/packmind-proprietary.md new file mode 100644 index 000000000..2a20761dd --- /dev/null +++ b/.packmind/standards/packmind-proprietary.md @@ -0,0 +1,7 @@ +# Packmind Proprietary + +. + +## Rules + +* Never import something from '@packmind/editions', this is for OSS only diff --git a/AGENTS.md b/AGENTS.md index 862e1dff3..c3c1a35bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,9 +80,18 @@ All rules and guidelines defined in these standards are mandatory and must be fo Failure to follow these standards may lead to inconsistencies, errors, or rework. Treat them as the source of truth for how code should be written, structured, and maintained. +# Standard: Amplitude analytics usage + +* We use Amplitude to get insights about users' behavior when using our product with the different UI (CLI / MCP / web app). : +* Event name ends with the verb (e.g 'standard_created', 'user_signed_up') +* Property name should be in lower camel case +* Tracked event name should be snake cased + +Full standard is available here for further request: [Amplitude analytics usage](.packmind/standards/amplitude-analytics-usage.md) + # Standard: Backend Tests Redaction -Enforce Jest backend test conventions in Packmind **/*.spec.ts (verb-first names, behavioral assertions, nested `describe('when...')`, one `expect`, `afterEach` cleanup with `datasource.destroy()` and `jest.clearAllMocks()`, `toEqual` for arrays, and `stubLogger()` for typed `PackmindLogger` stubs) to improve readability, consistency, and debuggability while preventing inter-test pollution. : +This standard establishes best practices for writing backend tests using Jest in the Packmind monorepo. It focuses on clarity, maintainability, and consistency across test suites by emphasizing behavi... : * Avoid asserting on stubbed logger output like specific messages or call counts; instead verify observable behavior or return values * Avoid testing that a method is a function; instead invoke the method and assert its observable behavior * Avoid testing that registry components are defined; instead test the actual behavior and functionality of the registry methods like registration, retrieval, and error handling @@ -100,7 +109,7 @@ Full standard is available here for further request: [Backend Tests Redaction](. # Standard: Changelog -Maintain CHANGELOG.MD using Keep a Changelog format with a top [Unreleased] section linked to HEAD, ISO 8601 dates (YYYY-MM-DD), and per-release comparison links like [X.Y.Z]: https://github.com/PackmindHub/packmind/compare/release/<previous>...release/X.Y.Z to ensure accurate, consistent release documentation and version links. : +Maintain a consistent and well-structured CHANGELOG.MD file following the Keep a Changelog format to ensure all releases are properly documented with accurate version links and dates. This standard ap... : * Ensure all released versions have their corresponding comparison links defined at the bottom of the CHANGELOG.MD file in the format [X.Y.Z]: https://github.com/PackmindHub/packmind/compare/release/<previous>...release/X.Y.Z * Format all release dates using the ISO 8601 date format YYYY-MM-DD (e.g., 2025-11-21) to ensure consistent and internationally recognized date representation * Maintain an [Unreleased] section at the top of the changelog with its corresponding link at the bottom pointing to HEAD to track ongoing changes between releases @@ -109,15 +118,22 @@ Full standard is available here for further request: [Changelog](.packmind/stand # Standard: Compliance - Logging Personal Information -Enforce masking of personal information in TypeScript logs, using a standard first-6-characters-plus-* format for emails and similar patterns for other identifiers, to protect user privacy, comply with data protection regulations, and reduce security risks when handling user-related log entries. : +This standard ensures personal information is not exposed in application logs across all environments (development, staging, and production). Logs are often forwarded to external processors such as Da... : * Never log personal information in clear text across all log levels. Always mask sensitive data such as emails, phone numbers, IP addresses, and other personally identifiable information before logging. * Use the standard masking format of first 6 characters followed by "*" for logging user emails. This ensures consistency across the codebase and makes it easier to audit logs for compliance. Full standard is available here for further request: [Compliance - Logging Personal Information](.packmind/standards/compliance-logging-personal-information.md) +# Standard: Packmind Proprietary + +. : +* Never import something from '@packmind/editions', this is for OSS only + +Full standard is available here for further request: [Packmind Proprietary](.packmind/standards/packmind-proprietary.md) + # Standard: Typescript good practices -Enforce TypeScript error and DTO conventions by prohibiting Object.setPrototypeOf in custom errors and requiring intersection types (DomainType & { extraField: T }) for presentation DTO enrichment to improve reliability and catch domain-field drift at compile time. : +Generic practices that can be applied for all TS code in our app : * Do not use `Object.setPrototypeOf` when defining errors. * When defining a presentation DTO that enriches a domain type, use an intersection type (`DomainType & { extraField: T }`) instead of manually re-declaring the domain type's fields, so that structural drift is caught at compile time. diff --git a/CHANGELOG.MD b/CHANGELOG.MD index cfb7dd134..eba059aed 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -2,6 +2,14 @@ ## Added +- Enable rendering packmind artifacts in `~/.claude` #332 +- Add GitHub App connection method in organization git settings +- Org-level marketplace governance — admins can link/unlink Git repositories as Packmind-governed marketplaces and members can browse enrolled marketplaces. Includes periodic health reconciliation (`healthy | drift | unreachable`). +- Publish a Packmind package to a linked marketplace as a managed plugin, opening a rolling pull request titled "Packmind sync" on the marketplace repository (GH-580). +- Retire a published plugin from a marketplace through a PR-governed flow — marking a distribution for removal automatically commits the plugin deletion onto the rolling `packmind/sync` PR (symmetric to publishing), reconciliation transitions it to a terminal `removed` state once that PR is merged, and admins can cancel a pending removal (gated behind the `marketplace-plugin-removal` feature flag). The same auto-deletion runs when a Packmind package with live distributions is deleted. +- Drift detection for marketplaces — when a published plugin disappears from the marketplace descriptor without going through the retirement flow, the marketplace details view surfaces a "Drift detected" indicator listing the affected plugin slugs. +- Package deletion cascade for marketplace distributions — deleting a Packmind package automatically marks every live marketplace distribution of that package as "to be removed" across all affected marketplaces. + ## Changed ## Fixed diff --git a/CLAUDE.md b/CLAUDE.md index abfe43af9..6cf188c6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ The following commands apply for both NX apps and packages (use `./node_modules/ - When running commands, ensure you use the correct Node version (see .nvmrc at the project's root level) - When renaming or moving a file that is commited to git, use `git mv` instead of `mv` -- ensure the env variable `PACKMIND_EDITION` is properly set to `oss` +- ensure the env variable `PACKMIND_EDITION` is properly set to `proprietary` - when asked to execute `packmind-cli`, use `node ./dist/apps/cli/main.cjs` - Some default skills use version-routed directories (`packmind-versions/<version>/`) and ask you to run `packmind-cli --version` to pick the right one. Since you already run the local build with `node ./dist/apps/cli/main.cjs`, you always have the latest — skip the `--version` check and use the `packmind-versions/next/` directory directly. diff --git a/amplitude-listener-events-adoption.md b/amplitude-listener-events-adoption.md new file mode 100644 index 000000000..3f4f1b2bd --- /dev/null +++ b/amplitude-listener-events-adoption.md @@ -0,0 +1,29 @@ +# Amplitude adoption — last 15 listener events (Packmind V3 [CLOUD], Last 30 Days) + +Excluded orgs: `AI Configuration orga`, `test_package_standards`, `packmind`, `Joan's orga`, `demo-orga`, `VIncent test`, `cedric.teyton's organization`, `cedric-test`, `test-skills`. + +Excluded events: `user_signed_in`, `user_signed_up`, `new_organization_created`. + +Events covered (newest → oldest by listener subscription): `plugin_publish_attempted`, `plugin_published`, `plugin_publish_failed`, `marketplace_plugin_removal_initiated`, `marketplace_linked`, `marketplace_unlinked`, `plugin_rendered`, `plugin_deleted`, `space_pinned`, `space_unpinned`, `space_renamed`, `space_deleted`, `space_members_role_updated`, `space_members_removed`, `space_members_added`. + +Note: `plugin_publish_failed` and `marketplace_plugin_removal_initiated` are fully wired (emit + listener subscription) but absent from the Amplitude schema — meaning the underlying action simply **never occurred in prod** in this window (zero publish failures, zero plugin removals). Not dead code; reported as zero-adoption. + +## Domain: space + +- space_deleted (2 orgs): DiliTrust CLM-MM (1), Matmut (1) +- space_members_added (4 orgs): DiliTrust CLM-MM (6), Optimetriks (3), Tschoener's orga (3), bloomcredit (1) +- space_members_role_updated (1 org): Tschoener's orga (3) +- space_renamed (1 org): Matmut (1) +- space_pinned (2 orgs): Tschoener's orga (4), DiliTrust CLM-MM (2) +- space_unpinned (1 org): DiliTrust CLM-MM (2) +- No adoption (0 orgs): space_members_removed + +## Domain: plugin + +- No adoption (0 orgs): plugin_rendered, plugin_deleted, plugin_publish_attempted, plugin_published, plugin_publish_failed + +## Domain: marketplace + +- No adoption (0 orgs): marketplace_linked, marketplace_unlinked, marketplace_plugin_removal_initiated + +Source chart: [Open in Amplitude](https://app.eu.amplitude.com/analytics/packmind/chart/new/e-iyolq2p9?utm_source=mcp&utm_content=%5BMCP%5D+Claude+Code+%28amplitude%29) diff --git a/apps/api/.claude/rules/packmind/standard-nestjs-module-hierarchy.md b/apps/api/.claude/rules/packmind/standard-nestjs-module-hierarchy.md index 74fdf3de7..bbf214618 100644 --- a/apps/api/.claude/rules/packmind/standard-nestjs-module-hierarchy.md +++ b/apps/api/.claude/rules/packmind/standard-nestjs-module-hierarchy.md @@ -5,12 +5,12 @@ paths: - "**/*.module.ts" - "**/*.service.ts" alwaysApply: false -description: 'Enforce a NestJS module structure where AppModule configures all hierarchical routes via RouterModule.register() and each resource lives in its own module with empty @Controller() paths, URL-mirroring directories, explicit parent IDs (using @Param(''orgId'')), and command-object service inputs to improve maintainability, scalability, and separation of concerns.' +description: 'Establish a consistent NestJS module structure in the API application where each resource is encapsulated in its own module with proper hierarchical organization to enhance maintainability, scalability, and clear separation of concerns across the codebase. This standard applies to all modules (new and existing) in apps/api/src/app/ and ensures that the module structure mirrors the URL hierarchy, making it easier to navigate and understand the application''s architecture.' --- # Standard: NestJS Module Hierarchy -Enforce a NestJS module structure where AppModule configures all hierarchical routes via RouterModule.register() and each resource lives in its own module with empty @Controller() paths, URL-mirroring directories, explicit parent IDs (using @Param('orgId')), and command-object service inputs to improve maintainability, scalability, and separation of concerns. : +Establish a consistent NestJS module structure in the API application where each resource is encapsulated in its own module with proper hierarchical organization to enhance maintainability, scalabilit... : * Accept a single typed Command object as the input parameter in service methods instead of multiple individual parameters to enforce explicit intent and maintain consistency with the hexagonal architecture * Configure all hierarchical routing exclusively in AppModule using RouterModule.register() with nested children arrays to ensure a single source of truth for the entire API route structure * Create a dedicated NestJS module for each resource type, preventing controllers from handling sub-resource routes to maintain clear separation of concerns diff --git a/apps/api/.claude/rules/packmind/standard-rest-api-endpoint-design.md b/apps/api/.claude/rules/packmind/standard-rest-api-endpoint-design.md index b79a56f26..25e778cec 100644 --- a/apps/api/.claude/rules/packmind/standard-rest-api-endpoint-design.md +++ b/apps/api/.claude/rules/packmind/standard-rest-api-endpoint-design.md @@ -3,12 +3,12 @@ name: 'REST API Endpoint Design' paths: - "REST API route definitions and controller endpoints" alwaysApply: false -description: 'Define REST API route and controller endpoint conventions using dedicated POST action endpoints and ownership-chain IDs with one endpoint per business action to keep endpoints predictable, self-documenting, and aligned with business intent.' +description: 'Conventions for designing REST API endpoints that are predictable, self-documenting, and aligned with distinct business actions. Favors dedicated action endpoints over generic status updates, and routes that reflect the ownership hierarchy without unrelated resource IDs.' --- # Standard: REST API Endpoint Design -Define REST API route and controller endpoint conventions using dedicated POST action endpoints and ownership-chain IDs with one endpoint per business action to keep endpoints predictable, self-documenting, and aligned with business intent. : +Conventions for designing REST API endpoints that are predictable, self-documenting, and aligned with distinct business actions. Favors dedicated action endpoints over generic status updates, and rout... : * Create one endpoint per business action rather than a single endpoint handling multiple actions via request body * Only include resource IDs from the ownership chain in the route — omit IDs of related but non-parent resources * Use dedicated POST action endpoints (e.g., `/reject`, `/accept`) instead of a generic PATCH with a status field in the body diff --git a/apps/api/.cursor/rules/packmind/standard-nestjs-module-hierarchy.mdc b/apps/api/.cursor/rules/packmind/standard-nestjs-module-hierarchy.mdc index 2101a677e..80314a27e 100644 --- a/apps/api/.cursor/rules/packmind/standard-nestjs-module-hierarchy.mdc +++ b/apps/api/.cursor/rules/packmind/standard-nestjs-module-hierarchy.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: NestJS Module Hierarchy -Enforce a NestJS module structure where AppModule configures all hierarchical routes via RouterModule.register() and each resource lives in its own module with empty @Controller() paths, URL-mirroring directories, explicit parent IDs (using @Param('orgId')), and command-object service inputs to improve maintainability, scalability, and separation of concerns. : +Establish a consistent NestJS module structure in the API application where each resource is encapsulated in its own module with proper hierarchical organization to enhance maintainability, scalabilit... : * Accept a single typed Command object as the input parameter in service methods instead of multiple individual parameters to enforce explicit intent and maintain consistency with the hexagonal architecture * Configure all hierarchical routing exclusively in AppModule using RouterModule.register() with nested children arrays to ensure a single source of truth for the entire API route structure * Create a dedicated NestJS module for each resource type, preventing controllers from handling sub-resource routes to maintain clear separation of concerns diff --git a/apps/api/.cursor/rules/packmind/standard-rest-api-endpoint-design.mdc b/apps/api/.cursor/rules/packmind/standard-rest-api-endpoint-design.mdc index 1083dd8fc..be1ea9a34 100644 --- a/apps/api/.cursor/rules/packmind/standard-rest-api-endpoint-design.mdc +++ b/apps/api/.cursor/rules/packmind/standard-rest-api-endpoint-design.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: REST API Endpoint Design -Define REST API route and controller endpoint conventions using dedicated POST action endpoints and ownership-chain IDs with one endpoint per business action to keep endpoints predictable, self-documenting, and aligned with business intent. : +Conventions for designing REST API endpoints that are predictable, self-documenting, and aligned with distinct business actions. Favors dedicated action endpoints over generic status updates, and rout... : * Create one endpoint per business action rather than a single endpoint handling multiple actions via request body * Only include resource IDs from the ownership chain in the route — omit IDs of related but non-parent resources * Use dedicated POST action endpoints (e.g., `/reject`, `/accept`) instead of a generic PATCH with a status field in the body diff --git a/apps/api/.github/instructions/packmind-nestjs-module-hierarchy.instructions.md b/apps/api/.github/instructions/packmind-nestjs-module-hierarchy.instructions.md index c3592c165..d9d1cff07 100644 --- a/apps/api/.github/instructions/packmind-nestjs-module-hierarchy.instructions.md +++ b/apps/api/.github/instructions/packmind-nestjs-module-hierarchy.instructions.md @@ -3,7 +3,7 @@ applyTo: '**/*.controller.ts,**/*.module.ts,**/*.service.ts' --- # Standard: NestJS Module Hierarchy -Enforce a NestJS module structure where AppModule configures all hierarchical routes via RouterModule.register() and each resource lives in its own module with empty @Controller() paths, URL-mirroring directories, explicit parent IDs (using @Param('orgId')), and command-object service inputs to improve maintainability, scalability, and separation of concerns. : +Establish a consistent NestJS module structure in the API application where each resource is encapsulated in its own module with proper hierarchical organization to enhance maintainability, scalabilit... : * Accept a single typed Command object as the input parameter in service methods instead of multiple individual parameters to enforce explicit intent and maintain consistency with the hexagonal architecture * Configure all hierarchical routing exclusively in AppModule using RouterModule.register() with nested children arrays to ensure a single source of truth for the entire API route structure * Create a dedicated NestJS module for each resource type, preventing controllers from handling sub-resource routes to maintain clear separation of concerns diff --git a/apps/api/.github/instructions/packmind-rest-api-endpoint-design.instructions.md b/apps/api/.github/instructions/packmind-rest-api-endpoint-design.instructions.md index f642fa27c..850b1321c 100644 --- a/apps/api/.github/instructions/packmind-rest-api-endpoint-design.instructions.md +++ b/apps/api/.github/instructions/packmind-rest-api-endpoint-design.instructions.md @@ -3,7 +3,7 @@ applyTo: 'REST API route definitions and controller endpoints' --- # Standard: REST API Endpoint Design -Define REST API route and controller endpoint conventions using dedicated POST action endpoints and ownership-chain IDs with one endpoint per business action to keep endpoints predictable, self-documenting, and aligned with business intent. : +Conventions for designing REST API endpoints that are predictable, self-documenting, and aligned with distinct business actions. Favors dedicated action endpoints over generic status updates, and rout... : * Create one endpoint per business action rather than a single endpoint handling multiple actions via request body * Only include resource IDs from the ownership chain in the route — omit IDs of related but non-parent resources * Use dedicated POST action endpoints (e.g., `/reject`, `/accept`) instead of a generic PATCH with a status field in the body diff --git a/apps/api/.gitlab/duo/chat-rules.md b/apps/api/.gitlab/duo/chat-rules.md index a8715daaa..389d6114f 100644 --- a/apps/api/.gitlab/duo/chat-rules.md +++ b/apps/api/.gitlab/duo/chat-rules.md @@ -11,7 +11,7 @@ Failure to follow these standards may lead to inconsistencies, errors, or rework # Standard: NestJS Module Hierarchy -Enforce a NestJS module structure where AppModule configures all hierarchical routes via RouterModule.register() and each resource lives in its own module with empty @Controller() paths, URL-mirroring directories, explicit parent IDs (using @Param('orgId')), and command-object service inputs to improve maintainability, scalability, and separation of concerns. : +Establish a consistent NestJS module structure in the API application where each resource is encapsulated in its own module with proper hierarchical organization to enhance maintainability, scalabilit... : * Accept a single typed Command object as the input parameter in service methods instead of multiple individual parameters to enforce explicit intent and maintain consistency with the hexagonal architecture * Configure all hierarchical routing exclusively in AppModule using RouterModule.register() with nested children arrays to ensure a single source of truth for the entire API route structure * Create a dedicated NestJS module for each resource type, preventing controllers from handling sub-resource routes to maintain clear separation of concerns @@ -25,7 +25,7 @@ Full standard is available here for further request: [NestJS Module Hierarchy](. # Standard: REST API Endpoint Design -Define REST API route and controller endpoint conventions using dedicated POST action endpoints and ownership-chain IDs with one endpoint per business action to keep endpoints predictable, self-documenting, and aligned with business intent. : +Conventions for designing REST API endpoints that are predictable, self-documenting, and aligned with distinct business actions. Favors dedicated action endpoints over generic status updates, and rout... : * Create one endpoint per business action rather than a single endpoint handling multiple actions via request body * Only include resource IDs from the ownership chain in the route — omit IDs of related but non-parent resources * Use dedicated POST action endpoints (e.g., `/reject`, `/accept`) instead of a generic PATCH with a status field in the body diff --git a/apps/api/.packmind/standards-index.md b/apps/api/.packmind/standards-index.md index b93c5813b..babe56e0b 100644 --- a/apps/api/.packmind/standards-index.md +++ b/apps/api/.packmind/standards-index.md @@ -4,8 +4,8 @@ This standards index contains all available coding standards that can be used by ## Available Standards -- [NestJS Module Hierarchy](./standards/nestjs-module-hierarchy.md) : Enforce a NestJS module structure where AppModule configures all hierarchical routes via RouterModule.register() and each resource lives in its own module with empty @Controller() paths, URL-mirroring directories, explicit parent IDs (using @Param('orgId')), and command-object service inputs to improve maintainability, scalability, and separation of concerns. -- [REST API Endpoint Design](./standards/rest-api-endpoint-design.md) : Define REST API route and controller endpoint conventions using dedicated POST action endpoints and ownership-chain IDs with one endpoint per business action to keep endpoints predictable, self-documenting, and aligned with business intent. +- [NestJS Module Hierarchy](./standards/nestjs-module-hierarchy.md) : Establish a consistent NestJS module structure in the API application where each resource is encapsulated in its own module with proper hierarchical organization to enhance maintainability, scalability, and clear separation of concerns across the codebase. This standard applies to all modules (new and existing) in apps/api/src/app/ and ensures that the module structure mirrors the URL hierarchy, making it easier to navigate and understand the application's architecture. +- [REST API Endpoint Design](./standards/rest-api-endpoint-design.md) : Conventions for designing REST API endpoints that are predictable, self-documenting, and aligned with distinct business actions. Favors dedicated action endpoints over generic status updates, and routes that reflect the ownership hierarchy without unrelated resource IDs. --- diff --git a/apps/api/AGENTS.md b/apps/api/AGENTS.md index 5d00d4474..b06d66720 100644 --- a/apps/api/AGENTS.md +++ b/apps/api/AGENTS.md @@ -47,7 +47,7 @@ Failure to follow these standards may lead to inconsistencies, errors, or rework # Standard: NestJS Module Hierarchy -Enforce a NestJS module structure where AppModule configures all hierarchical routes via RouterModule.register() and each resource lives in its own module with empty @Controller() paths, URL-mirroring directories, explicit parent IDs (using @Param('orgId')), and command-object service inputs to improve maintainability, scalability, and separation of concerns. : +Establish a consistent NestJS module structure in the API application where each resource is encapsulated in its own module with proper hierarchical organization to enhance maintainability, scalabilit... : * Accept a single typed Command object as the input parameter in service methods instead of multiple individual parameters to enforce explicit intent and maintain consistency with the hexagonal architecture * Configure all hierarchical routing exclusively in AppModule using RouterModule.register() with nested children arrays to ensure a single source of truth for the entire API route structure * Create a dedicated NestJS module for each resource type, preventing controllers from handling sub-resource routes to maintain clear separation of concerns @@ -61,7 +61,7 @@ Full standard is available here for further request: [NestJS Module Hierarchy](. # Standard: REST API Endpoint Design -Define REST API route and controller endpoint conventions using dedicated POST action endpoints and ownership-chain IDs with one endpoint per business action to keep endpoints predictable, self-documenting, and aligned with business intent. : +Conventions for designing REST API endpoints that are predictable, self-documenting, and aligned with distinct business actions. Favors dedicated action endpoints over generic status updates, and rout... : * Create one endpoint per business action rather than a single endpoint handling multiple actions via request body * Only include resource IDs from the ownership chain in the route — omit IDs of related but non-parent resources * Use dedicated POST action endpoints (e.g., `/reject`, `/accept`) instead of a generic PATCH with a status field in the body diff --git a/apps/api/docker-package.json b/apps/api/docker-package.json index ff02998dc..2e80bf5a3 100644 --- a/apps/api/docker-package.json +++ b/apps/api/docker-package.json @@ -22,6 +22,7 @@ "@sentry/node": "^9.40.0", "@teppeis/multimaps": "^3.0.0", "archiver": "^7.0.1", + "async-mutex": "^0.5.0", "axios": "1.15.0", "bcrypt": "^6.0.0", "body-parser": "^2.2.2", diff --git a/apps/api/packmind-lock.json b/apps/api/packmind-lock.json index 9e3a2de07..01f558bd8 100644 --- a/apps/api/packmind-lock.json +++ b/apps/api/packmind-lock.json @@ -6,12 +6,14 @@ "agents": [ "agents_md", "claude", + "codex", "copilot", "cursor", "gitlab_duo", + "opencode", "packmind" ], - "installedAt": "2026-06-16T03:12:26.672Z", + "installedAt": "2026-07-10T03:07:53.072Z", "artifacts": { "user:standard:nestjs-module-hierarchy": { "name": "NestJS Module Hierarchy", diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index a25d71806..8c58f5f42 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -51,6 +51,8 @@ import { OrganizationGitModule } from './organizations/git/git.module'; import { OrganizationGitProvidersModule } from './organizations/git/providers/git-providers.module'; import { OrganizationGitRepositoriesModule } from './organizations/git/repositories/git-repositories.module'; import { OrganizationLlmModule } from './organizations/llm/llm.module'; +import { OrganizationMarketplaceDistributionsModule } from './organizations/marketplace-distributions/marketplace-distributions.module'; +import { OrganizationMarketplacesModule } from './organizations/marketplaces/marketplaces.module'; import { OrganizationMcpModule } from './organizations/mcp/mcp.module'; import { OrganizationPluginsModule } from './organizations/plugins/plugins.module'; import { OrganizationSkillsModule } from './organizations/skills/skills.module'; @@ -62,6 +64,11 @@ import { CliVersionLoggerMiddleware } from './shared/middleware/CliVersionLogger import { HexaRegistryModule } from './shared/HexaRegistryModule'; import { PlaybookModule } from './organizations/playbook/playbook.module'; import { SSEModule } from './sse/sse.module'; +<<<<<<< HEAD +import { TrialModule } from './trial/trial.module'; +import { TrackingModule } from './tracking/tracking.module'; +======= +>>>>>>> origin/main const logger = new PackmindLogger('AppModule', LogLevel.INFO); @@ -130,10 +137,37 @@ const logger = new PackmindLogger('AppModule', LogLevel.INFO); AmplitudeModule, LinterModule, ImportLegacyModule, +<<<<<<< HEAD + TrialModule, +<<<<<<< HEAD + PublicSkillsModule, + TrackingModule, +======= +>>>>>>> origin/main +======= +>>>>>>> origin/main // RouterModule configuration for organization-scoped routes // This must come after OrganizationsModule and its child modules are imported RouterModule.register([ { +<<<<<<< HEAD + path: 'quick-start', + module: TrialModule, + }, + { +<<<<<<< HEAD + path: 'skills', + module: PublicSkillsModule, + }, + { + path: 'tracking', + module: TrackingModule, + }, + { +======= +>>>>>>> origin/main +======= +>>>>>>> origin/main path: 'organizations/:orgId', module: OrganizationsModule, children: [ @@ -177,6 +211,14 @@ const logger = new PackmindLogger('AppModule', LogLevel.INFO); path: 'llm', module: OrganizationLlmModule, }, + { + path: 'marketplace-distributions', + module: OrganizationMarketplaceDistributionsModule, + }, + { + path: 'marketplaces', + module: OrganizationMarketplacesModule, + }, { path: 'skills', module: OrganizationSkillsModule, diff --git a/apps/api/src/app/auth/auth.service.spec.ts b/apps/api/src/app/auth/auth.service.spec.ts index 0413580b5..932bf4b15 100644 --- a/apps/api/src/app/auth/auth.service.spec.ts +++ b/apps/api/src/app/auth/auth.service.spec.ts @@ -270,15 +270,6 @@ describe('AuthService - getMe method', () => { 2, ); }); - - it('logs the multi-organization state', () => { - expect(authService.logger.log).toHaveBeenCalledWith( - 'User is authenticated but has not selected an organization', - { - userId: '1', - }, - ); - }); }); }); diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts index 6cc8cc3a2..97532df33 100644 --- a/apps/api/src/app/auth/auth.service.ts +++ b/apps/api/src/app/auth/auth.service.ts @@ -637,8 +637,6 @@ export class AuthService { // Verify and decode the current JWT const payload: JwtPayload = this.jwtService.verify(accessToken); - // Get organization details from the user's memberships - // We need to fetch the organization details since memberships only have id and role const getUserResponse = await this.accountsAdapter.getUserById({ userId: payload.user.userId, }); @@ -655,11 +653,9 @@ export class AuthService { throw new Error('Organization membership not found'); } - // Get the organization details - const organizationResponse = - await this.accountsAdapter.getOrganizationById({ - organizationId: command.organizationId, - }); + // UserRepository.findById joins memberships.organization, so the org is + // already loaded — no extra round-trip needed. + const organizationResponse = userMembership.organization; if (!organizationResponse) { throw new Error('Organization not found'); diff --git a/apps/api/src/app/organizations/marketplace-distributions/marketplace-distributions.controller.spec.ts b/apps/api/src/app/organizations/marketplace-distributions/marketplace-distributions.controller.spec.ts new file mode 100644 index 000000000..73cad3573 --- /dev/null +++ b/apps/api/src/app/organizations/marketplace-distributions/marketplace-distributions.controller.spec.ts @@ -0,0 +1,117 @@ +import { + createMarketplaceDistributionId, + createMarketplaceId, + createOrganizationId, + createPackageId, + createUserId, + DistributionStatus, + FindMarketplaceDistributionByIdResponse, + IDeploymentPort, + MarketplaceDistribution, +} from '@packmind/types'; +import { AuthenticatedRequest } from '@packmind/node-utils'; +import { stubLogger } from '@packmind/test-utils'; +import { MarketplaceDistributionsController } from './marketplace-distributions.controller'; + +describe('MarketplaceDistributionsController', () => { + const organizationId = createOrganizationId( + '11111111-1111-1111-1111-111111111111', + ); + const userId = createUserId('22222222-2222-2222-2222-222222222222'); + const marketplaceId = createMarketplaceId( + '33333333-3333-3333-3333-333333333333', + ); + const packageId = createPackageId('44444444-4444-4444-4444-444444444444'); + const marketplaceDistributionId = createMarketplaceDistributionId( + '55555555-5555-5555-5555-555555555555', + ); + + const baseRequest = { + user: { userId, name: 'Test User' }, + organization: { + id: organizationId, + name: 'Test', + slug: 'test', + role: 'member', + }, + clientSource: 'ui', + } as unknown as AuthenticatedRequest; + + const distributionRow: MarketplaceDistribution = { + id: marketplaceDistributionId, + organizationId, + marketplaceId, + packageId, + pluginSlug: 'security', + authorId: userId, + status: DistributionStatus.in_progress, + source: 'app', + } as MarketplaceDistribution; + + let mockDeploymentAdapter: jest.Mocked<IDeploymentPort>; + let controller: MarketplaceDistributionsController; + + beforeEach(() => { + mockDeploymentAdapter = { + findMarketplaceDistributionById: jest.fn(), + } as unknown as jest.Mocked<IDeploymentPort>; + + controller = new MarketplaceDistributionsController( + mockDeploymentAdapter, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('on successful lookup', () => { + let result: FindMarketplaceDistributionByIdResponse; + + beforeEach(async () => { + mockDeploymentAdapter.findMarketplaceDistributionById.mockResolvedValue({ + marketplaceDistribution: distributionRow, + }); + result = await controller.getMarketplaceDistributionById( + organizationId, + marketplaceDistributionId, + baseRequest, + ); + }); + + it('returns the wrapped distribution row', () => { + expect(result).toEqual({ marketplaceDistribution: distributionRow }); + }); + + it('forwards the command to the deployment adapter', () => { + expect( + mockDeploymentAdapter.findMarketplaceDistributionById, + ).toHaveBeenCalledWith({ + userId, + organizationId, + marketplaceDistributionId, + source: 'ui', + }); + }); + }); + + describe('when the distribution is not found', () => { + let result: FindMarketplaceDistributionByIdResponse; + + beforeEach(async () => { + mockDeploymentAdapter.findMarketplaceDistributionById.mockResolvedValue({ + marketplaceDistribution: null, + }); + result = await controller.getMarketplaceDistributionById( + organizationId, + marketplaceDistributionId, + baseRequest, + ); + }); + + it('returns null inside the response wrapper', () => { + expect(result).toEqual({ marketplaceDistribution: null }); + }); + }); +}); diff --git a/apps/api/src/app/organizations/marketplace-distributions/marketplace-distributions.controller.ts b/apps/api/src/app/organizations/marketplace-distributions/marketplace-distributions.controller.ts new file mode 100644 index 000000000..feabfe1ea --- /dev/null +++ b/apps/api/src/app/organizations/marketplace-distributions/marketplace-distributions.controller.ts @@ -0,0 +1,65 @@ +import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { AuthenticatedRequest } from '@packmind/node-utils'; +import { + FindMarketplaceDistributionByIdCommand, + FindMarketplaceDistributionByIdResponse, + IDeploymentPort, + MarketplaceDistributionId, + OrganizationId, +} from '@packmind/types'; +import { OrganizationAccessGuard } from '../guards/organization-access.guard'; +import { InjectDeploymentAdapter } from '../../shared/HexaInjection'; + +const origin = 'OrganizationMarketplaceDistributionsController'; + +/** + * Read-only controller for marketplace publish distributions. + * + * Mounted under `/organizations/:orgId/marketplace-distributions` so the + * frontend can poll the publish lifecycle (`in_progress → success | failure | + * no_changes`) using the id returned by the publish endpoint. + */ +@Controller() +@UseGuards(OrganizationAccessGuard) +export class MarketplaceDistributionsController { + constructor( + @InjectDeploymentAdapter() + private readonly deploymentAdapter: IDeploymentPort, + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.INFO, + ), + ) { + this.logger.info( + 'OrganizationMarketplaceDistributionsController initialized', + ); + } + + @Get(':marketplaceDistributionId') + async getMarketplaceDistributionById( + @Param('orgId') organizationId: OrganizationId, + @Param('marketplaceDistributionId') + marketplaceDistributionId: MarketplaceDistributionId, + @Req() request: AuthenticatedRequest, + ): Promise<FindMarketplaceDistributionByIdResponse> { + const userId = request.user.userId; + + this.logger.info( + 'GET /organizations/:orgId/marketplace-distributions/:marketplaceDistributionId', + { + organizationId, + marketplaceDistributionId, + }, + ); + + const command: FindMarketplaceDistributionByIdCommand = { + userId, + organizationId, + marketplaceDistributionId, + source: request.clientSource, + }; + + return this.deploymentAdapter.findMarketplaceDistributionById(command); + } +} diff --git a/apps/api/src/app/organizations/marketplace-distributions/marketplace-distributions.module.ts b/apps/api/src/app/organizations/marketplace-distributions/marketplace-distributions.module.ts new file mode 100644 index 000000000..7a9946862 --- /dev/null +++ b/apps/api/src/app/organizations/marketplace-distributions/marketplace-distributions.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { MarketplaceDistributionsController } from './marketplace-distributions.controller'; + +/** + * NestJS module exposing organization-scoped marketplace publish status + * routes. + * + * Routes are mounted under `/organizations/:orgId/marketplace-distributions` + * by `RouterModule.register` in `AppModule`. No domain services are + * registered here — `IDeploymentPort` is resolved via the shared + * `HexaRegistry`. + */ +@Module({ + controllers: [MarketplaceDistributionsController], + providers: [ + { + provide: PackmindLogger, + useFactory: () => + new PackmindLogger( + 'OrganizationMarketplaceDistributionsModule', + LogLevel.INFO, + ), + }, + ], +}) +export class OrganizationMarketplaceDistributionsModule {} diff --git a/apps/api/src/app/organizations/marketplaces/dto/LinkMarketplaceBody.dto.ts b/apps/api/src/app/organizations/marketplaces/dto/LinkMarketplaceBody.dto.ts new file mode 100644 index 000000000..8551ee8c4 --- /dev/null +++ b/apps/api/src/app/organizations/marketplaces/dto/LinkMarketplaceBody.dto.ts @@ -0,0 +1,29 @@ +import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; + +/** + * Request body for `POST /organizations/:orgId/marketplaces`. + * + * Carries the coordinates of the Git repository that should be linked as an + * organization-level marketplace via the private (token-bearing) path. + */ +export class LinkMarketplaceBodyDto { + @IsUUID() + @IsNotEmpty() + gitProviderId!: string; + + @IsString() + @IsNotEmpty() + owner!: string; + + @IsString() + @IsNotEmpty() + repo!: string; + + @IsString() + @IsNotEmpty() + branch!: string; + + @IsString() + @IsNotEmpty() + name!: string; +} diff --git a/apps/api/src/app/organizations/marketplaces/dto/PublishPackageOnMarketplaceBody.dto.ts b/apps/api/src/app/organizations/marketplaces/dto/PublishPackageOnMarketplaceBody.dto.ts new file mode 100644 index 000000000..4f9171d76 --- /dev/null +++ b/apps/api/src/app/organizations/marketplaces/dto/PublishPackageOnMarketplaceBody.dto.ts @@ -0,0 +1,10 @@ +/** + * Body shape accepted by + * `POST /organizations/:orgId/marketplaces/:marketplaceId/publish`. + * + * The controller maps this to a `PublishPackageOnMarketplaceCommand` enriched + * with the authenticated user/org context. + */ +export class PublishPackageOnMarketplaceBodyDto { + packageId!: string; +} diff --git a/apps/api/src/app/organizations/marketplaces/dto/ValidateMarketplaceUrlQuery.dto.ts b/apps/api/src/app/organizations/marketplaces/dto/ValidateMarketplaceUrlQuery.dto.ts new file mode 100644 index 000000000..6e4251a5e --- /dev/null +++ b/apps/api/src/app/organizations/marketplaces/dto/ValidateMarketplaceUrlQuery.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +/** + * Query parameters for `GET /organizations/:orgId/marketplaces/validate-url`. + * + * Used by the public-link form to pre-flight a marketplace URL before + * submitting the link request. + */ +export class ValidateMarketplaceUrlQueryDto { + @IsString() + @IsNotEmpty() + url!: string; +} diff --git a/apps/api/src/app/organizations/marketplaces/marketplaces.controller.spec.ts b/apps/api/src/app/organizations/marketplaces/marketplaces.controller.spec.ts new file mode 100644 index 000000000..5ed91d16e --- /dev/null +++ b/apps/api/src/app/organizations/marketplaces/marketplaces.controller.spec.ts @@ -0,0 +1,1100 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { + createGitProviderId, + createGitRepoId, + createMarketplaceDistributionId, + createMarketplaceId, + createOrganizationId, + createPackageId, + createUserId, + DistributionStatus, + GitProviderMissingTokenError, + GitProviderNotFoundError, + GitProviderOrganizationMismatchError, + GitProviderTokenInvalidError, + GitRepoAlreadyLinkedAsStandardError, + IDeploymentPort, + LinkMarketplaceResponse, + ListMarketplaceDistributionsResponse, + ListMarketplacesResponse, + Marketplace, + MarketplaceAlreadyLinkedError, + MarketplaceDescriptor, + MarketplaceDescriptorBadFormatError, + MarketplaceDescriptorNotFoundError, + MarketplaceDescriptorParseError, + MarketplaceDistribution, + MarketplaceNotFoundError, + MarketplacePluginNameConflictError, + MarketplaceUrlNotReachableError, + MarkPluginForRemovalResponse, + PluginDistributionInvalidStateError, + PluginDistributionNotFoundError, + PublishPackageOnMarketplaceResponse, + UnknownMarketplaceDescriptorError, + UnlinkMarketplaceResponse, + ValidateMarketplaceUrlResponse, + ListMarketplacePluginInstallsResponse, +} from '@packmind/types'; +import { + AuthenticatedRequest, + OrganizationAdminRequiredError, +} from '@packmind/node-utils'; +import { stubLogger } from '@packmind/test-utils'; +import { MarketplacesController } from './marketplaces.controller'; +import { LinkMarketplaceBodyDto } from './dto/LinkMarketplaceBody.dto'; +import { PublishPackageOnMarketplaceBodyDto } from './dto/PublishPackageOnMarketplaceBody.dto'; +import { ValidateMarketplaceUrlQueryDto } from './dto/ValidateMarketplaceUrlQuery.dto'; + +describe('MarketplacesController', () => { + const organizationId = createOrganizationId( + '11111111-1111-1111-1111-111111111111', + ); + const userId = createUserId('22222222-2222-2222-2222-222222222222'); + const gitProviderId = createGitProviderId( + '33333333-3333-3333-3333-333333333333', + ); + const gitRepoId = createGitRepoId('44444444-4444-4444-4444-444444444444'); + const marketplaceId = createMarketplaceId( + '55555555-5555-5555-5555-555555555555', + ); + const distributionId = createMarketplaceDistributionId( + '66666666-6666-6666-6666-666666666666', + ); + const packageId = createPackageId('77777777-7777-7777-7777-777777777777'); + + const baseRequest = { + user: { userId, name: 'Test User' }, + organization: { + id: organizationId, + name: 'Test', + slug: 'test', + role: 'admin', + }, + clientSource: 'ui', + } as unknown as AuthenticatedRequest; + + const descriptor: MarketplaceDescriptor = { + vendor: 'anthropic', + name: 'Anthropic Marketplace', + plugins: [], + raw: {}, + }; + + const marketplace: Marketplace = { + id: marketplaceId, + organizationId, + gitRepoId, + name: 'Anthropic Marketplace', + vendor: 'anthropic', + addedBy: userId, + linkedAt: new Date('2026-05-29T10:00:00.000Z'), + state: 'healthy', + lastValidatedAt: new Date('2026-05-29T10:00:00.000Z'), + descriptor, + pluginCount: 3, + errorKind: null, + errorDetail: null, + pendingPrUrl: null, + outdatedPluginSlugs: null, + trackingToken: 'aaaabbbbcccc-tracking-token', + createdAt: new Date('2026-05-29T10:00:00.000Z'), + updatedAt: new Date('2026-05-29T10:00:00.000Z'), + deletedAt: null, + deletedBy: null, + }; + + const linkResponse: LinkMarketplaceResponse = { + ...marketplace, + addedByUserName: 'Test User', + }; + + let controller: MarketplacesController; + let mockDeploymentAdapter: jest.Mocked<IDeploymentPort>; + + beforeEach(() => { + mockDeploymentAdapter = { + linkMarketplace: jest.fn(), + unlinkMarketplace: jest.fn(), + listMarketplaces: jest.fn(), + validateMarketplaceUrl: jest.fn(), + publishPackageOnMarketplace: jest.fn(), + listMarketplaceDistributions: jest.fn(), + getMarketplaceDistributionChanges: jest.fn(), + markPluginForRemoval: jest.fn(), + listMarketplacePluginInstalls: jest.fn(), + } as unknown as jest.Mocked<IDeploymentPort>; + + controller = new MarketplacesController( + mockDeploymentAdapter, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('POST /organizations/:orgId/marketplaces (linkMarketplace)', () => { + const body: LinkMarketplaceBodyDto = { + gitProviderId, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace', + }; + + describe('on successful link', () => { + let result: LinkMarketplaceResponse; + + beforeEach(async () => { + mockDeploymentAdapter.linkMarketplace.mockResolvedValue(linkResponse); + + result = await controller.linkMarketplace( + organizationId, + body, + baseRequest, + ); + }); + + it('returns the response from the deployment adapter', () => { + expect(result).toEqual(linkResponse); + }); + + it('forwards the command to the deployment adapter', () => { + expect(mockDeploymentAdapter.linkMarketplace).toHaveBeenCalledWith({ + userId, + organizationId, + gitProviderId, + owner: body.owner, + repo: body.repo, + branch: body.branch, + name: body.name, + source: 'ui', + }); + }); + }); + + describe('error mapping', () => { + it('maps MarketplaceAlreadyLinkedError to ConflictException with verbatim contract message', async () => { + const error = new MarketplaceAlreadyLinkedError(body.owner, body.repo); + mockDeploymentAdapter.linkMarketplace.mockRejectedValue(error); + + await expect( + controller.linkMarketplace(organizationId, body, baseRequest), + ).rejects.toMatchObject({ + constructor: ConflictException, + message: `The marketplace ${body.owner}/${body.repo} has already been linked to your organization`, + }); + }); + + it('maps GitRepoAlreadyLinkedAsStandardError to ConflictException (409)', async () => { + mockDeploymentAdapter.linkMarketplace.mockRejectedValue( + new GitRepoAlreadyLinkedAsStandardError(body.owner, body.repo), + ); + + await expect( + controller.linkMarketplace(organizationId, body, baseRequest), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('maps MarketplaceDescriptorNotFoundError to BadRequestException (400) naming the missing file', async () => { + const descriptorError = new MarketplaceDescriptorNotFoundError( + body.owner, + body.repo, + ); + mockDeploymentAdapter.linkMarketplace.mockRejectedValue( + descriptorError, + ); + + await expect( + controller.linkMarketplace(organizationId, body, baseRequest), + ).rejects.toMatchObject({ + constructor: BadRequestException, + message: descriptorError.message, + }); + }); + + it('maps UnknownMarketplaceDescriptorError to BadRequestException (400)', async () => { + mockDeploymentAdapter.linkMarketplace.mockRejectedValue( + new UnknownMarketplaceDescriptorError(), + ); + + await expect( + controller.linkMarketplace(organizationId, body, baseRequest), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('maps MarketplaceDescriptorParseError to BadRequestException (400)', async () => { + mockDeploymentAdapter.linkMarketplace.mockRejectedValue( + new MarketplaceDescriptorParseError( + 'Invalid JSON', + new Error('boom'), + ), + ); + + await expect( + controller.linkMarketplace(organizationId, body, baseRequest), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('maps GitProviderNotFoundError to NotFoundException (404) without leaking the provider UUID', async () => { + mockDeploymentAdapter.linkMarketplace.mockRejectedValue( + new GitProviderNotFoundError(gitProviderId), + ); + + await expect( + controller.linkMarketplace(organizationId, body, baseRequest), + ).rejects.toMatchObject({ + constructor: NotFoundException, + // The fixed message carries no UUID — asserting it exactly proves + // the provider id never reaches the user. + message: 'The selected Git provider could not be found.', + }); + }); + + it('maps GitProviderOrganizationMismatchError to ForbiddenException (403) without leaking the provider UUID', async () => { + mockDeploymentAdapter.linkMarketplace.mockRejectedValue( + new GitProviderOrganizationMismatchError( + gitProviderId, + organizationId, + ), + ); + + await expect( + controller.linkMarketplace(organizationId, body, baseRequest), + ).rejects.toMatchObject({ + constructor: ForbiddenException, + message: + 'The selected Git provider does not belong to your organization.', + }); + }); + + it('maps GitProviderMissingTokenError to BadRequestException (400) without leaking the provider UUID', async () => { + mockDeploymentAdapter.linkMarketplace.mockRejectedValue( + new GitProviderMissingTokenError(gitProviderId), + ); + + await expect( + controller.linkMarketplace(organizationId, body, baseRequest), + ).rejects.toMatchObject({ + constructor: BadRequestException, + // The fixed message carries no UUID — asserting it exactly proves + // the provider id never reaches the user. + message: + 'The selected Git provider has no access token configured. Connect it in your Git settings and try again.', + }); + }); + + it('maps OrganizationAdminRequiredError to ForbiddenException (403) for non-admin link attempts', async () => { + mockDeploymentAdapter.linkMarketplace.mockRejectedValue( + new OrganizationAdminRequiredError({ userId, organizationId }), + ); + + await expect( + controller.linkMarketplace(organizationId, body, baseRequest), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rethrows unknown errors unchanged', async () => { + const original = new Error('boom'); + mockDeploymentAdapter.linkMarketplace.mockRejectedValue(original); + + await expect( + controller.linkMarketplace(organizationId, body, baseRequest), + ).rejects.toBe(original); + }); + }); + }); + + describe('DELETE /organizations/:orgId/marketplaces/:marketplaceId (unlinkMarketplace)', () => { + const unlinkResponse: UnlinkMarketplaceResponse = { marketplaceId }; + + describe('on successful unlink', () => { + let result: UnlinkMarketplaceResponse; + + beforeEach(async () => { + mockDeploymentAdapter.unlinkMarketplace.mockResolvedValue( + unlinkResponse, + ); + + result = await controller.unlinkMarketplace( + organizationId, + marketplaceId, + baseRequest, + ); + }); + + it('returns the response from the deployment adapter', () => { + expect(result).toEqual(unlinkResponse); + }); + + it('forwards the command to the deployment adapter', () => { + expect(mockDeploymentAdapter.unlinkMarketplace).toHaveBeenCalledWith({ + userId, + organizationId, + marketplaceId, + source: 'ui', + }); + }); + }); + + describe('error mapping', () => { + it('maps MarketplaceNotFoundError to NotFoundException (404) without leaking the marketplace UUID', async () => { + mockDeploymentAdapter.unlinkMarketplace.mockRejectedValue( + new MarketplaceNotFoundError(marketplaceId), + ); + + await expect( + controller.unlinkMarketplace( + organizationId, + marketplaceId, + baseRequest, + ), + ).rejects.toMatchObject({ + constructor: NotFoundException, + // The fixed message carries no UUID — asserting it exactly proves + // the marketplace id never reaches the user. + message: + 'The marketplace could not be found. It may have already been unlinked.', + }); + }); + + it('maps OrganizationAdminRequiredError to ForbiddenException (403) for non-admin unlink attempts', async () => { + mockDeploymentAdapter.unlinkMarketplace.mockRejectedValue( + new OrganizationAdminRequiredError({ userId, organizationId }), + ); + + await expect( + controller.unlinkMarketplace( + organizationId, + marketplaceId, + baseRequest, + ), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rethrows unknown errors unchanged', async () => { + const original = new Error('boom'); + mockDeploymentAdapter.unlinkMarketplace.mockRejectedValue(original); + + await expect( + controller.unlinkMarketplace( + organizationId, + marketplaceId, + baseRequest, + ), + ).rejects.toBe(original); + }); + }); + }); + + describe('GET /organizations/:orgId/marketplaces (listMarketplaces)', () => { + describe('for any member', () => { + const listResponse: ListMarketplacesResponse = [ + { + ...marketplace, + addedByUserName: 'Test User', + repository: { + gitProviderId, + owner: 'acme', + repo: 'plugins', + branch: 'main', + providerSource: 'github', + url: 'https://github.com/acme/plugins', + }, + }, + ]; + let result: ListMarketplacesResponse; + + beforeEach(async () => { + mockDeploymentAdapter.listMarketplaces.mockResolvedValue(listResponse); + + result = await controller.listMarketplaces(organizationId, baseRequest); + }); + + it('returns 200-equivalent body with the list of marketplaces', () => { + expect(result).toEqual(listResponse); + }); + + it('forwards the command to the deployment adapter', () => { + expect(mockDeploymentAdapter.listMarketplaces).toHaveBeenCalledWith({ + userId, + organizationId, + source: 'ui', + }); + }); + }); + + describe('when a marketplace carries live-state fields', () => { + const liveStateResponse: ListMarketplacesResponse = [ + { + ...marketplace, + state: 'unreachable', + errorKind: 'auth_failed', + errorDetail: 'creds expired', + pendingPrUrl: 'https://github.com/acme/plugins/pull/9', + outdatedPluginSlugs: ['plugin-one'], + addedByUserName: 'Test User', + repository: { + gitProviderId, + owner: 'acme', + repo: 'plugins', + branch: 'main', + providerSource: 'github', + url: 'https://github.com/acme/plugins', + }, + }, + ]; + + it('forwards the live-state fields to the response body', async () => { + mockDeploymentAdapter.listMarketplaces.mockResolvedValue( + liveStateResponse, + ); + const result = await controller.listMarketplaces( + organizationId, + baseRequest, + ); + expect(result).toEqual(liveStateResponse); + }); + }); + + describe('when the organization has no marketplaces', () => { + it('returns an empty array', async () => { + mockDeploymentAdapter.listMarketplaces.mockResolvedValue([]); + + const result = await controller.listMarketplaces( + organizationId, + baseRequest, + ); + + expect(result).toEqual([]); + }); + }); + + it('rethrows unknown errors unchanged', async () => { + const original = new Error('boom'); + mockDeploymentAdapter.listMarketplaces.mockRejectedValue(original); + + await expect( + controller.listMarketplaces(organizationId, baseRequest), + ).rejects.toBe(original); + }); + }); + + describe('GET /organizations/:orgId/marketplaces/validate-url (validateMarketplaceUrl)', () => { + const query: ValidateMarketplaceUrlQueryDto = { + url: 'https://github.com/anthropic/marketplace', + }; + + const validateResponse: ValidateMarketplaceUrlResponse = { + kind: 'verified', + repoPath: 'anthropic/marketplace', + defaultBranch: 'main', + pluginCount: 3, + }; + + describe('on successful validation', () => { + let result: ValidateMarketplaceUrlResponse; + + beforeEach(async () => { + mockDeploymentAdapter.validateMarketplaceUrl.mockResolvedValue( + validateResponse, + ); + + result = await controller.validateMarketplaceUrl( + organizationId, + query, + baseRequest, + ); + }); + + it('returns the response from the deployment adapter', () => { + expect(result).toEqual(validateResponse); + }); + + it('forwards the command to the deployment adapter', () => { + expect( + mockDeploymentAdapter.validateMarketplaceUrl, + ).toHaveBeenCalledWith({ + userId, + organizationId, + url: query.url, + source: 'ui', + }); + }); + }); + + describe('error mapping', () => { + it('maps MarketplaceDescriptorNotFoundError to BadRequestException (400)', async () => { + mockDeploymentAdapter.validateMarketplaceUrl.mockRejectedValue( + new MarketplaceDescriptorNotFoundError('anthropic', 'marketplace'), + ); + + await expect( + controller.validateMarketplaceUrl(organizationId, query, baseRequest), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('maps UnknownMarketplaceDescriptorError to BadRequestException (400)', async () => { + mockDeploymentAdapter.validateMarketplaceUrl.mockRejectedValue( + new UnknownMarketplaceDescriptorError(), + ); + + await expect( + controller.validateMarketplaceUrl(organizationId, query, baseRequest), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('maps MarketplaceDescriptorParseError to BadRequestException (400)', async () => { + mockDeploymentAdapter.validateMarketplaceUrl.mockRejectedValue( + new MarketplaceDescriptorParseError( + 'Invalid JSON', + new Error('boom'), + ), + ); + + await expect( + controller.validateMarketplaceUrl(organizationId, query, baseRequest), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('maps MarketplaceUrlNotReachableError to BadRequestException (400)', async () => { + mockDeploymentAdapter.validateMarketplaceUrl.mockRejectedValue( + new MarketplaceUrlNotReachableError(query.url), + ); + + await expect( + controller.validateMarketplaceUrl(organizationId, query, baseRequest), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + }); + + describe('POST /organizations/:orgId/marketplaces/:marketplaceId/publish (publishPackageOnMarketplace)', () => { + const packageId = createPackageId('99999999-9999-9999-9999-999999999999'); + const marketplaceDistributionId = createMarketplaceDistributionId( + '88888888-8888-8888-8888-888888888888', + ); + const body: PublishPackageOnMarketplaceBodyDto = { + packageId: packageId as unknown as string, + }; + const publishResponse: PublishPackageOnMarketplaceResponse = { + marketplaceDistributionId, + status: 'in_progress', + marketplaceId, + packageId, + pluginSlug: 'security', + }; + + describe('on successful publish', () => { + let result: PublishPackageOnMarketplaceResponse; + + beforeEach(async () => { + mockDeploymentAdapter.publishPackageOnMarketplace.mockResolvedValue( + publishResponse, + ); + + result = await controller.publishPackageOnMarketplace( + organizationId, + marketplaceId, + body, + baseRequest, + ); + }); + + it('returns the in-progress publish response', () => { + expect(result).toEqual(publishResponse); + }); + + it('forwards the command to the deployment adapter', () => { + expect( + mockDeploymentAdapter.publishPackageOnMarketplace, + ).toHaveBeenCalledWith({ + userId, + organizationId, + marketplaceId, + packageId, + source: 'ui', + }); + }); + }); + + describe('error mapping', () => { + it('maps MarketplacePluginNameConflictError to ConflictException (409)', async () => { + mockDeploymentAdapter.publishPackageOnMarketplace.mockRejectedValue( + new MarketplacePluginNameConflictError('security', 'ACME'), + ); + + await expect( + controller.publishPackageOnMarketplace( + organizationId, + marketplaceId, + body, + baseRequest, + ), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('maps GitProviderTokenInvalidError to BadRequestException with the verbatim user-facing message', async () => { + mockDeploymentAdapter.publishPackageOnMarketplace.mockRejectedValue( + new GitProviderTokenInvalidError(), + ); + + await expect( + controller.publishPackageOnMarketplace( + organizationId, + marketplaceId, + body, + baseRequest, + ), + ).rejects.toMatchObject({ + constructor: BadRequestException, + message: GitProviderTokenInvalidError.USER_FACING_MESSAGE, + }); + }); + + it('maps MarketplaceDescriptorBadFormatError to BadRequestException (400)', async () => { + mockDeploymentAdapter.publishPackageOnMarketplace.mockRejectedValue( + new MarketplaceDescriptorBadFormatError('acme', 'plugins'), + ); + + await expect( + controller.publishPackageOnMarketplace( + organizationId, + marketplaceId, + body, + baseRequest, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('maps MarketplaceNotFoundError to NotFoundException (404)', async () => { + mockDeploymentAdapter.publishPackageOnMarketplace.mockRejectedValue( + new MarketplaceNotFoundError(marketplaceId), + ); + + await expect( + controller.publishPackageOnMarketplace( + organizationId, + marketplaceId, + body, + baseRequest, + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('maps MarketplaceDescriptorNotFoundError to BadRequestException (400)', async () => { + mockDeploymentAdapter.publishPackageOnMarketplace.mockRejectedValue( + new MarketplaceDescriptorNotFoundError('acme', 'plugins'), + ); + + await expect( + controller.publishPackageOnMarketplace( + organizationId, + marketplaceId, + body, + baseRequest, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rethrows unknown errors unchanged', async () => { + const original = new Error('boom'); + mockDeploymentAdapter.publishPackageOnMarketplace.mockRejectedValue( + original, + ); + + await expect( + controller.publishPackageOnMarketplace( + organizationId, + marketplaceId, + body, + baseRequest, + ), + ).rejects.toBe(original); + }); + }); + }); + + describe('GET /organizations/:orgId/marketplaces/:marketplaceId/distributions (listMarketplaceDistributions)', () => { + const distribution: MarketplaceDistribution = { + id: distributionId, + organizationId, + marketplaceId, + packageId, + pluginSlug: 'sample-plugin', + authorId: userId, + status: DistributionStatus.success, + source: 'app', + createdAt: new Date('2026-05-29T10:00:00.000Z'), + updatedAt: new Date('2026-05-29T10:00:00.000Z'), + deletedAt: null, + } as MarketplaceDistribution; + + const listResponse: ListMarketplaceDistributionsResponse = [ + { + ...distribution, + packageName: 'Sample Package', + authorName: 'Test User', + }, + ]; + + describe('on successful list', () => { + let result: ListMarketplaceDistributionsResponse; + + beforeEach(async () => { + mockDeploymentAdapter.listMarketplaceDistributions.mockResolvedValue( + listResponse, + ); + + result = await controller.listMarketplaceDistributions( + organizationId, + marketplaceId, + baseRequest, + ); + }); + + it('returns the response from the deployment adapter', () => { + expect(result).toEqual(listResponse); + }); + + it('forwards the command to the deployment adapter', () => { + expect( + mockDeploymentAdapter.listMarketplaceDistributions, + ).toHaveBeenCalledWith({ + userId, + organizationId, + marketplaceId, + source: 'ui', + }); + }); + }); + + describe('when the marketplace has no distributions', () => { + it('returns an empty array', async () => { + mockDeploymentAdapter.listMarketplaceDistributions.mockResolvedValue( + [], + ); + + const result = await controller.listMarketplaceDistributions( + organizationId, + marketplaceId, + baseRequest, + ); + + expect(result).toEqual([]); + }); + }); + + describe('error mapping', () => { + it('maps MarketplaceNotFoundError to NotFoundException (404) without leaking the marketplace UUID', async () => { + mockDeploymentAdapter.listMarketplaceDistributions.mockRejectedValue( + new MarketplaceNotFoundError(marketplaceId), + ); + + await expect( + controller.listMarketplaceDistributions( + organizationId, + marketplaceId, + baseRequest, + ), + ).rejects.toMatchObject({ + constructor: NotFoundException, + message: + 'The marketplace could not be found. It may have already been unlinked.', + }); + }); + + it('rethrows unknown errors unchanged', async () => { + const original = new Error('boom'); + mockDeploymentAdapter.listMarketplaceDistributions.mockRejectedValue( + original, + ); + + await expect( + controller.listMarketplaceDistributions( + organizationId, + marketplaceId, + baseRequest, + ), + ).rejects.toBe(original); + }); + }); + }); + + describe('POST /organizations/:orgId/marketplaces/:marketplaceId/distributions/:distributionId/removal (markPluginForRemovalByDistribution)', () => { + const markedDistribution: MarketplaceDistribution = { + id: distributionId, + organizationId, + marketplaceId, + packageId, + pluginSlug: 'sample-plugin', + authorId: userId, + status: DistributionStatus.to_be_removed, + source: 'app', + createdAt: new Date('2026-05-29T10:00:00.000Z'), + updatedAt: new Date('2026-05-29T10:00:00.000Z'), + deletedAt: null, + } as MarketplaceDistribution; + + const markResponse: MarkPluginForRemovalResponse = { + distribution: markedDistribution, + }; + + describe('on successful mark by distribution', () => { + let result: MarkPluginForRemovalResponse; + + beforeEach(async () => { + mockDeploymentAdapter.markPluginForRemoval.mockResolvedValue( + markResponse, + ); + + result = await controller.markPluginForRemovalByDistribution( + organizationId, + marketplaceId, + distributionId, + baseRequest, + ); + }); + + it('returns the response from the deployment adapter', () => { + expect(result).toEqual(markResponse); + }); + + it('forwards the command to the deployment adapter with distributionId', () => { + expect(mockDeploymentAdapter.markPluginForRemoval).toHaveBeenCalledWith( + { + userId, + organizationId, + marketplaceId, + distributionId, + source: 'ui', + }, + ); + }); + }); + + describe('error mapping', () => { + it('maps PluginDistributionNotFoundError (by distributionId) to NotFoundException (404) without leaking the distribution UUID', async () => { + mockDeploymentAdapter.markPluginForRemoval.mockRejectedValue( + new PluginDistributionNotFoundError({ distributionId }), + ); + + await expect( + controller.markPluginForRemovalByDistribution( + organizationId, + marketplaceId, + distributionId, + baseRequest, + ), + ).rejects.toMatchObject({ + constructor: NotFoundException, + message: + 'The marketplace plugin distribution "666666*" could not be found. It may have already been removed.', + }); + }); + + it('maps PluginDistributionInvalidStateError to ConflictException (409) without leaking the distribution UUID', async () => { + mockDeploymentAdapter.markPluginForRemoval.mockRejectedValue( + new PluginDistributionInvalidStateError( + distributionId, + DistributionStatus.in_progress, + [DistributionStatus.success], + ), + ); + + await expect( + controller.markPluginForRemovalByDistribution( + organizationId, + marketplaceId, + distributionId, + baseRequest, + ), + ).rejects.toMatchObject({ + constructor: ConflictException, + message: + 'The marketplace plugin distribution "666666*" is in status "in_progress" but the operation requires one of [success].', + }); + }); + + it('maps OrganizationAdminRequiredError to ForbiddenException (403) for non-admin mark attempts', async () => { + mockDeploymentAdapter.markPluginForRemoval.mockRejectedValue( + new OrganizationAdminRequiredError({ userId, organizationId }), + ); + + await expect( + controller.markPluginForRemovalByDistribution( + organizationId, + marketplaceId, + distributionId, + baseRequest, + ), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rethrows unknown errors unchanged', async () => { + const original = new Error('boom'); + mockDeploymentAdapter.markPluginForRemoval.mockRejectedValue(original); + + await expect( + controller.markPluginForRemovalByDistribution( + organizationId, + marketplaceId, + distributionId, + baseRequest, + ), + ).rejects.toBe(original); + }); + }); + }); + + describe('POST /organizations/:orgId/marketplaces/:marketplaceId/packages/:packageId/removal (markPluginForRemovalByPackage)', () => { + const markedDistribution: MarketplaceDistribution = { + id: distributionId, + organizationId, + marketplaceId, + packageId, + pluginSlug: 'sample-plugin', + authorId: userId, + status: DistributionStatus.to_be_removed, + source: 'app', + createdAt: new Date('2026-05-29T10:00:00.000Z'), + updatedAt: new Date('2026-05-29T10:00:00.000Z'), + deletedAt: null, + } as MarketplaceDistribution; + + const markResponse: MarkPluginForRemovalResponse = { + distribution: markedDistribution, + }; + + describe('on successful mark by package', () => { + let result: MarkPluginForRemovalResponse; + + beforeEach(async () => { + mockDeploymentAdapter.markPluginForRemoval.mockResolvedValue( + markResponse, + ); + + result = await controller.markPluginForRemovalByPackage( + organizationId, + marketplaceId, + packageId, + baseRequest, + ); + }); + + it('returns the response from the deployment adapter', () => { + expect(result).toEqual(markResponse); + }); + + it('forwards the command to the deployment adapter with packageId', () => { + expect(mockDeploymentAdapter.markPluginForRemoval).toHaveBeenCalledWith( + { + userId, + organizationId, + marketplaceId, + packageId, + source: 'ui', + }, + ); + }); + }); + + describe('error mapping', () => { + it('maps PluginDistributionNotFoundError (by packageId) to NotFoundException (404) without leaking the package or marketplace UUIDs', async () => { + mockDeploymentAdapter.markPluginForRemoval.mockRejectedValue( + new PluginDistributionNotFoundError({ + packageId, + marketplaceId, + }), + ); + + await expect( + controller.markPluginForRemovalByPackage( + organizationId, + marketplaceId, + packageId, + baseRequest, + ), + ).rejects.toMatchObject({ + constructor: NotFoundException, + message: + 'No active marketplace plugin distribution was found for package "777777*" on marketplace "555555*".', + }); + }); + + it('maps OrganizationAdminRequiredError to ForbiddenException (403) for non-admin mark attempts', async () => { + mockDeploymentAdapter.markPluginForRemoval.mockRejectedValue( + new OrganizationAdminRequiredError({ userId, organizationId }), + ); + + await expect( + controller.markPluginForRemovalByPackage( + organizationId, + marketplaceId, + packageId, + baseRequest, + ), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + }); + }); + + describe('GET /organizations/:orgId/marketplaces/:marketplaceId/plugin-installs', () => { + const pluginInstallsResponse: ListMarketplacePluginInstallsResponse = []; + + describe('on successful list', () => { + let result: ListMarketplacePluginInstallsResponse; + + beforeEach(async () => { + mockDeploymentAdapter.listMarketplacePluginInstalls.mockResolvedValue( + pluginInstallsResponse, + ); + + result = await controller.listMarketplacePluginInstalls( + organizationId, + marketplaceId, + baseRequest, + ); + }); + + it('returns the response from the deployment adapter', () => { + expect(result).toEqual(pluginInstallsResponse); + }); + + it('forwards the command to the deployment adapter', () => { + expect( + mockDeploymentAdapter.listMarketplacePluginInstalls, + ).toHaveBeenCalledWith({ + userId, + organizationId, + marketplaceId, + source: 'ui', + }); + }); + }); + + describe('when the org access guard rejects a non-member', () => { + it('rethrows ForbiddenException unchanged (negative case only)', async () => { + const original = new ForbiddenException('Access denied'); + mockDeploymentAdapter.listMarketplacePluginInstalls.mockRejectedValue( + original, + ); + + await expect( + controller.listMarketplacePluginInstalls( + organizationId, + marketplaceId, + baseRequest, + ), + ).rejects.toBe(original); + }); + }); + }); +}); diff --git a/apps/api/src/app/organizations/marketplaces/marketplaces.controller.ts b/apps/api/src/app/organizations/marketplaces/marketplaces.controller.ts new file mode 100644 index 000000000..ac4781dfb --- /dev/null +++ b/apps/api/src/app/organizations/marketplaces/marketplaces.controller.ts @@ -0,0 +1,813 @@ +import { + BadRequestException, + Body, + ConflictException, + Controller, + Delete, + ForbiddenException, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { + AuthenticatedRequest, + OrganizationAdminRequiredError, +} from '@packmind/node-utils'; +import { + GitProviderId, + GitProviderMissingTokenError, + GitProviderNotFoundError, + GitProviderOrganizationMismatchError, + GitProviderTokenInvalidError, + GitRepoAlreadyLinkedAsStandardError, + IDeploymentPort, + LinkMarketplaceCommand, + LinkMarketplaceResponse, + GetMarketplaceDistributionChangesCommand, + GetMarketplaceDistributionChangesResponse, + ListMarketplaceDistributionsCommand, + ListMarketplaceDistributionsResponse, + ListMarketplacesCommand, + ListMarketplacesResponse, + ListMarketplacePluginInstallsCommand, + ListMarketplacePluginInstallsResponse, + MarketplaceAlreadyLinkedError, + MarketplaceDescriptorBadFormatError, + MarketplaceDescriptorNotFoundError, + MarketplaceDescriptorParseError, + MarketplaceDistributionId, + MarketplaceId, + MarketplaceNotFoundError, + MarketplacePluginNameConflictError, + MarketplaceUrlNotReachableError, + MarkPluginForRemovalCommand, + MarkPluginForRemovalResponse, + SyncMarketplaceNowCommand, + SyncMarketplaceNowResponse, + OrganizationId, + PackageId, + PluginDistributionInvalidStateError, + PluginDistributionNotFoundError, + PublishPackageOnMarketplaceCommand, + PublishPackageOnMarketplaceResponse, + UnknownMarketplaceDescriptorError, + UnlinkMarketplaceCommand, + UnlinkMarketplaceResponse, + ValidateMarketplaceUrlCommand, + ValidateMarketplaceUrlResponse, +} from '@packmind/types'; +import { OrganizationAccessGuard } from '../guards/organization-access.guard'; +import { InjectDeploymentAdapter } from '../../shared/HexaInjection'; +import { LinkMarketplaceBodyDto } from './dto/LinkMarketplaceBody.dto'; +import { PublishPackageOnMarketplaceBodyDto } from './dto/PublishPackageOnMarketplaceBody.dto'; +import { ValidateMarketplaceUrlQueryDto } from './dto/ValidateMarketplaceUrlQuery.dto'; + +const origin = 'OrganizationMarketplacesController'; + +/** + * User-facing messages for errors whose underlying domain message embeds an + * internal identifier (a provider/marketplace UUID). The raw `error.message` + * is still logged for diagnostics, but the HTTP response must never leak a + * UUID to the end user. + */ +const USER_FACING_ERROR_MESSAGE = { + gitProviderNotFound: 'The selected Git provider could not be found.', + gitProviderOrganizationMismatch: + 'The selected Git provider does not belong to your organization.', + gitProviderMissingToken: + 'The selected Git provider has no access token configured. Connect it in your Git settings and try again.', + marketplaceNotFound: + 'The marketplace could not be found. It may have already been unlinked.', +} as const; + +/** + * Build a user-facing message for `PluginDistributionNotFoundError` whose + * underlying message embeds raw UUIDs (distributionId, packageId, + * marketplaceId). UUIDs are masked to their first 6 characters per + * `standard-compliance-logging-personal-information.md`. + */ +function pluginDistributionNotFoundMessage( + error: PluginDistributionNotFoundError, +): string { + if ('distributionId' in error.identifier) { + return `The marketplace plugin distribution "${maskIdentifier( + error.identifier.distributionId, + )}" could not be found. It may have already been removed.`; + } + return `No active marketplace plugin distribution was found for package "${maskIdentifier( + error.identifier.packageId, + )}" on marketplace "${maskIdentifier(error.identifier.marketplaceId)}".`; +} + +/** + * Build a user-facing message for `PluginDistributionInvalidStateError` so the + * distribution UUID does not leak to the HTTP client. + */ +function pluginDistributionInvalidStateMessage( + error: PluginDistributionInvalidStateError, +): string { + return `The marketplace plugin distribution "${maskIdentifier( + error.distributionId, + )}" is in status "${error.from}" but the operation requires one of [${error.expected.join( + ', ', + )}].`; +} + +/** + * Mask the first 6 characters of a string identifier and replace the rest + * with `*`, per `standard-compliance-logging-personal-information.md`. Used + * to log the `addedBy` user id without leaking the full value. + */ +function maskIdentifier(value: string): string { + if (!value) return value; + if (value.length <= 6) return `${value}*`; + return `${value.slice(0, 6)}*`; +} + +/** + * NestJS controller for organization-scoped marketplace management. + * + * Mounted under `/organizations/:orgId/marketplaces` via `RouterModule` in + * `AppModule`. Admin enforcement happens inside the use cases (they extend + * `AbstractAdminUseCase`); the controller only forwards the authenticated + * context and maps the typed domain errors to HTTP responses. + */ +@Controller() +@UseGuards(OrganizationAccessGuard) +export class MarketplacesController { + constructor( + @InjectDeploymentAdapter() + private readonly deploymentAdapter: IDeploymentPort, + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.INFO, + ), + ) { + this.logger.info('OrganizationMarketplacesController initialized'); + } + + @Post() + async linkMarketplace( + @Param('orgId') organizationId: OrganizationId, + @Body() body: LinkMarketplaceBodyDto, + @Req() request: AuthenticatedRequest, + ): Promise<LinkMarketplaceResponse> { + const userId = request.user.userId; + + this.logger.info( + 'POST /organizations/:orgId/marketplaces - Linking marketplace', + { + organizationId, + addedBy: maskIdentifier(userId), + owner: body.owner, + repo: body.repo, + branch: body.branch, + }, + ); + + try { + const command: LinkMarketplaceCommand = { + userId, + organizationId, + gitProviderId: body.gitProviderId as GitProviderId, + owner: body.owner, + repo: body.repo, + branch: body.branch, + name: body.name, + source: request.clientSource, + }; + + const response = await this.deploymentAdapter.linkMarketplace(command); + + this.logger.info( + 'POST /organizations/:orgId/marketplaces - Marketplace linked successfully', + { + organizationId, + marketplaceId: response.id, + addedBy: maskIdentifier(userId), + }, + ); + + return response; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /organizations/:orgId/marketplaces - Failed to link marketplace', + { + organizationId, + addedBy: maskIdentifier(userId), + error: errorMessage, + }, + ); + throw this.mapError(error); + } + } + + @Delete(':marketplaceId') + async unlinkMarketplace( + @Param('orgId') organizationId: OrganizationId, + @Param('marketplaceId') marketplaceId: MarketplaceId, + @Req() request: AuthenticatedRequest, + ): Promise<UnlinkMarketplaceResponse> { + const userId = request.user.userId; + + this.logger.info( + 'DELETE /organizations/:orgId/marketplaces/:marketplaceId - Unlinking marketplace', + { + organizationId, + marketplaceId, + addedBy: maskIdentifier(userId), + }, + ); + + try { + const command: UnlinkMarketplaceCommand = { + userId, + organizationId, + marketplaceId, + source: request.clientSource, + }; + + const response = await this.deploymentAdapter.unlinkMarketplace(command); + + this.logger.info( + 'DELETE /organizations/:orgId/marketplaces/:marketplaceId - Marketplace unlinked successfully', + { + organizationId, + marketplaceId, + }, + ); + + return response; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'DELETE /organizations/:orgId/marketplaces/:marketplaceId - Failed to unlink marketplace', + { + organizationId, + marketplaceId, + error: errorMessage, + }, + ); + throw this.mapError(error); + } + } + + @Get() + async listMarketplaces( + @Param('orgId') organizationId: OrganizationId, + @Req() request: AuthenticatedRequest, + ): Promise<ListMarketplacesResponse> { + const userId = request.user.userId; + + this.logger.info( + 'GET /organizations/:orgId/marketplaces - Listing marketplaces', + { + organizationId, + }, + ); + + try { + const command: ListMarketplacesCommand = { + userId, + organizationId, + source: request.clientSource, + }; + + const response = await this.deploymentAdapter.listMarketplaces(command); + + this.logger.info( + 'GET /organizations/:orgId/marketplaces - Marketplaces listed successfully', + { + organizationId, + count: response.length, + }, + ); + + return response; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'GET /organizations/:orgId/marketplaces - Failed to list marketplaces', + { + organizationId, + error: errorMessage, + }, + ); + throw this.mapError(error); + } + } + + @Get('validate-url') + async validateMarketplaceUrl( + @Param('orgId') organizationId: OrganizationId, + @Query() query: ValidateMarketplaceUrlQueryDto, + @Req() request: AuthenticatedRequest, + ): Promise<ValidateMarketplaceUrlResponse> { + const userId = request.user.userId; + + this.logger.info( + 'GET /organizations/:orgId/marketplaces/validate-url - Validating marketplace URL', + { + organizationId, + url: query.url, + }, + ); + + try { + const command: ValidateMarketplaceUrlCommand = { + userId, + organizationId, + url: query.url, + source: request.clientSource, + }; + + const response = + await this.deploymentAdapter.validateMarketplaceUrl(command); + + this.logger.info( + 'GET /organizations/:orgId/marketplaces/validate-url - Marketplace URL validated successfully', + { + organizationId, + repoPath: response.repoPath, + }, + ); + + return response; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'GET /organizations/:orgId/marketplaces/validate-url - Failed to validate marketplace URL', + { + organizationId, + error: errorMessage, + }, + ); + throw this.mapError(error); + } + } + + @Post(':marketplaceId/publish') + @HttpCode(HttpStatus.ACCEPTED) + async publishPackageOnMarketplace( + @Param('orgId') organizationId: OrganizationId, + @Param('marketplaceId') marketplaceId: MarketplaceId, + @Body() body: PublishPackageOnMarketplaceBodyDto, + @Req() request: AuthenticatedRequest, + ): Promise<PublishPackageOnMarketplaceResponse> { + const userId = request.user.userId; + + this.logger.info( + 'POST /organizations/:orgId/marketplaces/:marketplaceId/publish - Publishing package', + { + organizationId, + marketplaceId, + packageId: body.packageId, + author: maskIdentifier(userId), + }, + ); + + try { + const command: PublishPackageOnMarketplaceCommand = { + userId, + organizationId, + marketplaceId, + packageId: body.packageId as PackageId, + source: request.clientSource, + }; + + const response = + await this.deploymentAdapter.publishPackageOnMarketplace(command); + + this.logger.info( + 'POST /organizations/:orgId/marketplaces/:marketplaceId/publish - Publish enqueued', + { + organizationId, + marketplaceId, + marketplaceDistributionId: response.marketplaceDistributionId, + }, + ); + + return response; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /organizations/:orgId/marketplaces/:marketplaceId/publish - Failed to publish package', + { + organizationId, + marketplaceId, + // Never echo the git token — only the failure category surfaces. + error: errorMessage, + }, + ); + throw this.mapError(error); + } + } + + @Get(':marketplaceId/distributions') + async listMarketplaceDistributions( + @Param('orgId') organizationId: OrganizationId, + @Param('marketplaceId') marketplaceId: MarketplaceId, + @Req() request: AuthenticatedRequest, + ): Promise<ListMarketplaceDistributionsResponse> { + const userId = request.user.userId; + + this.logger.info( + 'GET /organizations/:orgId/marketplaces/:marketplaceId/distributions - Listing distributions', + { + organizationId, + marketplaceId, + }, + ); + + try { + const command: ListMarketplaceDistributionsCommand = { + userId, + organizationId, + marketplaceId, + source: request.clientSource, + }; + + const response = + await this.deploymentAdapter.listMarketplaceDistributions(command); + + this.logger.info( + 'GET /organizations/:orgId/marketplaces/:marketplaceId/distributions - Distributions listed successfully', + { + organizationId, + marketplaceId, + count: response.length, + }, + ); + + return response; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'GET /organizations/:orgId/marketplaces/:marketplaceId/distributions - Failed to list distributions', + { + organizationId, + marketplaceId, + error: errorMessage, + }, + ); + throw this.mapError(error); + } + } + + @Get(':marketplaceId/distributions/:distributionId/changes') + async getMarketplaceDistributionChanges( + @Param('orgId') organizationId: OrganizationId, + @Param('marketplaceId') marketplaceId: MarketplaceId, + @Param('distributionId') distributionId: MarketplaceDistributionId, + @Req() request: AuthenticatedRequest, + ): Promise<GetMarketplaceDistributionChangesResponse> { + const userId = request.user.userId; + + this.logger.info( + 'GET /organizations/:orgId/marketplaces/:marketplaceId/distributions/:distributionId/changes - Listing distribution changes', + { + organizationId, + marketplaceId, + distributionId, + }, + ); + + try { + const command: GetMarketplaceDistributionChangesCommand = { + userId, + organizationId, + marketplaceId, + distributionId, + source: request.clientSource, + }; + + const response = + await this.deploymentAdapter.getMarketplaceDistributionChanges(command); + + this.logger.info( + 'GET /organizations/:orgId/marketplaces/:marketplaceId/distributions/:distributionId/changes - Changes listed successfully', + { + organizationId, + marketplaceId, + distributionId, + state: response.state, + count: response.changes.length, + }, + ); + + return response; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'GET /organizations/:orgId/marketplaces/:marketplaceId/distributions/:distributionId/changes - Failed to list changes', + { + organizationId, + marketplaceId, + distributionId, + error: errorMessage, + }, + ); + throw this.mapError(error); + } + } + + @Post(':marketplaceId/distributions/:distributionId/removal') + async markPluginForRemovalByDistribution( + @Param('orgId') organizationId: OrganizationId, + @Param('marketplaceId') marketplaceId: MarketplaceId, + @Param('distributionId') distributionId: MarketplaceDistributionId, + @Req() request: AuthenticatedRequest, + ): Promise<MarkPluginForRemovalResponse> { + const userId = request.user.userId; + + this.logger.info( + 'POST /organizations/:orgId/marketplaces/:marketplaceId/distributions/:distributionId/removal - Marking plugin for removal by distribution', + { + organizationId, + marketplaceId, + distributionId, + addedBy: maskIdentifier(userId), + }, + ); + + try { + const command: MarkPluginForRemovalCommand = { + userId, + organizationId, + marketplaceId, + distributionId, + source: request.clientSource, + }; + + const response = + await this.deploymentAdapter.markPluginForRemoval(command); + + this.logger.info( + 'POST /organizations/:orgId/marketplaces/:marketplaceId/distributions/:distributionId/removal - Plugin marked for removal', + { + organizationId, + marketplaceId, + distributionId, + }, + ); + + return response; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /organizations/:orgId/marketplaces/:marketplaceId/distributions/:distributionId/removal - Failed to mark plugin for removal', + { + organizationId, + marketplaceId, + distributionId, + error: errorMessage, + }, + ); + throw this.mapError(error); + } + } + + @Post(':marketplaceId/packages/:packageId/removal') + async markPluginForRemovalByPackage( + @Param('orgId') organizationId: OrganizationId, + @Param('marketplaceId') marketplaceId: MarketplaceId, + @Param('packageId') packageId: PackageId, + @Req() request: AuthenticatedRequest, + ): Promise<MarkPluginForRemovalResponse> { + const userId = request.user.userId; + + this.logger.info( + 'POST /organizations/:orgId/marketplaces/:marketplaceId/packages/:packageId/removal - Marking plugin for removal by package', + { + organizationId, + marketplaceId, + packageId, + addedBy: maskIdentifier(userId), + }, + ); + + try { + const command: MarkPluginForRemovalCommand = { + userId, + organizationId, + marketplaceId, + packageId, + source: request.clientSource, + }; + + const response = + await this.deploymentAdapter.markPluginForRemoval(command); + + this.logger.info( + 'POST /organizations/:orgId/marketplaces/:marketplaceId/packages/:packageId/removal - Plugin marked for removal', + { + organizationId, + marketplaceId, + packageId, + }, + ); + + return response; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /organizations/:orgId/marketplaces/:marketplaceId/packages/:packageId/removal - Failed to mark plugin for removal', + { + organizationId, + marketplaceId, + packageId, + error: errorMessage, + }, + ); + throw this.mapError(error); + } + } + + @Post(':marketplaceId/reconcile') + async syncMarketplaceNow( + @Param('orgId') organizationId: OrganizationId, + @Param('marketplaceId') marketplaceId: MarketplaceId, + @Req() request: AuthenticatedRequest, + ): Promise<SyncMarketplaceNowResponse> { + const userId = request.user.userId; + + this.logger.info( + 'POST /organizations/:orgId/marketplaces/:marketplaceId/reconcile - Manual marketplace reconciliation requested', + { + organizationId, + marketplaceId, + addedBy: maskIdentifier(userId), + }, + ); + + try { + const command: SyncMarketplaceNowCommand = { + userId, + organizationId, + marketplaceId, + source: request.clientSource, + }; + + const response = await this.deploymentAdapter.syncMarketplaceNow(command); + + this.logger.info( + 'POST /organizations/:orgId/marketplaces/:marketplaceId/reconcile - Reconciliation completed', + { + organizationId, + marketplaceId, + state: response.state, + }, + ); + + return response; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /organizations/:orgId/marketplaces/:marketplaceId/reconcile - Failed to reconcile marketplace', + { + organizationId, + marketplaceId, + error: errorMessage, + }, + ); + throw this.mapError(error); + } + } + + @Get(':marketplaceId/plugin-installs') + async listMarketplacePluginInstalls( + @Param('orgId') organizationId: OrganizationId, + @Param('marketplaceId') marketplaceId: MarketplaceId, + @Req() request: AuthenticatedRequest, + ): Promise<ListMarketplacePluginInstallsResponse> { + const userId = request.user.userId; + + this.logger.info( + 'GET /organizations/:orgId/marketplaces/:marketplaceId/plugin-installs - Listing plugin installs', + { + organizationId, + marketplaceId, + }, + ); + + try { + const command: ListMarketplacePluginInstallsCommand = { + userId, + organizationId, + marketplaceId, + source: request.clientSource, + }; + + const response = + await this.deploymentAdapter.listMarketplacePluginInstalls(command); + + this.logger.info( + 'GET /organizations/:orgId/marketplaces/:marketplaceId/plugin-installs - Plugin installs listed', + { + organizationId, + marketplaceId, + count: response.length, + }, + ); + + return response; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'GET /organizations/:orgId/marketplaces/:marketplaceId/plugin-installs - Failed to list plugin installs', + { + organizationId, + marketplaceId, + error: errorMessage, + }, + ); + throw this.mapError(error); + } + } + + /** + * Maps typed domain errors thrown by the use cases to NestJS HTTP exceptions + * with the contract messages the frontend relies on. Anything else falls + * through unchanged for the global exception filter to handle. + */ + private mapError(error: unknown): unknown { + if (error instanceof MarketplaceAlreadyLinkedError) { + return new ConflictException(error.message); + } + if (error instanceof MarketplacePluginNameConflictError) { + return new ConflictException(error.message); + } + if (error instanceof GitRepoAlreadyLinkedAsStandardError) { + return new ConflictException(error.message); + } + if (error instanceof MarketplaceDescriptorNotFoundError) { + return new BadRequestException(error.message); + } + if (error instanceof MarketplaceDescriptorBadFormatError) { + return new BadRequestException(error.message); + } + if (error instanceof UnknownMarketplaceDescriptorError) { + return new BadRequestException(error.message); + } + if (error instanceof MarketplaceDescriptorParseError) { + return new BadRequestException(error.message); + } + if (error instanceof MarketplaceUrlNotReachableError) { + return new BadRequestException(error.message); + } + if (error instanceof MarketplaceNotFoundError) { + return new NotFoundException( + USER_FACING_ERROR_MESSAGE.marketplaceNotFound, + ); + } + if (error instanceof PluginDistributionNotFoundError) { + return new NotFoundException(pluginDistributionNotFoundMessage(error)); + } + if (error instanceof PluginDistributionInvalidStateError) { + return new ConflictException( + pluginDistributionInvalidStateMessage(error), + ); + } + if (error instanceof OrganizationAdminRequiredError) { + return new ForbiddenException(error.message); + } + if (error instanceof GitProviderNotFoundError) { + return new NotFoundException( + USER_FACING_ERROR_MESSAGE.gitProviderNotFound, + ); + } + if (error instanceof GitProviderOrganizationMismatchError) { + return new ForbiddenException( + USER_FACING_ERROR_MESSAGE.gitProviderOrganizationMismatch, + ); + } + if (error instanceof GitProviderMissingTokenError) { + return new BadRequestException( + USER_FACING_ERROR_MESSAGE.gitProviderMissingToken, + ); + } + if (error instanceof GitProviderTokenInvalidError) { + return new BadRequestException(error.message); + } + return error; + } +} diff --git a/apps/api/src/app/organizations/marketplaces/marketplaces.module.ts b/apps/api/src/app/organizations/marketplaces/marketplaces.module.ts new file mode 100644 index 000000000..4de0fe9f7 --- /dev/null +++ b/apps/api/src/app/organizations/marketplaces/marketplaces.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { MarketplacesController } from './marketplaces.controller'; + +/** + * NestJS module exposing organization-scoped marketplace routes. + * + * Routes are mounted under `/organizations/:orgId/marketplaces` by + * `RouterModule.register` in `AppModule`. No domain services are registered + * here — `IDeploymentPort` is resolved via the shared `HexaRegistry`. + */ +@Module({ + controllers: [MarketplacesController], + providers: [ + { + provide: PackmindLogger, + useFactory: () => + new PackmindLogger('OrganizationMarketplacesModule', LogLevel.INFO), + }, + ], +}) +export class OrganizationMarketplacesModule {} diff --git a/apps/api/src/app/organizations/organizations.module.ts b/apps/api/src/app/organizations/organizations.module.ts index fecc85a2f..e59d9be89 100644 --- a/apps/api/src/app/organizations/organizations.module.ts +++ b/apps/api/src/app/organizations/organizations.module.ts @@ -6,6 +6,8 @@ import { OrganizationsUsersModule } from './users/users.module'; import { OrganizationDeploymentsModule } from './deployments/deployments.module'; import { OrganizationGitModule } from './git/git.module'; import { OrganizationLlmModule } from './llm/llm.module'; +import { OrganizationMarketplaceDistributionsModule } from './marketplace-distributions/marketplace-distributions.module'; +import { OrganizationMarketplacesModule } from './marketplaces/marketplaces.module'; import { OrganizationMcpModule } from './mcp/mcp.module'; import { OrganizationPluginsModule } from './plugins/plugins.module'; import { OrganizationSkillsModule } from './skills/skills.module'; @@ -39,6 +41,8 @@ import { PlaybookModule } from './playbook/playbook.module'; OrganizationDeploymentsModule, OrganizationGitModule, OrganizationLlmModule, + OrganizationMarketplaceDistributionsModule, + OrganizationMarketplacesModule, OrganizationMcpModule, OrganizationPluginsModule, OrganizationSkillsModule, diff --git a/apps/api/src/app/organizations/spaces/members/members.controller.spec.ts b/apps/api/src/app/organizations/spaces/members/members.controller.spec.ts new file mode 100644 index 000000000..0bd059aec --- /dev/null +++ b/apps/api/src/app/organizations/spaces/members/members.controller.spec.ts @@ -0,0 +1,81 @@ +import { PackmindLogger } from '@packmind/logger'; +import { stubLogger } from '@packmind/test-utils'; +import { + createOrganizationId, + createSpaceId, + createUserId, + UserSpaceRole, +} from '@packmind/types'; +import { AuthenticatedRequest } from '@packmind/node-utils'; +import { SpaceMembersController } from './members.controller'; +import { SpaceMembersService } from './members.service'; + +describe('SpaceMembersController', () => { + let controller: SpaceMembersController; + let membersService: jest.Mocked<SpaceMembersService>; + let logger: jest.Mocked<PackmindLogger>; + + const orgId = createOrganizationId('org-1'); + const spaceId = createSpaceId('space-1'); + const userId = createUserId('user-1'); + const targetUserId = createUserId('target-user-1'); + const request = { + organization: { + id: orgId, + name: 'Test Org', + slug: 'test-org', + role: 'admin', + }, + user: { + userId, + name: 'Test User', + }, + } as unknown as AuthenticatedRequest; + + beforeEach(() => { + logger = stubLogger(); + membersService = { + listSpaceMembers: jest.fn(), + addMembersToSpace: jest.fn(), + removeMemberFromSpace: jest.fn(), + updateMemberRole: jest.fn(), + } as unknown as jest.Mocked<SpaceMembersService>; + controller = new SpaceMembersController(membersService, logger); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when caller is an org admin (not a space member)', () => { + it('addMembers succeeds', async () => { + membersService.addMembersToSpace.mockResolvedValue({ memberships: [] }); + + await expect( + controller.addMembers(orgId, spaceId, { members: [] }, request), + ).resolves.toEqual({ memberships: [] }); + }); + + it('removeMember succeeds', async () => { + membersService.removeMemberFromSpace.mockResolvedValue({ removed: true }); + + await expect( + controller.removeMember(orgId, spaceId, targetUserId, request), + ).resolves.toEqual({ removed: true }); + }); + + it('updateMemberRole succeeds', async () => { + membersService.updateMemberRole.mockResolvedValue({ updated: true }); + + await expect( + controller.updateMemberRole( + orgId, + spaceId, + targetUserId, + { role: UserSpaceRole.ADMIN }, + request, + ), + ).resolves.toEqual({ updated: true }); + }); + }); +}); diff --git a/apps/api/src/app/shared/HexaRegistryModule.ts b/apps/api/src/app/shared/HexaRegistryModule.ts index 045c132aa..d19cba3f8 100644 --- a/apps/api/src/app/shared/HexaRegistryModule.ts +++ b/apps/api/src/app/shared/HexaRegistryModule.ts @@ -5,7 +5,7 @@ import { AccountsHexa } from '@packmind/accounts'; import { CodingAgentHexa } from '@packmind/coding-agent'; import { DeploymentsHexa } from '@packmind/deployments'; import { GitHexa } from '@packmind/git'; -import { LinterHexa } from '@packmind/editions'; +import { LinterHexa } from '@packmind/linter'; import { LlmHexa } from '@packmind/llm'; import { PlaybookChangeApplierHexa } from '@packmind/playbook-change-applier'; import { PlaybookChangeManagementHexa } from '@packmind/playbook-change-management'; diff --git a/apps/api/src/app/shared/PackmindApp.spec.ts b/apps/api/src/app/shared/PackmindApp.spec.ts index 51519aa94..0a62c5ec6 100644 --- a/apps/api/src/app/shared/PackmindApp.spec.ts +++ b/apps/api/src/app/shared/PackmindApp.spec.ts @@ -1,3 +1,36 @@ +// Prevent hexa initialization from opening real Redis/BullMQ connections. +// PackmindApp boots the full hexa registry; without this mock, RecipesAdapter +// and friends call queueFactory which connects to Redis on import. +jest.mock('@packmind/node-utils', () => { + const actual = jest.requireActual('@packmind/node-utils'); + return { + ...actual, + queueFactory: actual.mockQueueFactory, + Configuration: { + getConfig: jest.fn().mockResolvedValue(null), + getConfigWithDefault: jest + .fn() + .mockImplementation((_key: string, defaultValue: string) => + Promise.resolve(defaultValue), + ), + }, + SSEEventPublisher: { + getInstance: jest.fn(), + publishProgramStatusEvent: jest.fn().mockResolvedValue(undefined), + publishAssessmentStatusEvent: jest.fn().mockResolvedValue(undefined), + publishDetectionHeuristicsUpdatedEvent: jest + .fn() + .mockResolvedValue(undefined), + publishUserContextChangeEvent: jest.fn().mockResolvedValue(undefined), + publishDistributionStatusChangeEvent: jest + .fn() + .mockResolvedValue(undefined), + publishChangeProposalUpdateEvent: jest.fn().mockResolvedValue(undefined), + publishEvent: jest.fn().mockResolvedValue(undefined), + }, + }; +}); + import { JwtService } from '@nestjs/jwt'; import { AccountsHexa } from '@packmind/accounts'; import { CodingAgentHexa } from '@packmind/coding-agent'; diff --git a/apps/api/src/app/tracking/dto/TrackPluginInstallBody.dto.ts b/apps/api/src/app/tracking/dto/TrackPluginInstallBody.dto.ts new file mode 100644 index 000000000..ce73f0967 --- /dev/null +++ b/apps/api/src/app/tracking/dto/TrackPluginInstallBody.dto.ts @@ -0,0 +1,50 @@ +import { + IsEnum, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; +import { PluginInstallScope } from '@packmind/types'; + +/** + * Validated body for `POST /tracking/plugin-installs`. + * + * All fields mirror `TrackPluginInstallHeartbeatCommand` minus `trackingToken` + * (which arrives in the `X-Packmind-Tracking-Token` header) and `verifiedUserId` + * (which the controller resolves from the `Authorization` header). + */ +export class TrackPluginInstallBodyDto { + @IsString() + @MinLength(1) + @MaxLength(512) + pluginSlug!: string; + + @IsString() + @MinLength(1) + @MaxLength(512) + marketplaceName!: string; + + @IsEnum(['user', 'project', 'local']) + scope!: PluginInstallScope; + + @IsOptional() + @IsString() + @MaxLength(64) + installedVersion?: string; + + @IsOptional() + @IsString() + @MaxLength(2048) + repoRemoteUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(256) + anonymousIdHash?: string; + + @IsOptional() + @IsString() + @MaxLength(256) + anonymousEmailMasked?: string; +} diff --git a/apps/api/src/app/tracking/tracking-rate-limit.guard.ts b/apps/api/src/app/tracking/tracking-rate-limit.guard.ts new file mode 100644 index 000000000..a55a91785 --- /dev/null +++ b/apps/api/src/app/tracking/tracking-rate-limit.guard.ts @@ -0,0 +1,70 @@ +import { + CanActivate, + ExecutionContext, + HttpException, + HttpStatus, + Injectable, +} from '@nestjs/common'; +import { PackmindLogger, LogLevel } from '@packmind/logger'; + +/** + * Sliding-window in-memory rate limiter for the public plugin-install + * tracking endpoint. + * + * Keyed by `token+IP` (per spec §7.3). Window: 60 s, max 60 requests. + * Falls back to IP-only when the token header is absent. + * + * NOTE: This is a single-process in-memory implementation. For a + * multi-instance deployment, replace with a Redis-backed limiter. It is + * sufficient for current scale and can be swapped without touching the + * controller or module. + */ +@Injectable() +export class TrackingRateLimitGuard implements CanActivate { + private readonly windowMs = 60_000; // 1 minute + private readonly maxRequests = 60; + private readonly store = new Map<string, number[]>(); + + constructor( + private readonly logger: PackmindLogger = new PackmindLogger( + 'TrackingRateLimitGuard', + LogLevel.INFO, + ), + ) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ + headers: Record<string, string | string[] | undefined>; + ip?: string; + socket?: { remoteAddress?: string }; + }>(); + + const token = + (request.headers['x-packmind-tracking-token'] as string | undefined) ?? + ''; + const ip = request.ip ?? request.socket?.remoteAddress ?? 'unknown'; + + const key = `${token.substring(0, 8)}::${ip}`; + const now = Date.now(); + const windowStart = now - this.windowMs; + + let timestamps = this.store.get(key) ?? []; + // Evict entries outside the window + timestamps = timestamps.filter((ts) => ts > windowStart); + + if (timestamps.length >= this.maxRequests) { + this.logger.warn('Rate limit exceeded for tracking endpoint', { + keyPrefix: key.substring(0, 12) + '*', + }); + throw new HttpException( + 'Rate limit exceeded. Retry after 60 seconds.', + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + timestamps.push(now); + this.store.set(key, timestamps); + + return true; + } +} diff --git a/apps/api/src/app/tracking/tracking.controller.spec.ts b/apps/api/src/app/tracking/tracking.controller.spec.ts new file mode 100644 index 000000000..366478099 --- /dev/null +++ b/apps/api/src/app/tracking/tracking.controller.spec.ts @@ -0,0 +1,262 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { + IDeploymentPort, + TrackPluginInstallHeartbeatResponse, + createMarketplaceId, +} from '@packmind/types'; +import { stubLogger } from '@packmind/test-utils'; +import { ApiKeyService } from '@packmind/accounts'; +import { TrackingController } from './tracking.controller'; +import { TrackPluginInstallBodyDto } from './dto/TrackPluginInstallBody.dto'; + +describe('TrackingController', () => { + const marketplaceId = createMarketplaceId( + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + ); + + const validToken = 'valid-tracking-token-123'; + const validApiKey = 'bearer-api-key-value'; + + const baseBody: TrackPluginInstallBodyDto = { + pluginSlug: 'my-plugin', + marketplaceName: 'acme-marketplace', + scope: 'user', + }; + + const heartbeatResponse: TrackPluginInstallHeartbeatResponse = { + created: true, + marketplaceId, + }; + + let mockDeploymentAdapter: jest.Mocked< + Pick<IDeploymentPort, 'trackPluginInstallHeartbeat'> + >; + let mockJwtService: jest.Mocked<JwtService>; + let mockApiKeyService: { extractUserFromApiKey: jest.Mock }; + let controller: TrackingController; + + beforeEach(() => { + mockDeploymentAdapter = { + trackPluginInstallHeartbeat: jest + .fn() + .mockResolvedValue(heartbeatResponse), + }; + + mockJwtService = { + sign: jest.fn(), + verify: jest.fn(), + } as unknown as jest.Mocked<JwtService>; + + mockApiKeyService = { + extractUserFromApiKey: jest.fn().mockReturnValue(null), + }; + + controller = new TrackingController( + mockDeploymentAdapter as unknown as IDeploymentPort, + mockJwtService, + stubLogger(), + ); + + // Patch the internal ApiKeyService on the controller instance + // to use our mock, bypassing the constructor wiring. + ( + controller as unknown as { apiKeyService: typeof mockApiKeyService } + ).apiKeyService = mockApiKeyService as unknown as ApiKeyService; + }); + + afterEach(() => jest.clearAllMocks()); + + describe('POST /tracking/plugin-installs', () => { + describe('when tracking token header is missing', () => { + it('throws UnauthorizedException', async () => { + await expect( + controller.trackPluginInstallHeartbeat( + undefined, + undefined, + baseBody, + ), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + }); + + describe('when tracking token header is empty', () => { + it('throws UnauthorizedException', async () => { + await expect( + controller.trackPluginInstallHeartbeat(' ', undefined, baseBody), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + }); + + describe('when the use case throws an UnauthorizedError (unknown token)', () => { + beforeEach(() => { + const err = new Error('Invalid tracking token'); + err.name = 'UnauthorizedError'; + mockDeploymentAdapter.trackPluginInstallHeartbeat.mockRejectedValue( + err, + ); + }); + + it('maps UnauthorizedError to 401', async () => { + await expect( + controller.trackPluginInstallHeartbeat( + validToken, + undefined, + baseBody, + ), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + }); + + describe('when no Authorization header is provided (anonymous heartbeat)', () => { + let result: TrackPluginInstallHeartbeatResponse; + + beforeEach(async () => { + result = await controller.trackPluginInstallHeartbeat( + validToken, + undefined, + baseBody, + ); + }); + + it('returns the heartbeat response', () => { + expect(result).toEqual(heartbeatResponse); + }); + + it('calls trackPluginInstallHeartbeat with null verifiedUserId', () => { + expect( + mockDeploymentAdapter.trackPluginInstallHeartbeat, + ).toHaveBeenCalledWith( + expect.objectContaining({ verifiedUserId: null }), + ); + }); + }); + + describe('when Authorization header carries a valid API key', () => { + const userId = 'user-uuid-1234'; + const orgId = 'org-uuid-5678'; + + beforeEach(async () => { + mockApiKeyService.extractUserFromApiKey.mockReturnValue({ + user: { userId, name: 'Alice' }, + organization: { + id: orgId, + name: 'Acme', + slug: 'acme', + role: 'member', + }, + }); + + await controller.trackPluginInstallHeartbeat( + validToken, + `Bearer ${validApiKey}`, + baseBody, + ); + }); + + it('passes the verified userId from the API key', () => { + expect( + mockDeploymentAdapter.trackPluginInstallHeartbeat, + ).toHaveBeenCalledWith( + expect.objectContaining({ verifiedUserId: userId }), + ); + }); + }); + + describe('when Authorization header carries an invalid/expired API key', () => { + beforeEach(async () => { + mockApiKeyService.extractUserFromApiKey.mockReturnValue(null); + + await controller.trackPluginInstallHeartbeat( + validToken, + 'Bearer invalid-expired-key', + baseBody, + ); + }); + + it('degrades to anonymous (null verifiedUserId)', () => { + expect( + mockDeploymentAdapter.trackPluginInstallHeartbeat, + ).toHaveBeenCalledWith( + expect.objectContaining({ verifiedUserId: null }), + ); + }); + }); + + describe('when Authorization header has a malformed scheme', () => { + beforeEach(async () => { + await controller.trackPluginInstallHeartbeat( + validToken, + 'Basic dXNlcjpwYXNz', + baseBody, + ); + }); + + it('degrades to anonymous (null verifiedUserId)', () => { + expect( + mockDeploymentAdapter.trackPluginInstallHeartbeat, + ).toHaveBeenCalledWith( + expect.objectContaining({ verifiedUserId: null }), + ); + }); + }); + + describe('when a valid heartbeat is processed with all optional fields', () => { + let result: TrackPluginInstallHeartbeatResponse; + + beforeEach(async () => { + result = await controller.trackPluginInstallHeartbeat( + validToken, + undefined, + { + ...baseBody, + scope: 'project', + installedVersion: '0.1.0', + repoRemoteUrl: 'https://github.com/acme/repo.git', + anonymousIdHash: 'hash123', + anonymousEmailMasked: 'al***@acme.com', + }, + ); + }); + + it('returns the response from the use case', () => { + expect(result).toEqual(heartbeatResponse); + }); + + it('forwards all body fields to the adapter', () => { + expect( + mockDeploymentAdapter.trackPluginInstallHeartbeat, + ).toHaveBeenCalledWith( + expect.objectContaining({ + pluginSlug: 'my-plugin', + marketplaceName: 'acme-marketplace', + scope: 'project', + installedVersion: '0.1.0', + repoRemoteUrl: 'https://github.com/acme/repo.git', + anonymousIdHash: 'hash123', + anonymousEmailMasked: 'al***@acme.com', + trackingToken: validToken, + }), + ); + }); + }); + + describe('when the heartbeat omits the installed version', () => { + beforeEach(async () => { + await controller.trackPluginInstallHeartbeat( + validToken, + undefined, + baseBody, + ); + }); + + it('forwards installedVersion as null', () => { + expect( + mockDeploymentAdapter.trackPluginInstallHeartbeat, + ).toHaveBeenCalledWith( + expect.objectContaining({ installedVersion: null }), + ); + }); + }); + }); +}); diff --git a/apps/api/src/app/tracking/tracking.controller.ts b/apps/api/src/app/tracking/tracking.controller.ts new file mode 100644 index 000000000..cff8f996d --- /dev/null +++ b/apps/api/src/app/tracking/tracking.controller.ts @@ -0,0 +1,208 @@ +import { + BadRequestException, + Body, + Controller, + HttpCode, + HttpStatus, + Post, + Headers, + UnauthorizedException, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { JwtService, JwtSignOptions } from '@nestjs/jwt'; +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { Public } from '@packmind/node-utils'; +import { ApiKeyService, IJwtService } from '@packmind/accounts'; +import { + IDeploymentPort, + TrackPluginInstallHeartbeatResponse, +} from '@packmind/types'; +import { InjectDeploymentAdapter } from '../shared/HexaInjection'; +import { TrackPluginInstallBodyDto } from './dto/TrackPluginInstallBody.dto'; +import { TrackingRateLimitGuard } from './tracking-rate-limit.guard'; + +const origin = 'TrackingController'; + +/** + * Mask the first 6 characters of a string and replace the rest with '*', + * per `standard-compliance-logging-personal-information.md`. + */ +function maskIdentifier(value: string): string { + if (!value) return value; + if (value.length <= 6) return `${value}*`; + return `${value.slice(0, 6)}*`; +} + +/** + * Lightweight JWT service adapter for `ApiKeyService`. + * Mirrors the one in `AuthGuard` — kept local to avoid coupling. + */ +class JwtServiceAdapter implements IJwtService { + constructor(private readonly jwtService: JwtService) {} + sign(payload: Record<string, unknown>, options?: JwtSignOptions): string { + return this.jwtService.sign(payload, options); + } + verify(token: string): Record<string, unknown> { + return this.jwtService.verify(token); + } +} + +/** + * Public NestJS controller for the plugin-install tracking endpoint. + * + * Mounted at `/tracking` via `RouterModule` in `AppModule`. All handlers + * are decorated with `@Public()` to bypass the global `AuthGuard`. + * + * Rate limiting is applied per-handler via `TrackingRateLimitGuard`. + * + * Auth fallback matrix (spec §7.3, §9): + * - Valid API key, correct org → `verifiedUserId` set. + * - Invalid/expired API key → degrade silently to anonymous attribution. + * - Cross-org API key → ignore key, degrade to anonymous. + * - No Authorization header → anonymous attribution. + * - Unknown/missing token → 401. + * - Malformed body → 400. + * - Rate limit exceeded → 429. + */ +@Controller() +export class TrackingController { + private readonly apiKeyService: ApiKeyService; + + constructor( + @InjectDeploymentAdapter() + private readonly deploymentAdapter: IDeploymentPort, + private readonly jwtService: JwtService, + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.INFO, + ), + ) { + this.logger.info('TrackingController initialized'); + this.apiKeyService = new ApiKeyService( + new JwtServiceAdapter(this.jwtService), + logger, + ); + } + + /** + * `POST /tracking/plugin-installs` + * + * Receives a SessionStart heartbeat from a Packmind-published Claude Code + * plugin. The `X-Packmind-Tracking-Token` header identifies the marketplace; + * the optional `Authorization` header carries a Packmind CLI API key for + * verified-user attribution. + */ + @Public() + @UseGuards(TrackingRateLimitGuard) + @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) + @Post('plugin-installs') + @HttpCode(HttpStatus.OK) + async trackPluginInstallHeartbeat( + @Headers('x-packmind-tracking-token') trackingToken: string | undefined, + @Headers('authorization') authorizationHeader: string | undefined, + @Body() body: TrackPluginInstallBodyDto, + ): Promise<TrackPluginInstallHeartbeatResponse> { + if (!trackingToken || trackingToken.trim() === '') { + this.logger.warn( + 'POST /tracking/plugin-installs - Missing tracking token', + ); + throw new UnauthorizedException( + 'X-Packmind-Tracking-Token header is required', + ); + } + + this.logger.info('POST /tracking/plugin-installs - Processing heartbeat', { + pluginSlug: body.pluginSlug, + scope: body.scope, + tokenPrefix: maskIdentifier(trackingToken), + }); + + // Resolve verified user from Authorization header — NEVER reject on + // invalid/expired/cross-org key; always degrade to anonymous. + // Both userId and orgId are forwarded so the use case can enforce the + // cross-org guard (spec §7.3, §9). + const verifiedUser = this.resolveVerifiedUser(authorizationHeader); + + try { + const response = await this.deploymentAdapter.trackPluginInstallHeartbeat( + { + trackingToken: trackingToken.trim(), + pluginSlug: body.pluginSlug, + marketplaceName: body.marketplaceName, + scope: body.scope, + installedVersion: body.installedVersion ?? null, + repoRemoteUrl: body.repoRemoteUrl ?? null, + anonymousIdHash: body.anonymousIdHash ?? null, + anonymousEmailMasked: body.anonymousEmailMasked ?? null, + verifiedUserId: verifiedUser?.verifiedUserId ?? null, + verifiedUserOrgId: verifiedUser?.verifiedUserOrgId ?? null, + }, + ); + + this.logger.info('POST /tracking/plugin-installs - Heartbeat processed', { + pluginSlug: body.pluginSlug, + created: response.created, + marketplaceId: response.marketplaceId, + }); + + return response; + } catch (error) { + if (error instanceof Error && error.name === 'UnauthorizedError') { + this.logger.warn( + 'POST /tracking/plugin-installs - Invalid tracking token', + { tokenPrefix: maskIdentifier(trackingToken) }, + ); + throw new UnauthorizedException('Invalid tracking token'); + } + if (error instanceof BadRequestException) { + throw error; + } + + this.logger.error('POST /tracking/plugin-installs - Unexpected error', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * Attempts to extract verified user identity from the Authorization header. + * + * Rules (spec §7.3, §9): + * - Parse `Bearer <api_key>` from header. + * - Validate API key via `ApiKeyService.extractUserFromApiKey`. + * - Returns both userId and organizationId so the use case can enforce the + * cross-org guard: a key from org A must not attribute installs to org B. + * - Any failure (invalid, expired, malformed) → return null (degrade silently). + */ + private resolveVerifiedUser(authorizationHeader: string | undefined): { + verifiedUserId: string; + verifiedUserOrgId: string; + } | null { + if (!authorizationHeader) { + return null; + } + + try { + const parts = authorizationHeader.split(' '); + if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') { + return null; + } + const apiKey = parts[1].trim(); + const userInfo = this.apiKeyService.extractUserFromApiKey(apiKey); + if (!userInfo) { + return null; + } + + return { + verifiedUserId: userInfo.user.userId, + verifiedUserOrgId: userInfo.organization.id, + }; + } catch { + // Any error (JWT verify failure, malformed key, etc.) → degrade + return null; + } + } +} diff --git a/apps/api/src/app/tracking/tracking.module.ts b/apps/api/src/app/tracking/tracking.module.ts new file mode 100644 index 000000000..894d71cc3 --- /dev/null +++ b/apps/api/src/app/tracking/tracking.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { TrackingController } from './tracking.controller'; +import { TrackingRateLimitGuard } from './tracking-rate-limit.guard'; + +/** + * NestJS module exposing the public plugin-install tracking endpoint. + * + * Route: `POST /tracking/plugin-installs` + * + * Registered at top level (not under `organizations/:orgId`) via + * `RouterModule.register` in `AppModule` at path `'tracking'`. + * + * No domain services are registered here — `IDeploymentPort` is resolved + * via the shared `HexaRegistry`. The global `JwtModule` (registered in + * `AppModule`) is available to all modules and is used here to instantiate + * `ApiKeyService` for optional verified-user attribution. + */ +@Module({ + controllers: [TrackingController], + providers: [ + TrackingRateLimitGuard, + { + provide: PackmindLogger, + useFactory: () => new PackmindLogger('TrackingModule', LogLevel.INFO), + }, + ], +}) +export class TrackingModule {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index ae81ab40a..9286df54b 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -21,7 +21,7 @@ import cookieParser from 'cookie-parser'; import { AppModule } from './app/app.module'; import { PackmindLogger, LogLevel } from '@packmind/logger'; import { Configuration, Cache } from '@packmind/node-utils'; -import { enableAmplitudeProxy } from '@packmind/editions'; +import { enableAmplitudeProxy } from '@packmind/amplitude'; import { pingPackmindSetup } from './startup/ping-packmind-setup'; const logger = new PackmindLogger('PackmindAPI', LogLevel.INFO); diff --git a/apps/api/webpack.paths.proprietary.js b/apps/api/webpack.paths.proprietary.js index 4870229d9..6e9cc9a76 100644 --- a/apps/api/webpack.paths.proprietary.js +++ b/apps/api/webpack.paths.proprietary.js @@ -14,6 +14,7 @@ module.exports = function getProprietaryWebpackPaths(__dirname) { '@packmind/analytics': join(__dirname, '../../packages/analytics/src'), '@packmind/linter': join(__dirname, '../../packages/linter/src'), '@packmind/amplitude': join(__dirname, '../../packages/amplitude/src'), + '@packmind/crisp': join(__dirname, '../../packages/crisp/src'), '@packmind/plugins': join(__dirname, '../../packages/plugins/src'), '@packmind/import-practices-legacy': join( __dirname, diff --git a/apps/cli-e2e-tests/src/helpers/IPackmindGateway.ts b/apps/cli-e2e-tests/src/helpers/IPackmindGateway.ts index a76fa11bb..156dc651d 100644 --- a/apps/cli-e2e-tests/src/helpers/IPackmindGateway.ts +++ b/apps/cli-e2e-tests/src/helpers/IPackmindGateway.ts @@ -8,6 +8,7 @@ import { ICreateStandardUseCase, IGenerateApiKeyUseCase, IGetTargetsByOrganizationUseCase, + IListActiveDistributedPackagesBySpaceUseCase, IListChangeProposalsByArtefact, IListChangeProposalsBySpace, IListPackagesBySpaceUseCase, @@ -65,6 +66,7 @@ export interface IDeploymentsGateway { getTargetsByOrganization: Gateway<IGetTargetsByOrganizationUseCase>; updateRenderModeConfiguration: Gateway<IUpdateRenderModeConfigurationUseCase>; listDeploymentsByPackage(packageId: string): Promise<Distribution[]>; + listActiveDistributedPackagesBySpace: Gateway<IListActiveDistributedPackagesBySpaceUseCase>; } export interface ISkillsGateway { diff --git a/apps/cli-e2e-tests/src/helpers/gateways/DeploymentsGateway.ts b/apps/cli-e2e-tests/src/helpers/gateways/DeploymentsGateway.ts index f1ad39ca1..31f2dec09 100644 --- a/apps/cli-e2e-tests/src/helpers/gateways/DeploymentsGateway.ts +++ b/apps/cli-e2e-tests/src/helpers/gateways/DeploymentsGateway.ts @@ -1,9 +1,10 @@ import { PackmindHttpClient } from './PackmindHttpClient'; import { + Distribution, Gateway, IGetTargetsByOrganizationUseCase, + IListActiveDistributedPackagesBySpaceUseCase, IUpdateRenderModeConfigurationUseCase, - Distribution, } from '@packmind/types'; import { IDeploymentsGateway } from '../IPackmindGateway'; @@ -38,4 +39,12 @@ export class DeploymentsGateway implements IDeploymentsGateway { `/api/v0/organizations/${organizationId}/deployments/package/${packageId}`, ); }; + + listActiveDistributedPackagesBySpace: Gateway<IListActiveDistributedPackagesBySpaceUseCase> = + async ({ spaceId }) => { + const organizationId = this.httpClient.getOrganizationId(); + return this.httpClient.request( + `/api/v0/organizations/${organizationId}/deployments/spaces/${spaceId}/overview`, + ); + }; } diff --git a/apps/cli-e2e-tests/src/helpers/index.ts b/apps/cli-e2e-tests/src/helpers/index.ts index b268fbefb..ee5f719d0 100644 --- a/apps/cli-e2e-tests/src/helpers/index.ts +++ b/apps/cli-e2e-tests/src/helpers/index.ts @@ -6,7 +6,7 @@ export * from './fileHelpers'; export * from './setupGitRepo'; export * from './config'; export * from './describeForVersion'; -export { isProductionMode } from './cliVersion'; +export { isProductionMode, matchesVersionConstraint } from './cliVersion'; export { PackmindGateway } from './gateways/PackmindGateway'; export type { IPackmindGateway, diff --git a/apps/cli-e2e-tests/src/install-with-private-spaces.spec.ts b/apps/cli-e2e-tests/src/install-with-private-spaces.spec.ts new file mode 100644 index 000000000..996e4a7b2 --- /dev/null +++ b/apps/cli-e2e-tests/src/install-with-private-spaces.spec.ts @@ -0,0 +1,726 @@ +import { + describeForVersion, + describeWithExtraUser, + fileExists, + matchesVersionConstraint, + readFile, + RunCliResult, + setupGitRepo, + updateFile, + WithMemberContext, +} from './helpers'; +import { + Package, + PackmindLockFile, + PackmindLockFileEntry, + Recipe, + Space, + SpaceType, +} from '@packmind/types'; + +// v2 lockfiles prefix package-installed artefact keys with `user:`; v1 used +// just `<type>:<slug>`. Look up by both shapes so the test passes regardless +// of which lockfile version the running CLI emits. +function findCommandEntry( + lockFile: PackmindLockFile, + slug: string, +): PackmindLockFileEntry | undefined { + return ( + lockFile.artifacts[`user:command:${slug}`] ?? + lockFile.artifacts[`command:${slug}`] + ); +} + +describeForVersion('>= 0.26.0', 'install command with unjoined spaces', () => { + describeWithExtraUser( + 'install command with unjoined spaces', + (getContext) => { + let context: WithMemberContext; + + let publicSpace: Space; + let publicPackage: Package; + let publicCommand: Recipe; + + let privateSpace: Space; + let privatePackage: Package; + let privateCommand: Recipe; + + beforeEach(async () => { + context = await getContext(); + await setupGitRepo(context.testDir); + + publicSpace = await context.gateway.spaces.create({ + name: 'Public space', + type: SpaceType.open, + }); + publicCommand = await context.gateway.commands.create({ + name: 'Public command', + content: 'This is my public command', + spaceId: publicSpace.id, + }); + const createPublicPackageResponse = + await context.gateway.packages.create({ + name: 'Public package', + description: '', + recipeIds: [publicCommand.id], + standardIds: [], + spaceId: publicSpace.id, + }); + publicPackage = createPublicPackageResponse.package; + + privateSpace = await context.gateway.spaces.create({ + name: 'Private space', + type: SpaceType.private, + }); + privateCommand = await context.gateway.commands.create({ + name: 'Private command', + content: 'This is my private command', + spaceId: privateSpace.id, + }); + const createPrivatePackageResponse = + await context.gateway.packages.create({ + name: 'Private package', + description: '', + recipeIds: [privateCommand.id], + standardIds: [], + spaceId: privateSpace.id, + }); + privatePackage = createPrivatePackageResponse.package; + }); + + describe('when packages have been previously installed by someone with all access', () => { + beforeEach(async () => { + await context.runCli( + `install @${publicSpace.slug}/${publicPackage.slug} @${privateSpace.slug}/${privatePackage.slug}`, + ); + }); + + it('installs the artefacts from the public spaces', () => { + expect( + readFile( + `.packmind/commands/${publicCommand.slug}.md`, + context.testDir, + ), + )?.toEqual(publicCommand.content); + }); + + it('installs the artefacts from the private spaces', () => { + expect( + readFile( + `.packmind/commands/${privateCommand.slug}.md`, + context.testDir, + ), + )?.toEqual(privateCommand.content); + }); + + it('references all artefacts in the indices', () => { + expect( + readFile(`.packmind/commands-index.md`, context.testDir).split( + '\n', + ), + ).toEqual( + expect.arrayContaining([ + expect.stringContaining( + `[${privateCommand.name}](commands/${privateCommand.slug}.md)`, + ), + expect.stringContaining( + `[${publicCommand.name}](commands/${publicCommand.slug}.md)`, + ), + ]), + ); + }); + + it('references the public artefact in packmind-lock.json', () => { + const packmindLock = JSON.parse( + readFile(`packmind-lock.json`, context.testDir), + ) as PackmindLockFile; + + expect( + findCommandEntry(packmindLock, publicCommand.slug), + ).toMatchObject({ + id: publicCommand.id, + name: publicCommand.name, + }); + }); + + it('references the private artefact in packmind-lock.json', () => { + const packmindLock = JSON.parse( + readFile(`packmind-lock.json`, context.testDir), + ) as PackmindLockFile; + + expect( + findCommandEntry(packmindLock, privateCommand.slug), + ).toMatchObject({ + id: privateCommand.id, + name: privateCommand.name, + }); + }); + + it('notifies Packmind that the private command was deployed', async () => { + const overview = + await context.gateway.deployments.listActiveDistributedPackagesBySpace( + { + spaceId: privateSpace.id, + }, + ); + + expect(overview).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + packages: expect.arrayContaining([ + expect.objectContaining({ + packageId: privatePackage.id, + deployedRecipes: expect.arrayContaining([ + expect.objectContaining({ + recipe: expect.objectContaining({ + id: privateCommand.id, + }), + isUpToDate: true, + }), + ]), + }), + ]), + }), + ]), + ); + }); + + it('notifies Packmind that the public command was deployed', async () => { + const overview = + await context.gateway.deployments.listActiveDistributedPackagesBySpace( + { + spaceId: publicSpace.id, + }, + ); + + expect(overview).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + packages: expect.arrayContaining([ + expect.objectContaining({ + packageId: publicPackage.id, + deployedRecipes: expect.arrayContaining([ + expect.objectContaining({ + recipe: expect.objectContaining({ + id: publicCommand.id, + }), + isUpToDate: true, + }), + ]), + }), + ]), + }), + ]), + ); + }); + + describe('when user without access to the spaces runs install', () => { + let installResult: RunCliResult; + + beforeEach(async () => { + installResult = await context.runCli('install', { + apiKey: context.extraUserApiKey, + }); + }); + + it('succeeds', async () => { + const expectedSummary = matchesVersionConstraint('> 0.28.1') + ? 'Already up to date' + : 'Nothing to install'; + expect(installResult).toEqual({ + returnCode: 0, + stderr: + expect.toMatchOutput(`You don't have access to the following packages (their artifacts were preserved from the lock file): +- @private-space/private-package +- @public-space/public-package`), + stdout: expect.toMatchOutput(expectedSummary), + }); + }); + + it('keeps the references to the hidden artefacts in the indices', () => { + expect( + readFile(`.packmind/commands-index.md`, context.testDir).split( + '\n', + ), + ).toEqual( + expect.arrayContaining([ + expect.stringContaining( + `[${privateCommand.name}](commands/${privateCommand.slug}.md)`, + ), + expect.stringContaining( + `[${publicCommand.name}](commands/${publicCommand.slug}.md)`, + ), + ]), + ); + }); + + it('references the public artefact in packmind-lock.json', () => { + const packmindLock = JSON.parse( + readFile(`packmind-lock.json`, context.testDir), + ) as PackmindLockFile; + + expect( + findCommandEntry(packmindLock, publicCommand.slug), + ).toMatchObject({ + id: publicCommand.id, + name: publicCommand.name, + }); + }); + + it('references the private artefact in packmind-lock.json', () => { + const packmindLock = JSON.parse( + readFile(`packmind-lock.json`, context.testDir), + ) as PackmindLockFile; + + expect( + findCommandEntry(packmindLock, privateCommand.slug), + ).toMatchObject({ + id: privateCommand.id, + name: privateCommand.name, + }); + }); + }); + + describe('when an artefact is updated', () => { + const newCommandContent = 'My updated command'; + + beforeEach(async () => { + await context.gateway.commands.update({ + recipeId: privateCommand.id, + spaceId: privateSpace.id, + name: privateCommand.name, + content: newCommandContent, + }); + }); + + describe('when installing with access', () => { + beforeEach(async () => { + await context.runCli('install'); + }); + + it('updates the artefact', () => { + expect( + readFile( + `.packmind/commands/${privateCommand.slug}.md`, + context.testDir, + ), + )?.toEqual(newCommandContent); + }); + + it('updates the packmind-lock.json file with the correct version', () => { + const packmindLock = JSON.parse( + readFile(`packmind-lock.json`, context.testDir), + ) as PackmindLockFile; + + expect( + findCommandEntry(packmindLock, privateCommand.slug), + ).toMatchObject({ + id: privateCommand.id, + name: privateCommand.name, + version: 2, + }); + }); + + it('notifies Packmind that the private command update was deployed', async () => { + const overview = + await context.gateway.deployments.listActiveDistributedPackagesBySpace( + { + spaceId: privateSpace.id, + }, + ); + + expect(overview).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + packages: expect.arrayContaining([ + expect.objectContaining({ + packageId: privatePackage.id, + deployedRecipes: expect.arrayContaining([ + expect.objectContaining({ + recipe: expect.objectContaining({ + id: privateCommand.id, + }), + isUpToDate: true, + }), + ]), + }), + ]), + }), + ]), + ); + }); + + it('notifies Packmind that the public command is still up-to-date', async () => { + const overview = + await context.gateway.deployments.listActiveDistributedPackagesBySpace( + { + spaceId: publicSpace.id, + }, + ); + + expect(overview).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + packages: expect.arrayContaining([ + expect.objectContaining({ + packageId: publicPackage.id, + deployedRecipes: expect.arrayContaining([ + expect.objectContaining({ + recipe: expect.objectContaining({ + id: publicCommand.id, + }), + isUpToDate: true, + }), + ]), + }), + ]), + }), + ]), + ); + }); + }); + + describe('when installing without access', () => { + let installResult: RunCliResult; + + beforeEach(async () => { + installResult = await context.runCli('install', { + apiKey: context.extraUserApiKey, + }); + }); + + it('succeeds but warn the user that some packages are not installed', async () => { + expect(installResult.returnCode).toEqual(0); + }); + + it('does not update the artefact', () => { + expect( + readFile( + `.packmind/commands/${privateCommand.slug}.md`, + context.testDir, + ), + )?.toEqual(privateCommand.content); + }); + + it('keeps the installed version in the packmind-lock.json file', () => { + const packmindLock = JSON.parse( + readFile(`packmind-lock.json`, context.testDir), + ) as PackmindLockFile; + + expect( + findCommandEntry(packmindLock, privateCommand.slug), + ).toMatchObject({ + id: privateCommand.id, + name: privateCommand.name, + version: 1, + }); + }); + + it('does not update the distributed version of the private command in Packmind', async () => { + const overview = + await context.gateway.deployments.listActiveDistributedPackagesBySpace( + { + spaceId: privateSpace.id, + }, + ); + + expect(overview).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + packages: expect.arrayContaining([ + expect.objectContaining({ + packageId: privatePackage.id, + deployedRecipes: expect.arrayContaining([ + expect.objectContaining({ + recipe: expect.objectContaining({ + id: privateCommand.id, + }), + isUpToDate: false, + }), + ]), + }), + ]), + }), + ]), + ); + }); + + it('keeps the distributed version of the public command as up-to-date in Packmind', async () => { + const overview = + await context.gateway.deployments.listActiveDistributedPackagesBySpace( + { + spaceId: publicSpace.id, + }, + ); + + expect(overview).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + packages: expect.arrayContaining([ + expect.objectContaining({ + packageId: publicPackage.id, + deployedRecipes: expect.arrayContaining([ + expect.objectContaining({ + recipe: expect.objectContaining({ + id: publicCommand.id, + }), + isUpToDate: true, + }), + ]), + }), + ]), + }), + ]), + ); + }); + }); + }); + + describe('when removing packages', () => { + beforeEach(() => { + const currentPackmindJson = JSON.parse( + readFile('packmind.json', context.testDir), + ); + + updateFile( + 'packmind.json', + JSON.stringify( + { + ...currentPackmindJson, + packages: { + [`@${publicSpace.slug}/${publicPackage.slug}`]: '*', + }, + }, + null, + 2, + ), + context.testDir, + ); + }); + + describe('when installing with access to all packages ', () => { + beforeEach(async () => { + await context.runCli('install'); + }); + + it('removes the files from the removed package', () => { + expect( + fileExists( + `.packmind/commands/${privateCommand.slug}.md`, + context.testDir, + ), + ).toEqual(false); + }); + + it('removes the references to the removed artefacts in the indices', () => { + expect( + readFile(`.packmind/commands-index.md`, context.testDir).split( + '\n', + ), + ).toEqual( + expect.arrayContaining([ + expect.stringContaining( + `[${publicCommand.name}](commands/${publicCommand.slug}.md)`, + ), + ]), + ); + }); + + it('removes references to removed artefacts in packmind-lock.json', () => { + const packmindLock = JSON.parse( + readFile(`packmind-lock.json`, context.testDir), + ) as PackmindLockFile; + + expect( + findCommandEntry(packmindLock, publicCommand.slug), + ).toMatchObject({ + id: publicCommand.id, + name: publicCommand.name, + }); + }); + + it('notifies Packmind that the private command is no longer deployed', async () => { + const overview = + await context.gateway.deployments.listActiveDistributedPackagesBySpace( + { + spaceId: privateSpace.id, + }, + ); + + const allPackages = overview.flatMap((entry) => entry.packages); + expect( + allPackages.find((pkg) => pkg.packageId === privatePackage.id), + ).toBeUndefined(); + }); + + it('notifies Packmind that the public command is still deployed', async () => { + const overview = + await context.gateway.deployments.listActiveDistributedPackagesBySpace( + { + spaceId: publicSpace.id, + }, + ); + + expect(overview).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + packages: expect.arrayContaining([ + expect.objectContaining({ + packageId: publicPackage.id, + deployedRecipes: expect.arrayContaining([ + expect.objectContaining({ + recipe: expect.objectContaining({ + id: publicCommand.id, + }), + isUpToDate: true, + }), + ]), + }), + ]), + }), + ]), + ); + }); + }); + + describe('when installing without access to all packages ', () => { + beforeEach(async () => { + await context.runCli('install', { + apiKey: context.extraUserApiKey, + }); + }); + + it('removes the files from the removed package', () => { + expect( + fileExists( + `.packmind/commands/${privateCommand.slug}.md`, + context.testDir, + ), + ).toEqual(false); + }); + + it('removes the references to the removed artefacts in the indices', () => { + expect( + readFile(`.packmind/commands-index.md`, context.testDir).split( + '\n', + ), + ).toEqual( + expect.arrayContaining([ + expect.stringContaining( + `[${publicCommand.name}](commands/${publicCommand.slug}.md)`, + ), + ]), + ); + }); + + it('removes references to removed artefacts in packmind-lock.json', () => { + const packmindLock = JSON.parse( + readFile(`packmind-lock.json`, context.testDir), + ) as PackmindLockFile; + + expect( + findCommandEntry(packmindLock, publicCommand.slug), + ).toMatchObject({ + id: publicCommand.id, + name: publicCommand.name, + }); + }); + + it('notifies Packmind that the private command is no longer deployed', async () => { + const overview = + await context.gateway.deployments.listActiveDistributedPackagesBySpace( + { + spaceId: privateSpace.id, + }, + ); + + const allPackages = overview.flatMap((entry) => entry.packages); + expect( + allPackages.find((pkg) => pkg.packageId === privatePackage.id), + ).toBeUndefined(); + }); + + it('notifies Packmind that the public command is still deployed', async () => { + const overview = + await context.gateway.deployments.listActiveDistributedPackagesBySpace( + { + spaceId: publicSpace.id, + }, + ); + + expect(overview).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + packages: expect.arrayContaining([ + expect.objectContaining({ + packageId: publicPackage.id, + deployedRecipes: expect.arrayContaining([ + expect.objectContaining({ + recipe: expect.objectContaining({ + id: publicCommand.id, + }), + isUpToDate: true, + }), + ]), + }), + ]), + }), + ]), + ); + }); + }); + }); + }); + + describe('when a user tries to install packages from a space he does not belong to', () => { + describe('when installing from an open space', () => { + let installResult: RunCliResult; + + beforeEach(async () => { + installResult = await context.runCli( + `install @${publicSpace.slug}/${publicPackage.slug}`, + { apiKey: context.extraUserApiKey }, + ); + }); + + it('fails and tells the user how to join the space', async () => { + const expectedStdout = matchesVersionConstraint('> 0.28.1') + ? '' + : expect.toMatchOutput('Nothing to install'); + expect(installResult).toEqual({ + returnCode: 1, + stderr: expect.toMatchOutput( + `install failed: You don't have access to space @public-space. It is a public space — you can join at: http://localhost:4201/org/${context.organization.slug}/spaces/${publicSpace.slug}/join`, + ), + stdout: expectedStdout, + }); + }); + }); + + describe('when installing from a private space', () => { + let installResult: RunCliResult; + + beforeEach(async () => { + installResult = await context.runCli( + `install @${privateSpace.slug}/${privatePackage.slug}`, + { apiKey: context.extraUserApiKey }, + ); + }); + + it('fails', async () => { + const expectedStdout = matchesVersionConstraint('> 0.28.1') + ? '' + : expect.toMatchOutput('Nothing to install'); + expect(installResult).toEqual({ + returnCode: 1, + stderr: expect.toMatchOutput( + `Package @${privateSpace.slug}/${privatePackage.slug} does not exist`, + ), + stdout: expectedStdout, + }); + }); + }); + }); + }, + ); +}); diff --git a/apps/cli-e2e-tests/src/playbook/handling-targets.spec.ts b/apps/cli-e2e-tests/src/playbook/handling-targets.spec.ts new file mode 100644 index 000000000..b125feb1b --- /dev/null +++ b/apps/cli-e2e-tests/src/playbook/handling-targets.spec.ts @@ -0,0 +1,337 @@ +import { + describeWithUserSignedUp, + readFile, + RunCliResult, + setupGitRepo, + updateFile, + UserSignedUpContext, +} from '../helpers'; +import { Recipe, Target } from '@packmind/types'; +import { recipeFactory } from '@packmind/recipes/test'; +import fs from 'fs'; + +describe('...', () => { + describeWithUserSignedUp('playbook status command', (getContext) => { + let context: UserSignedUpContext; + + let command: Recipe; + + let rootTarget: Target; + let frontendTarget: Target; + + let commandPathInTarget: string; + + beforeEach(async () => { + context = await getContext(); + await setupGitRepo(context.testDir); + fs.mkdirSync(`${context.testDir}/apps/frontend`, { recursive: true }); + + command = await context.gateway.commands.create( + recipeFactory({ + spaceId: context.space.id, + name: 'My command', + slug: 'my-command', + }), + ); + const { package: withRecipe } = await context.gateway.packages.create({ + description: '', + name: 'My package', + recipeIds: [command.id], + spaceId: context.space.id, + standardIds: [], + }); + + const { package: emptyPackage } = await context.gateway.packages.create({ + description: '', + name: 'Empty package', + spaceId: context.space.id, + recipeIds: [], + standardIds: [], + }); + + await context.runCli(`install ${emptyPackage.slug}`); + await context.runCli(`install ${withRecipe.slug} --path apps/frontend`); + + const targets = + await context.gateway.deployments.getTargetsByOrganization({}); + for (const target of targets) { + if (target.path === '/') { + rootTarget = target; + } + + if (target.path === '/apps/frontend/') { + frontendTarget = target; + } + } + + if (!frontendTarget || !rootTarget) { + throw new Error( + `Unable to find targets. Got: ${JSON.stringify(targets)}`, + ); + } + + commandPathInTarget = `apps/frontend/.packmind/commands/${command.slug}.md`; + const originalContent = readFile(commandPathInTarget, context.testDir); + updateFile( + commandPathInTarget, + `${originalContent}\n* Never use var`, + context.testDir, + ); + }); + + describe('when running commands from repo root', () => { + describe('when running diff', () => { + it('shows the correct path of the change', async () => { + const result = await context.runCli('playbook diff'); + + expect(result.stdout).toMatchOutput([ + `Command "${command.name}"`, + commandPathInTarget, + ]); + }); + }); + + describe('when the file is removed', () => { + beforeEach(async () => { + await context.runCli(`playbook remove ${commandPathInTarget}`); + }); + + it('shows the correct path in playbook status', async () => { + const result = await context.runCli('playbook status'); + + expect(result.stdout).toMatchOutput([ + 'Changes to be submitted:', + `Command "${command.name}" (removed) in space "Global" ${commandPathInTarget}`, + ]); + }); + + describe('when submitting the change', () => { + beforeEach(async () => { + await context.runCli('playbook submit -m "Delete command"'); + }); + + it('creates a proposal with the correct targetId', async () => { + const { changeProposals } = + await context.gateway.changeProposals.listChangeProposalsByRecipe( + { + artefactId: command.id, + spaceId: context.space.id, + }, + ); + + expect(changeProposals).toEqual([ + expect.objectContaining({ + artefactId: command.id, + targetId: frontendTarget.id, + }), + ]); + }); + }); + }); + + describe('when the change are added', () => { + beforeEach(async () => { + await context.runCli(`playbook add ${commandPathInTarget}`); + }); + + it('shows the correct path in the status', async () => { + const result = await context.runCli('playbook status'); + + expect(result.stdout).toMatchOutput( + `Command "${command.name}" (updated) in space "Global" ${commandPathInTarget}`, + ); + }); + + describe('when undoing the change', () => { + let result: RunCliResult; + + beforeEach(async () => { + result = await context.runCli( + `playbook unstage ${commandPathInTarget}`, + ); + }); + + it('succeeds', () => { + expect(result.returnCode).toEqual(0); + }); + + it('does appear as unchanged in the status', async () => { + const result = await context.runCli('playbook status'); + + expect(result.stdout).toMatchOutput([ + 'Changes not tracked:', + `Command "${command.name}" ${commandPathInTarget}`, + ]); + }); + }); + + describe('when submitting the changes', () => { + beforeEach(async () => { + await context.runCli(`playbook submit -m "Some change"`); + }); + + it('creates a proposal with the correct targetId', async () => { + const { changeProposals } = + await context.gateway.changeProposals.listChangeProposalsByRecipe( + { + artefactId: command.id, + spaceId: context.space.id, + }, + ); + + expect(changeProposals).toEqual([ + expect.objectContaining({ + artefactId: command.id, + targetId: frontendTarget.id, + }), + ]); + }); + }); + }); + }); + + describe('when running commands inside the target', () => { + let cwd: string; + let commandPath: string; + + beforeEach(async () => { + cwd = `${context.testDir}/apps/frontend`; + commandPath = `.packmind/commands/${command.slug}.md`; + }); + + describe('when running diff', () => { + it('shows the correct path of the change', async () => { + const result = await context.runCli('playbook diff', { cwd }); + + expect(result.stdout).toMatchOutput([ + `Command "${command.name}"`, + commandPath, + ]); + }); + }); + + describe('when the file is removed', () => { + beforeEach(async () => { + await context.runCli(`playbook remove ${commandPath}`, { cwd }); + }); + + it('shows the correct path in playbook status', async () => { + const result = await context.runCli('playbook status', { cwd }); + + expect(result.stdout).toMatchOutput([ + 'Changes to be submitted:', + `Command "${command.name}" (removed) in space "Global" ${commandPath}`, + ]); + }); + + describe('when submitting the change', () => { + beforeEach(async () => { + await context.runCli('playbook submit -m "Delete command"', { + cwd, + }); + }); + + it('creates a proposal with the correct targetId', async () => { + const { changeProposals } = + await context.gateway.changeProposals.listChangeProposalsByRecipe( + { + artefactId: command.id, + spaceId: context.space.id, + }, + ); + + expect(changeProposals).toEqual([ + expect.objectContaining({ + artefactId: command.id, + targetId: frontendTarget.id, + }), + ]); + }); + }); + }); + + describe('when the change are added', () => { + beforeEach(async () => { + await context.runCli(`playbook add ${commandPath}`, { cwd }); + }); + + it('shows the correct path in the status', async () => { + const result = await context.runCli('playbook status', { cwd }); + + expect(result.stdout).toMatchOutput( + `Command "${command.name}" (updated) in space "Global" ${commandPath}`, + ); + }); + + describe('when undoing the change', () => { + let result: RunCliResult; + + beforeEach(async () => { + result = await context.runCli(`playbook unstage ${commandPath}`, { + cwd, + }); + }); + + it('succeeds', () => { + expect(result.returnCode).toEqual(0); + }); + + it('does appear as unchanged in the status', async () => { + const result = await context.runCli('playbook status', { cwd }); + + expect(result.stdout).toMatchOutput([ + 'Changes not tracked:', + `Command "${command.name}" ${commandPath}`, + ]); + }); + }); + + describe('when submitting the changes', () => { + beforeEach(async () => { + await context.runCli(`playbook submit -m "Some change"`, { cwd }); + }); + + it('creates a proposal with the correct targetId', async () => { + const { changeProposals } = + await context.gateway.changeProposals.listChangeProposalsByRecipe( + { + artefactId: command.id, + spaceId: context.space.id, + }, + ); + + expect(changeProposals).toEqual([ + expect.objectContaining({ + artefactId: command.id, + targetId: frontendTarget.id, + }), + ]); + }); + }); + }); + }); + + describe('when running commands inside another folder', () => { + let commandPath: string; + let cwd: string; + + beforeEach(async () => { + fs.mkdirSync(`${context.testDir}/packages/whatever`, { + recursive: true, + }); + cwd = `${context.testDir}/packages/whatever`; + commandPath = `../../apps/frontend/.packmind/commands/${command.slug}.md`; + + await context.runCli(`playbook add ${commandPath}`, { cwd }); + }); + + it('shows the correct path in the status', async () => { + const result = await context.runCli('playbook status', { cwd }); + + expect(result.stdout).toMatchOutput( + `Command "${command.name}" (updated) in space "Global" ${commandPath}`, + ); + }); + }); + }); +}); diff --git a/apps/cli-e2e-tests/src/playbook/playbook-diff.spec.ts b/apps/cli-e2e-tests/src/playbook/playbook-diff.spec.ts new file mode 100644 index 000000000..f356cd78b --- /dev/null +++ b/apps/cli-e2e-tests/src/playbook/playbook-diff.spec.ts @@ -0,0 +1,97 @@ +import { + describeWithUserSignedUp, + readFile, + setupGitRepo, + updateFile, + UserSignedUpContext, +} from '../helpers'; +import { Package, Recipe } from '@packmind/types'; +import { recipeFactory } from '@packmind/recipes/test'; + +describeWithUserSignedUp('playbook status command', (getContext) => { + let context: UserSignedUpContext; + + let command: Recipe; + let pkg: Package; + + beforeEach(async () => { + context = await getContext(); + await setupGitRepo(context.testDir); + + command = await context.gateway.commands.create( + recipeFactory({ + spaceId: context.space.id, + }), + ); + const createPackage = await context.gateway.packages.create({ + description: '', + name: 'My package', + recipeIds: [command.id], + spaceId: context.space.id, + standardIds: [], + }); + pkg = createPackage.package; + + await context.runCli(`install ${pkg.slug}`); + }); + + describe('when there are not changes', () => { + it('shows "No changes found."', async () => { + const result = await context.runCli('playbook diff'); + + expect(result.stdout).toContain('No changes found.'); + }); + }); + + describe('when a file has been changed', () => { + let commandPath: string; + + beforeEach(async () => { + commandPath = `.packmind/commands/${command.slug}.md`; + + const originalContent = readFile(commandPath, context.testDir); + updateFile( + commandPath, + `${originalContent}\n* Never use var`, + context.testDir, + ); + }); + + it('shows the changes for the modified file', async () => { + const result = await context.runCli('playbook diff'); + + expect(result.stderr).toMatchOutput([ + 'Summary: 1 change found on 1 artefact:', + `* Command "${command.name}"`, + ]); + }); + + describe('when changes have been submitted', () => { + beforeEach(async () => { + await context.runCli(`playbook status`); + await context.runCli(`playbook add ${commandPath}`); + await context.runCli('playbook submit -m "Update command"'); + }); + + it('shows "No new changes found."', async () => { + const result = await context.runCli('playbook diff'); + + expect(result.stdout).toContain('No new changes found.'); + }); + + describe('when using --include-submitted', () => { + it('shows the previously sent change', async () => { + const result = await context.runCli( + 'playbook diff --include-submitted', + ); + + expect(result.stdout).toMatchOutput([ + `Command "${command.name}"`, + commandPath, + `Instructions updated [already submitted on`, + ]); + }); + }); + }); + }); +}); diff --git a/apps/cli-e2e-tests/src/playbook/playbook-status.spec.ts b/apps/cli-e2e-tests/src/playbook/playbook-status.spec.ts new file mode 100644 index 000000000..60f8d75c2 --- /dev/null +++ b/apps/cli-e2e-tests/src/playbook/playbook-status.spec.ts @@ -0,0 +1,188 @@ +import { + describeWithUserSignedUp, + readFile, + updateFile, + setupGitRepo, + UserSignedUpContext, +} from '../helpers'; +import { Standard } from '@packmind/types'; + +describeWithUserSignedUp('playbook status command', (getContext) => { + let context: UserSignedUpContext; + beforeEach(async () => { + context = await getContext(); + await setupGitRepo(context.testDir); + }); + + describe('when a deployed standard is modified and staged with playbook add', () => { + let standard: Standard; + let statusResult: { returnCode: number; stdout: string; stderr: string }; + + beforeEach(async () => { + // Create a standard via API + const createStandardResponse = await context.gateway.standards.create({ + name: 'My standard', + description: 'A test standard description.', + rules: [{ content: 'Always use const' }], + scope: null, + spaceId: context.space.id, + }); + standard = createStandardResponse.standard; + + // Create a package containing the standard + const createPackageResponse = await context.gateway.packages.create({ + name: 'My package', + description: 'Test package for playbook', + recipeIds: [], + standardIds: [standard.id], + spaceId: context.space.id, + }); + + // Install the package locally (deploys the standard file) + const installResult = await context.runCli( + `install ${createPackageResponse.package.slug}`, + ); + + if (installResult.returnCode !== 0) { + throw new Error( + `Failed to install package: ${installResult.stderr || installResult.stdout}`, + ); + } + + // Find the deployed standard file + const { execSync } = await import('child_process'); + const files = execSync( + `find ${context.testDir} -name "*.md" -not -path "*/.git/*"`, + ) + .toString() + .trim() + .split('\n') + .filter(Boolean); + + const standardFile = files.find((f) => f.includes(standard.slug)); + if (!standardFile) { + throw new Error( + `Standard file not found after install. Files: ${files.join(', ')}`, + ); + } + const standardPath = standardFile.replace(`${context.testDir}/`, ''); + + // Modify the deployed standard file + const originalContent = readFile(standardPath, context.testDir); + updateFile( + standardPath, + `${originalContent}\n* Never use var`, + context.testDir, + ); + + // Stage the change + const addResult = await context.runCli(`playbook add ${standardPath}`); + + if (addResult.returnCode !== 0) { + throw new Error( + `Failed to add to playbook: ${addResult.stderr || addResult.stdout}`, + ); + } + + // Run playbook status + statusResult = await context.runCli('playbook status'); + }); + + it('succeeds', () => { + expect(statusResult.returnCode).toBe(0); + }); + + it('shows the changes as ready to be submitted', async () => { + expect(statusResult.stdout).toMatchOutput([ + 'Changes to be submitted:', + `Standard "${standard.name}" (updated) in space "Global" .packmind/standards/${standard.slug}.md`, + '', + 'Use `packmind playbook submit` to send them', + ]); + }); + }); + + describe('when a new standard is created and staged with playbook add', () => { + let statusResult: { returnCode: number; stdout: string; stderr: string }; + + beforeEach(async () => { + // Create a standard and package to establish the project (packmind.json) + const { standard: bootstrapStandard } = + await context.gateway.standards.create({ + name: 'Existing standard', + description: 'Used only to bootstrap the project.', + rules: [], + scope: null, + spaceId: context.space.id, + }); + + const createPackageResponse = await context.gateway.packages.create({ + name: 'Bootstrap package', + description: 'Bootstrap package for project setup', + recipeIds: [], + standardIds: [bootstrapStandard.id], + spaceId: context.space.id, + }); + + const installResult = await context.runCli( + `install ${createPackageResponse.package.slug}`, + ); + + if (installResult.returnCode !== 0) { + throw new Error( + `Failed to install package: ${installResult.stderr || installResult.stdout}`, + ); + } + + // Create a brand new standard file (not deployed by any package) + updateFile( + '.packmind/standards/react-router.md', + '# React router\n\nBest practices for React Router usage.', + context.testDir, + ); + + // Stage the new standard + const addResult = await context.runCli( + 'playbook add .packmind/standards/react-router.md', + ); + + if (addResult.returnCode !== 0) { + throw new Error( + `Failed to add to playbook: ${addResult.stderr || addResult.stdout}`, + ); + } + + // Run playbook status + statusResult = await context.runCli('playbook status'); + }); + + it('succeeds', () => { + expect(statusResult.returnCode).toBe(0); + }); + + it('shows the changes as ready to be submitted', async () => { + expect(statusResult.stdout).toMatchOutput([ + 'Changes to be submitted:', + `Standard "React router" (created) in space "Global" .packmind/standards/react-router.md`, + '', + 'Use `packmind playbook submit` to send them', + ]); + }); + + it('shows the "Changes to be submitted:" header', () => { + expect(statusResult.stdout).toContain('Changes to be submitted:'); + }); + + it('shows the standard as created', () => { + expect(statusResult.stdout).toContain( + 'Standard "React router" (created)', + ); + }); + + it('shows the submit hint', () => { + expect(statusResult.stdout).toContain( + 'Use `packmind playbook submit` to send them', + ); + }); + }); +}); diff --git a/apps/cli-e2e-tests/src/playbook/playbook-submit.spec.ts b/apps/cli-e2e-tests/src/playbook/playbook-submit.spec.ts new file mode 100644 index 000000000..2274faffd --- /dev/null +++ b/apps/cli-e2e-tests/src/playbook/playbook-submit.spec.ts @@ -0,0 +1,102 @@ +import { + describeWithUserSignedUp, + readFile, + updateFile, + setupGitRepo, + UserSignedUpContext, +} from '../helpers'; +import { Standard } from '@packmind/types'; + +describeWithUserSignedUp('playbook submit command', (getContext) => { + let context: UserSignedUpContext; + + beforeEach(async () => { + context = await getContext(); + await setupGitRepo(context.testDir); + }); + + describe('when a deployed standard is modified, staged, and submitted with -m flag', () => { + let standard: Standard; + + beforeEach(async () => { + // Create a standard via API + const createStandardResponse = await context.gateway.standards.create({ + name: 'My standard', + description: 'A test standard description.', + rules: [{ content: 'Always use const' }], + scope: null, + spaceId: context.space.id, + }); + standard = createStandardResponse.standard; + + // Create a package containing the standard + const createPackageResponse = await context.gateway.packages.create({ + name: 'My package', + description: 'Test package for playbook', + recipeIds: [], + standardIds: [standard.id], + spaceId: context.space.id, + }); + + // Install the package locally (deploys the standard file) + const installResult = await context.runCli( + `install ${createPackageResponse.package.slug}`, + ); + + if (installResult.returnCode !== 0) { + throw new Error( + `Failed to install package: ${installResult.stderr || installResult.stdout}`, + ); + } + + // Find the deployed standard file + const { execSync } = await import('child_process'); + const files = execSync( + `find ${context.testDir} -name "*.md" -not -path "*/.git/*"`, + ) + .toString() + .trim() + .split('\n') + .filter(Boolean); + + const standardFile = files.find((f) => f.includes(standard.slug)); + if (!standardFile) { + throw new Error( + `Standard file not found after install. Files: ${files.join(', ')}`, + ); + } + const standardPath = standardFile.replace(`${context.testDir}/`, ''); + + // Modify the deployed standard file + const originalContent = readFile(standardPath, context.testDir); + updateFile( + standardPath, + `${originalContent}\n* Never use var`, + context.testDir, + ); + + // Stage the change + const addResult = await context.runCli(`playbook add ${standardPath}`); + + if (addResult.returnCode !== 0) { + throw new Error( + `Failed to add to playbook: ${addResult.stderr || addResult.stdout}`, + ); + } + + // Submit with -m flag + await context.runCli('playbook submit -m "My changes"'); + }); + + it('creates change proposals in packmind', async () => { + const proposals = await context.gateway.changeProposals.listBySpace({ + spaceId: context.space.id, + }); + + const hasStandardProposals = proposals.standards.some( + (s) => s.artefactId === standard.id, + ); + expect(hasStandardProposals).toBe(true); + }); + }); +}); diff --git a/apps/cli/.claude/rules/packmind/standard-cli-command-structure.md b/apps/cli/.claude/rules/packmind/standard-cli-command-structure.md index e48c2c22e..aec7cf79a 100644 --- a/apps/cli/.claude/rules/packmind/standard-cli-command-structure.md +++ b/apps/cli/.claude/rules/packmind/standard-cli-command-structure.md @@ -3,12 +3,12 @@ name: 'CLI Command Structure' paths: - "Command files in apps/cli/src/infra/commands/" alwaysApply: false -description: 'Enforce cmd-ts CLI command definitions (Command.ts) to contain only name/description/args and delegate to separate Handler.ts functions that validate inputs, run PackmindCliHexa with PackmindLogger, handle domain errors, and standardize output/exit codes via consoleLogger utilities to improve testability, maintainability, and consistent user feedback.' +description: 'Enforce clear separation of concerns between command definition (parameters and metadata) and handler logic (validation, execution, and output) in apps/cli commands to improve testability, maintainability, and consistency.' --- # Standard: CLI Command Structure -Enforce cmd-ts CLI command definitions (Command.ts) to contain only name/description/args and delegate to separate Handler.ts functions that validate inputs, run PackmindCliHexa with PackmindLogger, handle domain errors, and standardize output/exit codes via consoleLogger utilities to improve testability, maintainability, and consistent user feedback. : +Enforce clear separation of concerns between command definition (parameters and metadata) and handler logic (validation, execution, and output) in apps/cli commands to improve testability, maintainabi... : * Call exit(1) after outputting error messages and exit(0) after success messages * Create a separate handler file (ending in Handler.ts) that exports the handler function for each command * Define command files (ending in Command.ts) using cmd-ts with only name, description, and args—do not implement handler logic inline diff --git a/apps/cli/.claude/rules/packmind/standard-cli-packmindgateway-method-implementation.md b/apps/cli/.claude/rules/packmind/standard-cli-packmindgateway-method-implementation.md index 3217ccf9e..a32c3cead 100644 --- a/apps/cli/.claude/rules/packmind/standard-cli-packmindgateway-method-implementation.md +++ b/apps/cli/.claude/rules/packmind/standard-cli-packmindgateway-method-implementation.md @@ -3,12 +3,12 @@ name: 'CLI Gateway Implementation' paths: - "apps/cli/src/infra/repositories/*Gateway.ts" alwaysApply: false -description: 'Standardize apps/cli/src/infra/repositories/*Gateway.ts PackmindGateway methods to use PackmindHttpClient (getAuthContext and typed request<T> with options for non-GET), delegate to sub-gateways, and expose only Gateway<UseCase> interfaces to reduce boilerplate, enforce type safety, and keep authentication and error handling consistent.' +description: 'When adding new methods to the PackmindGateway class in the CLI application, use the PackmindHttpClient abstraction to avoid code duplication and maintain consistency. The older methods contain extensive boilerplate (manual API key decoding, JWT parsing, duplicated error handling) that should not be replicated.' --- # Standard: CLI Gateway Implementation -Standardize apps/cli/src/infra/repositories/*Gateway.ts PackmindGateway methods to use PackmindHttpClient (getAuthContext and typed request<T> with options for non-GET), delegate to sub-gateways, and expose only Gateway<UseCase> interfaces to reduce boilerplate, enforce type safety, and keep authentication and error handling consistent. : +When adding new methods to the PackmindGateway class in the CLI application, use the PackmindHttpClient abstraction to avoid code duplication and maintain consistency. The older methods contain extens... : * Define the method return type using `Promise<ResponseType>` for type safety * Gateway implementations methods should always be typed using `Gateway<UseCase>` * Gateway interfaces should only expose `Gateway<UseCase>` diff --git a/apps/cli/.claude/rules/packmind/standard-cli-use-case-structure.md b/apps/cli/.claude/rules/packmind/standard-cli-use-case-structure.md index ff771f26a..cbad0f305 100644 --- a/apps/cli/.claude/rules/packmind/standard-cli-use-case-structure.md +++ b/apps/cli/.claude/rules/packmind/standard-cli-use-case-structure.md @@ -3,12 +3,12 @@ name: 'CLI Use Case Structure' paths: - "Use case files in apps/cli/src/domain/useCases/ and apps/cli/src/application/useCases/" alwaysApply: false -description: 'Enforce CLI use case separation by defining IPublicUseCase<Command, Response> interfaces with co-located Command/Response types in apps/cli/src/domain/useCases/ and implementing business-only logic in apps/cli/src/application/useCases/ using custom errors from apps/cli/src/domain/errors/ (no console or output handlers) to improve modularity, reuse, and predictable error handling.' +description: 'Enforce clean separation between domain contracts and application logic in apps/cli use cases, ensuring use cases focus purely on business operations without presentation concerns like user output or generic error handling.' --- # Standard: CLI Use Case Structure -Enforce CLI use case separation by defining IPublicUseCase<Command, Response> interfaces with co-located Command/Response types in apps/cli/src/domain/useCases/ and implementing business-only logic in apps/cli/src/application/useCases/ using custom errors from apps/cli/src/domain/errors/ (no console or output handlers) to improve modularity, reuse, and predictable error handling. : +Enforce clean separation between domain contracts and application logic in apps/cli use cases, ensuring use cases focus purely on business operations without presentation concerns like user output or ... : * Base all use case interfaces on IPublicUseCase<Command, Response> from @packmind/types * Create new error classes for domain-specific failure scenarios when existing errors do not apply * Define Command and Response types in the same file as the use case interface diff --git a/apps/cli/.cursor/rules/packmind/standard-cli-command-structure.mdc b/apps/cli/.cursor/rules/packmind/standard-cli-command-structure.mdc index c27665147..dfff1cfad 100644 --- a/apps/cli/.cursor/rules/packmind/standard-cli-command-structure.mdc +++ b/apps/cli/.cursor/rules/packmind/standard-cli-command-structure.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: CLI Command Structure -Enforce cmd-ts CLI command definitions (Command.ts) to contain only name/description/args and delegate to separate Handler.ts functions that validate inputs, run PackmindCliHexa with PackmindLogger, handle domain errors, and standardize output/exit codes via consoleLogger utilities to improve testability, maintainability, and consistent user feedback. : +Enforce clear separation of concerns between command definition (parameters and metadata) and handler logic (validation, execution, and output) in apps/cli commands to improve testability, maintainabi... : * Call exit(1) after outputting error messages and exit(0) after success messages * Create a separate handler file (ending in Handler.ts) that exports the handler function for each command * Define command files (ending in Command.ts) using cmd-ts with only name, description, and args—do not implement handler logic inline diff --git a/apps/cli/.cursor/rules/packmind/standard-cli-packmindgateway-method-implementation.mdc b/apps/cli/.cursor/rules/packmind/standard-cli-packmindgateway-method-implementation.mdc index 3e7ed95b0..3a4148fe5 100644 --- a/apps/cli/.cursor/rules/packmind/standard-cli-packmindgateway-method-implementation.mdc +++ b/apps/cli/.cursor/rules/packmind/standard-cli-packmindgateway-method-implementation.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: CLI Gateway Implementation -Standardize apps/cli/src/infra/repositories/*Gateway.ts PackmindGateway methods to use PackmindHttpClient (getAuthContext and typed request<T> with options for non-GET), delegate to sub-gateways, and expose only Gateway<UseCase> interfaces to reduce boilerplate, enforce type safety, and keep authentication and error handling consistent. : +When adding new methods to the PackmindGateway class in the CLI application, use the PackmindHttpClient abstraction to avoid code duplication and maintain consistency. The older methods contain extens... : * Define the method return type using `Promise<ResponseType>` for type safety * Gateway implementations methods should always be typed using `Gateway<UseCase>` * Gateway interfaces should only expose `Gateway<UseCase>` diff --git a/apps/cli/.cursor/rules/packmind/standard-cli-use-case-structure.mdc b/apps/cli/.cursor/rules/packmind/standard-cli-use-case-structure.mdc index 82c6452e5..8388a1e7c 100644 --- a/apps/cli/.cursor/rules/packmind/standard-cli-use-case-structure.mdc +++ b/apps/cli/.cursor/rules/packmind/standard-cli-use-case-structure.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: CLI Use Case Structure -Enforce CLI use case separation by defining IPublicUseCase<Command, Response> interfaces with co-located Command/Response types in apps/cli/src/domain/useCases/ and implementing business-only logic in apps/cli/src/application/useCases/ using custom errors from apps/cli/src/domain/errors/ (no console or output handlers) to improve modularity, reuse, and predictable error handling. : +Enforce clean separation between domain contracts and application logic in apps/cli use cases, ensuring use cases focus purely on business operations without presentation concerns like user output or ... : * Base all use case interfaces on IPublicUseCase<Command, Response> from @packmind/types * Create new error classes for domain-specific failure scenarios when existing errors do not apply * Define Command and Response types in the same file as the use case interface diff --git a/apps/cli/.github/instructions/packmind-cli-command-structure.instructions.md b/apps/cli/.github/instructions/packmind-cli-command-structure.instructions.md index 2fc281679..6e7733e99 100644 --- a/apps/cli/.github/instructions/packmind-cli-command-structure.instructions.md +++ b/apps/cli/.github/instructions/packmind-cli-command-structure.instructions.md @@ -3,7 +3,7 @@ applyTo: 'Command files in apps/cli/src/infra/commands/' --- # Standard: CLI Command Structure -Enforce cmd-ts CLI command definitions (Command.ts) to contain only name/description/args and delegate to separate Handler.ts functions that validate inputs, run PackmindCliHexa with PackmindLogger, handle domain errors, and standardize output/exit codes via consoleLogger utilities to improve testability, maintainability, and consistent user feedback. : +Enforce clear separation of concerns between command definition (parameters and metadata) and handler logic (validation, execution, and output) in apps/cli commands to improve testability, maintainabi... : * Call exit(1) after outputting error messages and exit(0) after success messages * Create a separate handler file (ending in Handler.ts) that exports the handler function for each command * Define command files (ending in Command.ts) using cmd-ts with only name, description, and args—do not implement handler logic inline diff --git a/apps/cli/.github/instructions/packmind-cli-packmindgateway-method-implementation.instructions.md b/apps/cli/.github/instructions/packmind-cli-packmindgateway-method-implementation.instructions.md index 9ba8e7fd7..87e77111f 100644 --- a/apps/cli/.github/instructions/packmind-cli-packmindgateway-method-implementation.instructions.md +++ b/apps/cli/.github/instructions/packmind-cli-packmindgateway-method-implementation.instructions.md @@ -3,7 +3,7 @@ applyTo: 'apps/cli/src/infra/repositories/*Gateway.ts' --- # Standard: CLI Gateway Implementation -Standardize apps/cli/src/infra/repositories/*Gateway.ts PackmindGateway methods to use PackmindHttpClient (getAuthContext and typed request<T> with options for non-GET), delegate to sub-gateways, and expose only Gateway<UseCase> interfaces to reduce boilerplate, enforce type safety, and keep authentication and error handling consistent. : +When adding new methods to the PackmindGateway class in the CLI application, use the PackmindHttpClient abstraction to avoid code duplication and maintain consistency. The older methods contain extens... : * Define the method return type using `Promise<ResponseType>` for type safety * Gateway implementations methods should always be typed using `Gateway<UseCase>` * Gateway interfaces should only expose `Gateway<UseCase>` diff --git a/apps/cli/.github/instructions/packmind-cli-use-case-structure.instructions.md b/apps/cli/.github/instructions/packmind-cli-use-case-structure.instructions.md index 583b81290..705dfc4e0 100644 --- a/apps/cli/.github/instructions/packmind-cli-use-case-structure.instructions.md +++ b/apps/cli/.github/instructions/packmind-cli-use-case-structure.instructions.md @@ -3,7 +3,7 @@ applyTo: 'Use case files in apps/cli/src/domain/useCases/ and apps/cli/src/appli --- # Standard: CLI Use Case Structure -Enforce CLI use case separation by defining IPublicUseCase<Command, Response> interfaces with co-located Command/Response types in apps/cli/src/domain/useCases/ and implementing business-only logic in apps/cli/src/application/useCases/ using custom errors from apps/cli/src/domain/errors/ (no console or output handlers) to improve modularity, reuse, and predictable error handling. : +Enforce clean separation between domain contracts and application logic in apps/cli use cases, ensuring use cases focus purely on business operations without presentation concerns like user output or ... : * Base all use case interfaces on IPublicUseCase<Command, Response> from @packmind/types * Create new error classes for domain-specific failure scenarios when existing errors do not apply * Define Command and Response types in the same file as the use case interface diff --git a/apps/cli/.gitlab/duo/chat-rules.md b/apps/cli/.gitlab/duo/chat-rules.md index e81b98cd9..f9b631845 100644 --- a/apps/cli/.gitlab/duo/chat-rules.md +++ b/apps/cli/.gitlab/duo/chat-rules.md @@ -11,7 +11,7 @@ Failure to follow these standards may lead to inconsistencies, errors, or rework # Standard: CLI Command Structure -Enforce cmd-ts CLI command definitions (Command.ts) to contain only name/description/args and delegate to separate Handler.ts functions that validate inputs, run PackmindCliHexa with PackmindLogger, handle domain errors, and standardize output/exit codes via consoleLogger utilities to improve testability, maintainability, and consistent user feedback. : +Enforce clear separation of concerns between command definition (parameters and metadata) and handler logic (validation, execution, and output) in apps/cli commands to improve testability, maintainabi... : * Call exit(1) after outputting error messages and exit(0) after success messages * Create a separate handler file (ending in Handler.ts) that exports the handler function for each command * Define command files (ending in Command.ts) using cmd-ts with only name, description, and args—do not implement handler logic inline @@ -29,7 +29,7 @@ Full standard is available here for further request: [CLI Command Structure](../ # Standard: CLI Gateway Implementation -Standardize apps/cli/src/infra/repositories/*Gateway.ts PackmindGateway methods to use PackmindHttpClient (getAuthContext and typed request<T> with options for non-GET), delegate to sub-gateways, and expose only Gateway<UseCase> interfaces to reduce boilerplate, enforce type safety, and keep authentication and error handling consistent. : +When adding new methods to the PackmindGateway class in the CLI application, use the PackmindHttpClient abstraction to avoid code duplication and maintain consistency. The older methods contain extens... : * Define the method return type using `Promise<ResponseType>` for type safety * Gateway implementations methods should always be typed using `Gateway<UseCase>` * Gateway interfaces should only expose `Gateway<UseCase>` @@ -44,7 +44,7 @@ Full standard is available here for further request: [CLI Gateway Implementation # Standard: CLI Use Case Structure -Enforce CLI use case separation by defining IPublicUseCase<Command, Response> interfaces with co-located Command/Response types in apps/cli/src/domain/useCases/ and implementing business-only logic in apps/cli/src/application/useCases/ using custom errors from apps/cli/src/domain/errors/ (no console or output handlers) to improve modularity, reuse, and predictable error handling. : +Enforce clean separation between domain contracts and application logic in apps/cli use cases, ensuring use cases focus purely on business operations without presentation concerns like user output or ... : * Base all use case interfaces on IPublicUseCase<Command, Response> from @packmind/types * Create new error classes for domain-specific failure scenarios when existing errors do not apply * Define Command and Response types in the same file as the use case interface diff --git a/apps/cli/.packmind/standards-index.md b/apps/cli/.packmind/standards-index.md index 5cc7d3cac..dfdd06e29 100644 --- a/apps/cli/.packmind/standards-index.md +++ b/apps/cli/.packmind/standards-index.md @@ -4,9 +4,9 @@ This standards index contains all available coding standards that can be used by ## Available Standards -- [CLI Command Structure](./standards/cli-command-structure.md) : Enforce cmd-ts CLI command definitions (Command.ts) to contain only name/description/args and delegate to separate Handler.ts functions that validate inputs, run PackmindCliHexa with PackmindLogger, handle domain errors, and standardize output/exit codes via consoleLogger utilities to improve testability, maintainability, and consistent user feedback. -- [CLI Gateway Implementation](./standards/cli-packmindgateway-method-implementation.md) : Standardize apps/cli/src/infra/repositories/*Gateway.ts PackmindGateway methods to use PackmindHttpClient (getAuthContext and typed request<T> with options for non-GET), delegate to sub-gateways, and expose only Gateway<UseCase> interfaces to reduce boilerplate, enforce type safety, and keep authentication and error handling consistent. -- [CLI Use Case Structure](./standards/cli-use-case-structure.md) : Enforce CLI use case separation by defining IPublicUseCase<Command, Response> interfaces with co-located Command/Response types in apps/cli/src/domain/useCases/ and implementing business-only logic in apps/cli/src/application/useCases/ using custom errors from apps/cli/src/domain/errors/ (no console or output handlers) to improve modularity, reuse, and predictable error handling. +- [CLI Command Structure](./standards/cli-command-structure.md) : Enforce clear separation of concerns between command definition (parameters and metadata) and handler logic (validation, execution, and output) in apps/cli commands to improve testability, maintainability, and consistency. +- [CLI Gateway Implementation](./standards/cli-packmindgateway-method-implementation.md) : When adding new methods to the PackmindGateway class in the CLI application, use the PackmindHttpClient abstraction to avoid code duplication and maintain consistency. The older methods contain extensive boilerplate (manual API key decoding, JWT parsing, duplicated error handling) that should not be replicated. +- [CLI Use Case Structure](./standards/cli-use-case-structure.md) : Enforce clean separation between domain contracts and application logic in apps/cli use cases, ensuring use cases focus purely on business operations without presentation concerns like user output or generic error handling. --- diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index bf1598e4e..713f8ff52 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -44,7 +44,7 @@ Failure to follow these standards may lead to inconsistencies, errors, or rework # Standard: CLI Command Structure -Enforce cmd-ts CLI command definitions (Command.ts) to contain only name/description/args and delegate to separate Handler.ts functions that validate inputs, run PackmindCliHexa with PackmindLogger, handle domain errors, and standardize output/exit codes via consoleLogger utilities to improve testability, maintainability, and consistent user feedback. : +Enforce clear separation of concerns between command definition (parameters and metadata) and handler logic (validation, execution, and output) in apps/cli commands to improve testability, maintainabi... : * Call exit(1) after outputting error messages and exit(0) after success messages * Create a separate handler file (ending in Handler.ts) that exports the handler function for each command * Define command files (ending in Command.ts) using cmd-ts with only name, description, and args—do not implement handler logic inline @@ -62,7 +62,7 @@ Full standard is available here for further request: [CLI Command Structure](.pa # Standard: CLI Gateway Implementation -Standardize apps/cli/src/infra/repositories/*Gateway.ts PackmindGateway methods to use PackmindHttpClient (getAuthContext and typed request<T> with options for non-GET), delegate to sub-gateways, and expose only Gateway<UseCase> interfaces to reduce boilerplate, enforce type safety, and keep authentication and error handling consistent. : +When adding new methods to the PackmindGateway class in the CLI application, use the PackmindHttpClient abstraction to avoid code duplication and maintain consistency. The older methods contain extens... : * Define the method return type using `Promise<ResponseType>` for type safety * Gateway implementations methods should always be typed using `Gateway<UseCase>` * Gateway interfaces should only expose `Gateway<UseCase>` @@ -77,7 +77,7 @@ Full standard is available here for further request: [CLI Gateway Implementation # Standard: CLI Use Case Structure -Enforce CLI use case separation by defining IPublicUseCase<Command, Response> interfaces with co-located Command/Response types in apps/cli/src/domain/useCases/ and implementing business-only logic in apps/cli/src/application/useCases/ using custom errors from apps/cli/src/domain/errors/ (no console or output handlers) to improve modularity, reuse, and predictable error handling. : +Enforce clean separation between domain contracts and application logic in apps/cli use cases, ensuring use cases focus purely on business operations without presentation concerns like user output or ... : * Base all use case interfaces on IPublicUseCase<Command, Response> from @packmind/types * Create new error classes for domain-specific failure scenarios when existing errors do not apply * Define Command and Response types in the same file as the use case interface diff --git a/apps/cli/packmind-lock.json b/apps/cli/packmind-lock.json index c7adaaf3c..a8ae1987c 100644 --- a/apps/cli/packmind-lock.json +++ b/apps/cli/packmind-lock.json @@ -6,12 +6,14 @@ "agents": [ "agents_md", "claude", + "codex", "copilot", "cursor", "gitlab_duo", + "opencode", "packmind" ], - "installedAt": "2026-06-16T03:12:27.021Z", + "installedAt": "2026-07-10T03:07:53.482Z", "artifacts": { "user:standard:cli-command-structure": { "name": "CLI Command Structure", diff --git a/apps/doc/administration/manage-organizations.mdx b/apps/doc/administration/manage-organizations.mdx index bb7753362..44c0982a2 100644 --- a/apps/doc/administration/manage-organizations.mdx +++ b/apps/doc/administration/manage-organizations.mdx @@ -41,8 +41,127 @@ Organization administrators have access to additional settings for managing thei - [Managing Users](/administration/manage-users) - Invite users and manage roles - [Manage AI Agents](/administration/manage-ai-agents) - Configure AI agent access - [LLM Configuration](/administration/llm-configuration) - Configure language models +- [Marketplace Governance](#marketplace-governance) - Curate Git repositories as Packmind-governed marketplaces <Info> Only users with **Admin** privileges can access organization settings. If you need administrative access, contact your organization administrator. </Info> + +## Marketplace Governance + +Marketplace governance lets organization administrators curate a trusted set of plugin sources for their members. Once a marketplace is linked, every member of the organization can browse the marketplace and the plugins it exposes; only administrators can link or unlink. + +### What is a marketplace? [#what-is-a-marketplace] + +A **marketplace** is a Git repository registered at the organization level that exposes a `marketplace.json` descriptor at its root. The descriptor declares the plugins the repository offers and the vendor format it follows (Anthropic in v1; the descriptor format is pluggable so additional vendors can be added without changing the link/unlink flow). + +Marketplaces are governance-only constructs: + +- **Admins** link and unlink marketplaces, and monitor their health. +- **Members** browse the enrolled marketplaces and discover the plugins they expose. + +<Info> + Marketplaces are **organization-scoped**. There is no per-user marketplace + link — every member of the organization sees the same enrolled marketplaces. +</Info> + +<Note> + A repository linked as a marketplace is kept separate from repositories used + for standard skill/command distribution. The same `(owner, repo)` pair cannot + be linked as both a marketplace and a standard distribution target. +</Note> + +### Linking a marketplace [#linking-a-marketplace] + +Linking is an **admin-only** action. The denial is enforced server-side — hiding the UI is not sufficient, and non-admin API callers receive an HTTP 403. + +Two paths are available: + +#### Private path — via a connected Git provider + +Use this path when the marketplace repository lives behind authentication (private GitHub/GitLab/Bitbucket org, internal SCM, etc.). + +1. From the organization settings, open **Marketplaces** and click **Link a marketplace**. +2. In the drawer, switch to the **Private** tab. +3. Pick one of your organization's connected Git providers. +4. Select the repository and branch that hosts `marketplace.json`. +5. Give the marketplace a human-readable name and confirm. + +Packmind validates that the chosen repository exposes a valid `marketplace.json` descriptor before persisting the link. + +#### Public path — via a tokenless URL + +Use this path when the marketplace repository is publicly reachable and you do not want to register a dedicated Git provider for it. + +1. Open the **Link a marketplace** drawer. +2. Switch to the **Public** tab. +3. Paste the public Git URL of the repository. +4. Packmind runs a pre-flight check (URL is reachable, repository is public, `marketplace.json` is present and parseable). +5. Name the marketplace and confirm. + +<Warning> + Linking is restricted to organization administrators. Members who attempt to + link via the API receive an HTTP 403 response. +</Warning> + +### Health state semantics [#health-state-semantics] + +Every linked marketplace carries a health **state** that Packmind refreshes via a periodic reconciliation job. The current state is visible on each row of the marketplace list. + +| State | Meaning | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **healthy** | The reconciliation job successfully re-fetched and re-validated `marketplace.json`, and the descriptor still matches the one captured at link time. | +| **drift** | The reconciliation job successfully re-fetched and re-validated `marketplace.json`, but the descriptor has changed since the last run (plugins added, removed, edited). | +| **unreachable** | The reconciliation job could not reach the repository or could not retrieve a parseable `marketplace.json` (network failure, repository removed, descriptor deleted). | + +<Info> + The initial state at link time is **healthy**. The state is refreshed on a + periodic schedule (default: every 30 minutes) and the `lastValidatedAt` + timestamp on the marketplace row is updated on every run. +</Info> + +<Tip> + When a marketplace appears as **drift**, review the new descriptor before + relying on the marketplace — plugins may have been added, removed, or modified + upstream. +</Tip> + +### Unlinking [#unlinking] + +Unlinking is also an **admin-only** action. It severs Packmind governance over the marketplace but **never** touches the Git repository. + +When you unlink a marketplace: + +- The marketplace record is soft-deleted on the Packmind side. +- The underlying marketplace-typed `GitRepo` record is soft-deleted alongside it. +- The Git repository, its branches, and any in-flight pull requests are **left untouched** — Packmind performs no GitHub/GitLab/Bitbucket mutation. + +<Warning> + Unlinking is a Packmind-side governance action only. If you need to remove the + underlying repository, branches, or pull requests, do that on the Git platform + itself — Packmind will not do it for you. +</Warning> + +To unlink: + +1. Open the marketplace row in the **Marketplaces** list. +2. Click **Unlink** and confirm the action in the dialog. + +### Errors and resolutions [#errors-and-resolutions] + +The table below maps the most common errors surfaced during link/unlink to the action that resolves them. + +| Error | When it appears | What to do | +| ------------------------------------ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `MarketplaceAlreadyLinkedError` | `The marketplace {owner}/{repo} has already been linked to your organization` (HTTP 409). | Open the existing marketplace from the list instead of linking again. Unlink the existing entry first if you intend to re-link it. | +| `MarketplaceDescriptorNotFoundError` | `Marketplace descriptor "marketplace.json" not found in repository {owner}/{repo}` (HTTP 400). | Add a `marketplace.json` file at the root of the target repository, then retry. Verify you selected the correct branch. | +| `UnknownMarketplaceDescriptorError` | `No registered marketplace descriptor parser recognised the provided content` (HTTP 400). | The descriptor format is not supported in this version of Packmind. v1 supports the Anthropic format; check the descriptor's vendor. | +| `MarketplaceDescriptorParseError` | A parser claimed the descriptor but failed to parse or validate it (HTTP 400). | Inspect `marketplace.json` for malformed JSON or missing required fields, fix it on the repository side, then retry. | +| `MarketplaceUrlNotReachableError` | `Marketplace URL "{url}" is not reachable` (HTTP 400). Public path only. | Confirm the URL is publicly reachable (no auth needed) and points to a Git repository host Packmind can route to, then retry. | +| `MarketplaceNotFoundError` | `Marketplace with id "{marketplaceId}" was not found` (HTTP 404). | The marketplace was already unlinked or never existed. Refresh the list to see the current state. | + +<Note> + The contract message *"The marketplace {owner}/{repo} has already been linked + to your organization"* is stable and safe to surface verbatim to end users. +</Note> diff --git a/apps/doc/docs.json b/apps/doc/docs.json index 1f804e885..ff9511c63 100644 --- a/apps/doc/docs.json +++ b/apps/doc/docs.json @@ -80,6 +80,7 @@ "icon": "shield", "pages": [ "governance/distribution", + "governance/retire-plugin", "governance/git-repository-connection", "governance/marketplaces", "governance/overview" diff --git a/apps/doc/governance/distribution.mdx b/apps/doc/governance/distribution.mdx index d42903c4c..93f2e4ebe 100644 --- a/apps/doc/governance/distribution.mdx +++ b/apps/doc/governance/distribution.mdx @@ -159,10 +159,13 @@ packmind-cli install This approach is useful when you want to make multiple changes to your package configuration at once. +<<<<<<< HEAD +======= ## Publishing to a Marketplace Besides committing instruction files into your repositories, you can publish a package as a **managed plugin** to a marketplace so your team can install it directly in Claude Code. See [Publish to a marketplace](/governance/marketplaces) for how to link a marketplace, publish packages, and monitor adoption. +>>>>>>> origin/main ## Retiring a Published Plugin If a package was published to a marketplace as a managed plugin, you can retire it through a dedicated flow that opens a deletion pull request in the marketplace repository. See [Retire a plugin from a marketplace](/governance/retire-plugin) for the step-by-step process. diff --git a/apps/doc/governance/retire-plugin.mdx b/apps/doc/governance/retire-plugin.mdx index c1c6a2c2b..c06fe3c46 100644 --- a/apps/doc/governance/retire-plugin.mdx +++ b/apps/doc/governance/retire-plugin.mdx @@ -4,6 +4,15 @@ title: 'Retire a plugin from a marketplace' When a published plugin becomes deprecated or no longer fits your marketplace, you can retire it through a guided, PR-governed flow. Packmind tracks the lifecycle so you and your team always know which plugins are live, which are pending removal, and which have already been removed. +<<<<<<< HEAD +<Note> + This feature is currently rolling out gradually. If you don't see the actions + described below in your marketplace details view, the feature isn't yet + enabled for your organization. +</Note> + +======= +>>>>>>> origin/main ## Overview Retiring a plugin mirrors how you published it: @@ -80,7 +89,10 @@ A plugin that is currently in **To be removed** state never triggers drift — P ## Related Documentation +<<<<<<< HEAD +======= - [Publish to a marketplace](/governance/marketplaces) — link a marketplace, publish packages as plugins, and monitor adoption +>>>>>>> origin/main - [Distribute artifacts](/governance/distribution) — publish packages to repositories and marketplaces - [Git repository connection](/governance/git-repository-connection) — connect the marketplace repository to Packmind - [Packmind CLI](/tools/cli) — reference for the `packmind-cli plugins delete` command, which removes a rendered plugin from a local workspace clone diff --git a/apps/e2e-tests/.claude/rules/packmind/standard-e2e-page-object.md b/apps/e2e-tests/.claude/rules/packmind/standard-e2e-page-object.md index d2047a483..3588f7b7a 100644 --- a/apps/e2e-tests/.claude/rules/packmind/standard-e2e-page-object.md +++ b/apps/e2e-tests/.claude/rules/packmind/standard-e2e-page-object.md @@ -1,12 +1,12 @@ --- name: '[E2E] Page object' alwaysApply: true -description: 'Define Playwright E2E PageObjects for each frontend route using regexp-based expectedUrl matching and mandatory this.pageFactory() after navigation to ensure safer URL validation and proper typing.' +description: 'Write proper PageObjects for our E2E tests' --- # Standard: [E2E] Page object -Define Playwright E2E PageObjects for each frontend route using regexp-based expectedUrl matching and mandatory this.pageFactory() after navigation to ensure safer URL validation and proper typing. : +Write proper PageObjects for our E2E tests : * Always add this.pageFactory() after navigating to ensure proper typing * Each route in the frontend should correspond to a Page object * Use regExp for `expectedUrl` to ensure safer matching (better than the simili-glob of Playwright) diff --git a/apps/e2e-tests/.claude/rules/packmind/standard-e2e-writing-e2e-tests.md b/apps/e2e-tests/.claude/rules/packmind/standard-e2e-writing-e2e-tests.md index 7c602f929..a772f2bed 100644 --- a/apps/e2e-tests/.claude/rules/packmind/standard-e2e-writing-e2e-tests.md +++ b/apps/e2e-tests/.claude/rules/packmind/standard-e2e-writing-e2e-tests.md @@ -1,12 +1,42 @@ --- name: '[E2E] Writing E2E tests' alwaysApply: true -description: 'Standardize Playwright E2E specs by creating one spec file per feature, using fixtures like testWithApi/testWithUser with API factories for irrelevant setup data and enabling feature flags via testWithApi.use({ underFeatureFlag: true }) to keep tests modular, fast, and reliable.' +description: 'Writing proper E2E tests with our stack. Each feature we add should have its own spec file. + +\ +Although we''re talking about E2E tests, we can still keep them modularized, example: + +```ts +testWithApi.describe(''My super feature'', () => { + let standard: Standard; + let defaultPackage: Package; + + beforeEach(({ { packmindApi }) => { + // Create test data using APIs + standard = await apiStandardFactory(packmindApi); + defaultPackage = await apiPackageFactory(packmindApi, { + standardIds: [standard.id], + }); + + // Trigger the feature + const packagesPage = await dashboardPage.openPackages(); + await packagesPage.deploy(defaultPackage.name); + }) + + testWithApi(''it creates a new distribution'', async () => { + // CHeck the package distribution + }); + + testWithApi(''it commits data on Git'', async () => { + // Check proper GIT integration + }) +}) +```' --- # Standard: [E2E] Writing E2E tests -Standardize Playwright E2E specs by creating one spec file per feature, using fixtures like testWithApi/testWithUser with API factories for irrelevant setup data and enabling feature flags via testWithApi.use({ underFeatureFlag: true }) to keep tests modular, fast, and reliable. : +Writing proper E2E tests with our stack. Each feature we add should have its own spec file. : * Always use the fixtures (testWithApi, testWithUser...) instead of the default `test` of Playwright * Data which are not relevant to the test itself should be created using API * Specify "testWithApi.use({ underFeatureFlag: true });" in the test file is the tested feature is under a feature flag. diff --git a/apps/e2e-tests/.cursor/rules/packmind/standard-e2e-page-object.mdc b/apps/e2e-tests/.cursor/rules/packmind/standard-e2e-page-object.mdc index 3b887f4a2..4f47f2235 100644 --- a/apps/e2e-tests/.cursor/rules/packmind/standard-e2e-page-object.mdc +++ b/apps/e2e-tests/.cursor/rules/packmind/standard-e2e-page-object.mdc @@ -3,7 +3,7 @@ alwaysApply: true --- # Standard: [E2E] Page object -Define Playwright E2E PageObjects for each frontend route using regexp-based expectedUrl matching and mandatory this.pageFactory() after navigation to ensure safer URL validation and proper typing. : +Write proper PageObjects for our E2E tests : * Always add this.pageFactory() after navigating to ensure proper typing * Each route in the frontend should correspond to a Page object * Use regExp for `expectedUrl` to ensure safer matching (better than the simili-glob of Playwright) diff --git a/apps/e2e-tests/.cursor/rules/packmind/standard-e2e-writing-e2e-tests.mdc b/apps/e2e-tests/.cursor/rules/packmind/standard-e2e-writing-e2e-tests.mdc index c581e2ebe..d9be8514f 100644 --- a/apps/e2e-tests/.cursor/rules/packmind/standard-e2e-writing-e2e-tests.mdc +++ b/apps/e2e-tests/.cursor/rules/packmind/standard-e2e-writing-e2e-tests.mdc @@ -3,7 +3,7 @@ alwaysApply: true --- # Standard: [E2E] Writing E2E tests -Standardize Playwright E2E specs by creating one spec file per feature, using fixtures like testWithApi/testWithUser with API factories for irrelevant setup data and enabling feature flags via testWithApi.use({ underFeatureFlag: true }) to keep tests modular, fast, and reliable. : +Writing proper E2E tests with our stack. Each feature we add should have its own spec file. : * Always use the fixtures (testWithApi, testWithUser...) instead of the default `test` of Playwright * Data which are not relevant to the test itself should be created using API * Specify "testWithApi.use({ underFeatureFlag: true });" in the test file is the tested feature is under a feature flag. diff --git a/apps/e2e-tests/.github/instructions/packmind-e2e-page-object.instructions.md b/apps/e2e-tests/.github/instructions/packmind-e2e-page-object.instructions.md index 372633b11..6c44b5f43 100644 --- a/apps/e2e-tests/.github/instructions/packmind-e2e-page-object.instructions.md +++ b/apps/e2e-tests/.github/instructions/packmind-e2e-page-object.instructions.md @@ -3,7 +3,7 @@ applyTo: '**' --- # Standard: [E2E] Page object -Define Playwright E2E PageObjects for each frontend route using regexp-based expectedUrl matching and mandatory this.pageFactory() after navigation to ensure safer URL validation and proper typing. : +Write proper PageObjects for our E2E tests : * Always add this.pageFactory() after navigating to ensure proper typing * Each route in the frontend should correspond to a Page object * Use regExp for `expectedUrl` to ensure safer matching (better than the simili-glob of Playwright) diff --git a/apps/e2e-tests/.github/instructions/packmind-e2e-writing-e2e-tests.instructions.md b/apps/e2e-tests/.github/instructions/packmind-e2e-writing-e2e-tests.instructions.md index 30ada95a5..4febd2da2 100644 --- a/apps/e2e-tests/.github/instructions/packmind-e2e-writing-e2e-tests.instructions.md +++ b/apps/e2e-tests/.github/instructions/packmind-e2e-writing-e2e-tests.instructions.md @@ -3,7 +3,7 @@ applyTo: '**' --- # Standard: [E2E] Writing E2E tests -Standardize Playwright E2E specs by creating one spec file per feature, using fixtures like testWithApi/testWithUser with API factories for irrelevant setup data and enabling feature flags via testWithApi.use({ underFeatureFlag: true }) to keep tests modular, fast, and reliable. : +Writing proper E2E tests with our stack. Each feature we add should have its own spec file. : * Always use the fixtures (testWithApi, testWithUser...) instead of the default `test` of Playwright * Data which are not relevant to the test itself should be created using API * Specify "testWithApi.use({ underFeatureFlag: true });" in the test file is the tested feature is under a feature flag. diff --git a/apps/e2e-tests/.gitlab/duo/chat-rules.md b/apps/e2e-tests/.gitlab/duo/chat-rules.md index 27c3d0495..88fa9a199 100644 --- a/apps/e2e-tests/.gitlab/duo/chat-rules.md +++ b/apps/e2e-tests/.gitlab/duo/chat-rules.md @@ -11,7 +11,7 @@ Failure to follow these standards may lead to inconsistencies, errors, or rework # Standard: [E2E] Page object -Define Playwright E2E PageObjects for each frontend route using regexp-based expectedUrl matching and mandatory this.pageFactory() after navigation to ensure safer URL validation and proper typing. : +Write proper PageObjects for our E2E tests : * Always add this.pageFactory() after navigating to ensure proper typing * Each route in the frontend should correspond to a Page object * Use regExp for `expectedUrl` to ensure safer matching (better than the simili-glob of Playwright) @@ -20,7 +20,7 @@ Full standard is available here for further request: [[E2E] Page object](../../. # Standard: [E2E] Writing E2E tests -Standardize Playwright E2E specs by creating one spec file per feature, using fixtures like testWithApi/testWithUser with API factories for irrelevant setup data and enabling feature flags via testWithApi.use({ underFeatureFlag: true }) to keep tests modular, fast, and reliable. : +Writing proper E2E tests with our stack. Each feature we add should have its own spec file. : * Always use the fixtures (testWithApi, testWithUser...) instead of the default `test` of Playwright * Data which are not relevant to the test itself should be created using API * Specify "testWithApi.use({ underFeatureFlag: true });" in the test file is the tested feature is under a feature flag. diff --git a/apps/e2e-tests/.packmind/standards-index.md b/apps/e2e-tests/.packmind/standards-index.md index fef1a169e..a3c4f61b9 100644 --- a/apps/e2e-tests/.packmind/standards-index.md +++ b/apps/e2e-tests/.packmind/standards-index.md @@ -4,8 +4,38 @@ This standards index contains all available coding standards that can be used by ## Available Standards -- [[E2E] Page object](./standards/e2e-page-object.md) : Define Playwright E2E PageObjects for each frontend route using regexp-based expectedUrl matching and mandatory this.pageFactory() after navigation to ensure safer URL validation and proper typing. -- [[E2E] Writing E2E tests](./standards/e2e-writing-e2e-tests.md) : Standardize Playwright E2E specs by creating one spec file per feature, using fixtures like testWithApi/testWithUser with API factories for irrelevant setup data and enabling feature flags via testWithApi.use({ underFeatureFlag: true }) to keep tests modular, fast, and reliable. +- [[E2E] Page object](./standards/e2e-page-object.md) : Write proper PageObjects for our E2E tests +- [[E2E] Writing E2E tests](./standards/e2e-writing-e2e-tests.md) : Writing proper E2E tests with our stack. Each feature we add should have its own spec file. + +\ +Although we're talking about E2E tests, we can still keep them modularized, example: + +```ts +testWithApi.describe('My super feature', () => { + let standard: Standard; + let defaultPackage: Package; + + beforeEach(({ { packmindApi }) => { + // Create test data using APIs + standard = await apiStandardFactory(packmindApi); + defaultPackage = await apiPackageFactory(packmindApi, { + standardIds: [standard.id], + }); + + // Trigger the feature + const packagesPage = await dashboardPage.openPackages(); + await packagesPage.deploy(defaultPackage.name); + }) + + testWithApi('it creates a new distribution', async () => { + // CHeck the package distribution + }); + + testWithApi('it commits data on Git', async () => { + // Check proper GIT integration + }) +}) +``` --- diff --git a/apps/e2e-tests/AGENTS.md b/apps/e2e-tests/AGENTS.md index 512bdaf14..7ce207bfc 100644 --- a/apps/e2e-tests/AGENTS.md +++ b/apps/e2e-tests/AGENTS.md @@ -54,7 +54,7 @@ Failure to follow these standards may lead to inconsistencies, errors, or rework # Standard: [E2E] Page object -Define Playwright E2E PageObjects for each frontend route using regexp-based expectedUrl matching and mandatory this.pageFactory() after navigation to ensure safer URL validation and proper typing. : +Write proper PageObjects for our E2E tests : * Always add this.pageFactory() after navigating to ensure proper typing * Each route in the frontend should correspond to a Page object * Use regExp for `expectedUrl` to ensure safer matching (better than the simili-glob of Playwright) @@ -63,7 +63,7 @@ Full standard is available here for further request: [[E2E] Page object](.packmi # Standard: [E2E] Writing E2E tests -Standardize Playwright E2E specs by creating one spec file per feature, using fixtures like testWithApi/testWithUser with API factories for irrelevant setup data and enabling feature flags via testWithApi.use({ underFeatureFlag: true }) to keep tests modular, fast, and reliable. : +Writing proper E2E tests with our stack. Each feature we add should have its own spec file. : * Always use the fixtures (testWithApi, testWithUser...) instead of the default `test` of Playwright * Data which are not relevant to the test itself should be created using API * Specify "testWithApi.use({ underFeatureFlag: true });" in the test file is the tested feature is under a feature flag. diff --git a/apps/e2e-tests/packmind-lock.json b/apps/e2e-tests/packmind-lock.json index 0cf47fcf0..3448501a7 100644 --- a/apps/e2e-tests/packmind-lock.json +++ b/apps/e2e-tests/packmind-lock.json @@ -6,12 +6,14 @@ "agents": [ "agents_md", "claude", + "codex", "copilot", "cursor", "gitlab_duo", + "opencode", "packmind" ], - "installedAt": "2026-06-16T03:12:27.337Z", + "installedAt": "2026-07-10T03:07:53.735Z", "artifacts": { "user:standard:e2e-page-object": { "name": "[E2E] Page object", diff --git a/apps/e2e-tests/src/domain/pages/index.ts b/apps/e2e-tests/src/domain/pages/index.ts index f59911c25..9f4593a0e 100644 --- a/apps/e2e-tests/src/domain/pages/index.ts +++ b/apps/e2e-tests/src/domain/pages/index.ts @@ -110,6 +110,13 @@ export interface ISpaceSettingsPage extends IPackmindAppPage { searchAndSelectMember(displayName: string): Promise<void>; submitAddMembers(): Promise<void>; listMembers(): Promise<{ displayName: string }[]>; + getSpaceNameInput(): Promise<string>; + setSpaceName(name: string): Promise<void>; + isSpaceNameDisabled(): Promise<boolean>; + selectColor(color: string): Promise<void>; + clickSaveIdentity(): Promise<void>; + waitForIdentityUpdateSuccess(): Promise<void>; + waitForIdentityUpdateError(): Promise<string>; } export interface IInvitationPage extends IPackmindPage { diff --git a/apps/e2e-tests/src/features/spaces-management/AddMembersToSpace.spec.ts b/apps/e2e-tests/src/features/spaces-management/AddMembersToSpace.spec.ts new file mode 100644 index 000000000..cb3aff278 --- /dev/null +++ b/apps/e2e-tests/src/features/spaces-management/AddMembersToSpace.spec.ts @@ -0,0 +1,59 @@ +import { v4 as uuidv4 } from 'uuid'; +import { expect } from '@playwright/test'; +import { testWithApi } from '../../fixtures/packmindTest'; + +const test = testWithApi.extend<{ + userData: { email: string; password: string }; +}>({ + // eslint-disable-next-line no-empty-pattern + userData: ({}, use) => { + use({ + email: `e2e-${uuidv4()}@packmind.com`, + password: `${uuidv4()}!!`, + method: 'password', + }); + }, +}); + +test.describe('Add members to a space', () => { + test('organization member can be added to a new space', async ({ + dashboardPage, + }) => { + const invitedEmail = `invited-${uuidv4().slice(0, 8)}@packmind.com`; + const invitedDisplayName = invitedEmail.split('@')[0]; + + // Invite a new user to the organization + const settingsPage = await dashboardPage.openSettings(); + const usersSettingsPage = await settingsPage.openUsersSettings(); + await usersSettingsPage.inviteUser(invitedEmail); + + // Navigate back to dashboard before creating a space + const defaultDashboard = await usersSettingsPage.navigateToDashboard(); + + // Create a new space + const spaceDashboard = await defaultDashboard.createSpace('team-space'); + + // Open space settings and go to Members tab + const spaceSettingsPage = await spaceDashboard.openSpaceSettings(); + await spaceSettingsPage.openMembersTab(); + + // Reload to clear TanStack Query cache after invitation + await spaceSettingsPage.reload(); + await spaceSettingsPage.openMembersTab(); + + // Add the invited user to the space + await spaceSettingsPage.clickAddMembers(); + await spaceSettingsPage.searchAndSelectMember(invitedDisplayName); + await spaceSettingsPage.submitAddMembers(); + + // Reload to clear TanStack Query cache + await spaceSettingsPage.reload(); + await spaceSettingsPage.openMembersTab(); + + // Verify the invited user appears in the members list + const members = await spaceSettingsPage.listMembers(); + const memberNames = members.map((m) => m.displayName); + + expect(memberNames).toContain(invitedDisplayName); + }); +}); diff --git a/apps/e2e-tests/src/features/spaces-management/ManageSpaceIdentity.spec.ts b/apps/e2e-tests/src/features/spaces-management/ManageSpaceIdentity.spec.ts new file mode 100644 index 000000000..4fd249350 --- /dev/null +++ b/apps/e2e-tests/src/features/spaces-management/ManageSpaceIdentity.spec.ts @@ -0,0 +1,77 @@ +import { v4 as uuidv4 } from 'uuid'; +import { expect } from '@playwright/test'; +import { testWithApi } from '../../fixtures/packmindTest'; + +const test = testWithApi.extend<{ + userData: { email: string; password: string }; +}>({ + // eslint-disable-next-line no-empty-pattern + userData: ({}, use) => { + use({ + email: `e2e-${uuidv4()}@packmind.com`, + password: `${uuidv4()}!!`, + method: 'password', + }); + }, +}); + +test.describe('Manage space identity', () => { + test('space admin updates name and color, sees it reflected in the sidebar', async ({ + dashboardPage, + }) => { + // Create a new space — this navigates into it, making it the active space + const oddityDashboard = await dashboardPage.createSpace('oddity'); + + // Open space settings for the active (newly created) space + const spaceSettingsPage = await oddityDashboard.openSpaceSettings(); + + // Update the name + await spaceSettingsPage.setSpaceName('security'); + + // Select a new color + await spaceSettingsPage.selectColor('purple'); + + // Save changes + await spaceSettingsPage.clickSaveIdentity(); + await spaceSettingsPage.waitForIdentityUpdateSuccess(); + }); + + test('space admin cannot rename to a slug-colliding name', async ({ + dashboardPage, + }) => { + // Create two spaces — each createSpace navigates into the new space + await dashboardPage.createSpace('security'); + const scDashboard = await dashboardPage.createSpace('security-connections'); + + // Open space settings for the active (second) space + const spaceSettingsPage = await scDashboard.openSpaceSettings(); + + // Try to rename to a name whose slug collides with the first space + await spaceSettingsPage.setSpaceName('Security'); + + // Save changes + await spaceSettingsPage.clickSaveIdentity(); + + // Expect error + const errorText = await spaceSettingsPage.waitForIdentityUpdateError(); + expect(errorText).toContain('similar name already exists'); + }); + + test('default space name input is disabled but color can be changed', async ({ + dashboardPage, + }) => { + // Navigate to default space settings + const spaceSettingsPage = await dashboardPage.openSpaceSettings(); + + // Verify name input is disabled + const isNameDisabled = await spaceSettingsPage.isSpaceNameDisabled(); + expect(isNameDisabled).toBe(true); + + // Select a new color + await spaceSettingsPage.selectColor('purple'); + + // Save changes (only color) + await spaceSettingsPage.clickSaveIdentity(); + await spaceSettingsPage.waitForIdentityUpdateSuccess(); + }); +}); diff --git a/apps/e2e-tests/src/features/spaces-management/MoveArtifacts.spec.ts b/apps/e2e-tests/src/features/spaces-management/MoveArtifacts.spec.ts new file mode 100644 index 000000000..174ccc049 --- /dev/null +++ b/apps/e2e-tests/src/features/spaces-management/MoveArtifacts.spec.ts @@ -0,0 +1,126 @@ +import { v4 as uuidv4 } from 'uuid'; +import { expect } from '@playwright/test'; +import { Standard } from '@packmind/types'; +import { testWithApi } from '../../fixtures/packmindTest'; +import { apiStandardFactory } from '../../domain/apiDataFactories/apiStandardFactory'; +import { apiPackageFactory } from '../../domain/apiDataFactories/apiPackageFactory'; +import { apiSkillFactory } from '../../domain/apiDataFactories/apiSkillFactory'; +import assert from 'node:assert'; + +const test = testWithApi.extend<{ + userData: { email: string; password: string }; +}>({ + // eslint-disable-next-line no-empty-pattern + userData: ({}, use) => { + use({ + email: `e2e-${uuidv4()}@packmind.com`, + password: `${uuidv4()}!!`, + method: 'password', + }); + }, +}); + +test.describe('Move artifacts between spaces', () => { + test('moved standards appear in the target space', async ({ + packmindApi, + dashboardPage, + }) => { + const standard = await apiStandardFactory(packmindApi, { + name: `Standard Alpha ${uuidv4().slice(0, 8)}`, + }); + + // Create target space via UI (navigates to target space dashboard) + const targetDashboard = await dashboardPage.createSpace('target'); + + // Navigate back to default space + const defaultDashboard = await targetDashboard.navigateToSpace('Global'); + + // Open Standards page, select all, and move + const standardsPage = await defaultDashboard.openStandards(); + await standardsPage.selectAll(); + await standardsPage.moveToSpace('target'); + + // Reload to clear TanStack Query cache after move + await standardsPage.reload(); + + // Verify: standards are in the target space + const targetDashboard2 = await standardsPage.navigateToSpace('target'); + const targetStandards = await targetDashboard2.openStandards(); + const standardsList = await targetStandards.listStandards(); + + expect(standardsList.map((s) => s.name)).toContain(standard.name); + }); + + test('Moved standards are withdrawn from packages', async ({ + packmindApi, + dashboardPage, + }) => { + // Setup: create standard and packages via API + const standard: Standard = await apiStandardFactory(packmindApi); + await apiPackageFactory(packmindApi, { + name: 'frontend', + standardIds: [standard.id], + }); + await apiPackageFactory(packmindApi, { + name: 'ui', + standardIds: [standard.id], + }); + + // Create target space via UI + const targetDashboard = await dashboardPage.createSpace('target'); + + // Navigate back to default space + const defaultDashboard = await targetDashboard.navigateToSpace('Global'); + + // Open Standards page, select the standard, and move it + const standardsPage = await defaultDashboard.openStandards(); + await standardsPage.selectStandardByName(standard.name); + await standardsPage.moveToSpace('target'); + + // Verify: frontend package is now empty + const packagesPage = await standardsPage.openPackages(); + const frontendPackage = await packagesPage.openPackage('frontend'); + + expect(await frontendPackage.isPackageEmpty()).toBe(true); + }); + + test('displays error when moving a skill to a space where same name exists', async ({ + packmindApi, + dashboardPage, + }) => { + // Create backend and frontend spaces via UI + const backendDashboard = await dashboardPage.createSpace('backend'); + const frontendDashboard = await backendDashboard.createSpace('frontend'); + + // Get space IDs via API + const { spaces } = await packmindApi.listSpaces({}); + const backendSpace = spaces.find((s) => s.name === 'backend'); + const frontendSpace = spaces.find((s) => s.name === 'frontend'); + + assert(backendSpace, 'Backend space not found'); + assert(frontendSpace, 'Frontend space not found'); + + // Upload skill "commit" in both spaces via API + await apiSkillFactory(packmindApi, { + name: 'commit', + spaceId: backendSpace.id, + }); + await apiSkillFactory(packmindApi, { + name: 'commit', + spaceId: frontendSpace.id, + }); + + // Reload to clear TanStack Query cache after API data creation + await frontendDashboard.reload(); + + // Navigate to backend space, open skills + const backendDash = await frontendDashboard.navigateToSpace('backend'); + const skillsPage = await backendDash.openSkills(); + + // Select the "commit" skill and try to move to frontend + await skillsPage.selectSkillByName('commit'); + const errorMessage = await skillsPage.moveToSpaceExpectingError('frontend'); + + expect(errorMessage).toContain('already exists'); + }); +}); diff --git a/apps/e2e-tests/src/features/spaces-management/PrivateSpaceAccess.spec.ts b/apps/e2e-tests/src/features/spaces-management/PrivateSpaceAccess.spec.ts new file mode 100644 index 000000000..720deb652 --- /dev/null +++ b/apps/e2e-tests/src/features/spaces-management/PrivateSpaceAccess.spec.ts @@ -0,0 +1,79 @@ +import { v4 as uuidv4 } from 'uuid'; +import { expect } from '@playwright/test'; +import { SpaceType } from '@packmind/types'; +import { standardFactory } from '@packmind/standards/test'; +import { testWithApi } from '../../fixtures/packmindTest'; +import { PageFactory } from '../../infra/PageFactory'; +import assert from 'node:assert'; + +const test = testWithApi.extend<{ + userData: { email: string; password: string }; +}>({ + // eslint-disable-next-line no-empty-pattern + userData: ({}, use) => { + use({ + email: `e2e-${uuidv4()}@packmind.com`, + password: `${uuidv4()}!!`, + method: 'password', + }); + }, +}); + +test.describe('Private space access', () => { + test('non-member is redirected away from a private space', async ({ + dashboardPage, + packmindApi, + page, + }) => { + const privateSpaceName = `private-${uuidv4().slice(0, 8)}`; + + // Alice creates a private space + const privateDashboard = await dashboardPage.createSpace(privateSpaceName, { + type: SpaceType.private, + }); + + // Capture the private space URL while Alice has access + const privateSpaceUrl = page.url(); + + // Create a standard inside the private space via API + const { spaces } = await packmindApi.listSpaces({}); + const privateSpace = spaces.find((s) => s.name === privateSpaceName); + assert(privateSpace, 'Private space not found'); + + const standardData = standardFactory({ + spaceId: privateSpace.id, + name: `Secret standard ${uuidv4().slice(0, 8)}`, + }); + await packmindApi.createStandard({ + spaceId: privateSpace.id, + name: standardData.name, + description: standardData.description, + scope: standardData.scope, + rules: [], + }); + + // Alice invites Bob to the organization + const bobEmail = `bob-${uuidv4().slice(0, 8)}@packmind.com`; + const bobPassword = `${uuidv4()}!!`; + + const settingsPage = await privateDashboard.openSettings(); + const usersSettingsPage = await settingsPage.openUsersSettings(); + await usersSettingsPage.inviteUser(bobEmail); + const invitationToken = await usersSettingsPage.getInvitationToken(); + + // Alice signs out + await usersSettingsPage.signOut(); + + // Bob activates his account + const pageFactory = new PageFactory(page); + const invitationPage = await pageFactory.getInvitationPage(invitationToken); + const bobDashboard = await invitationPage.activateAccount(bobPassword); + await bobDashboard.expectWelcomeMessage(); + + // Bob navigates directly to the private space URL + await page.goto(privateSpaceUrl); + + // Bob is redirected away from the private space + await expect(page).not.toHaveURL(new RegExp(privateSpaceName)); + }); +}); diff --git a/apps/e2e-tests/src/infra/pages/AbstractPackmindAppPage.ts b/apps/e2e-tests/src/infra/pages/AbstractPackmindAppPage.ts index 11924a765..d896e5a1f 100644 --- a/apps/e2e-tests/src/infra/pages/AbstractPackmindAppPage.ts +++ b/apps/e2e-tests/src/infra/pages/AbstractPackmindAppPage.ts @@ -124,7 +124,24 @@ export abstract class AbstractPackmindAppPage } async navigateToDashboard(): Promise<IDashboardPage> { - await this.page.getByRole('link', { name: 'Dashboard' }).first().click(); + // The Dashboard link lives under a space's nav section. On org-only + // routes (settings/setup/profile) no space is active, so the link is + // not rendered in the sidebar — open the default space drawer first + // to reveal it. + const dashboardLink = this.page.getByRole('link', { name: 'Overview' }); + + if (!(await dashboardLink.first().isVisible())) { + await this.page + .getByTestId(SidebarNavigationDataTestId.DefaultSpaceRow) + .click(); + const openDrawer = this.page.locator( + '[role="dialog"][data-state="open"]', + ); + await openDrawer.waitFor({ state: 'visible' }); + await openDrawer.getByRole('link', { name: 'Overview' }).click(); + } else { + await dashboardLink.first().click(); + } return this.pageFactory.getDashboardPage(); } @@ -150,7 +167,7 @@ export abstract class AbstractPackmindAppPage // Playwright's auto-wait on click handles stability through the open // animation — no extra animation barrier needed (and `getAnimations()` // evaluates against an element that React can unmount mid-call). - await openDrawer.getByRole('link', { name: 'Dashboard' }).click(); + await openDrawer.getByRole('link', { name: 'Overview' }).click(); // Wait for actual navigation (URL was already matching /org/** so waitForLoaded won't wait) await this.page.waitForURL((url) => url.toString() !== currentUrl); diff --git a/apps/e2e-tests/src/infra/pages/SpaceSettingsPage.ts b/apps/e2e-tests/src/infra/pages/SpaceSettingsPage.ts index 3587512c0..b5554e19b 100644 --- a/apps/e2e-tests/src/infra/pages/SpaceSettingsPage.ts +++ b/apps/e2e-tests/src/infra/pages/SpaceSettingsPage.ts @@ -1,3 +1,5 @@ +import { expect } from '@playwright/test'; + import { AbstractPackmindAppPage } from './AbstractPackmindAppPage'; import { ISpaceSettingsPage } from '../../domain/pages'; @@ -20,11 +22,16 @@ export class SpaceSettingsPage await comboboxInput.click(); await comboboxInput.fill(displayName); + // Ark UI's PMCombobox.Item does not propagate ItemText to the option's + // accessible name, so `getByRole('option', { name })` never matches. + // Match the role=option element by visible text via a CSS locator, + // which is not filtered by Playwright's accessibility heuristics. + // Use a longer timeout because the orgUsers query refetches on dialog + // mount after page reload and can take several seconds on slower envs. const option = this.page - .locator('[data-part="item-text"]') - .filter({ hasText: displayName }) - .first(); - await option.waitFor({ state: 'visible' }); + .locator('[role="option"]') + .filter({ hasText: displayName }); + await expect(option).toBeVisible({ timeout: 15000 }); await option.click(); } @@ -53,6 +60,56 @@ export class SpaceSettingsPage return members; } + async getSpaceNameInput(): Promise<string> { + return this.page.getByLabel('Name').inputValue(); + } + + async setSpaceName(name: string): Promise<void> { + const input = this.page.getByLabel('Name'); + await input.clear(); + await input.fill(name); + } + + async isSpaceNameDisabled(): Promise<boolean> { + return this.page.getByLabel('Name').isDisabled(); + } + + async selectColor(color: string): Promise<void> { + await this.page.getByLabel(`Select ${color} color`).click(); + } + + async clickSaveIdentity(): Promise<void> { + const saveButton = this.page + .getByRole('button', { name: /Save changes/i }) + .first(); + const saveResponse = this.page.waitForResponse( + (response) => + response.request().method() === 'PATCH' && + response.url().includes('/spaces-management/'), + ); + + await expect(saveButton).toBeVisible(); + await saveButton.click(); + await saveResponse; + } + + async waitForIdentityUpdateSuccess(): Promise<void> { + await this.page + .getByText('Space updated') + .waitFor({ state: 'visible', timeout: 10000 }); + } + + async waitForIdentityUpdateError(): Promise<string> { + const nameInput = this.page.getByLabel('Name'); + await expect(nameInput).toHaveAttribute('aria-invalid', 'true'); + + const errorText = this.page.getByText( + 'Another space with a similar name already exists.', + ); + await expect(errorText).toBeVisible({ timeout: 20000 }); + return errorText.innerText(); + } + expectedUrl(): string | RegExp { return /\/space\/[^/]+\/settings/; } diff --git a/apps/frontend/.claude/rules/packmind/standard-frontend-data-flow.md b/apps/frontend/.claude/rules/packmind/standard-frontend-data-flow.md index 2cd5f7b7f..f00cdc8b0 100644 --- a/apps/frontend/.claude/rules/packmind/standard-frontend-data-flow.md +++ b/apps/frontend/.claude/rules/packmind/standard-frontend-data-flow.md @@ -1,12 +1,12 @@ --- name: 'Frontend Data Flow' alwaysApply: true -description: 'Standardize frontend route data flow using React Router v7 framework mode with TanStack Query by centralizing fetching in route clientLoaders via queryClient.ensureQueryData(), organizing reusable query options/hooks under apps/frontend/src/domain/{entity}/api/queries/, and consuming results with useLoaderData() to reduce intermediate loading states and improve consistency and reuse.' +description: 'This standard defines the recommended data flow pattern for frontend routes in the Packmind codebase using React Router v7 in framework mode with TanStack Query for data management. It ensures consistent data fetching, loading patterns, and route organization across the application by centralizing data fetching logic in route loaders and ensuring data availability before rendering, which reduces intermediate loading states and promotes reusability of query options.' --- # Standard: Frontend Data Flow -Standardize frontend route data flow using React Router v7 framework mode with TanStack Query by centralizing fetching in route clientLoaders via queryClient.ensureQueryData(), organizing reusable query options/hooks under apps/frontend/src/domain/{entity}/api/queries/, and consuming results with useLoaderData() to reduce intermediate loading states and improve consistency and reuse. : +This standard defines the recommended data flow pattern for frontend routes in the Packmind codebase using React Router v7 in framework mode with TanStack Query for data management. It ensures consist... : * Access data via query or mutation hooks in frontend route modules rather than calling gateways directly to maintain separation between data access and presentation layers * Consume data in route module components using the useLoaderData() hook to access data returned by the clientLoader * Define a crumb property in the handle export of route modules to enable automatic navigation breadcrumb generation diff --git a/apps/frontend/.claude/rules/packmind/standard-frontend-error-management.md b/apps/frontend/.claude/rules/packmind/standard-frontend-error-management.md index fb1f83d2a..7d9985f6e 100644 --- a/apps/frontend/.claude/rules/packmind/standard-frontend-error-management.md +++ b/apps/frontend/.claude/rules/packmind/standard-frontend-error-management.md @@ -3,12 +3,12 @@ name: 'Frontend Error Management' paths: - "apps/frontend/**/*.tsx" alwaysApply: false -description: 'Establish frontend error management for apps/frontend/**/*.tsx that prescribes when to add React error boundaries beyond the global root.tsx fallback and how to handle errors they don''t catch—such as event handlers, async code, SSR, or errors thrown in the boundary—by using TypeScript-typed guards (e.g., isPackmindError), try/catch for async operations, TanStack Query onError callbacks and mutation-pending checks to prevent double submissions, inline validation for expected API/user errors, and selective page- or component-level boundaries for isolated third-party widgets like CodeMirror, to reduce complexity, improve UX, and keep error flows maintainable in React/TypeScript projects built with Node.js and typical tooling (Vite/Webpack, ESLint/Prettier) and tested with Jest/Cypress.' +description: 'A global error boundary is already configured at the root level (in root.tsx) that automatically catches all page crashes, 404s, and loader failures. This standard defines when and how to use explicit error boundaries beyond the global one, and how to handle errors that error boundaries don''t catch (async operations, event handlers). Error boundaries are a React feature that catches JavaScript errors during rendering, in lifecycle methods, and in constructors of the whole tree below them. However, they have important limitations: they do NOT catch errors in event handlers, asynchronous code (setTimeout, promises), server-side rendering, or errors thrown in the error boundary itself. This standard ensures developers use error boundaries appropriately while properly handling errors that fall outside their scope.' --- # Standard: Frontend Error Management -Establish frontend error management for apps/frontend/**/*.tsx that prescribes when to add React error boundaries beyond the global root.tsx fallback and how to handle errors they don't catch—such as event handlers, async code, SSR, or errors thrown in the boundary—by using TypeScript-typed guards (e.g., isPackmindError), try/catch for async operations, TanStack Query onError callbacks and mutation-pending checks to prevent double submissions, inline validation for expected API/user errors, and selective page- or component-level boundaries for isolated third-party widgets like CodeMirror, to reduce complexity, improve UX, and keep error flows maintainable in React/TypeScript projects built with Node.js and typical tooling (Vite/Webpack, ESLint/Prettier) and tested with Jest/Cypress. : +A global error boundary is already configured at the root level (in root.tsx) that automatically catches all page crashes, 404s, and loader failures. This standard defines when and how to use explicit... : * Avoid overusing error boundaries as they increase code complexity and make error flows harder to trace * Display validation errors inline near the relevant form fields for better user experience * Do NOT use error boundaries for errors that should be handled explicitly such as form validation, expected API errors, and user input errors diff --git a/apps/frontend/.claude/rules/packmind/standard-frontend-navigation-with-react-router.md b/apps/frontend/.claude/rules/packmind/standard-frontend-navigation-with-react-router.md index 81469acca..2111be5e5 100644 --- a/apps/frontend/.claude/rules/packmind/standard-frontend-navigation-with-react-router.md +++ b/apps/frontend/.claude/rules/packmind/standard-frontend-navigation-with-react-router.md @@ -3,12 +3,12 @@ name: 'Frontend Navigation with React Router' paths: - "apps/frontend/**/*.tsx" alwaysApply: false -description: 'Standardize frontend navigation using React Router v7 with centralized utilities in React applications to ensure consistent URL parameter handling and simplify navigation management, particularly when organizing and scoping URLs, across apps/frontend/**/*.tsx.' +description: 'This codebase uses React Router v7 with organization and space scoping in URLs (e.g., /org/{orgSlug}/space/{spaceSlug}/recipes). To maintain consistency, improve maintainability, and simplify navigation management across the application, all internal navigation must use centralized utilities instead of manual URL construction. This approach ensures that URL parameter handling (orgSlug, spaceSlug) is abstracted away and reduces the risk of broken links when routes change.' --- # Standard: Frontend Navigation with React Router -Standardize frontend navigation using React Router v7 with centralized utilities in React applications to ensure consistent URL parameter handling and simplify navigation management, particularly when organizing and scoping URLs, across apps/frontend/**/*.tsx. : +This codebase uses React Router v7 with organization and space scoping in URLs (e.g., /org/{orgSlug}/space/{spaceSlug}/recipes). To maintain consistency, improve maintainability, and simplify navigati... : * Omit orgSlug and spaceSlug parameters to use current organization and space context by default and only specify them explicitly when navigating to a different organization or space. * Use authentication route methods (routes.auth.*) for authentication-related navigation. * Use organization-scoped route methods (routes.org.*) for pages that require only organization context. diff --git a/apps/frontend/.claude/rules/packmind/standard-tanstack-query-key-management.md b/apps/frontend/.claude/rules/packmind/standard-tanstack-query-key-management.md index 7c736605e..5c2b79fa1 100644 --- a/apps/frontend/.claude/rules/packmind/standard-tanstack-query-key-management.md +++ b/apps/frontend/.claude/rules/packmind/standard-tanstack-query-key-management.md @@ -3,12 +3,12 @@ name: 'TanStack Query Key Management' paths: - "apps/frontend/**/*.tsx" alwaysApply: false -description: 'Standardize TanStack Query query key definitions in per-domain api/queryKeys.ts files using const base key arrays, separate scope constants, and operation enums with hierarchical organization→domain→operation→identifiers ordering to enable type-safe prefix-based cache invalidation without circular dependencies and to ensure mutations invalidate all affected sibling scopes.' +description: 'TanStack Query uses prefix matching to invalidate cached queries, matching keys from left to right like a file path. Properly structured query keys enable efficient cache invalidation at any scope level (organization-wide, domain-wide, or specific operations). Each domain defines its query scope constant and operation enum in a dedicated queryKeys.ts file, ensuring type safety and preventing circular dependencies when domains import each other''s keys for invalidation.' --- # Standard: TanStack Query Key Management -Standardize TanStack Query query key definitions in per-domain api/queryKeys.ts files using const base key arrays, separate scope constants, and operation enums with hierarchical organization→domain→operation→identifiers ordering to enable type-safe prefix-based cache invalidation without circular dependencies and to ensure mutations invalidate all affected sibling scopes. : +TanStack Query uses prefix matching to invalidate cached queries, matching keys from left to right like a file path. Properly structured query keys enable efficient cache invalidation at any scope lev... : * Define base query key arrays as const to enable precise invalidation patterns and avoid duplication * Define domain query scope as a separate const outside enum to maintain clear separation between scope and operations * Define query keys in a dedicated queryKeys.ts file in the domain's api folder for centralized management diff --git a/apps/frontend/.cursor/rules/packmind/standard-frontend-data-flow.mdc b/apps/frontend/.cursor/rules/packmind/standard-frontend-data-flow.mdc index b52264ed7..298292df6 100644 --- a/apps/frontend/.cursor/rules/packmind/standard-frontend-data-flow.mdc +++ b/apps/frontend/.cursor/rules/packmind/standard-frontend-data-flow.mdc @@ -3,7 +3,7 @@ alwaysApply: true --- # Standard: Frontend Data Flow -Standardize frontend route data flow using React Router v7 framework mode with TanStack Query by centralizing fetching in route clientLoaders via queryClient.ensureQueryData(), organizing reusable query options/hooks under apps/frontend/src/domain/{entity}/api/queries/, and consuming results with useLoaderData() to reduce intermediate loading states and improve consistency and reuse. : +This standard defines the recommended data flow pattern for frontend routes in the Packmind codebase using React Router v7 in framework mode with TanStack Query for data management. It ensures consist... : * Access data via query or mutation hooks in frontend route modules rather than calling gateways directly to maintain separation between data access and presentation layers * Consume data in route module components using the useLoaderData() hook to access data returned by the clientLoader * Define a crumb property in the handle export of route modules to enable automatic navigation breadcrumb generation diff --git a/apps/frontend/.cursor/rules/packmind/standard-frontend-error-management.mdc b/apps/frontend/.cursor/rules/packmind/standard-frontend-error-management.mdc index d69f11130..3d567e435 100644 --- a/apps/frontend/.cursor/rules/packmind/standard-frontend-error-management.mdc +++ b/apps/frontend/.cursor/rules/packmind/standard-frontend-error-management.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: Frontend Error Management -Establish frontend error management for apps/frontend/**/*.tsx that prescribes when to add React error boundaries beyond the global root.tsx fallback and how to handle errors they don't catch—such as event handlers, async code, SSR, or errors thrown in the boundary—by using TypeScript-typed guards (e.g., isPackmindError), try/catch for async operations, TanStack Query onError callbacks and mutation-pending checks to prevent double submissions, inline validation for expected API/user errors, and selective page- or component-level boundaries for isolated third-party widgets like CodeMirror, to reduce complexity, improve UX, and keep error flows maintainable in React/TypeScript projects built with Node.js and typical tooling (Vite/Webpack, ESLint/Prettier) and tested with Jest/Cypress. : +A global error boundary is already configured at the root level (in root.tsx) that automatically catches all page crashes, 404s, and loader failures. This standard defines when and how to use explicit... : * Avoid overusing error boundaries as they increase code complexity and make error flows harder to trace * Display validation errors inline near the relevant form fields for better user experience * Do NOT use error boundaries for errors that should be handled explicitly such as form validation, expected API errors, and user input errors diff --git a/apps/frontend/.cursor/rules/packmind/standard-frontend-navigation-with-react-router.mdc b/apps/frontend/.cursor/rules/packmind/standard-frontend-navigation-with-react-router.mdc index 686e3dece..80a03d635 100644 --- a/apps/frontend/.cursor/rules/packmind/standard-frontend-navigation-with-react-router.mdc +++ b/apps/frontend/.cursor/rules/packmind/standard-frontend-navigation-with-react-router.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: Frontend Navigation with React Router -Standardize frontend navigation using React Router v7 with centralized utilities in React applications to ensure consistent URL parameter handling and simplify navigation management, particularly when organizing and scoping URLs, across apps/frontend/**/*.tsx. : +This codebase uses React Router v7 with organization and space scoping in URLs (e.g., /org/{orgSlug}/space/{spaceSlug}/recipes). To maintain consistency, improve maintainability, and simplify navigati... : * Omit orgSlug and spaceSlug parameters to use current organization and space context by default and only specify them explicitly when navigating to a different organization or space. * Use authentication route methods (routes.auth.*) for authentication-related navigation. * Use organization-scoped route methods (routes.org.*) for pages that require only organization context. diff --git a/apps/frontend/.cursor/rules/packmind/standard-tanstack-query-key-management.mdc b/apps/frontend/.cursor/rules/packmind/standard-tanstack-query-key-management.mdc index 00c66e504..3cc409239 100644 --- a/apps/frontend/.cursor/rules/packmind/standard-tanstack-query-key-management.mdc +++ b/apps/frontend/.cursor/rules/packmind/standard-tanstack-query-key-management.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: TanStack Query Key Management -Standardize TanStack Query query key definitions in per-domain api/queryKeys.ts files using const base key arrays, separate scope constants, and operation enums with hierarchical organization→domain→operation→identifiers ordering to enable type-safe prefix-based cache invalidation without circular dependencies and to ensure mutations invalidate all affected sibling scopes. : +TanStack Query uses prefix matching to invalidate cached queries, matching keys from left to right like a file path. Properly structured query keys enable efficient cache invalidation at any scope lev... : * Define base query key arrays as const to enable precise invalidation patterns and avoid duplication * Define domain query scope as a separate const outside enum to maintain clear separation between scope and operations * Define query keys in a dedicated queryKeys.ts file in the domain's api folder for centralized management diff --git a/apps/frontend/.github/instructions/packmind-frontend-data-flow.instructions.md b/apps/frontend/.github/instructions/packmind-frontend-data-flow.instructions.md index d3cc00e82..156f2a714 100644 --- a/apps/frontend/.github/instructions/packmind-frontend-data-flow.instructions.md +++ b/apps/frontend/.github/instructions/packmind-frontend-data-flow.instructions.md @@ -3,7 +3,7 @@ applyTo: '**' --- # Standard: Frontend Data Flow -Standardize frontend route data flow using React Router v7 framework mode with TanStack Query by centralizing fetching in route clientLoaders via queryClient.ensureQueryData(), organizing reusable query options/hooks under apps/frontend/src/domain/{entity}/api/queries/, and consuming results with useLoaderData() to reduce intermediate loading states and improve consistency and reuse. : +This standard defines the recommended data flow pattern for frontend routes in the Packmind codebase using React Router v7 in framework mode with TanStack Query for data management. It ensures consist... : * Access data via query or mutation hooks in frontend route modules rather than calling gateways directly to maintain separation between data access and presentation layers * Consume data in route module components using the useLoaderData() hook to access data returned by the clientLoader * Define a crumb property in the handle export of route modules to enable automatic navigation breadcrumb generation diff --git a/apps/frontend/.github/instructions/packmind-frontend-error-management.instructions.md b/apps/frontend/.github/instructions/packmind-frontend-error-management.instructions.md index efc71f7d0..47959d8ec 100644 --- a/apps/frontend/.github/instructions/packmind-frontend-error-management.instructions.md +++ b/apps/frontend/.github/instructions/packmind-frontend-error-management.instructions.md @@ -3,7 +3,7 @@ applyTo: 'apps/frontend/**/*.tsx' --- # Standard: Frontend Error Management -Establish frontend error management for apps/frontend/**/*.tsx that prescribes when to add React error boundaries beyond the global root.tsx fallback and how to handle errors they don't catch—such as event handlers, async code, SSR, or errors thrown in the boundary—by using TypeScript-typed guards (e.g., isPackmindError), try/catch for async operations, TanStack Query onError callbacks and mutation-pending checks to prevent double submissions, inline validation for expected API/user errors, and selective page- or component-level boundaries for isolated third-party widgets like CodeMirror, to reduce complexity, improve UX, and keep error flows maintainable in React/TypeScript projects built with Node.js and typical tooling (Vite/Webpack, ESLint/Prettier) and tested with Jest/Cypress. : +A global error boundary is already configured at the root level (in root.tsx) that automatically catches all page crashes, 404s, and loader failures. This standard defines when and how to use explicit... : * Avoid overusing error boundaries as they increase code complexity and make error flows harder to trace * Display validation errors inline near the relevant form fields for better user experience * Do NOT use error boundaries for errors that should be handled explicitly such as form validation, expected API errors, and user input errors diff --git a/apps/frontend/.github/instructions/packmind-frontend-navigation-with-react-router.instructions.md b/apps/frontend/.github/instructions/packmind-frontend-navigation-with-react-router.instructions.md index 3917f1469..985eb28c4 100644 --- a/apps/frontend/.github/instructions/packmind-frontend-navigation-with-react-router.instructions.md +++ b/apps/frontend/.github/instructions/packmind-frontend-navigation-with-react-router.instructions.md @@ -3,7 +3,7 @@ applyTo: 'apps/frontend/**/*.tsx' --- # Standard: Frontend Navigation with React Router -Standardize frontend navigation using React Router v7 with centralized utilities in React applications to ensure consistent URL parameter handling and simplify navigation management, particularly when organizing and scoping URLs, across apps/frontend/**/*.tsx. : +This codebase uses React Router v7 with organization and space scoping in URLs (e.g., /org/{orgSlug}/space/{spaceSlug}/recipes). To maintain consistency, improve maintainability, and simplify navigati... : * Omit orgSlug and spaceSlug parameters to use current organization and space context by default and only specify them explicitly when navigating to a different organization or space. * Use authentication route methods (routes.auth.*) for authentication-related navigation. * Use organization-scoped route methods (routes.org.*) for pages that require only organization context. diff --git a/apps/frontend/.github/instructions/packmind-tanstack-query-key-management.instructions.md b/apps/frontend/.github/instructions/packmind-tanstack-query-key-management.instructions.md index ca577114e..465304751 100644 --- a/apps/frontend/.github/instructions/packmind-tanstack-query-key-management.instructions.md +++ b/apps/frontend/.github/instructions/packmind-tanstack-query-key-management.instructions.md @@ -3,7 +3,7 @@ applyTo: 'apps/frontend/**/*.tsx' --- # Standard: TanStack Query Key Management -Standardize TanStack Query query key definitions in per-domain api/queryKeys.ts files using const base key arrays, separate scope constants, and operation enums with hierarchical organization→domain→operation→identifiers ordering to enable type-safe prefix-based cache invalidation without circular dependencies and to ensure mutations invalidate all affected sibling scopes. : +TanStack Query uses prefix matching to invalidate cached queries, matching keys from left to right like a file path. Properly structured query keys enable efficient cache invalidation at any scope lev... : * Define base query key arrays as const to enable precise invalidation patterns and avoid duplication * Define domain query scope as a separate const outside enum to maintain clear separation between scope and operations * Define query keys in a dedicated queryKeys.ts file in the domain's api folder for centralized management diff --git a/apps/frontend/.gitlab/duo/chat-rules.md b/apps/frontend/.gitlab/duo/chat-rules.md index b6ca16b5c..d2c4306f6 100644 --- a/apps/frontend/.gitlab/duo/chat-rules.md +++ b/apps/frontend/.gitlab/duo/chat-rules.md @@ -11,7 +11,7 @@ Failure to follow these standards may lead to inconsistencies, errors, or rework # Standard: Frontend Data Flow -Standardize frontend route data flow using React Router v7 framework mode with TanStack Query by centralizing fetching in route clientLoaders via queryClient.ensureQueryData(), organizing reusable query options/hooks under apps/frontend/src/domain/{entity}/api/queries/, and consuming results with useLoaderData() to reduce intermediate loading states and improve consistency and reuse. : +This standard defines the recommended data flow pattern for frontend routes in the Packmind codebase using React Router v7 in framework mode with TanStack Query for data management. It ensures consist... : * Access data via query or mutation hooks in frontend route modules rather than calling gateways directly to maintain separation between data access and presentation layers * Consume data in route module components using the useLoaderData() hook to access data returned by the clientLoader * Define a crumb property in the handle export of route modules to enable automatic navigation breadcrumb generation @@ -29,7 +29,7 @@ Full standard is available here for further request: [Frontend Data Flow](../../ # Standard: Frontend Error Management -Establish frontend error management for apps/frontend/**/*.tsx that prescribes when to add React error boundaries beyond the global root.tsx fallback and how to handle errors they don't catch—such as event handlers, async code, SSR, or errors thrown in the boundary—by using TypeScript-typed guards (e.g., isPackmindError), try/catch for async operations, TanStack Query onError callbacks and mutation-pending checks to prevent double submissions, inline validation for expected API/user errors, and selective page- or component-level boundaries for isolated third-party widgets like CodeMirror, to reduce complexity, improve UX, and keep error flows maintainable in React/TypeScript projects built with Node.js and typical tooling (Vite/Webpack, ESLint/Prettier) and tested with Jest/Cypress. : +A global error boundary is already configured at the root level (in root.tsx) that automatically catches all page crashes, 404s, and loader failures. This standard defines when and how to use explicit... : * Avoid overusing error boundaries as they increase code complexity and make error flows harder to trace * Display validation errors inline near the relevant form fields for better user experience * Do NOT use error boundaries for errors that should be handled explicitly such as form validation, expected API errors, and user input errors @@ -44,7 +44,7 @@ Full standard is available here for further request: [Frontend Error Management] # Standard: Frontend Navigation with React Router -Standardize frontend navigation using React Router v7 with centralized utilities in React applications to ensure consistent URL parameter handling and simplify navigation management, particularly when organizing and scoping URLs, across apps/frontend/**/*.tsx. : +This codebase uses React Router v7 with organization and space scoping in URLs (e.g., /org/{orgSlug}/space/{spaceSlug}/recipes). To maintain consistency, improve maintainability, and simplify navigati... : * Omit orgSlug and spaceSlug parameters to use current organization and space context by default and only specify them explicitly when navigating to a different organization or space. * Use authentication route methods (routes.auth.*) for authentication-related navigation. * Use organization-scoped route methods (routes.org.*) for pages that require only organization context. @@ -56,7 +56,7 @@ Full standard is available here for further request: [Frontend Navigation with R # Standard: TanStack Query Key Management -Standardize TanStack Query query key definitions in per-domain api/queryKeys.ts files using const base key arrays, separate scope constants, and operation enums with hierarchical organization→domain→operation→identifiers ordering to enable type-safe prefix-based cache invalidation without circular dependencies and to ensure mutations invalidate all affected sibling scopes. : +TanStack Query uses prefix matching to invalidate cached queries, matching keys from left to right like a file path. Properly structured query keys enable efficient cache invalidation at any scope lev... : * Define base query key arrays as const to enable precise invalidation patterns and avoid duplication * Define domain query scope as a separate const outside enum to maintain clear separation between scope and operations * Define query keys in a dedicated queryKeys.ts file in the domain's api folder for centralized management diff --git a/apps/frontend/.packmind/standards-index.md b/apps/frontend/.packmind/standards-index.md index 8bf68d3bb..f13121cbe 100644 --- a/apps/frontend/.packmind/standards-index.md +++ b/apps/frontend/.packmind/standards-index.md @@ -4,10 +4,10 @@ This standards index contains all available coding standards that can be used by ## Available Standards -- [Frontend Data Flow](./standards/frontend-data-flow.md) : Standardize frontend route data flow using React Router v7 framework mode with TanStack Query by centralizing fetching in route clientLoaders via queryClient.ensureQueryData(), organizing reusable query options/hooks under apps/frontend/src/domain/{entity}/api/queries/, and consuming results with useLoaderData() to reduce intermediate loading states and improve consistency and reuse. -- [Frontend Error Management](./standards/frontend-error-management.md) : Establish frontend error management for apps/frontend/**/*.tsx that prescribes when to add React error boundaries beyond the global root.tsx fallback and how to handle errors they don't catch—such as event handlers, async code, SSR, or errors thrown in the boundary—by using TypeScript-typed guards (e.g., isPackmindError), try/catch for async operations, TanStack Query onError callbacks and mutation-pending checks to prevent double submissions, inline validation for expected API/user errors, and selective page- or component-level boundaries for isolated third-party widgets like CodeMirror, to reduce complexity, improve UX, and keep error flows maintainable in React/TypeScript projects built with Node.js and typical tooling (Vite/Webpack, ESLint/Prettier) and tested with Jest/Cypress. -- [Frontend Navigation with React Router](./standards/frontend-navigation-with-react-router.md) : Standardize frontend navigation using React Router v7 with centralized utilities in React applications to ensure consistent URL parameter handling and simplify navigation management, particularly when organizing and scoping URLs, across apps/frontend/**/*.tsx. -- [TanStack Query Key Management](./standards/tanstack-query-key-management.md) : Standardize TanStack Query query key definitions in per-domain api/queryKeys.ts files using const base key arrays, separate scope constants, and operation enums with hierarchical organization→domain→operation→identifiers ordering to enable type-safe prefix-based cache invalidation without circular dependencies and to ensure mutations invalidate all affected sibling scopes. +- [Frontend Data Flow](./standards/frontend-data-flow.md) : This standard defines the recommended data flow pattern for frontend routes in the Packmind codebase using React Router v7 in framework mode with TanStack Query for data management. It ensures consistent data fetching, loading patterns, and route organization across the application by centralizing data fetching logic in route loaders and ensuring data availability before rendering, which reduces intermediate loading states and promotes reusability of query options. +- [Frontend Error Management](./standards/frontend-error-management.md) : A global error boundary is already configured at the root level (in root.tsx) that automatically catches all page crashes, 404s, and loader failures. This standard defines when and how to use explicit error boundaries beyond the global one, and how to handle errors that error boundaries don't catch (async operations, event handlers). Error boundaries are a React feature that catches JavaScript errors during rendering, in lifecycle methods, and in constructors of the whole tree below them. However, they have important limitations: they do NOT catch errors in event handlers, asynchronous code (setTimeout, promises), server-side rendering, or errors thrown in the error boundary itself. This standard ensures developers use error boundaries appropriately while properly handling errors that fall outside their scope. +- [Frontend Navigation with React Router](./standards/frontend-navigation-with-react-router.md) : This codebase uses React Router v7 with organization and space scoping in URLs (e.g., /org/{orgSlug}/space/{spaceSlug}/recipes). To maintain consistency, improve maintainability, and simplify navigation management across the application, all internal navigation must use centralized utilities instead of manual URL construction. This approach ensures that URL parameter handling (orgSlug, spaceSlug) is abstracted away and reduces the risk of broken links when routes change. +- [TanStack Query Key Management](./standards/tanstack-query-key-management.md) : TanStack Query uses prefix matching to invalidate cached queries, matching keys from left to right like a file path. Properly structured query keys enable efficient cache invalidation at any scope level (organization-wide, domain-wide, or specific operations). Each domain defines its query scope constant and operation enum in a dedicated queryKeys.ts file, ensuring type safety and preventing circular dependencies when domains import each other's keys for invalidation. --- diff --git a/apps/frontend/.storybook/main.ts b/apps/frontend/.storybook/main.ts new file mode 100644 index 000000000..d42fcb554 --- /dev/null +++ b/apps/frontend/.storybook/main.ts @@ -0,0 +1,23 @@ +import type { StorybookConfig } from '@storybook/react-vite'; +import { mergeConfig } from 'vite'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: [], + framework: { + name: '@storybook/react-vite', + options: { + builder: { + viteConfigPath: 'apps/frontend/.storybook/vite.config.ts', + }, + }, + }, + viteFinal: async (config) => { + return mergeConfig(config, { + plugins: [tsconfigPaths()], + }); + }, +}; + +export default config; diff --git a/apps/frontend/.storybook/preview.tsx b/apps/frontend/.storybook/preview.tsx new file mode 100644 index 000000000..f032f1309 --- /dev/null +++ b/apps/frontend/.storybook/preview.tsx @@ -0,0 +1,35 @@ +import type { Preview } from '@storybook/react'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UIProvider } from '@packmind/ui'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, +}); + +const preview: Preview = { + decorators: [ + (Story) => ( + <UIProvider> + <QueryClientProvider client={queryClient}> + <Story /> + </QueryClientProvider> + </UIProvider> + ), + ], + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, +}; + +export default preview; diff --git a/apps/frontend/.storybook/vite.config.ts b/apps/frontend/.storybook/vite.config.ts new file mode 100644 index 000000000..dddf4215d --- /dev/null +++ b/apps/frontend/.storybook/vite.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import path from 'path'; + +export default defineConfig(() => { + // Determine edition mode (defaults to OSS if not explicitly set to 'proprietary') + const isOssMode = process.env.PACKMIND_EDITION !== 'proprietary'; + + // Configure resolve aliases based on edition + const resolveAliases = isOssMode + ? { + '@packmind/proprietary/frontend': path.resolve( + __dirname, + '../src/domain/editions/stubs', + ), + } + : { + '@packmind/proprietary/frontend': path.resolve(__dirname, '../src'), + }; + + return { + root: path.resolve(__dirname, '..'), + cacheDir: '../../../node_modules/.vite/apps/frontend-storybook', + define: { + __PACKMIND_EDITION__: JSON.stringify( + process.env.PACKMIND_EDITION || 'oss', + ), + }, + resolve: { + alias: resolveAliases, + }, + plugins: [nxViteTsPaths()], + }; +}); diff --git a/apps/frontend/AGENTS.md b/apps/frontend/AGENTS.md index 7dbb4f94c..4774f7db5 100644 --- a/apps/frontend/AGENTS.md +++ b/apps/frontend/AGENTS.md @@ -48,7 +48,7 @@ Failure to follow these standards may lead to inconsistencies, errors, or rework # Standard: Frontend Data Flow -Standardize frontend route data flow using React Router v7 framework mode with TanStack Query by centralizing fetching in route clientLoaders via queryClient.ensureQueryData(), organizing reusable query options/hooks under apps/frontend/src/domain/{entity}/api/queries/, and consuming results with useLoaderData() to reduce intermediate loading states and improve consistency and reuse. : +This standard defines the recommended data flow pattern for frontend routes in the Packmind codebase using React Router v7 in framework mode with TanStack Query for data management. It ensures consist... : * Access data via query or mutation hooks in frontend route modules rather than calling gateways directly to maintain separation between data access and presentation layers * Consume data in route module components using the useLoaderData() hook to access data returned by the clientLoader * Define a crumb property in the handle export of route modules to enable automatic navigation breadcrumb generation @@ -66,7 +66,7 @@ Full standard is available here for further request: [Frontend Data Flow](.packm # Standard: Frontend Error Management -Establish frontend error management for apps/frontend/**/*.tsx that prescribes when to add React error boundaries beyond the global root.tsx fallback and how to handle errors they don't catch—such as event handlers, async code, SSR, or errors thrown in the boundary—by using TypeScript-typed guards (e.g., isPackmindError), try/catch for async operations, TanStack Query onError callbacks and mutation-pending checks to prevent double submissions, inline validation for expected API/user errors, and selective page- or component-level boundaries for isolated third-party widgets like CodeMirror, to reduce complexity, improve UX, and keep error flows maintainable in React/TypeScript projects built with Node.js and typical tooling (Vite/Webpack, ESLint/Prettier) and tested with Jest/Cypress. : +A global error boundary is already configured at the root level (in root.tsx) that automatically catches all page crashes, 404s, and loader failures. This standard defines when and how to use explicit... : * Avoid overusing error boundaries as they increase code complexity and make error flows harder to trace * Display validation errors inline near the relevant form fields for better user experience * Do NOT use error boundaries for errors that should be handled explicitly such as form validation, expected API errors, and user input errors @@ -81,7 +81,7 @@ Full standard is available here for further request: [Frontend Error Management] # Standard: Frontend Navigation with React Router -Standardize frontend navigation using React Router v7 with centralized utilities in React applications to ensure consistent URL parameter handling and simplify navigation management, particularly when organizing and scoping URLs, across apps/frontend/**/*.tsx. : +This codebase uses React Router v7 with organization and space scoping in URLs (e.g., /org/{orgSlug}/space/{spaceSlug}/recipes). To maintain consistency, improve maintainability, and simplify navigati... : * Omit orgSlug and spaceSlug parameters to use current organization and space context by default and only specify them explicitly when navigating to a different organization or space. * Use authentication route methods (routes.auth.*) for authentication-related navigation. * Use organization-scoped route methods (routes.org.*) for pages that require only organization context. @@ -93,7 +93,7 @@ Full standard is available here for further request: [Frontend Navigation with R # Standard: TanStack Query Key Management -Standardize TanStack Query query key definitions in per-domain api/queryKeys.ts files using const base key arrays, separate scope constants, and operation enums with hierarchical organization→domain→operation→identifiers ordering to enable type-safe prefix-based cache invalidation without circular dependencies and to ensure mutations invalidate all affected sibling scopes. : +TanStack Query uses prefix matching to invalidate cached queries, matching keys from left to right like a file path. Properly structured query keys enable efficient cache invalidation at any scope lev... : * Define base query key arrays as const to enable precise invalidation patterns and avoid duplication * Define domain query scope as a separate const outside enum to maintain clear separation between scope and operations * Define query keys in a dedicated queryKeys.ts file in the domain's api folder for centralized management diff --git a/apps/frontend/app/root.tsx b/apps/frontend/app/root.tsx index 988b95e49..e8fe8bfd3 100644 --- a/apps/frontend/app/root.tsx +++ b/apps/frontend/app/root.tsx @@ -18,6 +18,7 @@ import { import { useAuthContext } from '../src/domain/accounts/hooks'; import { UserContextChangeSubscription } from '../src/domain/accounts/components/UserContextChangeSubscription'; import { DistributionStatusChangeSubscription } from '../src/domain/deployments/components/DistributionStatusChangeSubscription'; +import { MarketplacePublishCompletedSubscription } from '../src/domain/deployments/components/MarketplacePublishCompletedSubscription'; import { QueryProvider } from '../src/providers/QueryProvider'; import { AuthProvider } from '../src/providers/AuthProvider'; import { SSEProvider } from '../src/services/sse'; @@ -149,6 +150,7 @@ function AppContent() { > {isAuthenticated && <UserContextChangeSubscription />} {isAuthenticated && <DistributionStatusChangeSubscription />} + {isAuthenticated && <MarketplacePublishCompletedSubscription />} <Outlet /> <PMToaster /> </PMVStack> diff --git a/apps/frontend/app/routes/org.$orgSlug._protected.marketplaces.$marketplaceId._index.tsx b/apps/frontend/app/routes/org.$orgSlug._protected.marketplaces.$marketplaceId._index.tsx new file mode 100644 index 000000000..d6752be9a --- /dev/null +++ b/apps/frontend/app/routes/org.$orgSlug._protected.marketplaces.$marketplaceId._index.tsx @@ -0,0 +1,116 @@ +import type { LoaderFunctionArgs } from 'react-router'; +import { useMemo } from 'react'; +import { useParams } from 'react-router'; +import { PMPage, PMVStack } from '@packmind/ui'; +import type { MarketplaceId } from '@packmind/types'; +import { queryClient } from '../../src/shared/data/queryClient'; +import { ensureOrgContext } from '../../src/shared/data/ensureOrgContext'; +import { + marketplaceQueries, + useMarketplaces, +} from '../../src/domain/marketplaces/api/queries'; +import { + MarketplaceDetailAlerts, + MarketplaceDetailBacklink, + MarketplaceDetailHeaderActions, + MarketplaceDetailLayout, +} from '../../src/domain/marketplaces/components'; +import { useRefreshMarketplacesOnOpen } from '../../src/domain/marketplaces/hooks'; +import { useAuthContext } from '../../src/domain/accounts/hooks'; + +export const handle = { + crumb: ({ + params, + }: { + params: { orgSlug: string; marketplaceId: string }; + }) => ({ + label: 'Marketplace', + to: `/org/${params.orgSlug}/marketplaces/${params.marketplaceId}`, + }), +}; + +/** + * `clientLoader` warms both the marketplace list (so the header can resolve + * its display data from cache) and the distributions query for this specific + * marketplace, per `standard-frontend-data-flow.md`. + */ +export async function clientLoader({ params }: LoaderFunctionArgs) { + const me = await ensureOrgContext(params.orgSlug!); + const marketplaceId = params.marketplaceId as MarketplaceId; + + try { + await Promise.all([ + queryClient.ensureQueryData( + marketplaceQueries.list({ orgId: me.organization.id }), + ), + queryClient.ensureQueryData( + marketplaceQueries.distributions({ + orgId: me.organization.id, + marketplaceId, + }), + ), + ]); + } catch { + // The live queries in the component will surface their own error/loading + // states. + } + + return null; +} + +export default function MarketplaceDetailsRouteModule() { + const { organization } = useAuthContext(); + const { orgSlug, marketplaceId } = useParams<{ + orgSlug: string; + marketplaceId: string; + }>(); + const orgId = organization?.id ?? ''; + const { data: marketplaces } = useMarketplaces(orgId); + const marketplace = marketplaces?.find((m) => m.id === marketplaceId); + // Auto-reconcile the active marketplace on detail-page open so the header + // pill ("Changes ready"), pendingPrUrl, and per-distribution diffs reflect + // the latest server state without forcing the user to click "Sync now" + // after merging a publish PR. The hook's 10s freshness window protects + // against back-and-forth navigation thrash. + const marketplacesForRefresh = useMemo( + () => (marketplace ? [marketplace] : []), + [marketplace], + ); + useRefreshMarketplacesOnOpen(orgId, marketplacesForRefresh); + + if (!organization || !marketplaceId || !orgSlug) { + return null; + } + + return ( + <PMPage + title={marketplace?.name ?? 'Marketplace'} + subtitle="Manage the plugin distributions published to this marketplace." + isFullWidth + breadcrumbComponent={ + <MarketplaceDetailBacklink to={`/org/${orgSlug}/marketplaces`} /> + } + actions={ + marketplace ? ( + <MarketplaceDetailHeaderActions + organizationId={orgId} + marketplace={marketplace} + /> + ) : undefined + } + > + {marketplace && ( + <PMVStack align="stretch" gap={4}> + <MarketplaceDetailAlerts + organizationId={orgId} + marketplace={marketplace} + /> + <MarketplaceDetailLayout + organizationId={orgId} + marketplace={marketplace} + /> + </PMVStack> + )} + </PMPage> + ); +} diff --git a/apps/frontend/app/routes/org.$orgSlug._protected.marketplaces._index.tsx b/apps/frontend/app/routes/org.$orgSlug._protected.marketplaces._index.tsx new file mode 100644 index 000000000..e2b2b25ef --- /dev/null +++ b/apps/frontend/app/routes/org.$orgSlug._protected.marketplaces._index.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from 'react'; +import { redirect, useNavigate } from 'react-router'; +import type { LoaderFunctionArgs } from 'react-router'; +import { PMPage, pmToaster } from '@packmind/ui'; +import type { MarketplaceId, UserOrganizationRole } from '@packmind/types'; +import { queryClient } from '../../src/shared/data/queryClient'; +import { ensureOrgContext } from '../../src/shared/data/ensureOrgContext'; +import { + marketplaceQueries, + useMarketplaces, + useUnlinkMarketplace, +} from '../../src/domain/marketplaces/api/queries'; +import { + LinkMarketplacePanel, + MarketplacesIndex, +} from '../../src/domain/marketplaces/components'; +import { useRefreshMarketplacesOnOpen } from '../../src/domain/marketplaces/hooks'; +import { useAuthContext } from '../../src/domain/accounts/hooks'; + +type AdminGuardOrganization = + | { slug: string; role: UserOrganizationRole } + | undefined; + +type HasAccessResponse = + | { + hasAccess: true; + toast?: never; + redirect?: never; + } + | { + hasAccess: false; + toast: { title: string; type: string }; + redirect: { url: string }; + }; + +/** + * Marketplaces are administered at the organization level: only org admins may + * link/unlink them (the API rejects mutations from members). This guard keeps + * non-admin members out of the page even if they reach the URL directly. + */ +function hasAccess(organization: AdminGuardOrganization): HasAccessResponse { + if (organization && organization.role !== 'admin') { + return { + hasAccess: false, + toast: { + type: 'error', + title: 'Marketplaces are limited to administrators', + }, + redirect: { url: `/org/${organization.slug}` }, + }; + } + + return { hasAccess: true }; +} + +/** + * `clientLoader` warms the marketplace list query via + * `queryClient.ensureQueryData` per `standard-frontend-data-flow.md`, so the + * route renders with cached data on the first paint. + */ +export async function clientLoader({ params }: LoaderFunctionArgs) { + const me = await ensureOrgContext(params.orgSlug!); + + const hasAccessResponse = hasAccess(me.organization); + if (!hasAccessResponse.hasAccess) { + pmToaster.create(hasAccessResponse.toast); + throw redirect(hasAccessResponse.redirect.url); + } + + try { + await queryClient.ensureQueryData( + marketplaceQueries.list({ orgId: me.organization.id }), + ); + } catch { + // The live query in the component will surface its own error/loading state. + } + return null; +} + +export default function MarketplacesRouteModule() { + const { organization } = useAuthContext(); + const navigate = useNavigate(); + const orgId = organization?.id ?? ''; + const orgSlug = organization?.slug ?? ''; + const { data: marketplaces, isLoading } = useMarketplaces(orgId); + const { refreshingIds } = useRefreshMarketplacesOnOpen( + orgId, + marketplaces ?? [], + ); + const unlinkMutation = useUnlinkMarketplace(orgId); + const [unlinkingId, setUnlinkingId] = useState<MarketplaceId | null>(null); + + const handleUnlink = (marketplaceId: MarketplaceId) => { + setUnlinkingId(marketplaceId); + unlinkMutation.mutate(marketplaceId, { + onSettled: () => setUnlinkingId(null), + }); + }; + + useEffect(() => { + const hasAccessResponse = hasAccess(organization); + if (!hasAccessResponse.hasAccess) { + pmToaster.create(hasAccessResponse.toast); + navigate(hasAccessResponse.redirect.url); + } + }, [organization, navigate]); + + if (!organization) { + return null; + } + + return ( + <PMPage + title="Marketplaces" + subtitle="Curate the Git-backed marketplaces enrolled in your organization" + > + <LinkMarketplacePanel organizationId={orgId} orgSlug={orgSlug} /> + <MarketplacesIndex + marketplaces={marketplaces ?? []} + isLoading={isLoading} + unlinkingMarketplaceId={unlinkingId} + refreshingIds={refreshingIds} + onUnlink={handleUnlink} + organizationId={orgId} + orgSlug={orgSlug} + /> + </PMPage> + ); +} diff --git a/apps/frontend/app/routes/org.$orgSlug._protected.settings.spaces._index.tsx b/apps/frontend/app/routes/org.$orgSlug._protected.settings.spaces._index.tsx index ce9d2573b..2621b92a6 100644 --- a/apps/frontend/app/routes/org.$orgSlug._protected.settings.spaces._index.tsx +++ b/apps/frontend/app/routes/org.$orgSlug._protected.settings.spaces._index.tsx @@ -1,14 +1,15 @@ import type { LoaderFunctionArgs } from 'react-router'; -import { useLoaderData } from 'react-router'; import { DEFAULT_FEATURE_DOMAIN_MAP, - ORGA_SPACE_MANAGEMENT_FEATURE_KEY, PMFeatureFlag, PMPage, } from '@packmind/ui'; import { queryClient } from '../../src/shared/data/queryClient'; import { ensureOrgContext } from '../../src/shared/data/ensureOrgContext'; -import { getSpacesQueryOptions } from '../../src/domain/spaces/api/queries/SpacesQueries'; +import { + getOrganizationSpacesForManagementQueryOptions, + useGetOrganizationSpacesForManagementQuery, +} from '../../src/domain/spaces/api/queries/SpacesQueries'; import { useAuthContext } from '../../src/domain/accounts/hooks/useAuthContext'; import { SpacesManagementPage } from '../../src/domain/spaces/components/SpacesManagementPage'; import { SpacesToolbar } from '../../src/domain/spaces/components/SpacesManagementPage/SpacesToolbar'; @@ -16,39 +17,37 @@ import { SpacesToolbar } from '../../src/domain/spaces/components/SpacesManageme export async function clientLoader({ params }: LoaderFunctionArgs) { const me = await ensureOrgContext(params.orgSlug!); try { - const spaces = await queryClient.ensureQueryData( - getSpacesQueryOptions(me.organization.id), + await queryClient.ensureQueryData( + getOrganizationSpacesForManagementQueryOptions(me.organization.id, 1), ); - return { spaceCount: spaces.length }; } catch { - return { spaceCount: null }; + // Live query in the component will surface its own error/loading state. } + return null; } export default function SettingsSpacesRouteModule() { const { user, organization } = useAuthContext(); - const { spaceCount } = useLoaderData<typeof clientLoader>(); + const { data } = useGetOrganizationSpacesForManagementQuery( + organization?.id ?? '', + 1, + ); if (!organization) { return null; } + const totalCount = data?.totalCount ?? null; const subtitle = - spaceCount === null + totalCount === null ? 'Manage every space in your organization' - : `Manage every space in your organization · ${spaceCount} ${ - spaceCount === 1 ? 'space' : 'spaces' + : `Manage every space in your organization · ${totalCount} ${ + totalCount === 1 ? 'space' : 'spaces' }`; return ( - <PMFeatureFlag - featureKeys={[ORGA_SPACE_MANAGEMENT_FEATURE_KEY]} - featureDomainMap={DEFAULT_FEATURE_DOMAIN_MAP} - userEmail={user?.email} - > - <PMPage title="Spaces" subtitle={subtitle} actions={<SpacesToolbar />}> - <SpacesManagementPage /> - </PMPage> - </PMFeatureFlag> + <PMPage title="Spaces" subtitle={subtitle} actions={<SpacesToolbar />}> + <SpacesManagementPage /> + </PMPage> ); } diff --git a/apps/frontend/app/routes/org.$orgSlug._protected.settings.tsx b/apps/frontend/app/routes/org.$orgSlug._protected.settings.tsx index ceeb030a8..f326bc518 100644 --- a/apps/frontend/app/routes/org.$orgSlug._protected.settings.tsx +++ b/apps/frontend/app/routes/org.$orgSlug._protected.settings.tsx @@ -97,6 +97,11 @@ export default function SettingsIndexRouteModule() { exact data-testid={SettingsRouteDataTestIds.UsersLink} />, + <SidebarNavigationLink + url={orgSlug ? routes.org.toSettingsSpaces(orgSlug) : '#'} + label="Spaces" + exact + />, ]} /> diff --git a/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes.$artefactType.$artefactId.tsx b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes.$artefactType.$artefactId.tsx new file mode 100644 index 000000000..f3e4a4399 --- /dev/null +++ b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes.$artefactType.$artefactId.tsx @@ -0,0 +1,63 @@ +import { useParams } from 'react-router'; +import { PMBox, PMText } from '@packmind/ui'; +import { CommandReviewDetail } from '../../src/domain/change-proposals/components/CommandReviewDetail'; +import { SkillReviewDetail } from '../../src/domain/change-proposals/components/SkillReviewDetail'; +import { StandardReviewDetail } from '../../src/domain/change-proposals/components/StandardReviewDetail'; + +export default function ReviewChangesDetailRouteModule() { + const { artefactType, artefactId, orgSlug, spaceSlug } = useParams<{ + artefactType: string; + artefactId: string; + orgSlug: string; + spaceSlug: string; + }>(); + + if (!artefactType || !artefactId) return null; + + if (artefactType === 'commands') { + return ( + <CommandReviewDetail + key={artefactId} + artefactId={artefactId} + orgSlug={orgSlug} + spaceSlug={spaceSlug} + /> + ); + } + + if (artefactType === 'skills') { + return ( + <SkillReviewDetail + key={artefactId} + artefactId={artefactId} + orgSlug={orgSlug} + spaceSlug={spaceSlug} + /> + ); + } + + if (artefactType === 'standards') { + return ( + <StandardReviewDetail + key={artefactId} + artefactId={artefactId} + orgSlug={orgSlug} + spaceSlug={spaceSlug} + /> + ); + } + + return ( + <PMBox + display="flex" + alignItems="center" + justifyContent="center" + minH="300px" + gridColumn="span 2" + > + <PMText color="secondary"> + Review not yet supported for this artefact type + </PMText> + </PMBox> + ); +} diff --git a/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes.$artefactType._index.tsx b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes.$artefactType._index.tsx new file mode 100644 index 000000000..b2963e2ed --- /dev/null +++ b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes.$artefactType._index.tsx @@ -0,0 +1,15 @@ +import { Navigate, useParams } from 'react-router'; +import { routes } from '../../src/shared/utils/routes'; + +export default function ReviewChangesArtefactTypeRedirectRouteModule() { + const { orgSlug, spaceSlug } = useParams<{ + orgSlug: string; + spaceSlug: string; + }>(); + + if (!orgSlug || !spaceSlug) return null; + + return ( + <Navigate to={routes.space.toReviewChanges(orgSlug, spaceSlug)} replace /> + ); +} diff --git a/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes.$artefactType.new.$proposalId.tsx b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes.$artefactType.new.$proposalId.tsx new file mode 100644 index 000000000..6dda664be --- /dev/null +++ b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes.$artefactType.new.$proposalId.tsx @@ -0,0 +1,51 @@ +import { useParams } from 'react-router'; +import { CreateCommandReviewDetail } from '../../src/domain/change-proposals/components/CreateCommandReviewDetail'; +import { CreateSkillReviewDetail } from '../../src/domain/change-proposals/components/CreateSkillReviewDetail'; +import { CreateStandardReviewDetail } from '../../src/domain/change-proposals/components/CreateStandardReviewDetail'; +import { createChangeProposalId } from '@packmind/types'; + +export default function ReviewChangesCreationDetailRouteModule() { + const { artefactType, proposalId, orgSlug, spaceSlug } = useParams<{ + artefactType: string; + proposalId: string; + orgSlug: string; + spaceSlug: string; + }>(); + + if (!proposalId) return null; + + if (artefactType === 'commands') { + return ( + <CreateCommandReviewDetail + key={proposalId} + proposalId={createChangeProposalId(proposalId)} + orgSlug={orgSlug} + spaceSlug={spaceSlug} + /> + ); + } + + if (artefactType === 'standards') { + return ( + <CreateStandardReviewDetail + key={proposalId} + proposalId={createChangeProposalId(proposalId)} + orgSlug={orgSlug} + spaceSlug={spaceSlug} + /> + ); + } + + if (artefactType === 'skills') { + return ( + <CreateSkillReviewDetail + key={proposalId} + proposalId={createChangeProposalId(proposalId)} + orgSlug={orgSlug} + spaceSlug={spaceSlug} + /> + ); + } + + return null; +} diff --git a/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes._index.tsx b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes._index.tsx new file mode 100644 index 000000000..8b4704629 --- /dev/null +++ b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes._index.tsx @@ -0,0 +1,78 @@ +import { Navigate, useParams } from 'react-router'; +import { PMBox, PMText } from '@packmind/ui'; +import { useGetGroupedChangeProposalsQuery } from '../../src/domain/change-proposals/api/queries/ChangeProposalsQueries'; +import { routes } from '../../src/shared/utils/routes'; + +export default function ReviewChangesIndexRouteModule() { + const { orgSlug, spaceSlug } = useParams<{ + orgSlug: string; + spaceSlug: string; + }>(); + + const { data: groupedProposals } = useGetGroupedChangeProposalsQuery(); + + if (orgSlug && spaceSlug && groupedProposals) { + const allItems = [ + ...groupedProposals.commands.map((item) => ({ + ...item, + artefactType: 'commands' as const, + })), + ...groupedProposals.standards.map((item) => ({ + ...item, + artefactType: 'standards' as const, + })), + ...groupedProposals.skills.map((item) => ({ + ...item, + artefactType: 'skills' as const, + })), + ].sort((a, b) => b.lastContributedAt.localeCompare(a.lastContributedAt)); + + const sortedCreations = [...(groupedProposals.creations ?? [])].sort( + (a, b) => b.lastContributedAt.localeCompare(a.lastContributedAt), + ); + + if (allItems.length > 0) { + const first = allItems[0]; + return ( + <Navigate + to={routes.space.toReviewChangesArtefact( + orgSlug, + spaceSlug, + first.artefactType, + first.artefactId, + )} + replace + /> + ); + } + + if (sortedCreations.length > 0) { + const first = sortedCreations[0]; + return ( + <Navigate + to={routes.space.toReviewChangesCreation( + orgSlug, + spaceSlug, + first.artefactType, + first.id, + )} + replace + /> + ); + } + } + + return ( + <PMBox + display="flex" + alignItems="center" + justifyContent="center" + minH="300px" + gridColumn="span 2" + > + <PMText fontSize="md"> + Select an artefact to review in the left panel + </PMText> + </PMBox> + ); +} diff --git a/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes.tsx b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes.tsx new file mode 100644 index 000000000..5a61d7f00 --- /dev/null +++ b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes.tsx @@ -0,0 +1,65 @@ +import { Outlet } from 'react-router'; +import { PMGrid, PMBox, PMText, PMSpinner, PMVStack } from '@packmind/ui'; +import { useGetGroupedChangeProposalsQuery } from '../../src/domain/change-proposals/api/queries/ChangeProposalsQueries'; +import { ReviewChangesSidebar } from '../../src/domain/change-proposals/components/ReviewChangesSidebar'; +import { ReviewChangesBlankState } from '../../src/domain/change-proposals/components/ReviewChangesBlankState'; +import { EnterprisePlanBanner } from '../../src/shared/components/EnterprisePlanBanner'; + +export default function ReviewChangesLayoutRouteModule() { + const { + data: groupedProposals, + isLoading, + isError, + } = useGetGroupedChangeProposalsQuery(); + + if (isLoading) { + return ( + <PMVStack alignItems="center" justifyContent="center" height="full" p={8}> + <PMSpinner size="lg" /> + </PMVStack> + ); + } + + if (isError) { + return ( + <PMBox p={8}> + <PMText> + Failed to load change proposals. Please try again later. + </PMText> + </PMBox> + ); + } + + const hasProposals = + !!groupedProposals?.commands?.length || + !!groupedProposals?.creations?.length || + !!groupedProposals?.standards?.length || + !!groupedProposals?.skills?.length; + + if (!hasProposals) { + return ( + <PMBox p={8}> + <ReviewChangesBlankState /> + </PMBox> + ); + } + + return ( + <PMBox height="full" display="flex" flexDirection="column"> + <EnterprisePlanBanner /> + <PMGrid + flex="1" + minHeight={0} + gridTemplateColumns={{ + base: 'minmax(240px, 270px) 1fr minmax(280px, 320px)', + }} + overflowX="auto" + > + <PMBox gridColumn="1" overflowY="auto"> + <ReviewChangesSidebar groupedProposals={groupedProposals} /> + </PMBox> + <Outlet /> + </PMGrid> + </PMBox> + ); +} diff --git a/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.settings.tsx b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.settings.tsx index c0f2faebd..2b748ffd1 100644 --- a/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.settings.tsx +++ b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.settings.tsx @@ -1,4 +1,35 @@ +import { redirect } from 'react-router'; +import type { LoaderFunctionArgs } from 'react-router'; import { SpaceSettingsPage } from '../../src/domain/spaces'; +import { queryClient } from '../../src/shared/data/queryClient'; +import { getMeQueryOptions } from '../../src/domain/accounts/api/queries/UserQueries'; +import { + getSpaceBySlugQueryOptions, + getSpaceMembersQueryOptions, +} from '../../src/domain/spaces/api/queries/SpacesQueries'; +import type { AuthenticatedMeWithOrganization } from '../../src/shared/data/ensureOrgContext'; + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const me = (await queryClient.ensureQueryData( + getMeQueryOptions(), + )) as AuthenticatedMeWithOrganization; + + const orgId = me.organization.id; + + const space = await queryClient.ensureQueryData( + getSpaceBySlugQueryOptions(params.spaceSlug || '', orgId), + ); + + if (!space) { + throw redirect(`/org/${params.orgSlug}/space/${params.spaceSlug}`); + } + + await queryClient.ensureQueryData( + getSpaceMembersQueryOptions(orgId, space.id), + ); + + return null; +} export default function SpaceSettingsRouteModule() { return <SpaceSettingsPage />; diff --git a/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.skills.$skillSlug.tsx b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.skills.$skillSlug.tsx index 26f4093e5..5c91886ae 100644 --- a/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.skills.$skillSlug.tsx +++ b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.skills.$skillSlug.tsx @@ -33,7 +33,7 @@ import { SkillVersionHistoryHeader } from '../../src/domain/skills/components/Sk import { useSkillSectionNavigation } from '../../src/domain/skills/hooks/useSkillSectionNavigation'; import { SkillActions } from '../../src/domain/skills/components/SkillActions'; import { SKILL_MESSAGES } from '../../src/domain/skills/constants/messages'; -import { useListChangeProposalsBySkillQuery } from '@packmind/proprietary/frontend/domain/change-proposals/api/queries/ChangeProposalsQueries'; +import { useListChangeProposalsBySkillQuery } from '../../src/domain/change-proposals/api/queries/ChangeProposalsQueries'; const SKILL_MD_FILENAME = 'SKILL.md'; @@ -119,7 +119,7 @@ export default function SkillDetailLayoutRouteModule() { ); const pendingCount = changeProposals?.changeProposals?.filter( - (p: { status: string }) => p.status === ChangeProposalStatus.pending, + (p) => p.status === ChangeProposalStatus.pending, ).length ?? 0; const skillWithFiles = skillWithFilesFromQuery ?? loaderData?.skill; diff --git a/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.standards.$standardId.summary.tsx b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.standards.$standardId.summary.tsx index bb2188972..ee7424cc2 100644 --- a/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.standards.$standardId.summary.tsx +++ b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.standards.$standardId.summary.tsx @@ -4,7 +4,7 @@ import { useOutletContext } from 'react-router'; import { StandardDetailsOutletContext } from '../../src/domain/standards/components/StandardDetails'; import { RuleSummaryTable } from '@packmind/proprietary/frontend/domain/standards/components/RuleSummaryTable'; import { ArtifactResultFilePreview } from '../../src/domain/artifacts/components/ArtifactResultFilePreview'; -import { serializeStandardToMarkdown } from '@packmind/proprietary/frontend/domain/change-proposals/utils/serializeArtifactToMarkdown'; +import { serializeStandardToMarkdown } from '../../src/domain/change-proposals/utils/serializeArtifactToMarkdown'; export default function StandardDetailSummaryRouteModule() { const { standard, rules, rulesLoading, rulesError } = diff --git a/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.tsx b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.tsx index 943110c55..d8a17e539 100644 --- a/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.tsx +++ b/apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.tsx @@ -8,7 +8,6 @@ import { getSpacesQueryOptions, } from '../../src/domain/spaces/api/queries/SpacesQueries'; import { getStandardsBySpaceQueryOptions } from '../../src/domain/standards/api/queries/StandardsQueries'; -import { pmToaster } from '@packmind/ui'; import { getMeQueryOptions } from '../../src/domain/accounts/api/queries/UserQueries'; import { type AuthenticatedMeWithOrganization } from '../../src/shared/data/ensureOrgContext'; import { @@ -26,103 +25,54 @@ async function clientLoaderWithMe( params: Params<string>, me: AuthenticatedMeWithOrganization, ): Promise<{ space: unknown }> { - // Validate space exists and belongs to this org + // Fetch user's accessible spaces first — this never reveals private space info + let userSpaces; try { - const space = await queryClient.ensureQueryData( - getSpaceBySlugQueryOptions(params.spaceSlug || '', me.organization.id), - ); - - if (!space) { - // Fetch all spaces to redirect to the first available one - const spaces = await queryClient.fetchQuery( - getSpacesQueryOptions(me.organization.id), - ); - - if (spaces && spaces.length > 0) { - setFlashToast({ - type: 'error', - title: 'Space not found', - description: `The space '${params.spaceSlug}' does not exist. Redirecting to ${spaces[0].name}.`, - }); - throw redirect(`/org/${params.orgSlug}/space/${spaces[0].slug}`); - } else { - // No spaces at all - this shouldn't happen - setFlashToast({ - type: 'error', - title: 'No spaces available', - description: 'Please contact support.', - }); - throw redirect(`/org/${params.orgSlug}`); - } - } - - // Verify the current user is a member of this space - const userSpaces = await queryClient.ensureQueryData( + userSpaces = await queryClient.ensureQueryData( getSpacesQueryOptions(me.organization.id), ); - const isMember = userSpaces.some((s) => s.id === space.id); - - if (!isMember) { - if (userSpaces.length > 0) { - setFlashToast({ - type: 'error', - title: 'Access denied', - description: `You do not have permission to access the space '${space.name}'.`, - }); - throw redirect(`/org/${params.orgSlug}/space/${userSpaces[0].slug}`); - } else { - setFlashToast({ - type: 'error', - title: 'No spaces available', - description: - 'You are not a member of any space. Please contact your administrator.', - }); - throw redirect(`/org/${params.orgSlug}`); - } - } - - // Prefetch standards list for this space to warm cache for child routes - // This runs in parallel and doesn't block the loader - queryClient.prefetchQuery( - getStandardsBySpaceQueryOptions(space.id, me.organization.id), - ); - - return { space }; - } catch (error) { - // If space not found, redirect to first available space - if (error instanceof Response) { - throw error; // Re-throw redirect responses - } + } catch { + setFlashToast({ + type: 'error', + title: 'Error loading spaces', + description: 'Please try again.', + }); + throw redirect(`/org/${params.orgSlug}`); + } - try { - const spaces = await queryClient.fetchQuery( - getSpacesQueryOptions(me.organization.id), - ); + // Check membership using only the user's own space list to avoid leaking + // whether a space exists. This prevents space enumeration attacks: + // "not found" and "no access" produce the same response. + const space = userSpaces.find((s) => s.slug === params.spaceSlug); - if (spaces && spaces.length > 0) { - setFlashToast({ - type: 'error', - title: 'Error loading space', - description: `Redirecting to ${spaces[0].name}.`, - }); - throw redirect(`/org/${params.orgSlug}/space/${spaces[0].slug}`); - } - } catch (innerError) { - if (innerError instanceof Response) { - throw innerError; - } - // Fallback to org page + if (!space) { + if (userSpaces.length > 0) { + setFlashToast({ + type: 'error', + title: 'Space not found', + description: `The space '${params.spaceSlug}' could not be found. Redirecting to ${userSpaces[0].name}.`, + }); + throw redirect(`/org/${params.orgSlug}/space/${userSpaces[0].slug}`); + } else { setFlashToast({ type: 'error', - title: 'Error loading spaces', - description: 'Please try again.', + title: 'No spaces available', + description: + 'You are not a member of any space. Please contact your administrator.', }); throw redirect(`/org/${params.orgSlug}`); } - - // If we reach here, redirect to org page as final fallback - throw redirect(`/org/${params.orgSlug}`); } + + // Prefetch full space details and standards list for child routes + queryClient.prefetchQuery( + getSpaceBySlugQueryOptions(params.spaceSlug || '', me.organization.id), + ); + queryClient.prefetchQuery( + getStandardsBySpaceQueryOptions(space.id, me.organization.id), + ); + + return { space }; } export default function SpaceProtectedLayout() { diff --git a/apps/frontend/app/routes/org.$orgSlug._protected.spaces.$spaceSlug.join.tsx b/apps/frontend/app/routes/org.$orgSlug._protected.spaces.$spaceSlug.join.tsx new file mode 100644 index 000000000..7fd602ecd --- /dev/null +++ b/apps/frontend/app/routes/org.$orgSlug._protected.spaces.$spaceSlug.join.tsx @@ -0,0 +1,127 @@ +import { redirect, useLoaderData, useNavigate, useParams } from 'react-router'; +import { queryClient } from '../../src/shared/data/queryClient'; +import { getMeQueryOptions } from '../../src/domain/accounts/api/queries/UserQueries'; +import { + getBrowseSpacesQueryOptions, + useJoinSpaceBySlugMutation, +} from '../../src/domain/spaces-management/api/queries/SpacesManagementQueries'; +import { setFlashToast } from '../../src/shared/utils/flashToast'; +import { routes } from '../../src/shared/utils/routes'; +import { useAuthContext } from '../../src/domain/accounts/hooks/useAuthContext'; +import { + PMBox, + PMButton, + PMHeading, + PMStatus, + PMText, + PMVStack, + pmToaster, +} from '@packmind/ui'; + +// ─── CLIENT LOADER ─────────────────────────────────────────────────────── +export async function clientLoader({ + params, +}: { + params: { orgSlug: string; spaceSlug: string }; +}) { + const me = await queryClient.ensureQueryData(getMeQueryOptions()); + if (!me.organization) { + throw redirect('/sign-in'); + } + + const browseData = await queryClient.fetchQuery( + getBrowseSpacesQueryOptions(me.organization.id), + ); + + const spaceSlug = params.spaceSlug; + + // Already a member → redirect to space dashboard with toast + const mySpace = browseData.mySpaces.find((s) => s.slug === spaceSlug); + if (mySpace) { + setFlashToast({ + type: 'info', + title: 'Already a member', + description: `You're already a member of ${mySpace.name}.`, + }); + throw redirect(routes.space.toDashboard(params.orgSlug, mySpace.slug)); + } + + // Find in discoverable spaces + const space = browseData.allSpaces.find((s) => s.slug === spaceSlug); + + return { space: space ?? null }; +} + +// ─── ROUTE COMPONENT ──────────────────────────────────────────────────── +export default function JoinSpaceRouteModule() { + const { space } = useLoaderData<typeof clientLoader>(); + const { orgSlug, spaceSlug } = useParams<{ + orgSlug: string; + spaceSlug: string; + }>(); + const { organization } = useAuthContext(); + const navigate = useNavigate(); + const joinMutation = useJoinSpaceBySlugMutation(); + + if (!organization || !orgSlug || !spaceSlug) return null; + + if (!space) { + return ( + <PMBox + display="flex" + justifyContent="center" + alignItems="center" + h="100%" + > + <PMVStack align="center" gap={4} maxW="md" textAlign="center"> + <PMHeading size="lg">Space not found</PMHeading> + <PMText color="faded"> + This space doesn't exist or is not available to join. + </PMText> + </PMVStack> + </PMBox> + ); + } + + const handleJoin = () => { + joinMutation.mutate( + { spaceSlug }, + { + onSuccess: () => { + pmToaster.success({ + title: 'Joined!', + description: `You've joined ${space.name}.`, + }); + navigate(routes.org.toDashboard(orgSlug)); + }, + onError: () => { + pmToaster.error({ + title: 'Failed to join', + description: 'Something went wrong. Please try again.', + }); + }, + }, + ); + }; + + return ( + <PMBox display="flex" justifyContent="center" alignItems="center" h="100%"> + <PMVStack align="center" gap={6} maxW="sm" textAlign="center"> + <PMStatus.Root colorPalette={space.color}> + <PMStatus.Indicator boxSize={4} /> + </PMStatus.Root> + <PMVStack gap={2}> + <PMHeading size="lg">{space.name}</PMHeading> + <PMText color="faded">You've been invited to join this space.</PMText> + </PMVStack> + <PMButton + onClick={handleJoin} + loading={joinMutation.isPending} + size="lg" + > + Join this space + </PMButton> + </PMVStack> + </PMBox> + ); +} diff --git a/apps/frontend/jest.config.ts b/apps/frontend/jest.config.ts index 729538f2e..a3412729f 100644 --- a/apps/frontend/jest.config.ts +++ b/apps/frontend/jest.config.ts @@ -26,6 +26,20 @@ module.exports = { '<rootDir>/src/**/*.(spec|test).[jt]s?(x)', '<rootDir>/app/**/*.(spec|test).[jt]s?(x)', ], + // The @nx/jest preset ships moduleFileExtensions without tsx/jsx. + // Override so React component specs (and source files) resolve correctly — + // otherwise Jest silently skips every *.spec.tsx / *.test.tsx in the suite. + moduleFileExtensions: [ + 'ts', + 'tsx', + 'js', + 'jsx', + 'mts', + 'mjs', + 'cts', + 'cjs', + 'html', + ], setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'], coverageDirectory: '../../coverage/apps/frontend', moduleNameMapper: { diff --git a/apps/frontend/packmind-lock.json b/apps/frontend/packmind-lock.json index 2d3aa9db8..c0adc2c01 100644 --- a/apps/frontend/packmind-lock.json +++ b/apps/frontend/packmind-lock.json @@ -6,12 +6,14 @@ "agents": [ "agents_md", "claude", + "codex", "copilot", "cursor", "gitlab_duo", + "opencode", "packmind" ], - "installedAt": "2026-06-16T03:12:27.646Z", + "installedAt": "2026-07-10T03:07:54.023Z", "artifacts": { "user:standard:frontend-data-flow": { "name": "Frontend Data Flow", diff --git a/apps/frontend/project.json b/apps/frontend/project.json index 5f462e0ee..d4454a7c2 100644 --- a/apps/frontend/project.json +++ b/apps/frontend/project.json @@ -26,6 +26,21 @@ "options": { "command": "nx dev frontend" } + }, + "storybook": { + "executor": "@nx/storybook:storybook", + "options": { + "port": 54005, + "configDir": "apps/frontend/.storybook" + } + }, + "build-storybook": { + "executor": "@nx/storybook:build", + "outputs": ["{options.outputDir}"], + "options": { + "outputDir": "dist/storybook/frontend", + "configDir": "apps/frontend/.storybook" + } } } } diff --git a/apps/frontend/src/domain/amplitude/api/gateways/AmplitudeGatewayApi.ts b/apps/frontend/src/domain/amplitude/api/gateways/AmplitudeGatewayApi.ts new file mode 100644 index 000000000..88823db03 --- /dev/null +++ b/apps/frontend/src/domain/amplitude/api/gateways/AmplitudeGatewayApi.ts @@ -0,0 +1,19 @@ +import { PackmindGateway } from '../../../../shared/PackmindGateway'; +import { AmplitudeConfig, IAmplitudeGateway } from './IAmplitudeGateway'; + +export class AmplitudeGatewayApi + extends PackmindGateway + implements IAmplitudeGateway +{ + constructor() { + super('/amplitude'); + } + + getProxyUrl(): string { + return `${this.getFullEndpoint()}/collect`; + } + + async getConfig(): Promise<AmplitudeConfig> { + return this._api.get<AmplitudeConfig>(`${this._endpoint}/config`); + } +} diff --git a/apps/frontend/src/domain/amplitude/api/gateways/IAmplitudeGateway.ts b/apps/frontend/src/domain/amplitude/api/gateways/IAmplitudeGateway.ts new file mode 100644 index 000000000..e22128958 --- /dev/null +++ b/apps/frontend/src/domain/amplitude/api/gateways/IAmplitudeGateway.ts @@ -0,0 +1,9 @@ +export interface AmplitudeConfig { + amplitudeKey: string | undefined; + amplitudeRegion: string | undefined; +} + +export interface IAmplitudeGateway { + getConfig(): Promise<AmplitudeConfig>; + getProxyUrl(): string; +} diff --git a/apps/frontend/src/domain/amplitude/api/gateways/index.ts b/apps/frontend/src/domain/amplitude/api/gateways/index.ts new file mode 100644 index 000000000..34b774db9 --- /dev/null +++ b/apps/frontend/src/domain/amplitude/api/gateways/index.ts @@ -0,0 +1,4 @@ +import { IAmplitudeGateway } from './IAmplitudeGateway'; +import { AmplitudeGatewayApi } from './AmplitudeGatewayApi'; + +export const amplitudeGateway: IAmplitudeGateway = new AmplitudeGatewayApi(); diff --git a/apps/frontend/src/domain/amplitude/providers/AnalyticsProvider.tsx b/apps/frontend/src/domain/amplitude/providers/AnalyticsProvider.tsx new file mode 100644 index 000000000..8a44f2744 --- /dev/null +++ b/apps/frontend/src/domain/amplitude/providers/AnalyticsProvider.tsx @@ -0,0 +1,64 @@ +import React, { createContext, useContext, useEffect } from 'react'; +import { Analytics, type AnalyticsOptions } from './analytics'; +import { useAuthContext } from '../../accounts/hooks/useAuthContext'; + +const AnalyticsContext = createContext(Analytics); + +export const AnalyticsProvider: React.FC<{ + options?: AnalyticsOptions; + children?: React.ReactNode; +}> = ({ options, children }) => { + const { isAuthenticated, user, organization, organizations } = + useAuthContext(); + + useEffect(() => { + const initAnalytics = async () => { + await Analytics.init(options); + }; + initAnalytics(); + }, [options]); + + useEffect(() => { + if (!isAuthenticated) { + Analytics.setUserId(); + Analytics.setUserOrganizations([]); + return; + } + if (user?.id) { + Analytics.setUserId(String(user.id)); + } + // Sync org context when available (covers refresh on org routes) + if (organization) { + Analytics.setUserProperties({ + orgId: String(organization.id), + orgSlug: organization.slug, + orgName: organization.name, + }); + // Enrich group properties with organization name for dashboards/filters in Amplitude + Analytics.setOrganizationGroupNames([ + { id: String(organization.id), name: organization.name }, + ]); + } + // Track all organizations of the user + if (organizations && organizations.length > 0) { + const orgIds = organizations.map((o) => String(o.organization.id)); + Analytics.setUserOrganizations(orgIds); + // Also push group names for all orgs in user's scope (helps even before switching orgs) + const groupNames = organizations.map((o) => ({ + id: String(o.organization.id), + name: o.organization.name, + })); + Analytics.setOrganizationGroupNames(groupNames); + } else { + Analytics.setUserOrganizations([]); + } + }, [isAuthenticated, user?.id, organization, organizations]); + + return ( + <AnalyticsContext.Provider value={Analytics}> + {children} + </AnalyticsContext.Provider> + ); +}; + +export const useAnalytics = () => useContext(AnalyticsContext); diff --git a/apps/frontend/src/domain/amplitude/providers/analytics.ts b/apps/frontend/src/domain/amplitude/providers/analytics.ts new file mode 100644 index 000000000..63bf502d3 --- /dev/null +++ b/apps/frontend/src/domain/amplitude/providers/analytics.ts @@ -0,0 +1,218 @@ +import * as amplitude from '@amplitude/analytics-browser'; +import type { + AnalyticsEventMap, + AnalyticsEventName, + AnalyticsOptions, + UserProperties, +} from './types'; +import { ServerZoneType } from '@amplitude/analytics-core'; +import { amplitudeGateway } from '../api/gateways'; + +class AnalyticsService { + private initialized = false; + private enabled = true; + private queue: Array<() => void> = []; + + async init(options?: AnalyticsOptions) { + if (this.initialized) return; + + const { apiKey, serverZone } = await this.getConfig(); + if (options?.enabled !== undefined) { + this.enabled = options.enabled; + } + + if (!apiKey) { + this.initialized = true; // mark initialized to flush no-ops cleanly + this.flushQueue(); + return; + } + + amplitude.init(apiKey, undefined, { + serverUrl: amplitudeGateway.getProxyUrl(), + serverZone, + defaultTracking: { + sessions: true, + pageViews: true, + formInteractions: false, + fileDownloads: false, + }, + trackingOptions: { + ipAddress: false, + }, + }); + + // Respect consent immediately + try { + if (!this.enabled) { + amplitude.setOptOut(true); + } + } catch { + // ignore + } + + this.initialized = true; + this.flushQueue(); + } + + enable() { + this.enabled = true; + if (this.initialized) { + try { + amplitude.setOptOut(false); + } catch { + // ignore errors setting opt-out + } + } + } + + disable() { + this.enabled = false; + if (this.initialized) { + try { + amplitude.setOptOut(true); + } catch { + // ignore errors setting opt-out + } + } + } + + reset() { + this.runOrQueue(() => { + try { + amplitude.reset(); + } catch { + // ignore + } + }); + } + + setUserId(userId?: string) { + this.runOrQueue(() => amplitude.setUserId(userId)); + } + + setUserProperties(props: UserProperties) { + this.runOrQueue(() => { + type AllowedValue = string | number | boolean | Array<string | number>; + const toAllowedValue = (value: unknown): AllowedValue => { + if (Array.isArray(value)) { + const coerced = (value as unknown[]).map((e) => + typeof e === 'number' || typeof e === 'string' ? e : String(e), + ); + return coerced; + } + if (value !== null && typeof value === 'object') { + return JSON.stringify(value); + } + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value; + } + return String(value); + }; + + const identify = new amplitude.Identify(); + Object.entries(props).forEach(([key, value]) => { + try { + identify.set(key, toAllowedValue(value)); + } catch { + // ignore invalid types + } + }); + amplitude.identify(identify); + }); + } + + setUserOrganizations(organizationIds: string[]) { + this.runOrQueue(() => { + if (organizationIds && organizationIds.length > 0) { + amplitude.setGroup('organization', organizationIds); + } else { + amplitude.setGroup('organization', []); + } + }); + } + + /** + * Attach organization names to the current user's org groups using group properties. + * Amplitude best practice: use setGroup to bind user to a group, then groupIdentify to enrich group properties + * with stable identifiers and human-friendly names for filtering and dashboards. + */ + setOrganizationGroupNames(orgIdToName: Array<{ id: string; name: string }>) { + this.runOrQueue(() => { + try { + // Enrich each organization group with its display name + for (const { id, name } of orgIdToName) { + if (!id) continue; + const identify = new amplitude.Identify(); + identify.set('name', name || ''); + // groupType must match the one used in setGroup above ("organization") + amplitude.groupIdentify('organization', id, identify); + } + } catch { + // ignore + } + }); + } + + track<E extends AnalyticsEventName>(event: E, payload: AnalyticsEventMap[E]) { + this.runOrQueue(() => + amplitude.track(event, payload as Record<string, unknown>), + ); + } + + private async getConfig( + options?: AnalyticsOptions, + ): Promise<{ apiKey?: string; serverZone?: ServerZoneType }> { + try { + const config = await amplitudeGateway.getConfig(); + + return { + apiKey: config.amplitudeKey, + serverZone: config.amplitudeRegion as ServerZoneType, + ...options, + }; + } catch { + return { ...options }; + } + } + + private runOrQueue(fn: () => void) { + if (!this.enabled) return; // respect consent + if (!this.initialized) { + this.queue.push(fn); + return; + } + try { + fn(); + } catch { + // avoid throwing in UI code + } + } + + private flushQueue() { + if (!this.enabled) { + this.queue = []; + return; + } + const q = this.queue; + this.queue = []; + for (const fn of q) { + try { + fn(); + } catch { + // swallow + } + } + } +} + +export const Analytics = new AnalyticsService(); +export type { + AnalyticsEventMap, + AnalyticsEventName, + AnalyticsOptions, + UserProperties, +}; diff --git a/apps/frontend/src/domain/amplitude/providers/types.ts b/apps/frontend/src/domain/amplitude/providers/types.ts new file mode 100644 index 000000000..ea841d40e --- /dev/null +++ b/apps/frontend/src/domain/amplitude/providers/types.ts @@ -0,0 +1,87 @@ +import { CodingAgent, SkillId, StartTrialCommandAgents } from '@packmind/types'; + +export enum McpUnavailableReason { + CantUseMcp = 'cant-use-mcp', + DontWantMcp = 'dont-want-mcp', + DontKnowMcp = 'dont-know-mcp', + Other = 'other', +} + +export type AnalyticsEventMap = { + page_view: { + path: string; + routeId?: string; + orgSlug?: string; + title?: string; + }; + user_signed_in: { + method: 'email' | 'github' | 'gitlab' | 'google' | 'sso'; + }; + artifact_updated: { + artifactType: 'recipe' | 'standard'; + id: string; + from: number; + to: number; + }; + mcp_configuration_card_clicked: { + agent: string; + }; + cli_login_done: Record<string, never>; + mcp_installed: { + method: 'cli' | 'magic-link' | 'json' | 'install-cli'; + agent: StartTrialCommandAgents; + }; + onboarding_prompt_copied: { + agent: StartTrialCommandAgents; + }; + onboarding_reason_selected: { + reason_key: string; + reason_label: string; + }; + mcp_unavailable_feedback: { + reason: McpUnavailableReason; + otherDetails?: string; + selectedAgent: StartTrialCommandAgents; + }; + skill_downloaded: { + agent: CodingAgent; + skillId: SkillId; + }; + default_skills_downloaded: { + agent: CodingAgent; + }; + create_standard_from_samples_clicked: Record<string, never>; + post_signup_onboarding_started: Record<string, never>; + post_signup_onboarding_skipped: Record<string, never>; + post_signup_onboarding_completed: Record<string, never>; + post_signup_onboarding_agent_clicked: { + agent: StartTrialCommandAgents; + }; + post_signup_onboarding_field_copied: { + field: + | 'installSh' + | 'installNpm' + | 'installHomebrew' + | 'cliInit' + | 'cliStartAnalysis' + | 'mcpStartAnalysis'; + }; +}; + +export type AnalyticsEventName = keyof AnalyticsEventMap; + +export type AnalyticsOptions = { + apiKey?: string; + enabled?: boolean; + appVersion?: string; + environment?: string; + serverZone?: 'US' | 'EU'; +}; + +export type UserProperties = Record<string, unknown> & { + orgId?: string; + orgSlug?: string; + orgName?: string; + plan?: string; + edition?: 'oss' | 'proprietary'; +}; diff --git a/apps/frontend/src/domain/artifacts/components/ArtifactResultFilePreview.tsx b/apps/frontend/src/domain/artifacts/components/ArtifactResultFilePreview.tsx index da7c271e4..6a3108c9f 100644 --- a/apps/frontend/src/domain/artifacts/components/ArtifactResultFilePreview.tsx +++ b/apps/frontend/src/domain/artifacts/components/ArtifactResultFilePreview.tsx @@ -9,6 +9,7 @@ import { PMText, PMVStack, } from '@packmind/ui'; +import { PreviewArtifactRenderingCommand } from '@packmind/types'; import { CopyMarkdownButton } from './CopyMarkdownButton'; import { DownloadAsAgentButton } from './DownloadAsAgentButton'; import { stripFrontmatter } from '../utils/stripFrontmatter'; @@ -19,7 +20,10 @@ interface ArtifactResultFilePreviewProps { previewContent?: ReactNode; hideActions?: boolean; hideFileName?: boolean; - getPreviewCommand?: () => unknown; + getPreviewCommand?: () => Omit< + PreviewArtifactRenderingCommand, + 'codingAgent' + >; } export function ArtifactResultFilePreview({ diff --git a/apps/frontend/src/domain/artifacts/components/DownloadAsAgentButton.spec.tsx b/apps/frontend/src/domain/artifacts/components/DownloadAsAgentButton.spec.tsx new file mode 100644 index 000000000..ac4098d4b --- /dev/null +++ b/apps/frontend/src/domain/artifacts/components/DownloadAsAgentButton.spec.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { UIProvider } from '@packmind/ui'; +import { DownloadAsAgentButton } from './DownloadAsAgentButton'; +import { useAuthContext } from '../../accounts/hooks/useAuthContext'; +import { useCurrentSpace } from '../../spaces/hooks/useCurrentSpace'; + +jest.mock('../../accounts/hooks/useAuthContext', () => ({ + useAuthContext: jest.fn(), +})); + +jest.mock('../../spaces/hooks/useCurrentSpace', () => ({ + useCurrentSpace: jest.fn(), +})); + +const renderWithProviders = (component: React.ReactElement) => + render(<UIProvider>{component}</UIProvider>); + +describe('DownloadAsAgentButton', () => { + const getPreviewCommand = jest.fn().mockReturnValue({ + recipeVersions: [], + standardVersions: [], + skillVersions: [], + }); + let clickSpy: jest.SpyInstance; + let createObjectURLMock: jest.Mock; + let revokeObjectURLMock: jest.Mock; + let lastAnchor: HTMLAnchorElement | null; + const originalCreateObjectURL = ( + URL as unknown as { createObjectURL?: (obj: Blob) => string } + ).createObjectURL; + const originalRevokeObjectURL = ( + URL as unknown as { revokeObjectURL?: (url: string) => void } + ).revokeObjectURL; + + beforeEach(() => { + (useAuthContext as jest.Mock).mockReturnValue({ + organization: { id: 'org-1' }, + }); + (useCurrentSpace as jest.Mock).mockReturnValue({ spaceId: 'space-1' }); + + lastAnchor = null; + const originalCreateElement = document.createElement.bind(document); + jest + .spyOn(document, 'createElement') + .mockImplementation((tagName: string) => { + const element = originalCreateElement(tagName); + if (tagName === 'a') { + lastAnchor = element as HTMLAnchorElement; + } + return element; + }); + + clickSpy = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => undefined); + + createObjectURLMock = jest.fn().mockReturnValue('blob:mock'); + revokeObjectURLMock = jest.fn(); + (URL as unknown as { createObjectURL: jest.Mock }).createObjectURL = + createObjectURLMock; + (URL as unknown as { revokeObjectURL: jest.Mock }).revokeObjectURL = + revokeObjectURLMock; + }); + + afterEach(() => { + jest.restoreAllMocks(); + (URL as unknown as { createObjectURL?: unknown }).createObjectURL = + originalCreateObjectURL; + (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL = + originalRevokeObjectURL; + }); + + const mockFetchResponse = (contentDisposition: string | null) => { + const headerStore = new Map<string, string>(); + if (contentDisposition) { + headerStore.set('content-disposition', contentDisposition); + } + const response = { + ok: true, + statusText: 'OK', + headers: { + get: (name: string) => headerStore.get(name.toLowerCase()) ?? null, + }, + blob: async () => new Blob(['zip-bytes']), + } as unknown as Response; + (globalThis as unknown as { fetch: jest.Mock }).fetch = jest + .fn() + .mockResolvedValue(response); + }; + + const triggerClaudeDownload = async () => { + await userEvent.click( + screen.getByRole('button', { name: /download for agent/i }), + ); + await userEvent.click(screen.getByRole('button', { name: /claude/i })); + }; + + it('uses the filename from Content-Disposition when present', async () => { + mockFetchResponse('attachment; filename="packmind-claude-my-slug.zip"'); + + renderWithProviders( + <DownloadAsAgentButton getPreviewCommand={getPreviewCommand} />, + ); + + await triggerClaudeDownload(); + + await waitForAnchor(() => lastAnchor); + expect(lastAnchor?.download).toBe('packmind-claude-my-slug.zip'); + expect(clickSpy).toHaveBeenCalled(); + expect(createObjectURLMock).toHaveBeenCalled(); + expect(revokeObjectURLMock).toHaveBeenCalled(); + }); + + it('falls back to the legacy name when Content-Disposition is missing', async () => { + mockFetchResponse(null); + + renderWithProviders( + <DownloadAsAgentButton getPreviewCommand={getPreviewCommand} />, + ); + + await triggerClaudeDownload(); + + await waitForAnchor(() => lastAnchor); + expect(lastAnchor?.download).toBe('packmind-claude-preview.zip'); + }); + + it('falls back to the legacy name when Content-Disposition is malformed', async () => { + mockFetchResponse('attachment; something-else'); + + renderWithProviders( + <DownloadAsAgentButton getPreviewCommand={getPreviewCommand} />, + ); + + await triggerClaudeDownload(); + + await waitForAnchor(() => lastAnchor); + expect(lastAnchor?.download).toBe('packmind-claude-preview.zip'); + }); +}); + +async function waitForAnchor(get: () => HTMLAnchorElement | null) { + await waitFor(() => { + expect(get()).not.toBeNull(); + }); +} diff --git a/apps/frontend/src/domain/artifacts/components/DownloadAsAgentButton.tsx b/apps/frontend/src/domain/artifacts/components/DownloadAsAgentButton.tsx index 2e587d01c..46459bacb 100644 --- a/apps/frontend/src/domain/artifacts/components/DownloadAsAgentButton.tsx +++ b/apps/frontend/src/domain/artifacts/components/DownloadAsAgentButton.tsx @@ -1,10 +1,141 @@ +import { useState } from 'react'; +import { + PMButton, + PMHStack, + PMIcon, + PMIconButton, + PMPopover, + PMText, + PMVStack, +} from '@packmind/ui'; +import { CodingAgent, PreviewArtifactRenderingCommand } from '@packmind/types'; +import { RiClaudeLine } from 'react-icons/ri'; +import { VscVscode } from 'react-icons/vsc'; +import { CursorIcon } from '@packmind/assets/icons/CursorIcon'; +import { LuDownload } from 'react-icons/lu'; +import { useAuthContext } from '../../accounts/hooks/useAuthContext'; +import { useCurrentSpace } from '../../spaces/hooks/useCurrentSpace'; + +type AgentOption = { + value: CodingAgent; + label: string; + icon: React.ComponentType; +}; + +const AGENT_OPTIONS: AgentOption[] = [ + { value: 'claude', label: 'Claude', icon: RiClaudeLine }, + { value: 'copilot', label: 'Copilot', icon: VscVscode }, + { value: 'cursor', label: 'Cursor', icon: CursorIcon }, +]; + +function parseFilenameFromContentDisposition( + header: string | null, +): string | null { + if (!header) return null; + const match = header.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i); + if (!match) return null; + const value = match[1].trim(); + return value.length > 0 ? value : null; +} + interface DownloadAsAgentButtonProps { - getPreviewCommand: () => unknown; + getPreviewCommand: () => Omit<PreviewArtifactRenderingCommand, 'codingAgent'>; size?: 'xs' | 'sm'; } -export function DownloadAsAgentButton( - _props: Readonly<DownloadAsAgentButtonProps>, -): React.ReactElement { - return <></>; +export function DownloadAsAgentButton({ + getPreviewCommand, + size = 'sm', +}: Readonly<DownloadAsAgentButtonProps>) { + const [downloadingAgent, setDownloadingAgent] = useState<CodingAgent | null>( + null, + ); + const { organization } = useAuthContext(); + const { spaceId } = useCurrentSpace(); + + const handleDownload = async (agent: CodingAgent) => { + if (!organization?.id || !spaceId) return; + + setDownloadingAgent(agent); + try { + const command = getPreviewCommand(); + const response = await fetch( + `/api/v0/organizations/${organization.id}/spaces/${spaceId}/change-proposals/preview-rendering`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ ...command, codingAgent: agent }), + }, + ); + + if (!response.ok) { + throw new Error(`Download failed: ${response.statusText}`); + } + + const blob = await response.blob(); + const fileName = + parseFilenameFromContentDisposition( + response.headers.get('Content-Disposition'), + ) ?? `packmind-${agent}-preview.zip`; + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); + } catch (error) { + console.error(`Failed to download ${agent} preview zip:`, error); + } finally { + setDownloadingAgent(null); + } + }; + + return ( + <PMPopover.Root positioning={{ placement: 'bottom-end' }}> + <PMPopover.Trigger asChild> + <PMIconButton + aria-label="Download for agent" + title="Download for agent" + variant="tertiary" + size={size} + > + <LuDownload /> + </PMIconButton> + </PMPopover.Trigger> + <PMPopover.Positioner> + <PMPopover.Content width="380px"> + <PMPopover.Arrow> + <PMPopover.ArrowTip /> + </PMPopover.Arrow> + <PMPopover.Body> + <PMPopover.Title>Download as agent files</PMPopover.Title> + <PMVStack gap={2} align="stretch" mt={3}> + <PMText color="secondary" as="p" variant="small"> + Download the rendered artifact files for your AI coding + assistant. Extract the zip at the root of your repository to + test locally. + </PMText> + <PMHStack gap={2} mt={2} flexWrap="wrap"> + {AGENT_OPTIONS.map((agent) => ( + <PMButton + key={agent.value} + variant="outline" + size="sm" + onClick={() => handleDownload(agent.value)} + loading={downloadingAgent === agent.value} + disabled={downloadingAgent !== null} + > + <PMIcon as={agent.icon} /> + {agent.label} + </PMButton> + ))} + </PMHStack> + </PMVStack> + </PMPopover.Body> + </PMPopover.Content> + </PMPopover.Positioner> + </PMPopover.Root> + ); } diff --git a/apps/frontend/src/domain/artifacts/utils/stripFrontmatter.spec.ts b/apps/frontend/src/domain/artifacts/utils/stripFrontmatter.spec.ts new file mode 100644 index 000000000..a9b2f0dd3 --- /dev/null +++ b/apps/frontend/src/domain/artifacts/utils/stripFrontmatter.spec.ts @@ -0,0 +1,65 @@ +import { stripFrontmatter } from './stripFrontmatter'; + +describe('stripFrontmatter', () => { + describe('when content has standard frontmatter', () => { + it('returns body only', () => { + const content = `---\ndescription: 'My command'\nagent: 'agent'\n---\n\nThis is the body`; + + expect(stripFrontmatter(content)).toBe('This is the body'); + }); + }); + + describe('when content has no frontmatter', () => { + it('returns content as-is', () => { + const content = 'Just some plain content'; + + expect(stripFrontmatter(content)).toBe('Just some plain content'); + }); + }); + + describe('when content has frontmatter but no body', () => { + it('returns empty string', () => { + const content = `---\ndescription: 'My command'\n---\n`; + + expect(stripFrontmatter(content)).toBe(''); + }); + }); + + describe('when content has --- inside body but not at start', () => { + it('returns content as-is', () => { + const content = 'Some text\n---\nMore text'; + + expect(stripFrontmatter(content)).toBe('Some text\n---\nMore text'); + }); + }); + + describe('when frontmatter has no closing delimiter', () => { + it('returns content as-is', () => { + const content = `---\ndescription: 'unclosed'\nno closing`; + + expect(stripFrontmatter(content)).toBe(content); + }); + }); + + describe('when content is empty', () => { + it('returns empty string', () => { + expect(stripFrontmatter('')).toBe(''); + }); + }); + + describe('when content starts with --- but no newline after', () => { + it('returns content as-is', () => { + const content = '---something'; + + expect(stripFrontmatter(content)).toBe('---something'); + }); + }); + + describe('when content has CRLF line endings', () => { + it('strips frontmatter correctly', () => { + const content = `---\r\ndescription: 'My command'\r\nagent: 'agent'\r\n---\r\n\r\nThis is the body`; + + expect(stripFrontmatter(content)).toBe('This is the body'); + }); + }); +}); diff --git a/apps/frontend/src/domain/change-proposals/api/gateways/ChangeProposalsGatewayApi.ts b/apps/frontend/src/domain/change-proposals/api/gateways/ChangeProposalsGatewayApi.ts new file mode 100644 index 000000000..88740320d --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/api/gateways/ChangeProposalsGatewayApi.ts @@ -0,0 +1,115 @@ +import { + ChangeProposalType, + IApplyChangeProposalsUseCase, + IApplyCreationChangeProposalsUseCase, + IListChangeProposalsBySpace, + IListChangeProposalsByArtefact, + IRecomputeConflictsUseCase, + NewGateway, + RecipeId, + StandardId, + SkillId, +} from '@packmind/types'; +import { PackmindGateway } from '../../../../shared/PackmindGateway'; +import { + CreateChangeProposalParams, + IChangeProposalsGateway, +} from './IChangeProposalsGateway'; + +export class ChangeProposalsGatewayApi + extends PackmindGateway + implements IChangeProposalsGateway +{ + constructor() { + super('/changeProposals'); + } + + async createChangeProposal<T extends ChangeProposalType>( + params: CreateChangeProposalParams<T>, + ): Promise<void> { + const { organizationId, spaceId, ...body } = params; + await this._api.post( + `/organizations/${organizationId}/spaces/${spaceId}/change-proposals`, + body, + ); + } + + getGroupedChangeProposals: NewGateway<IListChangeProposalsBySpace> = async ({ + organizationId, + spaceId, + }) => { + return this._api.get( + `/organizations/${organizationId}/spaces/${spaceId}/change-proposals/grouped`, + ); + }; + + listChangeProposalsByRecipe: NewGateway< + IListChangeProposalsByArtefact<RecipeId> + > = async ({ organizationId, spaceId, artefactId }) => { + return this._api.get( + `/organizations/${organizationId}/spaces/${spaceId}/recipes/${artefactId}/change-proposals`, + ); + }; + + listChangeProposalsByStandard: NewGateway< + IListChangeProposalsByArtefact<StandardId> + > = async ({ organizationId, spaceId, artefactId }) => { + return this._api.get( + `/organizations/${organizationId}/spaces/${spaceId}/standards/${artefactId}/change-proposals`, + ); + }; + + listChangeProposalsBySkill: NewGateway< + IListChangeProposalsByArtefact<SkillId> + > = async ({ organizationId, spaceId, artefactId }) => { + return this._api.get( + `/organizations/${organizationId}/spaces/${spaceId}/skills/${artefactId}/change-proposals`, + ); + }; + + applyRecipeChangeProposals: NewGateway< + IApplyChangeProposalsUseCase<RecipeId> + > = async ({ organizationId, spaceId, artefactId, accepted, rejected }) => { + return this._api.post( + `/organizations/${organizationId}/spaces/${spaceId}/recipes/${artefactId}/change-proposals/apply`, + { accepted, rejected }, + ); + }; + + applyStandardChangeProposals: NewGateway< + IApplyChangeProposalsUseCase<StandardId> + > = async ({ organizationId, spaceId, artefactId, accepted, rejected }) => { + return this._api.post( + `/organizations/${organizationId}/spaces/${spaceId}/standards/${artefactId}/change-proposals/apply`, + { accepted, rejected }, + ); + }; + + applySkillChangeProposals: NewGateway<IApplyChangeProposalsUseCase<SkillId>> = + async ({ organizationId, spaceId, artefactId, accepted, rejected }) => { + return this._api.post( + `/organizations/${organizationId}/spaces/${spaceId}/skills/${artefactId}/change-proposals/apply`, + { accepted, rejected }, + ); + }; + + applyCreationChangeProposals: NewGateway<IApplyCreationChangeProposalsUseCase> = + async ({ organizationId, spaceId, accepted, rejected }) => { + return this._api.post( + `/organizations/${organizationId}/spaces/${spaceId}/change-proposals/apply`, + { accepted, rejected }, + ); + }; + + recomputeConflicts: NewGateway<IRecomputeConflictsUseCase> = async ({ + organizationId, + spaceId, + artefactId, + decisions, + }) => { + return this._api.post( + `/organizations/${organizationId}/spaces/${spaceId}/change-proposals/recompute-conflicts`, + { artefactId, decisions }, + ); + }; +} diff --git a/apps/frontend/src/domain/change-proposals/api/gateways/IChangeProposalsGateway.ts b/apps/frontend/src/domain/change-proposals/api/gateways/IChangeProposalsGateway.ts new file mode 100644 index 000000000..cc8f890b2 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/api/gateways/IChangeProposalsGateway.ts @@ -0,0 +1,56 @@ +import { + ChangeProposalCaptureMode, + ChangeProposalPayload, + ChangeProposalType, + ChangeProposalArtefactId, + OrganizationId, + SpaceId, + IListChangeProposalsBySpace, + IListChangeProposalsByArtefact, + IApplyChangeProposalsUseCase, + IApplyCreationChangeProposalsUseCase, + IRecomputeConflictsUseCase, + NewGateway, + RecipeId, + StandardId, + SkillId, +} from '@packmind/types'; + +export interface CreateChangeProposalParams<T extends ChangeProposalType> { + organizationId: OrganizationId; + spaceId: SpaceId; + type: T; + artefactId: ChangeProposalArtefactId<T>; + payload: ChangeProposalPayload<T>; + captureMode: ChangeProposalCaptureMode; +} + +export interface IChangeProposalsGateway { + getGroupedChangeProposals: NewGateway<IListChangeProposalsBySpace>; + + listChangeProposalsByRecipe: NewGateway< + IListChangeProposalsByArtefact<RecipeId> + >; + listChangeProposalsByStandard: NewGateway< + IListChangeProposalsByArtefact<StandardId> + >; + listChangeProposalsBySkill: NewGateway< + IListChangeProposalsByArtefact<SkillId> + >; + + applyRecipeChangeProposals: NewGateway< + IApplyChangeProposalsUseCase<RecipeId> + >; + applyStandardChangeProposals: NewGateway< + IApplyChangeProposalsUseCase<StandardId> + >; + applySkillChangeProposals: NewGateway<IApplyChangeProposalsUseCase<SkillId>>; + + applyCreationChangeProposals: NewGateway<IApplyCreationChangeProposalsUseCase>; + + recomputeConflicts: NewGateway<IRecomputeConflictsUseCase>; + + createChangeProposal<T extends ChangeProposalType>( + params: CreateChangeProposalParams<T>, + ): Promise<void>; +} diff --git a/apps/frontend/src/domain/change-proposals/api/gateways/index.ts b/apps/frontend/src/domain/change-proposals/api/gateways/index.ts new file mode 100644 index 000000000..5411e036f --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/api/gateways/index.ts @@ -0,0 +1,5 @@ +import { IChangeProposalsGateway } from './IChangeProposalsGateway'; +import { ChangeProposalsGatewayApi } from './ChangeProposalsGatewayApi'; + +export const changeProposalsGateway: IChangeProposalsGateway = + new ChangeProposalsGatewayApi(); diff --git a/apps/frontend/src/domain/change-proposals/api/queries/ChangeProposalsQueries.ts b/apps/frontend/src/domain/change-proposals/api/queries/ChangeProposalsQueries.ts new file mode 100644 index 000000000..70f6792bc --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/api/queries/ChangeProposalsQueries.ts @@ -0,0 +1,554 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { isPackmindError } from '../../../../services/api/errors/PackmindError'; +import { + ApplyChangeProposalsCommand, + ApplyCreationChangeProposalsCommand, + ApplyCreationChangeProposalsResponse, + ChangeProposalType, + OrganizationId, + RecipeId, + SkillId, + SpaceId, + StandardId, +} from '@packmind/types'; +import { pmToaster } from '@packmind/ui'; +import { changeProposalsGateway } from '../gateways'; +import { CreateChangeProposalParams } from '../gateways/IChangeProposalsGateway'; +import { + APPLY_CREATION_CHANGE_PROPOSALS_MUTATION_KEY, + APPLY_RECIPE_CHANGE_PROPOSALS_MUTATION_KEY, + APPLY_SKILL_CHANGE_PROPOSALS_MUTATION_KEY, + APPLY_STANDARD_CHANGE_PROPOSALS_MUTATION_KEY, + CREATE_CHANGE_PROPOSAL_MUTATION_KEY, + GET_CHANGE_PROPOSALS_BY_RECIPE_KEY, + GET_CHANGE_PROPOSALS_BY_SKILL_KEY, + GET_CHANGE_PROPOSALS_BY_STANDARD_KEY, + GET_GROUPED_CHANGE_PROPOSALS_KEY, +} from '../queryKeys'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; +import { useCurrentSpace } from '../../../spaces/hooks/useCurrentSpace'; +import { + getRecipesBySpaceKey, + getRecipeByIdKey, +} from '../../../recipes/api/queryKeys'; +import { getSkillsBySpaceKey } from '../../../skills/api/queryKeys'; +import { SPACES_SCOPE } from '../../../spaces/api/queryKeys'; +import { ORGANIZATION_QUERY_SCOPE } from '../../../organizations/api/queryKeys'; +import { + GET_RULES_BY_STANDARD_ID_KEY, + getStandardByIdKey, + getStandardsBySpaceKey, +} from '../../../standards/api/queryKeys'; +import { + GET_PACKAGE_BY_ID_KEY, + LIST_PACKAGES_BY_SPACE_KEY, +} from '../../../deployments/api/queryKeys'; +import { routes } from '../../../../shared/utils/routes'; + +export const getGroupedChangeProposalsOptions = ( + organizationId: OrganizationId | undefined, + spaceId: SpaceId | undefined, +) => ({ + queryKey: [...GET_GROUPED_CHANGE_PROPOSALS_KEY, spaceId], + queryFn: () => { + if (!organizationId) { + throw new Error( + 'Organization ID is required to fetch grouped change proposals', + ); + } + if (!spaceId) { + throw new Error('Space ID is required to fetch grouped change proposals'); + } + return changeProposalsGateway.getGroupedChangeProposals({ + organizationId, + spaceId, + }); + }, + enabled: !!organizationId && !!spaceId, +}); + +export const useGetGroupedChangeProposalsQuery = ( + overrideSpaceId?: SpaceId, +) => { + const { organization } = useAuthContext(); + const { spaceId: currentSpaceId } = useCurrentSpace(); + const spaceId = overrideSpaceId ?? currentSpaceId; + + return useQuery(getGroupedChangeProposalsOptions(organization?.id, spaceId)); +}; + +export const listChangeProposalsByRecipeOptions = ( + organizationId: OrganizationId | undefined, + spaceId: SpaceId | undefined, + recipeId: RecipeId | undefined, +) => ({ + queryKey: [...GET_CHANGE_PROPOSALS_BY_RECIPE_KEY, recipeId], + queryFn: () => { + if (!organizationId) { + throw new Error('Organization ID is required to fetch change proposals'); + } + if (!spaceId) { + throw new Error('Space ID is required to fetch change proposals'); + } + if (!recipeId) { + throw new Error('Recipe ID is required to fetch change proposals'); + } + return changeProposalsGateway.listChangeProposalsByRecipe({ + organizationId, + spaceId, + artefactId: recipeId, + }); + }, + enabled: !!organizationId && !!spaceId && !!recipeId, +}); + +export const useListChangeProposalsByRecipeQuery = ( + recipeId: RecipeId | undefined, +) => { + const { organization } = useAuthContext(); + const { spaceId } = useCurrentSpace(); + + return useQuery( + listChangeProposalsByRecipeOptions(organization?.id, spaceId, recipeId), + ); +}; + +export const listChangeProposalsBySkillOptions = ( + organizationId: OrganizationId | undefined, + spaceId: SpaceId | undefined, + skillId: SkillId | undefined, +) => ({ + queryKey: [...GET_CHANGE_PROPOSALS_BY_SKILL_KEY, skillId], + queryFn: () => { + if (!organizationId) { + throw new Error('Organization ID is required to fetch change proposals'); + } + if (!spaceId) { + throw new Error('Space ID is required to fetch change proposals'); + } + if (!skillId) { + throw new Error('Skill ID is required to fetch change proposals'); + } + return changeProposalsGateway.listChangeProposalsBySkill({ + organizationId, + spaceId, + artefactId: skillId, + }); + }, + enabled: !!organizationId && !!spaceId && !!skillId, +}); + +export const useListChangeProposalsBySkillQuery = ( + skillId: SkillId | undefined, +) => { + const { organization } = useAuthContext(); + const { spaceId } = useCurrentSpace(); + + return useQuery( + listChangeProposalsBySkillOptions(organization?.id, spaceId, skillId), + ); +}; + +export const listChangeProposalsByStandardOptions = ( + organizationId: OrganizationId | undefined, + spaceId: SpaceId | undefined, + standardId: StandardId | undefined, +) => ({ + queryKey: [...GET_CHANGE_PROPOSALS_BY_STANDARD_KEY, standardId], + queryFn: () => { + if (!organizationId) { + throw new Error('Organization ID is required to fetch change proposals'); + } + if (!spaceId) { + throw new Error('Space ID is required to fetch change proposals'); + } + if (!standardId) { + throw new Error('Standard ID is required to fetch change proposals'); + } + return changeProposalsGateway.listChangeProposalsByStandard({ + organizationId, + spaceId, + artefactId: standardId, + }); + }, + enabled: !!organizationId && !!spaceId && !!standardId, +}); + +export const useListChangeProposalsByStandardQuery = ( + standardId: StandardId | undefined, +) => { + const { organization } = useAuthContext(); + const { spaceId } = useCurrentSpace(); + + return useQuery( + listChangeProposalsByStandardOptions(organization?.id, spaceId, standardId), + ); +}; + +export const useCreateChangeProposalMutation = () => { + return useMutation({ + mutationKey: [...CREATE_CHANGE_PROPOSAL_MUTATION_KEY], + mutationFn: async ( + params: CreateChangeProposalParams<ChangeProposalType>, + ) => { + return changeProposalsGateway.createChangeProposal(params); + }, + onError: (error, variables, context) => { + console.error('Error creating change proposal'); + console.log('error: ', error); + console.log('variables: ', variables); + console.log('context: ', context); + }, + }); +}; + +export const useApplyRecipeChangeProposalsMutation = (params?: { + orgSlug?: string; + spaceSlug?: string; +}) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: [...APPLY_RECIPE_CHANGE_PROPOSALS_MUTATION_KEY], + mutationFn: async ( + command: Omit<ApplyChangeProposalsCommand<RecipeId>, 'userId'>, + ) => { + return changeProposalsGateway.applyRecipeChangeProposals(command); + }, + onSuccess: async (response, variables) => { + const invalidations: Promise<void>[] = [ + queryClient.invalidateQueries({ + queryKey: GET_GROUPED_CHANGE_PROPOSALS_KEY, + }), + queryClient.invalidateQueries({ + queryKey: GET_CHANGE_PROPOSALS_BY_RECIPE_KEY, + }), + queryClient.invalidateQueries({ + queryKey: getRecipesBySpaceKey(variables.spaceId), + }), + queryClient.invalidateQueries({ + queryKey: getRecipeByIdKey(variables.spaceId, variables.artefactId), + }), + ]; + + if (response.updatedPackages && response.updatedPackages.length > 0) { + invalidations.push( + queryClient.invalidateQueries({ + queryKey: LIST_PACKAGES_BY_SPACE_KEY, + }), + queryClient.invalidateQueries({ + queryKey: GET_PACKAGE_BY_ID_KEY, + }), + ); + } + + await Promise.all(invalidations); + + // Show success toast with link to the new recipe version + if (params?.orgSlug && params?.spaceSlug) { + const recipeUrl = routes.space.toCommand( + params.orgSlug, + params.spaceSlug, + variables.artefactId, + ); + pmToaster.create({ + title: 'Changes applied successfully', + description: `View the updated command`, + type: 'success', + action: { + label: 'View command', + onClick: () => { + window.location.href = recipeUrl; + }, + }, + }); + } else { + pmToaster.create({ + title: 'Changes applied successfully', + type: 'success', + }); + } + }, + onError: (error, variables, context) => { + console.error('Error applying recipe change proposals'); + console.log('error: ', error); + console.log('variables: ', variables); + console.log('context: ', context); + + pmToaster.create({ + title: 'Failed to apply changes', + description: isPackmindError(error) + ? error.message + : 'The changes could not be applied. Please try again or contact support if the problem persists.', + type: 'error', + }); + }, + }); +}; + +export const useApplyStandardChangeProposalsMutation = (params?: { + orgSlug?: string; + spaceSlug?: string; +}) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: [...APPLY_STANDARD_CHANGE_PROPOSALS_MUTATION_KEY], + mutationFn: async ( + command: Omit<ApplyChangeProposalsCommand<StandardId>, 'userId'>, + ) => { + return changeProposalsGateway.applyStandardChangeProposals(command); + }, + onSuccess: async (response, variables) => { + const invalidations: Promise<void>[] = [ + queryClient.invalidateQueries({ + queryKey: GET_GROUPED_CHANGE_PROPOSALS_KEY, + }), + queryClient.invalidateQueries({ + queryKey: [ + ...GET_CHANGE_PROPOSALS_BY_STANDARD_KEY, + variables.artefactId, + ], + }), + queryClient.invalidateQueries({ + queryKey: getStandardByIdKey(variables.spaceId, variables.artefactId), + }), + queryClient.invalidateQueries({ + queryKey: GET_RULES_BY_STANDARD_ID_KEY, + }), + queryClient.invalidateQueries({ + queryKey: [ORGANIZATION_QUERY_SCOPE, SPACES_SCOPE, variables.spaceId], + }), + ]; + + if (response.updatedPackages && response.updatedPackages.length > 0) { + invalidations.push( + queryClient.invalidateQueries({ + queryKey: LIST_PACKAGES_BY_SPACE_KEY, + }), + queryClient.invalidateQueries({ + queryKey: GET_PACKAGE_BY_ID_KEY, + }), + ); + } + + await Promise.all(invalidations); + + // Show success toast with link to the standard + if (params?.orgSlug && params?.spaceSlug) { + const standardUrl = routes.space.toStandard( + params.orgSlug, + params.spaceSlug, + variables.artefactId, + ); + pmToaster.create({ + title: 'Changes applied successfully', + description: `View the updated standard`, + type: 'success', + action: { + label: 'View standard', + onClick: () => { + window.location.href = standardUrl; + }, + }, + }); + } else { + pmToaster.create({ + title: 'Changes applied successfully', + type: 'success', + }); + } + }, + onError: (error, variables, context) => { + console.error('Error applying standard change proposals'); + console.log('error: ', error); + console.log('variables: ', variables); + console.log('context: ', context); + + pmToaster.create({ + title: 'Failed to apply changes', + description: isPackmindError(error) + ? error.message + : 'The changes could not be applied. Please try again or contact support if the problem persists.', + type: 'error', + }); + }, + }); +}; + +export const useApplySkillChangeProposalsMutation = (params?: { + orgSlug?: string; + spaceSlug?: string; + skillSlug?: string; +}) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: [...APPLY_SKILL_CHANGE_PROPOSALS_MUTATION_KEY], + mutationFn: async ( + command: Omit<ApplyChangeProposalsCommand<SkillId>, 'userId'>, + ) => { + return changeProposalsGateway.applySkillChangeProposals(command); + }, + onSuccess: async (response, variables) => { + const invalidations: Promise<void>[] = [ + queryClient.invalidateQueries({ + queryKey: GET_GROUPED_CHANGE_PROPOSALS_KEY, + }), + queryClient.invalidateQueries({ + queryKey: GET_CHANGE_PROPOSALS_BY_SKILL_KEY, + }), + queryClient.invalidateQueries({ + queryKey: getSkillsBySpaceKey(variables.spaceId), + }), + ]; + + if (response.updatedPackages && response.updatedPackages.length > 0) { + invalidations.push( + queryClient.invalidateQueries({ + queryKey: LIST_PACKAGES_BY_SPACE_KEY, + }), + queryClient.invalidateQueries({ + queryKey: GET_PACKAGE_BY_ID_KEY, + }), + ); + } + + await Promise.all(invalidations); + + // Show success toast with link to the skill + if (params?.orgSlug && params?.spaceSlug && params?.skillSlug) { + const skillUrl = routes.space.toSkill( + params.orgSlug, + params.spaceSlug, + params.skillSlug, + ); + pmToaster.create({ + title: 'Changes applied successfully', + description: `View the updated skill`, + type: 'success', + action: { + label: 'View skill', + onClick: () => { + window.location.href = skillUrl; + }, + }, + }); + } else { + pmToaster.create({ + title: 'Changes applied successfully', + type: 'success', + }); + } + }, + onError: (error, variables, context) => { + console.error('Error applying skill change proposals'); + console.log('error: ', error); + console.log('variables: ', variables); + console.log('context: ', context); + + pmToaster.create({ + title: 'Failed to apply changes', + description: isPackmindError(error) + ? error.message + : 'The changes could not be applied. Please try again or contact support if the problem persists.', + type: 'error', + }); + }, + }); +}; + +export const useApplyCreationChangeProposalsMutation = (params?: { + orgSlug?: string; + spaceSlug?: string; +}) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: [...APPLY_CREATION_CHANGE_PROPOSALS_MUTATION_KEY], + mutationFn: async ( + command: Omit<ApplyCreationChangeProposalsCommand, 'userId'>, + ): Promise<ApplyCreationChangeProposalsResponse> => { + return changeProposalsGateway.applyCreationChangeProposals(command); + }, + onSuccess: async (response, variables) => { + const invalidations: Promise<void>[] = [ + queryClient.invalidateQueries({ + queryKey: GET_GROUPED_CHANGE_PROPOSALS_KEY, + }), + ]; + + if (response.created.standards.length > 0) { + invalidations.push( + queryClient.invalidateQueries({ + queryKey: getStandardsBySpaceKey(variables.spaceId), + }), + ); + } + + if (response.created.skills.length > 0) { + invalidations.push( + queryClient.invalidateQueries({ + queryKey: getSkillsBySpaceKey(variables.spaceId), + }), + ); + } + + if (response.created.commands.length > 0) { + invalidations.push( + queryClient.invalidateQueries({ + queryKey: getRecipesBySpaceKey(variables.spaceId), + }), + ); + } + + await Promise.all(invalidations); + + const accepted = variables.accepted.length > 0; + // orgSlug/spaceSlug guard removed: the toast is informational only and does not + // depend on navigation slugs; post-accept redirect is handled by the caller components. + if (accepted) { + const createdType = + response.created.standards.length > 0 + ? 'standard' + : response.created.skills.length > 0 + ? 'skill' + : 'command'; + const toastMessages = { + standard: { + title: 'Standard created', + description: 'The new standard has been added to your space.', + }, + skill: { + title: 'Skill created', + description: 'The new skill has been added to your space.', + }, + command: { + title: 'Command created', + description: 'The new command has been added to your space.', + }, + } as const; + pmToaster.create({ ...toastMessages[createdType], type: 'success' }); + } else { + pmToaster.create({ + title: 'Proposal rejected', + type: 'success', + }); + } + }, + onError: (error, variables, context) => { + console.error('Error applying creation change proposals'); + console.log('error:', error); + console.log('variables:', variables); + console.log('context:', context); + + pmToaster.create({ + title: 'Failed to apply changes', + description: isPackmindError(error) + ? error.message + : 'The changes could not be applied. Please try again or contact support if the problem persists.', + type: 'error', + }); + }, + }); +}; diff --git a/apps/frontend/src/domain/change-proposals/api/queryKeys.ts b/apps/frontend/src/domain/change-proposals/api/queryKeys.ts new file mode 100644 index 000000000..b71a05b8b --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/api/queryKeys.ts @@ -0,0 +1,63 @@ +import { ORGANIZATION_QUERY_SCOPE } from '../../organizations/api/queryKeys'; + +export const CHANGE_PROPOSALS_QUERY_SCOPE = 'changeProposals'; + +export const GET_GROUPED_CHANGE_PROPOSALS_KEY = [ + ORGANIZATION_QUERY_SCOPE, + CHANGE_PROPOSALS_QUERY_SCOPE, + 'grouped', +] as const; + +export const GET_CHANGE_PROPOSALS_BY_RECIPE_KEY = [ + ORGANIZATION_QUERY_SCOPE, + CHANGE_PROPOSALS_QUERY_SCOPE, + 'by-recipe', +] as const; + +export const GET_CHANGE_PROPOSALS_BY_SKILL_KEY = [ + ORGANIZATION_QUERY_SCOPE, + CHANGE_PROPOSALS_QUERY_SCOPE, + 'by-skill', +] as const; + +export const GET_CHANGE_PROPOSALS_BY_STANDARD_KEY = [ + ORGANIZATION_QUERY_SCOPE, + CHANGE_PROPOSALS_QUERY_SCOPE, + 'by-standard', +] as const; + +export const CREATE_CHANGE_PROPOSAL_MUTATION_KEY = [ + ORGANIZATION_QUERY_SCOPE, + CHANGE_PROPOSALS_QUERY_SCOPE, + 'create', +] as const; + +export const APPLY_RECIPE_CHANGE_PROPOSALS_MUTATION_KEY = [ + ORGANIZATION_QUERY_SCOPE, + CHANGE_PROPOSALS_QUERY_SCOPE, + 'apply-recipe', +] as const; + +export const APPLY_STANDARD_CHANGE_PROPOSALS_MUTATION_KEY = [ + ORGANIZATION_QUERY_SCOPE, + CHANGE_PROPOSALS_QUERY_SCOPE, + 'apply-standard', +] as const; + +export const APPLY_SKILL_CHANGE_PROPOSALS_MUTATION_KEY = [ + ORGANIZATION_QUERY_SCOPE, + CHANGE_PROPOSALS_QUERY_SCOPE, + 'apply-skill', +] as const; + +export const APPLY_CREATION_CHANGE_PROPOSALS_MUTATION_KEY = [ + ORGANIZATION_QUERY_SCOPE, + CHANGE_PROPOSALS_QUERY_SCOPE, + 'apply-creation', +] as const; + +export const RECOMPUTE_CONFLICTS_KEY = [ + ORGANIZATION_QUERY_SCOPE, + CHANGE_PROPOSALS_QUERY_SCOPE, + 'recompute-conflicts', +] as const; diff --git a/apps/frontend/src/domain/change-proposals/components/ChangeProposalUpdateSubscription.tsx b/apps/frontend/src/domain/change-proposals/components/ChangeProposalUpdateSubscription.tsx new file mode 100644 index 000000000..457955652 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ChangeProposalUpdateSubscription.tsx @@ -0,0 +1,42 @@ +import { useCallback, useMemo } from 'react'; +import { queryClient } from '../../../shared/data/queryClient'; +import { useSSESubscription } from '../../sse'; +import { ORGANIZATION_QUERY_SCOPE } from '../../organizations/api/queryKeys'; +import { CHANGE_PROPOSALS_QUERY_SCOPE } from '../api/queryKeys'; +import { GET_CHANGE_PROPOSALS_KEY } from '../../recipes/api/queryKeys'; +import { useCurrentSpace } from '../../spaces/hooks/useCurrentSpace'; + +export function ChangeProposalUpdateSubscription(): null { + const { spaceId } = useCurrentSpace(); + + const handleChangeProposalUpdate = useCallback((event: MessageEvent) => { + if (event.type && event.type !== 'CHANGE_PROPOSAL_UPDATE') { + return; + } + + Promise.all([ + queryClient.invalidateQueries({ + queryKey: [ORGANIZATION_QUERY_SCOPE, CHANGE_PROPOSALS_QUERY_SCOPE], + }), + queryClient.invalidateQueries({ + queryKey: GET_CHANGE_PROPOSALS_KEY, + }), + ]).catch((error) => { + console.error('SSE: Failed to process change proposal update event', { + error, + raw: event.data, + }); + }); + }, []); + + const params = useMemo(() => (spaceId ? [spaceId] : []), [spaceId]); + + useSSESubscription({ + eventType: 'CHANGE_PROPOSAL_UPDATE', + params, + onEvent: handleChangeProposalUpdate, + enabled: !!spaceId, + }); + + return null; +} diff --git a/apps/frontend/src/domain/change-proposals/components/ChangeProposals/ChangeProposalsChangesList.tsx b/apps/frontend/src/domain/change-proposals/components/ChangeProposals/ChangeProposalsChangesList.tsx new file mode 100644 index 000000000..b8bedc612 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ChangeProposals/ChangeProposalsChangesList.tsx @@ -0,0 +1,176 @@ +import { useMemo, useState } from 'react'; +import { PMBadge, PMBox, PMHStack, PMText, PMVStack } from '@packmind/ui'; +import { + ChangeProposalId, + ChangeProposalStatus, + UserId, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../../types'; +import { buildProposalNumberMap } from '../../utils/changeProposalHelpers'; +import { PendingProposalCard } from './PendingProposalCard'; +import { PoolProposalCard } from './PoolProposalCard'; +import { CollapsiblePoolSection } from './CollapsiblePoolSection'; + +interface ChangeProposalsChangesListProps { + proposals: ChangeProposalWithConflicts[]; + reviewingProposalId: ChangeProposalId | null; + acceptedProposalIds: Set<ChangeProposalId>; + rejectedProposalIds: Set<ChangeProposalId>; + blockedByConflictIds: Set<ChangeProposalId>; + outdatedProposalIds: Set<ChangeProposalId>; + userLookup: Map<UserId, string>; + onSelectProposal: (proposalId: ChangeProposalId) => void; + onPoolAccept: (proposalId: ChangeProposalId) => void; + onPoolReject: (proposalId: ChangeProposalId) => void; + onUndoPool: (proposalId: ChangeProposalId) => void; +} + +export function ChangeProposalsChangesList({ + proposals, + reviewingProposalId, + acceptedProposalIds, + rejectedProposalIds, + blockedByConflictIds, + outdatedProposalIds, + userLookup, + onSelectProposal, + onPoolAccept, + onPoolReject, + onUndoPool, +}: ChangeProposalsChangesListProps) { + const [showAccepted, setShowAccepted] = useState(true); + const [showRejected, setShowRejected] = useState(true); + + const proposalNumberMap = useMemo( + () => buildProposalNumberMap(proposals), + [proposals], + ); + + const sortByDate = ( + a: ChangeProposalWithConflicts, + b: ChangeProposalWithConflicts, + ) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + + const { pendingProposals, acceptedProposals, rejectedProposals } = + useMemo(() => { + const pending = proposals + .filter( + (p) => + p.status === ChangeProposalStatus.pending && + !acceptedProposalIds.has(p.id) && + !rejectedProposalIds.has(p.id), + ) + .sort(sortByDate); + + const accepted = proposals + .filter((p) => acceptedProposalIds.has(p.id)) + .sort(sortByDate); + const rejected = proposals + .filter((p) => rejectedProposalIds.has(p.id)) + .sort(sortByDate); + + return { + pendingProposals: pending, + acceptedProposals: accepted, + rejectedProposals: rejected, + }; + }, [proposals, acceptedProposalIds, rejectedProposalIds]); + + return ( + <PMBox display="flex" flexDirection="column" height="full"> + <PMBox overflowY="auto" flex={1}> + <PMVStack gap={4} align="stretch"> + <PMText fontSize="md" fontWeight="bold"> + Changes to review + </PMText> + + {pendingProposals.length > 0 && ( + <PMVStack gap={2}> + <PMHStack gap={1} align="center" width="full"> + <PMText + fontSize="xs" + fontWeight="bold" + color="secondary" + textTransform="uppercase" + > + Pending + </PMText> + <PMBadge colorPalette="blue" size="sm"> + {pendingProposals.length} + </PMBadge> + </PMHStack> + {pendingProposals.map((proposal) => ( + <PendingProposalCard + key={proposal.id} + proposal={proposal} + isSelected={proposal.id === reviewingProposalId} + isBlockedByConflict={blockedByConflictIds.has(proposal.id)} + proposalNumber={proposalNumberMap.get(proposal.id)} + userLookup={userLookup} + outdatedProposalIds={outdatedProposalIds} + onSelect={() => onSelectProposal(proposal.id)} + onAccept={() => onPoolAccept(proposal.id)} + onReject={() => onPoolReject(proposal.id)} + /> + ))} + </PMVStack> + )} + + {acceptedProposals.length > 0 && ( + <CollapsiblePoolSection + label="Accepted" + count={acceptedProposals.length} + isOpen={showAccepted} + onToggle={() => setShowAccepted((prev) => !prev)} + colorPalette="green" + > + {acceptedProposals.map((proposal) => ( + <PoolProposalCard + key={proposal.id} + proposal={proposal} + isSelected={proposal.id === reviewingProposalId} + proposalNumber={proposalNumberMap.get(proposal.id)} + userLookup={userLookup} + outdatedProposalIds={outdatedProposalIds} + onSelect={() => onSelectProposal(proposal.id)} + onUndo={() => onUndoPool(proposal.id)} + /> + ))} + </CollapsiblePoolSection> + )} + + {rejectedProposals.length > 0 && ( + <CollapsiblePoolSection + label="Dismissed" + count={rejectedProposals.length} + isOpen={showRejected} + onToggle={() => setShowRejected((prev) => !prev)} + colorPalette="red" + > + {rejectedProposals.map((proposal) => ( + <PoolProposalCard + key={proposal.id} + proposal={proposal} + isSelected={proposal.id === reviewingProposalId} + proposalNumber={proposalNumberMap.get(proposal.id)} + userLookup={userLookup} + outdatedProposalIds={outdatedProposalIds} + onSelect={() => onSelectProposal(proposal.id)} + onUndo={() => onUndoPool(proposal.id)} + /> + ))} + </CollapsiblePoolSection> + )} + + {proposals.length === 0 && ( + <PMBox py={4} textAlign="center"> + <PMText fontSize="sm" color="secondary"> + No pending proposals + </PMText> + </PMBox> + )} + </PMVStack> + </PMBox> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/ChangeProposals/CollapsiblePoolSection.tsx b/apps/frontend/src/domain/change-proposals/components/ChangeProposals/CollapsiblePoolSection.tsx new file mode 100644 index 000000000..6cffa77f9 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ChangeProposals/CollapsiblePoolSection.tsx @@ -0,0 +1,53 @@ +import { ReactNode } from 'react'; +import { PMBadge, PMHStack, PMText, PMVStack } from '@packmind/ui'; +import { LuChevronDown, LuChevronRight } from 'react-icons/lu'; + +interface CollapsiblePoolSectionProps { + label: string; + count: number; + isOpen: boolean; + onToggle: () => void; + colorPalette: string; + children: ReactNode; +} + +export function CollapsiblePoolSection({ + label, + count, + isOpen, + onToggle, + colorPalette, + children, +}: CollapsiblePoolSectionProps) { + return ( + <PMVStack gap={2} width="full"> + <PMHStack + gap={1} + align="center" + width="full" + cursor="pointer" + onClick={onToggle} + role="button" + aria-expanded={isOpen} + > + {isOpen ? <LuChevronDown size={14} /> : <LuChevronRight size={14} />} + <PMText + fontSize="xs" + fontWeight="bold" + color="secondary" + textTransform="uppercase" + > + {label} + </PMText> + <PMBadge colorPalette={colorPalette} size="sm"> + {count} + </PMBadge> + </PMHStack> + {isOpen && ( + <PMVStack gap={1} width="full"> + {children} + </PMVStack> + )} + </PMVStack> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/ChangeProposals/ConflictWarning.tsx b/apps/frontend/src/domain/change-proposals/components/ChangeProposals/ConflictWarning.tsx new file mode 100644 index 000000000..332471352 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ChangeProposals/ConflictWarning.tsx @@ -0,0 +1,37 @@ +import { PMAlert, PMText } from '@packmind/ui'; +import { ChangeProposalId } from '@packmind/types'; + +interface ConflictWarningProps { + conflictingAcceptedNumbers: { id: ChangeProposalId; number: number }[]; + onSelectConflicting: (id: ChangeProposalId) => void; +} + +export function ConflictWarning({ + conflictingAcceptedNumbers, + onSelectConflicting, +}: Readonly<ConflictWarningProps>) { + return ( + <PMAlert.Root status="error" size="sm" alignItems="center"> + <PMAlert.Indicator /> + <PMAlert.Title fontSize="xs"> + Conflicts with{' '} + {conflictingAcceptedNumbers.map((item, index) => ( + <PMText + key={item.id} + as="span" + cursor="pointer" + textDecoration="underline" + fontWeight="bold" + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + onSelectConflicting(item.id); + }} + > + #{item.number} + {index < conflictingAcceptedNumbers.length - 1 ? ', ' : ''} + </PMText> + ))} + </PMAlert.Title> + </PMAlert.Root> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/ChangeProposals/PendingProposalCard.tsx b/apps/frontend/src/domain/change-proposals/components/ChangeProposals/PendingProposalCard.tsx new file mode 100644 index 000000000..8f5f4f54e --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ChangeProposals/PendingProposalCard.tsx @@ -0,0 +1,102 @@ +import { PMHStack, PMIconButton, PMTooltip } from '@packmind/ui'; +import { ChangeProposalId, UserId } from '@packmind/types'; +import { LuCheck, LuX } from 'react-icons/lu'; +import { ChangeProposalWithConflicts } from '../../types'; +import { ProposalCardBase } from './ProposalCardBase'; + +interface PendingProposalCardProps { + proposal: ChangeProposalWithConflicts; + isSelected: boolean; + isBlockedByConflict: boolean; + proposalNumber?: number; + userLookup: Map<UserId, string>; + outdatedProposalIds: Set<ChangeProposalId>; + onSelect: () => void; + onAccept: () => void; + onReject: () => void; +} + +function AcceptIconButton({ + isOutdated, + isBlocked, + onAccept, +}: { + isOutdated: boolean; + isBlocked: boolean; + onAccept: () => void; +}) { + const isDisabled = isOutdated || isBlocked; + + const button = ( + <PMIconButton + aria-label="Accept proposal" + size="xs" + variant="solid" + disabled={isDisabled} + onClick={onAccept} + > + <LuCheck size={14} /> + </PMIconButton> + ); + + if (isOutdated) { + return ( + <PMTooltip label="This proposal is based on an outdated version"> + {button} + </PMTooltip> + ); + } + + if (isBlocked) { + return ( + <PMTooltip label="Conflicts with an accepted proposal"> + {button} + </PMTooltip> + ); + } + + return button; +} + +export function PendingProposalCard({ + proposal, + isSelected, + isBlockedByConflict, + proposalNumber, + userLookup, + outdatedProposalIds, + onSelect, + onAccept, + onReject, +}: PendingProposalCardProps) { + const isOutdated = outdatedProposalIds.has(proposal.id); + + return ( + <ProposalCardBase + proposal={proposal} + isSelected={isSelected} + borderColor={isBlockedByConflict ? 'border.error' : 'border.tertiary'} + proposalNumber={proposalNumber} + userLookup={userLookup} + outdatedProposalIds={outdatedProposalIds} + onSelect={onSelect} + actions={ + <PMHStack gap={1}> + <AcceptIconButton + isOutdated={isOutdated} + isBlocked={isBlockedByConflict} + onAccept={onAccept} + /> + <PMIconButton + aria-label="Reject proposal" + size="xs" + variant="solid" + onClick={onReject} + > + <LuX size={14} /> + </PMIconButton> + </PMHStack> + } + /> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/ChangeProposals/PoolProposalCard.tsx b/apps/frontend/src/domain/change-proposals/components/ChangeProposals/PoolProposalCard.tsx new file mode 100644 index 000000000..b39a7d766 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ChangeProposals/PoolProposalCard.tsx @@ -0,0 +1,49 @@ +import { PMIconButton } from '@packmind/ui'; +import { ChangeProposalId, UserId } from '@packmind/types'; +import { LuUndo2 } from 'react-icons/lu'; +import { ChangeProposalWithConflicts } from '../../types'; +import { ProposalCardBase } from './ProposalCardBase'; + +interface PoolProposalCardProps { + proposal: ChangeProposalWithConflicts; + isSelected: boolean; + proposalNumber?: number; + userLookup: Map<UserId, string>; + outdatedProposalIds: Set<ChangeProposalId>; + onSelect: () => void; + onUndo: () => void; +} + +export function PoolProposalCard({ + proposal, + isSelected, + proposalNumber, + userLookup, + outdatedProposalIds, + onSelect, + onUndo, +}: PoolProposalCardProps) { + return ( + <ProposalCardBase + proposal={proposal} + isSelected={isSelected} + proposalNumber={proposalNumber} + userLookup={userLookup} + outdatedProposalIds={outdatedProposalIds} + onSelect={onSelect} + actions={ + <PMIconButton + aria-label="Undo" + size="xs" + variant="ghost" + onClick={(e) => { + e.stopPropagation(); + onUndo(); + }} + > + <LuUndo2 size={14} /> + </PMIconButton> + } + /> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/ChangeProposals/ProposalCardBase.tsx b/apps/frontend/src/domain/change-proposals/components/ChangeProposals/ProposalCardBase.tsx new file mode 100644 index 000000000..5475dc32d --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ChangeProposals/ProposalCardBase.tsx @@ -0,0 +1,90 @@ +import { ReactNode } from 'react'; +import { + PMBadge, + PMBox, + PMHStack, + PMIcon, + PMText, + PMTooltip, + PMVStack, +} from '@packmind/ui'; +import { ChangeProposalId, UserId } from '@packmind/types'; +import { LuCircleAlert } from 'react-icons/lu'; +import { ChangeProposalWithConflicts } from '../../types'; +import { getChangeProposalFieldLabel } from '../../utils/changeProposalHelpers'; +import { UserAvatarWithInitials } from '../../../accounts/components/UserAvatarWithInitials'; +import { RelativeTime } from '../shared/RelativeTime'; + +interface ProposalCardBaseProps { + proposal: ChangeProposalWithConflicts; + isSelected: boolean; + borderColor?: string; + proposalNumber?: number; + userLookup: Map<UserId, string>; + outdatedProposalIds: Set<ChangeProposalId>; + onSelect: () => void; + actions: ReactNode; +} + +export function ProposalCardBase({ + proposal, + isSelected, + borderColor = 'border.tertiary', + proposalNumber, + userLookup, + outdatedProposalIds, + onSelect, + actions, +}: ProposalCardBaseProps) { + const authorDisplayName = + userLookup.get(proposal.createdBy) ?? 'Unknown user'; + const isOutdated = outdatedProposalIds.has(proposal.id); + + return ( + <PMBox + borderRadius="md" + border="1px solid" + borderColor={borderColor} + cursor="pointer" + width="full" + p={2} + backgroundColor={isSelected ? 'background.tertiary' : undefined} + _hover={isSelected ? undefined : { background: 'background.tertiary' }} + onClick={onSelect} + > + <PMVStack gap={2} align="stretch"> + <PMHStack gap={2} justify="space-between" align="center"> + <PMHStack gap={1} align="center"> + <PMText fontSize="xs" color="secondary"> + {proposalNumber !== undefined && `#${proposalNumber} - `} + <RelativeTime date={proposal.createdAt} /> + </PMText> + <PMText fontSize="xs" color="secondary"> + - + </PMText> + <UserAvatarWithInitials displayName={authorDisplayName} size="xs" /> + </PMHStack> + {actions} + </PMHStack> + <PMText fontSize="sm" fontWeight="bold"> + {getChangeProposalFieldLabel(proposal.type)} + </PMText> + {isOutdated && ( + <PMHStack gap={2} align="center"> + <PMText fontSize="xs" color="secondary"> + From version {proposal.artefactVersion} + </PMText> + <PMTooltip label="This proposal was made on an outdated version"> + <PMBadge colorPalette="orange" variant="subtle" size="sm"> + <PMIcon> + <LuCircleAlert /> + </PMIcon> + Outdated + </PMBadge> + </PMTooltip> + </PMHStack> + )} + </PMVStack> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/ChangeProposals/index.ts b/apps/frontend/src/domain/change-proposals/components/ChangeProposals/index.ts new file mode 100644 index 000000000..fb101e9d6 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ChangeProposals/index.ts @@ -0,0 +1 @@ +export { ChangeProposalsChangesList } from './ChangeProposalsChangesList'; diff --git a/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/CommandReviewDetail.tsx b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/CommandReviewDetail.tsx new file mode 100644 index 000000000..16b98f511 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/CommandReviewDetail.tsx @@ -0,0 +1,315 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { PMAlertDialog, PMBox, PMSpinner } from '@packmind/ui'; +import { + AcceptedChangeProposal, + ChangeProposalDecision, + ChangeProposalId, + ChangeProposalStatus, + OrganizationId, + RecipeId, + SpaceId, +} from '@packmind/types'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; +import { useCurrentSpace } from '../../../spaces/hooks/useCurrentSpace'; +import { useGetRecipeByIdQuery } from '../../../recipes/api/queries/RecipesQueries'; +import { + useApplyRecipeChangeProposalsMutation, + useListChangeProposalsByRecipeQuery, +} from '../../api/queries/ChangeProposalsQueries'; +import { + GET_CHANGE_PROPOSALS_BY_RECIPE_KEY, + GET_GROUPED_CHANGE_PROPOSALS_KEY, +} from '../../api/queryKeys'; +import { useUserLookup } from '../../hooks/useUserLookup'; +import { useChangeProposalPool } from '../../hooks/useChangeProposalPool'; +import { useNavigateAfterApply } from '../../hooks/useNavigateAfterApply'; +import { useCardReviewState, ViewMode } from '../../hooks/useCardReviewState'; +import { ChangeProposalWithConflicts } from '../../types'; +import { getRecipeByIdKey } from '../../../recipes/api/queryKeys'; +import { + computeCommandOutdatedIds, + computeRemovalOutdatedIds, + mergeOutdatedIds, +} from '../../utils/computeOutdatedProposalIds'; +import { ChangeProposalAccordion } from '../shared/ChangeProposalAccordion'; +import { CommandReviewHeader } from './CommandReviewHeader'; +import { DiffView } from './DiffView'; +import { InlineView } from './InlineView'; +import { OriginalTabContent } from './OriginalTabContent'; +import { ResultTabContent } from './ResultTabContent'; +import { useParams, useBlocker, useBeforeUnload } from 'react-router'; +import { routes } from '../../../../shared/utils/routes'; +import { isEditableProposalType } from '../../utils/editableProposalTypes'; + +interface CommandReviewDetailProps { + artefactId: string; + orgSlug?: string; + spaceSlug?: string; +} + +export function CommandReviewDetail({ + artefactId, + orgSlug: orgSlugProp, + spaceSlug: spaceSlugProp, +}: Readonly<CommandReviewDetailProps>) { + const recipeId = artefactId as RecipeId; + const { organization, user } = useAuthContext(); + const { spaceId, space } = useCurrentSpace(); + const { orgSlug: orgSlugParam } = useParams<{ orgSlug: string }>(); + const queryClient = useQueryClient(); + const userLookup = useUserLookup(); + + const organizationId = organization?.id; + + const orgSlug = orgSlugProp ?? orgSlugParam; + const spaceSlug = spaceSlugProp ?? space?.slug; + + const applyRecipeChangeProposalsMutation = + useApplyRecipeChangeProposalsMutation({ + orgSlug, + spaceSlug, + }); + const { data: selectedRecipeProposalsData, isLoading: isLoadingProposals } = + useListChangeProposalsByRecipeQuery(recipeId); + + const selectedRecipeProposals = + selectedRecipeProposalsData?.changeProposals ?? []; + const currentPackageIds = + selectedRecipeProposalsData?.currentPackageIds ?? []; + + const { data: selectedRecipe } = useGetRecipeByIdQuery(recipeId); + + const pool = useChangeProposalPool(selectedRecipeProposals); + const navigateToNextArtifact = useNavigateAfterApply(artefactId); + + const reviewState = useCardReviewState(); + + const hasInitiallyExpanded = useRef(false); + useEffect(() => { + if (hasInitiallyExpanded.current) return; + if (selectedRecipeProposals.length === 0) return; + + const sorted = [...selectedRecipeProposals].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + reviewState.toggleCard([sorted[0].id]); + hasInitiallyExpanded.current = true; + }, [selectedRecipeProposals, reviewState.toggleCard]); + + const handleAcceptAndCollapse = useCallback( + (proposalId: ChangeProposalId, decision: ChangeProposalDecision) => { + pool.handlePoolAccept(proposalId, decision); + reviewState.collapseCard(proposalId); + }, + [pool.handlePoolAccept, reviewState.collapseCard], + ); + + const handleDismissAndCollapse = useCallback( + (proposalId: ChangeProposalId) => { + pool.handlePoolReject(proposalId); + reviewState.collapseCard(proposalId); + }, + [pool.handlePoolReject, reviewState.collapseCard], + ); + + const outdatedProposalIds = useMemo( + () => + mergeOutdatedIds( + computeCommandOutdatedIds(selectedRecipeProposals, selectedRecipe), + computeRemovalOutdatedIds(selectedRecipeProposals, currentPackageIds), + ), + [selectedRecipeProposals, selectedRecipe, currentPackageIds], + ); + + useBeforeUnload( + useCallback( + (event) => { + if (pool.hasPooledDecisions) { + event.preventDefault(); + } + }, + [pool.hasPooledDecisions], + ), + ); + + const blocker = useBlocker(pool.hasPooledDecisions); + + const handleSave = useCallback(async () => { + if (!organizationId || !spaceId) return; + if (!pool.hasPooledDecisions) return; + + const accepted = selectedRecipeProposals.reduce((acc, changeProposal) => { + if (pool.acceptedProposalIds.has(changeProposal.id)) { + acc.push({ + ...changeProposal, + status: ChangeProposalStatus.applied, + decision: pool.getDecisionForChangeProposal(changeProposal), + }); + } + return acc; + }, [] as AcceptedChangeProposal[]); + + try { + await applyRecipeChangeProposalsMutation.mutateAsync({ + organizationId: organizationId as OrganizationId, + spaceId: spaceId as SpaceId, + artefactId: recipeId, + accepted, + rejected: Array.from(pool.rejectedProposalIds), + }); + + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: GET_GROUPED_CHANGE_PROPOSALS_KEY, + }), + queryClient.invalidateQueries({ + queryKey: [...GET_CHANGE_PROPOSALS_BY_RECIPE_KEY, recipeId], + }), + queryClient.invalidateQueries({ + queryKey: getRecipeByIdKey(spaceId, recipeId), + }), + ]); + + pool.resetPool(); + navigateToNextArtifact(); + } catch { + // Errors are handled by the mutation onError callbacks + } + }, [ + organizationId, + spaceId, + pool.acceptedProposalIds, + pool.rejectedProposalIds, + pool.hasPooledDecisions, + applyRecipeChangeProposalsMutation, + queryClient, + pool.resetPool, + navigateToNextArtifact, + ]); + + const renderExpandedView = useCallback( + (viewMode: ViewMode, proposal: ChangeProposalWithConflicts) => { + if (!selectedRecipe) return null; + + if (viewMode === 'diff') + return <DiffView recipe={selectedRecipe} proposal={proposal} />; + if (viewMode === 'inline') + return <InlineView recipe={selectedRecipe} proposal={proposal} />; + return null; + }, + [selectedRecipe], + ); + + const latestProposal = useMemo(() => { + if (selectedRecipeProposals.length === 0) return null; + return [...selectedRecipeProposals].sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0]; + }, [selectedRecipeProposals]); + + const latestAuthor = latestProposal + ? (userLookup.get(latestProposal.createdBy) ?? 'Unknown') + : ''; + const latestTime = latestProposal?.createdAt ?? new Date(); + + if (isLoadingProposals) { + return ( + <PMBox gridColumn="span 2" display="flex" justifyContent="center" p={8}> + <PMSpinner /> + </PMBox> + ); + } + + if (!selectedRecipe) { + return null; + } + + const artefactLink = + orgSlug && spaceSlug + ? routes.space.toCommand(orgSlug, spaceSlug, artefactId) + : undefined; + + return ( + <> + <PMBox + gridColumn="span 2" + display="flex" + flexDirection="column" + height="full" + overflowY="auto" + > + <CommandReviewHeader + artefactName={selectedRecipe.name} + artefactVersion={selectedRecipe.version} + latestAuthor={latestAuthor} + latestTime={latestTime} + activeTab={reviewState.activeTab} + onTabChange={reviewState.setActiveTab} + acceptedCount={pool.acceptedProposalIds.size} + dismissedCount={pool.rejectedProposalIds.size} + pendingCount={ + selectedRecipeProposals.length - + pool.acceptedProposalIds.size - + pool.rejectedProposalIds.size + } + hasPooledDecisions={pool.hasPooledDecisions} + isSaving={applyRecipeChangeProposalsMutation.isPending} + onSave={handleSave} + artefactLink={artefactLink} + /> + + {reviewState.activeTab === 'changes' && ( + <ChangeProposalAccordion + proposals={selectedRecipeProposals} + acceptedProposalIds={pool.acceptedProposalIds} + rejectedProposalIds={pool.rejectedProposalIds} + blockedByConflictIds={pool.blockedByConflictIds} + outdatedProposalIds={outdatedProposalIds} + expandedCardIds={reviewState.expandedCardIds} + showEditButton={isEditableProposalType} + userLookup={userLookup} + onToggleCard={reviewState.toggleCard} + getViewMode={reviewState.getViewMode} + onViewModeChange={reviewState.setViewMode} + onAccept={handleAcceptAndCollapse} + onDismiss={handleDismissAndCollapse} + onUndo={pool.handleUndoPool} + getDecisionForProposal={pool.getDecisionForChangeProposal} + onExpandCard={reviewState.expandCard} + renderExpandedView={renderExpandedView} + /> + )} + + {reviewState.activeTab === 'original' && ( + <OriginalTabContent recipe={selectedRecipe} /> + )} + + {reviewState.activeTab === 'result' && ( + <ResultTabContent + recipe={selectedRecipe} + proposals={pool.proposalsWithDecisions} + acceptedProposalIds={pool.acceptedProposalIds} + /> + )} + </PMBox> + + <PMAlertDialog + open={blocker.state === 'blocked'} + onOpenChange={(details) => { + if (!details.open) { + blocker.reset?.(); + } + }} + title="Unsaved changes" + message="You have unsaved changes. If you leave this page, your changes will be lost." + confirmText="Leave" + cancelText="Stay" + confirmColorScheme="red" + onConfirm={() => blocker.proceed?.()} + /> + </> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/CommandReviewHeader.tsx b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/CommandReviewHeader.tsx new file mode 100644 index 000000000..234a8b558 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/CommandReviewHeader.tsx @@ -0,0 +1,22 @@ +import { ReviewHeader } from '../shared/ReviewHeader'; +import type { ReviewTab } from '../../hooks/useCardReviewState'; + +interface CommandReviewHeaderProps { + artefactName: string; + artefactVersion: number; + latestAuthor: string; + latestTime: Date; + activeTab: ReviewTab; + onTabChange: (tab: ReviewTab) => void; + acceptedCount: number; + dismissedCount: number; + pendingCount: number; + hasPooledDecisions: boolean; + isSaving: boolean; + onSave: () => void; + artefactLink?: string; +} + +export function CommandReviewHeader(props: Readonly<CommandReviewHeaderProps>) { + return <ReviewHeader {...props} />; +} diff --git a/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/DiffView.tsx b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/DiffView.tsx new file mode 100644 index 000000000..41e6d2992 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/DiffView.tsx @@ -0,0 +1,102 @@ +import { useMemo } from 'react'; +import { PMBox, PMHeading, PMMarkdownViewer, PMText } from '@packmind/ui'; +import { ChangeProposalType, Recipe } from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../../types'; +import { buildDiffSections } from '../../utils/buildDiffSections'; +import { stripFrontmatter } from '../../../artifacts/utils/stripFrontmatter'; +import { DiffBlock } from '../shared/DiffBlock'; + +interface DiffViewProps { + recipe: Recipe; + proposal: ChangeProposalWithConflicts; +} + +export function DiffView({ recipe, proposal }: Readonly<DiffViewProps>) { + const isDescriptionChange = + proposal.type === ChangeProposalType.updateCommandDescription; + const isNameChange = proposal.type === ChangeProposalType.updateCommandName; + + const payload = proposal.payload as { oldValue: string; newValue: string }; + + const sections = useMemo( + () => + isDescriptionChange + ? buildDiffSections(payload.oldValue, payload.newValue) + : [], + [isDescriptionChange, payload.oldValue, payload.newValue], + ); + + if (isNameChange) { + return ( + <PMBox> + <PMBox borderRadius="md" p={4} mb={4}> + <PMBox mb={3}> + <PMText fontSize="xs" fontWeight="semibold" color="secondary"> + Name change + </PMText> + </PMBox> + <DiffBlock + value={payload.oldValue} + variant="removed" + isMarkdown={false} + /> + <PMBox mt={2}> + <DiffBlock + value={payload.newValue} + variant="added" + isMarkdown={false} + /> + </PMBox> + </PMBox> + <PMBox fontSize="sm"> + <PMMarkdownViewer content={stripFrontmatter(recipe.content)} /> + </PMBox> + </PMBox> + ); + } + + if (isDescriptionChange) { + return ( + <PMBox> + <PMBox mb={2}> + <PMHeading size="h5">{recipe.name}</PMHeading> + </PMBox> + <PMBox fontSize="sm"> + {sections.map((section, index) => + section.type === 'unchanged' ? ( + <PMMarkdownViewer key={index} content={section.value} /> + ) : ( + <PMBox + key={index} + borderRadius="md" + border="1px dashed" + borderColor="border.tertiary" + p={4} + my={2} + > + {section.oldValue && ( + <DiffBlock + value={section.oldValue} + variant="removed" + isMarkdown={true} + /> + )} + {section.newValue && ( + <PMBox mt={section.oldValue ? 2 : 0}> + <DiffBlock + value={section.newValue} + variant="added" + isMarkdown={true} + /> + </PMBox> + )} + </PMBox> + ), + )} + </PMBox> + </PMBox> + ); + } + + return null; +} diff --git a/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/EditPreview.tsx b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/EditPreview.tsx new file mode 100644 index 000000000..277ea16f2 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/EditPreview.tsx @@ -0,0 +1,29 @@ +import { PMBox, PMMarkdownViewer, PMText } from '@packmind/ui'; + +interface EditPreviewProps { + value: string; +} + +export function EditPreview({ value }: Readonly<EditPreviewProps>) { + return ( + <PMBox> + <PMText + fontSize="xs" + fontWeight="semibold" + textTransform="uppercase" + color="secondary" + mb={2} + > + Preview + </PMText> + <PMBox + border="1px solid" + borderColor="border.tertiary" + borderRadius="md" + p={3} + > + <PMMarkdownViewer content={value} /> + </PMBox> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/EditableProposalField.tsx b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/EditableProposalField.tsx new file mode 100644 index 000000000..4c1e2461a --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/EditableProposalField.tsx @@ -0,0 +1,37 @@ +import { PMBox, PMInput } from '@packmind/ui'; +import { MilkdownProvider } from '@milkdown/react'; +import { MarkdownEditor } from '../../../../shared/components/editor/MarkdownEditor'; + +interface EditableProposalFieldProps { + value: string; + onChange: (value: string) => void; + isDescriptionField: boolean; +} + +export function EditableProposalField({ + value, + onChange, + isDescriptionField, +}: Readonly<EditableProposalFieldProps>) { + if (isDescriptionField) { + return ( + <PMBox border="1px solid" borderColor="border.tertiary" borderRadius="md"> + <MilkdownProvider> + <MarkdownEditor + defaultValue={value} + onMarkdownChange={onChange} + paddingVariant="none" + /> + </MilkdownProvider> + </PMBox> + ); + } + + return ( + <PMInput + value={value} + onChange={(e) => onChange(e.target.value)} + size="sm" + /> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/InlineView.tsx b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/InlineView.tsx new file mode 100644 index 000000000..0bcfc3158 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/InlineView.tsx @@ -0,0 +1,102 @@ +import { useMemo } from 'react'; +import { PMBox, PMHeading, PMMarkdownViewer, PMText } from '@packmind/ui'; +import { ChangeProposalType, Recipe } from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../../types'; +import { buildDiffSections } from '../../utils/buildDiffSections'; +import { stripFrontmatter } from '../../../artifacts/utils/stripFrontmatter'; +import { DiffBlock } from '../shared/DiffBlock'; + +interface InlineViewProps { + recipe: Recipe; + proposal: ChangeProposalWithConflicts; +} + +export function InlineView({ recipe, proposal }: Readonly<InlineViewProps>) { + const isDescriptionChange = + proposal.type === ChangeProposalType.updateCommandDescription; + const isNameChange = proposal.type === ChangeProposalType.updateCommandName; + + const payload = proposal.payload as { oldValue: string; newValue: string }; + + const sections = useMemo( + () => + isDescriptionChange + ? buildDiffSections(payload.oldValue, payload.newValue) + : [], + [isDescriptionChange, payload.oldValue, payload.newValue], + ); + + if (isNameChange) { + return ( + <PMBox> + <PMBox borderRadius="md" p={4} mb={4}> + <PMBox mb={3}> + <PMText fontSize="xs" fontWeight="semibold" color="secondary"> + Name change + </PMText> + </PMBox> + {payload.newValue ? ( + <DiffBlock + value={payload.newValue} + variant="added" + isMarkdown={false} + /> + ) : ( + <DiffBlock + value={payload.oldValue} + variant="removed" + isMarkdown={false} + /> + )} + </PMBox> + <PMBox fontSize="sm"> + <PMMarkdownViewer content={stripFrontmatter(recipe.content)} /> + </PMBox> + </PMBox> + ); + } + + if (isDescriptionChange) { + return ( + <PMBox> + <PMBox mb={2}> + <PMHeading size="h5">{recipe.name}</PMHeading> + </PMBox> + <PMBox fontSize="sm"> + {sections.map((section, index) => + section.type === 'unchanged' ? ( + <PMMarkdownViewer key={index} content={section.value} /> + ) : ( + <PMBox + key={index} + borderRadius="md" + border="1px dashed" + borderColor="border.tertiary" + p={4} + my={2} + > + {section.newValue ? ( + <DiffBlock + value={section.newValue} + variant="added" + isMarkdown={true} + showIndicator={false} + /> + ) : ( + <DiffBlock + value={section.oldValue} + variant="removed" + isMarkdown={true} + showIndicator={false} + /> + )} + </PMBox> + ), + )} + </PMBox> + </PMBox> + ); + } + + return null; +} diff --git a/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/OriginalTabContent.tsx b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/OriginalTabContent.tsx new file mode 100644 index 000000000..0249696cc --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/OriginalTabContent.tsx @@ -0,0 +1,29 @@ +import { PMBox, PMText } from '@packmind/ui'; +import { Recipe } from '@packmind/types'; +import { ArtifactResultFilePreview } from '../../../artifacts/components/ArtifactResultFilePreview'; + +interface OriginalTabContentProps { + recipe: Recipe; +} + +export function OriginalTabContent({ + recipe, +}: Readonly<OriginalTabContentProps>) { + return ( + <PMBox p={6}> + <PMText + fontSize="2xs" + fontWeight="medium" + textTransform="uppercase" + color="faded" + mb={6} + > + Original Version + </PMText> + <ArtifactResultFilePreview + fileName={`${recipe.slug}.md`} + markdown={recipe.content} + /> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/ResultTabContent.tsx b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/ResultTabContent.tsx new file mode 100644 index 000000000..e6fb8be3d --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/ResultTabContent.tsx @@ -0,0 +1,79 @@ +import { PMBox, PMText } from '@packmind/ui'; +import { useCallback, useMemo } from 'react'; +import { ChangeProposalId, ChangeProposalType, Recipe } from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../../types'; +import { applyRecipeProposals } from '../../utils/applyRecipeProposals'; +import { PREVIEW_RECIPE_VERSION_ID } from '../../utils/changeProposalHelpers'; +import { ArtifactResultFilePreview } from '../../../artifacts/components/ArtifactResultFilePreview'; + +interface ResultTabContentProps { + recipe: Recipe; + proposals: ChangeProposalWithConflicts[]; + acceptedProposalIds: Set<ChangeProposalId>; +} + +export function ResultTabContent({ + recipe, + proposals, + acceptedProposalIds, +}: Readonly<ResultTabContentProps>) { + const applied = useMemo( + () => applyRecipeProposals(recipe, proposals, acceptedProposalIds), + [recipe, proposals, acceptedProposalIds], + ); + + const hasAccepted = acceptedProposalIds.size > 0; + + const hasAcceptedRemoval = proposals.some( + (p) => + acceptedProposalIds.has(p.id) && + p.type === ChangeProposalType.removeCommand, + ); + + const getPreviewCommand = useCallback( + () => ({ + recipeVersions: [ + { + id: PREVIEW_RECIPE_VERSION_ID, + recipeId: recipe.id, + name: applied.name, + slug: recipe.slug, + content: applied.content, + version: recipe.version, + userId: recipe.userId, + }, + ], + standardVersions: [], + skillVersions: [], + }), + [applied, recipe], + ); + + return ( + <PMBox p={6}> + <PMText + fontSize="2xs" + fontWeight="medium" + textTransform="uppercase" + color="faded" + mb={6} + > + Version with accepted changes + </PMText> + {hasAccepted ? ( + <ArtifactResultFilePreview + fileName={`${recipe.slug}.md`} + markdown={applied.content} + hideActions={hasAcceptedRemoval} + getPreviewCommand={getPreviewCommand} + /> + ) : ( + <PMBox py={12} textAlign="center"> + <PMText color="faded" fontStyle="italic"> + No accepted changes yet + </PMText> + </PMBox> + )} + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/index.ts b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/index.ts new file mode 100644 index 000000000..87823d3d1 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/CommandReviewDetail/index.ts @@ -0,0 +1 @@ +export { CommandReviewDetail } from './CommandReviewDetail'; diff --git a/apps/frontend/src/domain/change-proposals/components/CreateCommandReviewDetail/CreateCommandReviewDetail.tsx b/apps/frontend/src/domain/change-proposals/components/CreateCommandReviewDetail/CreateCommandReviewDetail.tsx new file mode 100644 index 000000000..318f1b70d --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/CreateCommandReviewDetail/CreateCommandReviewDetail.tsx @@ -0,0 +1,156 @@ +import { PMBox, PMHeading, PMMarkdownViewer, PMVStack } from '@packmind/ui'; +import { useCallback, useState } from 'react'; +import { + ChangeProposalId, + CommandCreationProposalOverview, + RecipeId, +} from '@packmind/types'; +import { PREVIEW_RECIPE_VERSION_ID } from '../../utils/changeProposalHelpers'; +import { routes } from '../../../../shared/utils/routes'; +import { useCreationReviewDetail } from '../../hooks/useCreationReviewDetail'; +import { useUserLookup } from '../../hooks/useUserLookup'; +import { stripFrontmatter } from '../../../artifacts/utils/stripFrontmatter'; +import { SubmissionBanner } from '../SubmissionBanner'; +import { CreationReviewHeader } from '../shared/CreationReviewHeader'; +import { + ConfirmCreationDecisionDialog, + type CreationDecision, +} from '../shared/ConfirmCreationDecisionDialog'; +import { ProposalMessage } from '../shared/ProposalMessage'; +import { + ProposalDetailEmpty, + ProposalDetailLoading, +} from '../ProposalDetailPlaceholder'; + +interface CreateCommandReviewDetailProps { + proposalId: ChangeProposalId; + orgSlug?: string; + spaceSlug?: string; +} + +export function CreateCommandReviewDetail({ + proposalId, + orgSlug: orgSlugProp, + spaceSlug: spaceSlugProp, +}: Readonly<CreateCommandReviewDetailProps>) { + const { + displayedProposal, + submittedState, + handleAccept, + handleReject, + isPending, + isLoading, + } = useCreationReviewDetail<CommandCreationProposalOverview>({ + proposalId, + orgSlugProp, + spaceSlugProp, + filter: (c): c is CommandCreationProposalOverview => + c.artefactType === 'commands', + getAcceptUrl: (response, orgSlug, spaceSlug) => { + const createdCommandId = response.created.commands[0]; + return createdCommandId + ? routes.space.toCommand(orgSlug, spaceSlug, createdCommandId) + : routes.space.toCommands(orgSlug, spaceSlug); + }, + }); + + const [pendingDecision, setPendingDecision] = + useState<CreationDecision | null>(null); + + const userLookup = useUserLookup(); + + const getPreviewCommand = useCallback(() => { + if (!displayedProposal) { + return { recipeVersions: [], standardVersions: [], skillVersions: [] }; + } + return { + recipeVersions: [ + { + id: PREVIEW_RECIPE_VERSION_ID, + recipeId: 'preview' as RecipeId, + name: displayedProposal.payload.name, + slug: displayedProposal.payload.name + .toLowerCase() + .replace(/\s+/g, '-'), + content: displayedProposal.payload.content, + version: 1, + userId: displayedProposal.createdBy, + }, + ], + standardVersions: [], + skillVersions: [], + }; + }, [displayedProposal]); + + const handleDialogConfirm = useCallback(async () => { + if (!displayedProposal) return; + if (pendingDecision === 'accept') { + await handleAccept(displayedProposal.payload); + } else if (pendingDecision === 'dismiss') { + await handleReject(); + } + setPendingDecision(null); + }, [pendingDecision, handleAccept, handleReject, displayedProposal]); + + if (isLoading && !displayedProposal) { + return <ProposalDetailLoading />; + } + + if (!displayedProposal) { + return <ProposalDetailEmpty />; + } + + const authorName = + userLookup.get(displayedProposal.createdBy) ?? 'Unknown user'; + + return ( + <PMBox gridColumn="span 2" overflowY="auto"> + <CreationReviewHeader + artefactName={displayedProposal.name} + latestAuthor={authorName} + latestTime={new Date(displayedProposal.lastContributedAt)} + onAccept={() => setPendingDecision('accept')} + onDismiss={() => setPendingDecision('dismiss')} + isPending={isPending} + isSubmitted={!!submittedState} + getPreviewCommand={getPreviewCommand} + /> + <ConfirmCreationDecisionDialog + artefactLabel="command" + open={pendingDecision !== null} + decision={pendingDecision ?? 'accept'} + artefactName={displayedProposal.payload.name} + isPending={isPending} + onConfirm={handleDialogConfirm} + onOpenChange={(open) => { + if (!open && !isPending) { + setPendingDecision(null); + } + }} + /> + <PMBox + px={6} + py={2} + border="sm" + borderTop="none" + borderColor="border.tertiary" + > + <ProposalMessage message={displayedProposal.message} /> + </PMBox> + <PMVStack gap={2} align="stretch" p={6}> + {submittedState && ( + <SubmissionBanner + submittedState={submittedState} + artefactLabel="command" + /> + )} + <PMHeading size="md" mb={4}> + {displayedProposal.payload.name} + </PMHeading> + <PMMarkdownViewer + content={stripFrontmatter(displayedProposal.payload.content)} + /> + </PMVStack> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/CreateCommandReviewDetail/index.ts b/apps/frontend/src/domain/change-proposals/components/CreateCommandReviewDetail/index.ts new file mode 100644 index 000000000..21a801cf2 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/CreateCommandReviewDetail/index.ts @@ -0,0 +1 @@ +export { CreateCommandReviewDetail } from './CreateCommandReviewDetail'; diff --git a/apps/frontend/src/domain/change-proposals/components/CreateSkillReviewDetail/CreateSkillReviewDetail.tsx b/apps/frontend/src/domain/change-proposals/components/CreateSkillReviewDetail/CreateSkillReviewDetail.tsx new file mode 100644 index 000000000..56de6711f --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/CreateSkillReviewDetail/CreateSkillReviewDetail.tsx @@ -0,0 +1,268 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router'; +import { LuFile } from 'react-icons/lu'; +import { + PMAccordion, + PMBox, + PMHStack, + PMIcon, + PMText, + PMVStack, +} from '@packmind/ui'; +import { + ChangeProposalId, + SkillCreationProposalOverview, + SkillFileId, + SkillId, +} from '@packmind/types'; +import { PREVIEW_SKILL_VERSION_ID } from '../../utils/changeProposalHelpers'; +import { routes } from '../../../../shared/utils/routes'; +import { useCreationReviewDetail } from '../../hooks/useCreationReviewDetail'; +import { useUserLookup } from '../../hooks/useUserLookup'; +import { SubmissionBanner } from '../SubmissionBanner'; +import { CreationReviewHeader } from '../shared/CreationReviewHeader'; +import { ProposalMessage } from '../shared/ProposalMessage'; +import { + ProposalDetailEmpty, + ProposalDetailLoading, +} from '../ProposalDetailPlaceholder'; +import { SkillFrontmatterInfo } from '../../../skills/components/SkillFrontmatterInfo'; +import { FileContent } from '../SkillReviewDetail/FileItems/FileContent'; +import { + ConfirmCreationDecisionDialog, + type CreationDecision, +} from '../shared/ConfirmCreationDecisionDialog'; + +interface CreateSkillReviewDetailProps { + proposalId: ChangeProposalId; + orgSlug?: string; + spaceSlug?: string; +} + +export function CreateSkillReviewDetail({ + proposalId, + orgSlug: orgSlugProp, + spaceSlug: spaceSlugProp, +}: Readonly<CreateSkillReviewDetailProps>) { + const { + displayedProposal, + submittedState, + handleAccept, + handleReject, + isPending, + isLoading, + } = useCreationReviewDetail<SkillCreationProposalOverview>({ + proposalId, + orgSlugProp, + spaceSlugProp, + filter: (c): c is SkillCreationProposalOverview => + c.artefactType === 'skills', + getAcceptUrl: (_response, orgSlug, spaceSlug) => + routes.space.toSkills(orgSlug, spaceSlug), + }); + + const [pendingDecision, setPendingDecision] = + useState<CreationDecision | null>(null); + + const userLookup = useUserLookup(); + const [searchParams] = useSearchParams(); + const fileFilter = searchParams.get('file') ?? ''; + + const allFiles = useMemo(() => { + const skillMdFile = { + path: 'SKILL.md', + content: displayedProposal?.payload?.prompt ?? '', + isBase64: false, + }; + + const otherFiles = displayedProposal?.payload?.files + ? [...displayedProposal.payload.files] + .filter((f) => f.path !== 'SKILL.md') + .sort((a, b) => a.path.localeCompare(b.path)) + : []; + + return [skillMdFile, ...otherFiles]; + }, [displayedProposal?.payload?.files, displayedProposal?.payload?.prompt]); + + const filteredFiles = useMemo(() => { + if (!fileFilter) return allFiles; + if (fileFilter === '/SKILL.md') + return allFiles.filter((f) => f.path === 'SKILL.md'); + return allFiles.filter( + (f) => f.path === fileFilter || f.path.startsWith(fileFilter + '/'), + ); + }, [allFiles, fileFilter]); + + const showDescription = !fileFilter || fileFilter === '/SKILL.md'; + + const defaultExpandedValues = useMemo( + () => allFiles.map((f) => f.path), + [allFiles], + ); + + const getPreviewCommand = useCallback(() => { + if (!displayedProposal) { + return { recipeVersions: [], standardVersions: [], skillVersions: [] }; + } + return { + recipeVersions: [], + standardVersions: [], + skillVersions: [ + { + id: PREVIEW_SKILL_VERSION_ID, + skillId: 'preview' as SkillId, + version: 1, + userId: displayedProposal.createdBy, + name: displayedProposal.payload.name, + slug: displayedProposal.payload.name + .toLowerCase() + .replace(/\s+/g, '-'), + description: displayedProposal.payload.description, + prompt: displayedProposal.payload.prompt, + license: displayedProposal.payload.license, + compatibility: displayedProposal.payload.compatibility, + metadata: displayedProposal.payload.metadata, + allowedTools: displayedProposal.payload.allowedTools, + files: (displayedProposal.payload.files ?? []).map((f, i) => ({ + ...f, + id: `file-${i}` as SkillFileId, + skillVersionId: PREVIEW_SKILL_VERSION_ID, + })), + }, + ], + }; + }, [displayedProposal]); + + const handleDialogConfirm = useCallback(async () => { + if (!displayedProposal) return; + if (pendingDecision === 'accept') { + await handleAccept(displayedProposal.payload); + } else if (pendingDecision === 'dismiss') { + await handleReject(); + } + setPendingDecision(null); + }, [pendingDecision, handleAccept, handleReject, displayedProposal]); + + if (isLoading && !displayedProposal) { + return <ProposalDetailLoading />; + } + + if (!displayedProposal) { + return <ProposalDetailEmpty />; + } + + const authorName = + userLookup.get(displayedProposal.createdBy) ?? 'Unknown user'; + + return ( + <PMBox gridColumn="span 2" overflowY="auto"> + <CreationReviewHeader + artefactName={displayedProposal.payload.name} + latestAuthor={authorName} + latestTime={new Date(displayedProposal.lastContributedAt)} + onAccept={() => setPendingDecision('accept')} + onDismiss={() => setPendingDecision('dismiss')} + isPending={isPending} + isSubmitted={!!submittedState} + getPreviewCommand={getPreviewCommand} + /> + <ConfirmCreationDecisionDialog + artefactLabel="skill" + open={pendingDecision !== null} + decision={pendingDecision ?? 'accept'} + artefactName={displayedProposal.payload.name} + isPending={isPending} + onConfirm={handleDialogConfirm} + onOpenChange={(open) => { + if (!open && !isPending) { + setPendingDecision(null); + } + }} + /> + <PMBox + px={6} + py={2} + border="sm" + borderTop="none" + borderColor="border.tertiary" + > + <ProposalMessage message={displayedProposal.message} /> + </PMBox> + <PMVStack gap={6} align="stretch" p={6}> + {submittedState && ( + <SubmissionBanner + submittedState={submittedState} + artefactLabel="skill" + /> + )} + + {showDescription && ( + <SkillFrontmatterInfo skillVersion={displayedProposal.payload} /> + )} + + {filteredFiles.length > 0 && ( + <PMAccordion.Root + collapsible + multiple + width="full" + defaultValue={defaultExpandedValues} + > + <PMVStack gap={4} width="full"> + {filteredFiles.map((file) => ( + <PMAccordion.Item + key={file.path} + value={file.path} + width="full" + border="none" + > + <PMAccordion.ItemTrigger cursor="pointer" padding={0}> + <PMBox + width="full" + bg="bg.panel" + borderRadius="md" + px={4} + py={2} + css={{ + '[data-state=open] &': { + borderBottomRadius: 0, + }, + }} + > + <PMHStack + gap={3} + alignItems="center" + justifyContent="flex-start" + > + <PMIcon color="text.faded"> + <LuFile /> + </PMIcon> + <PMText + fontSize="sm" + fontWeight="semibold" + color="faded" + > + {file.path} + </PMText> + </PMHStack> + </PMBox> + </PMAccordion.ItemTrigger> + <PMAccordion.ItemContent> + <PMBox + p={2} + border="sm" + borderBottomRadius="sm" + borderTop="none" + borderColor="border.tertiary" + > + <FileContent file={file} /> + </PMBox> + </PMAccordion.ItemContent> + </PMAccordion.Item> + ))} + </PMVStack> + </PMAccordion.Root> + )} + </PMVStack> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/CreateSkillReviewDetail/index.ts b/apps/frontend/src/domain/change-proposals/components/CreateSkillReviewDetail/index.ts new file mode 100644 index 000000000..d52994ece --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/CreateSkillReviewDetail/index.ts @@ -0,0 +1 @@ +export { CreateSkillReviewDetail } from './CreateSkillReviewDetail'; diff --git a/apps/frontend/src/domain/change-proposals/components/CreateStandardReviewDetail/CreateStandardReviewDetail.tsx b/apps/frontend/src/domain/change-proposals/components/CreateStandardReviewDetail/CreateStandardReviewDetail.tsx new file mode 100644 index 000000000..d2076c3e5 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/CreateStandardReviewDetail/CreateStandardReviewDetail.tsx @@ -0,0 +1,203 @@ +import { + PMBox, + PMHeading, + PMMarkdownViewer, + PMText, + PMVStack, +} from '@packmind/ui'; +import { useCallback, useMemo, useState } from 'react'; +import { + ChangeProposalId, + RuleId, + StandardCreationProposalOverview, + StandardId, +} from '@packmind/types'; +import { PREVIEW_STANDARD_VERSION_ID } from '../../utils/changeProposalHelpers'; +import { routes } from '../../../../shared/utils/routes'; +import { useCreationReviewDetail } from '../../hooks/useCreationReviewDetail'; +import { useUserLookup } from '../../hooks/useUserLookup'; +import { SubmissionBanner } from '../SubmissionBanner'; +import { CreationReviewHeader } from '../shared/CreationReviewHeader'; +import { + ConfirmCreationDecisionDialog, + type CreationDecision, +} from '../shared/ConfirmCreationDecisionDialog'; +import { ProposalMessage } from '../shared/ProposalMessage'; +import { + ProposalDetailEmpty, + ProposalDetailLoading, +} from '../ProposalDetailPlaceholder'; + +interface CreateStandardReviewDetailProps { + proposalId: ChangeProposalId; + orgSlug?: string; + spaceSlug?: string; +} + +export function CreateStandardReviewDetail({ + proposalId, + orgSlug: orgSlugProp, + spaceSlug: spaceSlugProp, +}: Readonly<CreateStandardReviewDetailProps>) { + const { + displayedProposal, + submittedState, + handleAccept, + handleReject, + isPending, + isLoading, + } = useCreationReviewDetail<StandardCreationProposalOverview>({ + proposalId, + orgSlugProp, + spaceSlugProp, + filter: (c): c is StandardCreationProposalOverview => + c.artefactType === 'standards', + getAcceptUrl: (response, orgSlug, spaceSlug) => { + const createdStandardId = response.created.standards[0]; + return createdStandardId + ? routes.space.toStandard(orgSlug, spaceSlug, createdStandardId) + : routes.space.toStandards(orgSlug, spaceSlug); + }, + }); + + const [pendingDecision, setPendingDecision] = + useState<CreationDecision | null>(null); + + const userLookup = useUserLookup(); + + const sortedRules = useMemo( + () => + displayedProposal + ? [...displayedProposal.payload.rules].sort((a, b) => + a.content.toLowerCase().localeCompare(b.content.toLowerCase()), + ) + : [], + [displayedProposal], + ); + + const getPreviewCommand = useCallback(() => { + if (!displayedProposal) { + return { recipeVersions: [], standardVersions: [], skillVersions: [] }; + } + return { + recipeVersions: [], + standardVersions: [ + { + id: PREVIEW_STANDARD_VERSION_ID, + standardId: 'preview' as StandardId, + name: displayedProposal.payload.name, + slug: displayedProposal.payload.name + .toLowerCase() + .replace(/\s+/g, '-'), + description: displayedProposal.payload.description, + version: 1, + scope: Array.isArray(displayedProposal.payload.scope) + ? displayedProposal.payload.scope.join(', ') + : displayedProposal.payload.scope, + rules: displayedProposal.payload.rules.map((r, i) => ({ + id: `rule-${i}` as RuleId, + content: r.content, + standardVersionId: PREVIEW_STANDARD_VERSION_ID, + })), + }, + ], + skillVersions: [], + }; + }, [displayedProposal]); + + const handleDialogConfirm = useCallback(async () => { + if (!displayedProposal) return; + if (pendingDecision === 'accept') { + await handleAccept(displayedProposal.payload); + } else if (pendingDecision === 'dismiss') { + await handleReject(); + } + setPendingDecision(null); + }, [pendingDecision, handleAccept, handleReject, displayedProposal]); + + if (isLoading && !displayedProposal) { + return <ProposalDetailLoading />; + } + + if (!displayedProposal) { + return <ProposalDetailEmpty />; + } + + const authorName = + userLookup.get(displayedProposal.createdBy) ?? 'Unknown user'; + + return ( + <PMBox gridColumn="span 2" overflowY="auto"> + <CreationReviewHeader + artefactName={displayedProposal.name} + latestAuthor={authorName} + latestTime={new Date(displayedProposal.lastContributedAt)} + onAccept={() => setPendingDecision('accept')} + onDismiss={() => setPendingDecision('dismiss')} + isPending={isPending} + isSubmitted={!!submittedState} + getPreviewCommand={getPreviewCommand} + /> + <ConfirmCreationDecisionDialog + artefactLabel="standard" + open={pendingDecision !== null} + decision={pendingDecision ?? 'accept'} + artefactName={displayedProposal.payload.name} + isPending={isPending} + onConfirm={handleDialogConfirm} + onOpenChange={(open) => { + if (!open && !isPending) { + setPendingDecision(null); + } + }} + /> + <PMBox + px={6} + py={2} + border="sm" + borderTop="none" + borderColor="border.tertiary" + > + <ProposalMessage message={displayedProposal.message} /> + </PMBox> + <PMVStack gap={6} align="stretch" p={6}> + {submittedState && ( + <SubmissionBanner + submittedState={submittedState} + artefactLabel="standard" + /> + )} + <PMHeading size="md" mb={4}> + {displayedProposal.payload.name} + </PMHeading> + <PMMarkdownViewer content={displayedProposal.payload.description} /> + + {displayedProposal.payload.scope && ( + <PMBox mt={4}> + <PMText as="p" fontSize="sm" fontWeight="semibold" mb={1}> + Scope + </PMText> + <PMText fontSize="sm" color="faded"> + {displayedProposal.payload.scope} + </PMText> + </PMBox> + )} + + {sortedRules.length > 0 && ( + <PMVStack gap={2} align="stretch" marginTop={6}> + <PMText fontSize="md" fontWeight="semibold"> + Rules + </PMText> + {sortedRules.map((rule, index) => ( + <PMBox key={index} p={3} bg="background.tertiary"> + <PMText fontSize="sm" color="primary"> + {rule.content} + </PMText> + </PMBox> + ))} + </PMVStack> + )} + </PMVStack> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/CreateStandardReviewDetail/index.ts b/apps/frontend/src/domain/change-proposals/components/CreateStandardReviewDetail/index.ts new file mode 100644 index 000000000..a1ce84a28 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/CreateStandardReviewDetail/index.ts @@ -0,0 +1 @@ +export { CreateStandardReviewDetail } from './CreateStandardReviewDetail'; diff --git a/apps/frontend/src/domain/change-proposals/components/DiffNavigator.tsx b/apps/frontend/src/domain/change-proposals/components/DiffNavigator.tsx new file mode 100644 index 000000000..fe859ae6b --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/DiffNavigator.tsx @@ -0,0 +1,66 @@ +import { PMHStack, PMIconButton, PMText, PMTooltip } from '@packmind/ui'; +import { LuChevronUp, LuChevronDown, LuScanSearch } from 'react-icons/lu'; + +interface DiffNavigatorProps { + currentIndex: number; + totalChanges: number; + hasScroll: boolean; + onNext: () => void; + onPrevious: () => void; + onScrollToCurrent: () => void; +} + +export function DiffNavigator({ + currentIndex, + totalChanges, + hasScroll, + onNext, + onPrevious, + onScrollToCurrent, +}: DiffNavigatorProps) { + if (totalChanges === 0 || !hasScroll) { + return null; + } + + return ( + <PMHStack + gap={0} + align="center" + backgroundColor="background.primary" + borderRadius="md" + > + <PMTooltip label="Previous change"> + <PMIconButton + aria-label="Previous change" + size="xs" + onClick={onPrevious} + disabled={currentIndex === 0} + > + <LuChevronUp /> + </PMIconButton> + </PMTooltip> + <PMText fontSize="sm"> + {currentIndex + 1} / {totalChanges} + </PMText> + <PMTooltip label="Next change"> + <PMIconButton + aria-label="Next change" + size="xs" + onClick={onNext} + disabled={currentIndex === totalChanges - 1} + > + <LuChevronDown /> + </PMIconButton> + </PMTooltip> + <PMTooltip label="Scroll to change"> + <PMIconButton + aria-label="Scroll to current change" + size="xs" + onClick={onScrollToCurrent} + > + <LuScanSearch /> + </PMIconButton> + </PMTooltip> + </PMHStack> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/ProposalDetailPlaceholder.tsx b/apps/frontend/src/domain/change-proposals/components/ProposalDetailPlaceholder.tsx new file mode 100644 index 000000000..bf84d5e20 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ProposalDetailPlaceholder.tsx @@ -0,0 +1,31 @@ +import { PMBox, PMSpinner, PMText } from '@packmind/ui'; + +export function ProposalDetailLoading() { + return ( + <PMBox + display="flex" + alignItems="center" + justifyContent="center" + minH="300px" + gridColumn="span 2" + > + <PMSpinner /> + </PMBox> + ); +} + +export function ProposalDetailEmpty() { + return ( + <PMBox + display="flex" + alignItems="center" + justifyContent="center" + minH="300px" + gridColumn="span 2" + > + <PMText color="secondary"> + This proposal has already been processed or does not exist. + </PMText> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/ProposalReviewHeader/ProposalReviewHeader.tsx b/apps/frontend/src/domain/change-proposals/components/ProposalReviewHeader/ProposalReviewHeader.tsx new file mode 100644 index 000000000..d362db56c --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ProposalReviewHeader/ProposalReviewHeader.tsx @@ -0,0 +1,246 @@ +import { + PMBadge, + PMBox, + PMButton, + PMHStack, + PMIcon, + PMSwitch, + PMText, + PMTooltip, + PMVStack, +} from '@packmind/ui'; +import { ChangeProposalId, UserId } from '@packmind/types'; +import { LuCheck, LuCircleAlert, LuUndo2, LuX } from 'react-icons/lu'; +import { + getChangeProposalFieldLabel, + getStatusBadgeProps, +} from '../../utils/changeProposalHelpers'; +import { ConflictWarning } from '../ChangeProposals/ConflictWarning'; +import { DiffNavigator } from '../DiffNavigator'; +import { ChangeProposalWithConflicts } from '../../types'; +import { RelativeTime } from '../shared/RelativeTime'; + +interface ProposalReviewHeaderProps { + proposal: ChangeProposalWithConflicts; + isOutdated: boolean; + proposalNumberMap: Map<ChangeProposalId, number>; + acceptedProposalIds: Set<ChangeProposalId>; + rejectedProposalIds: Set<ChangeProposalId>; + blockedByConflictIds: Set<ChangeProposalId>; + blockedByAcceptedMap: Map<ChangeProposalId, ChangeProposalId[]>; + userLookup: Map<UserId, string>; + showDiffPreviewToggle: boolean; + showPreview: boolean; + onPreviewChange: (checked: boolean) => void; + onSelectProposal: (id: ChangeProposalId) => void; + onPoolAccept: (id: ChangeProposalId) => void; + onPoolReject: (id: ChangeProposalId) => void; + onUndoPool: (id: ChangeProposalId) => void; + diffNavigation?: { + currentIndex: number; + totalChanges: number; + hasScroll: boolean; + onNext: () => void; + onPrevious: () => void; + onScrollToCurrent: () => void; + }; +} + +function AcceptButton({ + isOutdated, + isBlocked, + onAccept, +}: { + isOutdated: boolean; + isBlocked: boolean; + onAccept: () => void; +}) { + const isDisabled = isOutdated || isBlocked; + + const button = ( + <PMButton + size="xs" + variant="secondary" + disabled={isDisabled} + onClick={onAccept} + > + <LuCheck /> + Accept + </PMButton> + ); + + if (isOutdated) { + return ( + <PMTooltip label="This proposal is based on an outdated version and cannot be accepted"> + {button} + </PMTooltip> + ); + } + + if (isBlocked) { + return ( + <PMTooltip label="This proposal conflicts with an already accepted proposal"> + {button} + </PMTooltip> + ); + } + + return button; +} + +export function ProposalReviewHeader({ + proposal, + isOutdated, + proposalNumberMap, + acceptedProposalIds, + rejectedProposalIds, + blockedByConflictIds, + blockedByAcceptedMap, + userLookup, + showDiffPreviewToggle, + showPreview, + onPreviewChange, + onSelectProposal, + onPoolAccept, + onPoolReject, + onUndoPool, + diffNavigation, +}: Readonly<ProposalReviewHeaderProps>) { + const statusBadge = getStatusBadgeProps(proposal.status); + + const acceptedIds = blockedByConflictIds.has(proposal.id) + ? (blockedByAcceptedMap.get(proposal.id) ?? []) + : []; + const conflictingNumbers = acceptedIds + .map((id) => ({ id, number: proposalNumberMap.get(id) ?? 0 })) + .sort((a, b) => a.number - b.number); + + return ( + <PMBox + position="sticky" + top={0} + zIndex={1} + background="background.secondary" + borderBottom="1px solid" + borderColor="border.primary" + p={4} + > + <PMVStack gap={3}> + <PMHStack justify="space-between" align="center" width="full"> + <PMHStack gap={3} align="center"> + {isOutdated ? ( + <PMTooltip label="This proposal was made on an outdated version"> + <PMBadge colorPalette="orange" variant="subtle" size="sm"> + <PMIcon> + <LuCircleAlert /> + </PMIcon> + Outdated + </PMBadge> + </PMTooltip> + ) : ( + <PMBadge colorPalette={statusBadge.colorPalette} size="sm"> + {statusBadge.label} + </PMBadge> + )} + <PMText fontSize="sm" color="secondary"> + #{proposalNumberMap.get(proposal.id)} -{' '} + <RelativeTime date={proposal.createdAt} /> + </PMText> + {diffNavigation && ( + <DiffNavigator + currentIndex={diffNavigation.currentIndex} + totalChanges={diffNavigation.totalChanges} + hasScroll={diffNavigation.hasScroll} + onNext={diffNavigation.onNext} + onPrevious={diffNavigation.onPrevious} + onScrollToCurrent={diffNavigation.onScrollToCurrent} + /> + )} + </PMHStack> + <PMHStack gap={6} align="center"> + {showDiffPreviewToggle && ( + <PMHStack gap={2} align="center"> + <PMText fontSize="sm" color={showPreview ? 'faded' : 'primary'}> + Diff + </PMText> + <PMSwitch + size="sm" + checked={showPreview} + onCheckedChange={(e) => onPreviewChange(e.checked)} + css={{ + '& span[data-scope="switch"][data-part="control"]': { + bg: 'background.primary', + }, + }} + /> + <PMText fontSize="sm" color={showPreview ? 'primary' : 'faded'}> + Preview + </PMText> + </PMHStack> + )} + {acceptedProposalIds.has(proposal.id) || + rejectedProposalIds.has(proposal.id) ? ( + <PMButton + size="sm" + variant="outline" + onClick={() => onUndoPool(proposal.id)} + > + <LuUndo2 /> + Undo + </PMButton> + ) : ( + <PMHStack gap={2}> + <AcceptButton + isOutdated={isOutdated} + isBlocked={blockedByConflictIds.has(proposal.id)} + onAccept={() => onPoolAccept(proposal.id)} + /> + <PMButton + size="xs" + variant="secondary" + onClick={() => onPoolReject(proposal.id)} + > + <LuX /> + Dismiss + </PMButton> + </PMHStack> + )} + </PMHStack> + </PMHStack> + {conflictingNumbers.length > 0 && ( + <ConflictWarning + conflictingAcceptedNumbers={conflictingNumbers} + onSelectConflicting={onSelectProposal} + /> + )} + <PMVStack gap={1} align="stretch" width="full"> + <PMText fontWeight="bold" fontSize="sm"> + {getChangeProposalFieldLabel(proposal.type)} + </PMText> + <PMHStack gap={1}> + <PMText fontWeight="bold" fontSize="sm"> + From + </PMText> + <PMText fontSize="sm"> + {userLookup.get(proposal.createdBy) ?? 'Unknown user'} + </PMText> + </PMHStack> + <PMHStack gap={1}> + <PMText fontWeight="bold" fontSize="sm"> + Base version + </PMText> + <PMText fontSize="sm">{proposal.artefactVersion}</PMText> + </PMHStack> + {proposal.message ? ( + <PMHStack gap={1}> + <PMText fontWeight="bold" fontSize="sm"> + Message + </PMText> + <PMText fontSize="sm">{proposal.message}</PMText> + </PMHStack> + ) : null} + </PMVStack> + </PMVStack> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/ProposalReviewHeader/index.ts b/apps/frontend/src/domain/change-proposals/components/ProposalReviewHeader/index.ts new file mode 100644 index 000000000..6826df8c9 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ProposalReviewHeader/index.ts @@ -0,0 +1 @@ +export { ProposalReviewHeader } from './ProposalReviewHeader'; diff --git a/apps/frontend/src/domain/change-proposals/components/ReviewActionButtons.tsx b/apps/frontend/src/domain/change-proposals/components/ReviewActionButtons.tsx new file mode 100644 index 000000000..fc1101694 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ReviewActionButtons.tsx @@ -0,0 +1,39 @@ +import { PMButton, PMHStack } from '@packmind/ui'; +import { LuCheck, LuX } from 'react-icons/lu'; + +interface ReviewActionButtonsProps { + onAccept: () => void; + onDismiss: () => void; + isPending: boolean; +} + +export function ReviewActionButtons({ + onAccept, + onDismiss, + isPending, +}: ReviewActionButtonsProps) { + return ( + <PMHStack gap={2}> + <PMButton + size="xs" + variant="outline" + color="green.300" + borderColor="green.300" + disabled={isPending} + onClick={onAccept} + > + <LuCheck /> {isPending ? 'Applying...' : 'Accept'} + </PMButton> + <PMButton + size="xs" + variant="outline" + color="red.300" + borderColor="red.300" + disabled={isPending} + onClick={onDismiss} + > + <LuX /> Dismiss + </PMButton> + </PMHStack> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/ReviewChangesBlankState.tsx b/apps/frontend/src/domain/change-proposals/components/ReviewChangesBlankState.tsx new file mode 100644 index 000000000..2f0858caf --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ReviewChangesBlankState.tsx @@ -0,0 +1,496 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { + PMAccordion, + PMAlert, + PMBox, + PMButton, + PMGrid, + PMGridItem, + PMHeading, + PMHStack, + PMIcon, + PMLink, + PMRadioCard, + PMText, + PMVStack, +} from '@packmind/ui'; +import { + LuDownload, + LuFolderSync, + LuTerminal, + LuWandSparkles, +} from 'react-icons/lu'; +import { + CopiableTextField, + CopiableTextarea, +} from '../../../shared/components/inputs'; +import { useCreateCliLoginCodeMutation } from '../../accounts/api/queries/AuthQueries'; +import { + HOMEBREW_INSTALL_COMMAND, + NPM_INSTALL_COMMAND, + buildCurlInstallCommand, + formatCodeExpiresAt, + detectUserOs, +} from '../../accounts/components/LocalEnvironmentSetup/utils'; + +const DIFF_SUBMIT_COMMAND = `packmind-cli diff --submit -m "<your commit message>"`; + +const ARCADE_LOAD_TIMEOUT_MS = 5000; + +const ArcadeEmbed = () => { + const [isLoaded, setIsLoaded] = useState(false); + const [isTimedOut, setIsTimedOut] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setIsTimedOut(true), ARCADE_LOAD_TIMEOUT_MS); + return () => clearTimeout(timer); + }, []); + + if (isTimedOut && !isLoaded) return null; + + return ( + <div + style={{ + position: 'relative', + paddingBottom: 'calc(64.94708994708994% + 41px)', + height: '0', + width: '100%', + visibility: isLoaded ? 'visible' : 'hidden', + }} + > + <iframe + src="https://demo.arcade.software/wbBeKqYxj18jhRkKMHwo?embed&embed_mobile=tab&embed_desktop=inline&show_copy_link=true" + title="Review and Approve Accessibility Updates in Design System" + frameBorder="0" + loading="lazy" + allowFullScreen + allow="clipboard-write" + onLoad={() => setIsLoaded(true)} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + colorScheme: 'light', + }} + /> + </div> + ); +}; +const SKILL_COMMAND = '/packmind-update-playbook'; + +interface InstallSectionProps { + title: string; + description: string; + variant: 'primary' | 'secondary'; + children: React.ReactNode; +} + +const InstallSection: React.FC<InstallSectionProps> = ({ + title, + description, + variant, + children, +}) => ( + <PMVStack + align="flex-start" + gap={4} + width="full" + border="solid 1px" + borderColor={variant === 'primary' ? 'blue.700' : 'border.tertiary'} + padding={4} + borderRadius={4} + > + <PMVStack align="flex-start" gap={1}> + <PMHeading level="h6">{title}</PMHeading> + <PMText as="p" color="tertiary" variant="small"> + {description} + </PMText> + </PMVStack> + {children} + </PMVStack> +); + +export const ReviewChangesBlankState = () => { + const loginCodeMutation = useCreateCliLoginCodeMutation(); + const [selectedOs, setSelectedOs] = useState<'macos-linux' | 'windows'>( + detectUserOs, + ); + + useEffect(() => { + if (!loginCodeMutation.data && !loginCodeMutation.isPending) { + loginCodeMutation.mutate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleRegenerateCode = useCallback(() => { + loginCodeMutation.mutate(); + }, [loginCodeMutation]); + + const installCommand = useMemo( + () => + loginCodeMutation.data?.code + ? buildCurlInstallCommand(loginCodeMutation.data.code) + : '', + [loginCodeMutation.data?.code], + ); + + const codeExpiration = useMemo( + () => formatCodeExpiresAt(loginCodeMutation.data?.expiresAt), + [loginCodeMutation.data?.expiresAt], + ); + + return ( + <PMVStack gap={4} width="full"> + <PMBox + borderRadius={'md'} + backgroundColor={'background.primary'} + p={8} + border="solid 1px" + borderColor={'border.tertiary'} + > + <PMAlert.Root status="info" mb={4}> + <PMAlert.Indicator /> + <PMAlert.Content> + <PMAlert.Description> + Playbook update management will soon require an Enterprise plan. + </PMAlert.Description> + </PMAlert.Content> + </PMAlert.Root> + + <PMHeading level="h2">Review your team's playbook updates</PMHeading> + <PMText as="p" fontWeight={'medium'} color="secondary"> + When teammates propose changes to your coding standards, skills, or + commands, they'll appear here for your team to review and approve + together. + </PMText> + + <PMBox mt={8} width="full" maxWidth="640px"> + <ArcadeEmbed /> + </PMBox> + + <PMVStack alignItems={'flex-start'} width={'full'} mt={8} gap={4}> + <PMHeading level="h3">How to start?</PMHeading> + <PMAccordion.Root collapsible width="full"> + <PMAccordion.Item + value="install-cli" + backgroundColor="background.primary" + p={2} + border={'solid 1px'} + borderColor={'border.tertiary'} + borderRadius={'md'} + _open={{ borderColor: 'blue.500' }} + > + <PMAccordion.ItemTrigger cursor="pointer"> + <PMAccordion.ItemIndicator /> + <PMVStack align="flex-start" gap={1} width="full"> + <PMHStack gap={3}> + <PMIcon as={LuDownload} size="lg" color="text.secondary" /> + <PMHeading level="h5">Install the Packmind CLI</PMHeading> + </PMHStack> + <PMText as="p" color="tertiary" variant="small"> + Requires CLI >= 0.21.0 + </PMText> + </PMVStack> + </PMAccordion.ItemTrigger> + <PMAccordion.ItemContent p={6}> + <PMVStack align="flex-start" gap={4} width="full"> + <PMRadioCard.Root + size="sm" + variant="outline" + value={selectedOs} + onValueChange={(e) => + setSelectedOs(e.value as 'macos-linux' | 'windows') + } + > + <PMRadioCard.Label>Your operating system</PMRadioCard.Label> + <PMHStack gap={2} alignItems="stretch" justify="center"> + <PMRadioCard.Item value="macos-linux"> + <PMRadioCard.ItemHiddenInput /> + <PMRadioCard.ItemControl> + <PMRadioCard.ItemText> + macOS / Linux + </PMRadioCard.ItemText> + <PMRadioCard.ItemIndicator /> + </PMRadioCard.ItemControl> + </PMRadioCard.Item> + <PMRadioCard.Item value="windows"> + <PMRadioCard.ItemHiddenInput /> + <PMRadioCard.ItemControl> + <PMRadioCard.ItemText>Windows</PMRadioCard.ItemText> + <PMRadioCard.ItemIndicator /> + </PMRadioCard.ItemControl> + </PMRadioCard.Item> + </PMHStack> + </PMRadioCard.Root> + + {selectedOs === 'macos-linux' ? ( + <> + <InstallSection + title="Guided install" + description="One-line install script (installs the CLI and continues automatically)." + variant="primary" + > + <PMBox width="full"> + {loginCodeMutation.isPending ? ( + <PMText as="p" color="tertiary"> + Generating install command... + </PMText> + ) : ( + loginCodeMutation.data?.code && ( + <> + <PMText + variant="small" + color="primary" + as="p" + style={{ + fontWeight: 'medium', + marginBottom: '4px', + display: 'inline-block', + }} + > + Terminal + </PMText> + <CopiableTextarea + value={installCommand} + readOnly + rows={3} + /> + <PMHStack gap={2} marginTop={2}> + <PMText variant="small" color="tertiary"> + {codeExpiration} + </PMText> + <PMButton + variant="tertiary" + size="xs" + onClick={handleRegenerateCode} + > + Regenerate code + </PMButton> + </PMHStack> + </> + ) + )} + </PMBox> + </InstallSection> + + <InstallSection + title="Alternative" + description="Other installation methods." + variant="secondary" + > + <PMText + variant="small" + color="primary" + as="p" + style={{ + fontWeight: 'medium', + marginBottom: '4px', + display: 'inline-block', + }} + > + Terminal (Homebrew) + </PMText> + <CopiableTextarea + value={HOMEBREW_INSTALL_COMMAND} + readOnly + rows={2} + /> + <PMText + variant="small" + color="primary" + as="p" + style={{ + fontWeight: 'medium', + marginBottom: '4px', + marginTop: '12px', + display: 'inline-block', + }} + > + Terminal (NPM) + </PMText> + <CopiableTextField + value={NPM_INSTALL_COMMAND} + readOnly + /> + <PMAlert.Root status="info"> + <PMAlert.Indicator /> + <PMAlert.Content> + <PMAlert.Description> + Requires Node.js 22 or higher. + </PMAlert.Description> + </PMAlert.Content> + </PMAlert.Root> + </InstallSection> + </> + ) : ( + <InstallSection + title="Recommended" + description="Install via npm (most reliable across environments)." + variant="primary" + > + <PMAlert.Root status="info"> + <PMAlert.Indicator /> + <PMAlert.Content> + <PMAlert.Description> + Requires Node.js 22 or higher. + </PMAlert.Description> + </PMAlert.Content> + </PMAlert.Root> + <PMBox width="1/2"> + <CopiableTextField + value={NPM_INSTALL_COMMAND} + readOnly + label="Terminal (NPM)" + /> + </PMBox> + </InstallSection> + )} + + <PMText color="secondary" fontSize="sm"> + For more installation methods, see the{' '} + <PMLink + href="https://docs.packmind.com/cli#installation" + target="_blank" + variant="active" + > + CLI documentation + </PMLink> + . + </PMText> + </PMVStack> + </PMAccordion.ItemContent> + </PMAccordion.Item> + + <PMAccordion.Item + value="setup-repo" + backgroundColor="background.primary" + mt={4} + p={2} + border={'solid 1px'} + borderColor={'border.tertiary'} + borderRadius={'md'} + _open={{ borderColor: 'blue.500' }} + > + <PMAccordion.ItemTrigger cursor="pointer"> + <PMAccordion.ItemIndicator /> + <PMVStack align="flex-start" gap={1} width="full"> + <PMHStack gap={3}> + <PMIcon + as={LuFolderSync} + size="lg" + color="text.secondary" + /> + <PMHeading level="h5">Setup repo and skills</PMHeading> + </PMHStack> + <PMText as="p" color="tertiary" variant="small"> + Initialize Packmind in your repository to sync your + playbook. + </PMText> + </PMVStack> + </PMAccordion.ItemTrigger> + <PMAccordion.ItemContent p={6}> + <PMVStack align="flex-start" gap={4} width="full"> + <PMBox width="full"> + <CopiableTextField + value="packmind-cli init" + readOnly + label="New repo" + /> + </PMBox> + <PMBox width="full"> + <CopiableTextField + value="packmind-cli skills init" + readOnly + label="Existing repo" + /> + </PMBox> + </PMVStack> + </PMAccordion.ItemContent> + </PMAccordion.Item> + </PMAccordion.Root> + + <PMGrid gridTemplateColumns={'1fr 1fr'} gap={4} width={'full'}> + <PMGridItem> + <PMBox + backgroundColor={'background.primary'} + borderRadius={'md'} + p={6} + display={'flex'} + flexDirection={'column'} + gap={4} + alignItems={'flex-start'} + border={'solid 1px'} + borderColor={'border.tertiary'} + height={'full'} + > + <PMBox> + <PMHStack mb={2}> + <PMIcon color={'branding.primary'} size={'lg'}> + <LuWandSparkles /> + </PMIcon> + <PMHeading level="h5" fontWeight={'bold'}> + Use skill /packmind-update-playbook + </PMHeading> + </PMHStack> + <PMBox fontSize={'sm'} color={'text.secondary'}> + Invoke the skill in your AI agent to guide you through + submitting artifact updates. + </PMBox> + </PMBox> + <PMBox width="full"> + <CopiableTextField + value={SKILL_COMMAND} + readOnly + label="Terminal" + /> + </PMBox> + </PMBox> + </PMGridItem> + <PMGridItem> + <PMBox + backgroundColor={'background.primary'} + borderRadius={'md'} + p={6} + display={'flex'} + flexDirection={'column'} + gap={4} + alignItems={'flex-start'} + border={'solid 1px'} + borderColor={'border.tertiary'} + height={'full'} + > + <PMBox> + <PMHStack mb={2}> + <PMIcon color={'branding.primary'} size={'lg'}> + <LuTerminal /> + </PMIcon> + <PMHeading level="h5" fontWeight={'bold'}> + Update manually + </PMHeading> + </PMHStack> + <PMBox fontSize={'sm'} color={'text.secondary'}> + Edit your local playbook files (e.g.{' '} + <PMText as="span" fontFamily="mono" fontSize="xs"> + .claude/commands/git-commit.md + </PMText> + ), then submit changes via CLI. + </PMBox> + </PMBox> + <PMBox width="full"> + <CopiableTextField + value={DIFF_SUBMIT_COMMAND} + readOnly + label="Terminal" + /> + </PMBox> + </PMBox> + </PMGridItem> + </PMGrid> + </PMVStack> + </PMBox> + </PMVStack> + ); +}; diff --git a/apps/frontend/src/domain/change-proposals/components/ReviewChangesLearnMoreContent.tsx b/apps/frontend/src/domain/change-proposals/components/ReviewChangesLearnMoreContent.tsx new file mode 100644 index 000000000..1adea5256 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ReviewChangesLearnMoreContent.tsx @@ -0,0 +1,364 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + PMBox, + PMLink, + PMHStack, + PMVStack, + PMText, + PMHeading, + PMRadioCard, + PMButton, + PMBadge, + PMIcon, + PMAccordion, + PMAlert, +} from '@packmind/ui'; +import { LuTerminal, LuSend } from 'react-icons/lu'; + +import { + CopiableTextField, + CopiableTextarea, +} from '../../../shared/components/inputs'; +import { useCreateCliLoginCodeMutation } from '../../accounts/api/queries/AuthQueries'; +import { + HOMEBREW_INSTALL_COMMAND, + NPM_INSTALL_COMMAND, + buildCurlInstallCommand, + formatCodeExpiresAt, + detectUserOs, +} from '../../accounts/components/LocalEnvironmentSetup/utils'; + +const DIFF_SUBMIT_COMMAND = 'packmind-cli diff --submit'; + +interface InstallSectionProps { + title: string; + description: string; + variant: 'primary' | 'secondary'; + children: React.ReactNode; +} + +const InstallSection: React.FC<InstallSectionProps> = ({ + title, + description, + variant, + children, +}) => ( + <PMVStack + align="flex-start" + gap={4} + width="full" + border="solid 1px" + borderColor={variant === 'primary' ? 'blue.700' : 'border.tertiary'} + padding={4} + borderRadius={4} + > + <PMVStack align="flex-start" gap={1}> + <PMHeading level="h6">{title}</PMHeading> + <PMText as="p" color="tertiary" variant="small"> + {description} + </PMText> + </PMVStack> + {children} + </PMVStack> +); + +interface AccordionItemHeaderProps { + stepNumber: number; + icon: typeof LuTerminal; + title: string; + description: string; +} + +const AccordionItemHeader: React.FC<AccordionItemHeaderProps> = ({ + stepNumber, + icon, + title, + description, +}) => ( + <PMVStack align="flex-start" gap={1} width="full"> + <PMHStack gap={3}> + <PMBadge size="xs" borderRadius="full" px={3} py={1} fontWeight="bold"> + {stepNumber} + </PMBadge> + <PMIcon as={icon} size="lg" color="text.secondary" /> + <PMHeading level="h5">{title}</PMHeading> + </PMHStack> + <PMText as="p" color="tertiary" variant="small"> + {description} + </PMText> + </PMVStack> +); + +export const ReviewChangesLearnMoreContent: React.FC = () => { + const loginCodeMutation = useCreateCliLoginCodeMutation(); + const [selectedOs, setSelectedOs] = useState<'macos-linux' | 'windows'>( + detectUserOs, + ); + + useEffect(() => { + if (!loginCodeMutation.data && !loginCodeMutation.isPending) { + loginCodeMutation.mutate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleRegenerateCode = useCallback(() => { + loginCodeMutation.mutate(); + }, [loginCodeMutation]); + + const installCommand = useMemo( + () => + loginCodeMutation.data?.code + ? buildCurlInstallCommand(loginCodeMutation.data.code) + : '', + [loginCodeMutation.data?.code], + ); + + const codeExpiration = useMemo( + () => formatCodeExpiresAt(loginCodeMutation.data?.expiresAt), + [loginCodeMutation.data?.expiresAt], + ); + + return ( + <PMVStack gap={8} align="stretch"> + <PMBox> + <PMText color="tertiary"> + The Packmind CLI detects local changes to your standards, commands, + and skills, then submits them as change proposals for your team to + review. + </PMText> + </PMBox> + + <PMAccordion.Root collapsible> + <PMAccordion.Item + value="step-1" + backgroundColor="background.primary" + p={2} + border={'solid 1px'} + borderColor={'border.tertiary'} + borderRadius={'md'} + _open={{ borderColor: 'blue.500' }} + > + <PMAccordion.ItemTrigger cursor="pointer"> + <PMAccordion.ItemIndicator /> + <AccordionItemHeader + stepNumber={1} + icon={LuTerminal} + title="Install the Packmind CLI" + description="The CLI is required to detect and submit changes." + /> + </PMAccordion.ItemTrigger> + <PMAccordion.ItemContent p={6}> + <PMVStack align="flex-start" gap={4} width="full"> + <PMRadioCard.Root + size="sm" + variant="outline" + value={selectedOs} + onValueChange={(e) => + setSelectedOs(e.value as 'macos-linux' | 'windows') + } + > + <PMRadioCard.Label>Your operating system</PMRadioCard.Label> + <PMHStack gap={2} alignItems="stretch" justify="center"> + <PMRadioCard.Item value="macos-linux"> + <PMRadioCard.ItemHiddenInput /> + <PMRadioCard.ItemControl> + <PMRadioCard.ItemText>macOS / Linux</PMRadioCard.ItemText> + <PMRadioCard.ItemIndicator /> + </PMRadioCard.ItemControl> + </PMRadioCard.Item> + <PMRadioCard.Item value="windows"> + <PMRadioCard.ItemHiddenInput /> + <PMRadioCard.ItemControl> + <PMRadioCard.ItemText>Windows</PMRadioCard.ItemText> + <PMRadioCard.ItemIndicator /> + </PMRadioCard.ItemControl> + </PMRadioCard.Item> + </PMHStack> + </PMRadioCard.Root> + + {selectedOs === 'macos-linux' ? ( + <> + <InstallSection + title="Guided install" + description="One-line install script (installs the CLI and continues automatically)." + variant="primary" + > + <PMBox width="full"> + {loginCodeMutation.isPending ? ( + <PMText as="p" color="tertiary"> + Generating install command... + </PMText> + ) : ( + loginCodeMutation.data?.code && ( + <> + <PMText + variant="small" + color="primary" + as="p" + style={{ + fontWeight: 'medium', + marginBottom: '4px', + display: 'inline-block', + }} + > + Terminal + </PMText> + <CopiableTextarea + value={installCommand} + readOnly + rows={3} + /> + <PMHStack gap={2} marginTop={2}> + <PMText variant="small" color="tertiary"> + {codeExpiration} + </PMText> + <PMButton + variant="tertiary" + size="xs" + onClick={handleRegenerateCode} + > + Regenerate code + </PMButton> + </PMHStack> + </> + ) + )} + </PMBox> + </InstallSection> + + <InstallSection + title="Alternative" + description="Other installation methods." + variant="secondary" + > + <PMText + variant="small" + color="primary" + as="p" + style={{ + fontWeight: 'medium', + marginBottom: '4px', + display: 'inline-block', + }} + > + Terminal (Homebrew) + </PMText> + <CopiableTextarea + value={HOMEBREW_INSTALL_COMMAND} + readOnly + rows={2} + /> + <PMText + variant="small" + color="primary" + as="p" + style={{ + fontWeight: 'medium', + marginBottom: '4px', + marginTop: '12px', + display: 'inline-block', + }} + > + Terminal (NPM) + </PMText> + <CopiableTextField value={NPM_INSTALL_COMMAND} readOnly /> + <PMAlert.Root status="info"> + <PMAlert.Indicator /> + <PMAlert.Content> + <PMAlert.Description> + Requires Node.js 22 or higher. + </PMAlert.Description> + </PMAlert.Content> + </PMAlert.Root> + </InstallSection> + </> + ) : ( + <InstallSection + title="Recommended" + description="Install via npm (most reliable across environments)." + variant="primary" + > + <PMAlert.Root status="info"> + <PMAlert.Indicator /> + <PMAlert.Content> + <PMAlert.Description> + Requires Node.js 22 or higher. + </PMAlert.Description> + </PMAlert.Content> + </PMAlert.Root> + <PMBox width="1/2"> + <CopiableTextField + value={NPM_INSTALL_COMMAND} + readOnly + label="Terminal (NPM)" + /> + </PMBox> + </InstallSection> + )} + + <PMText color="secondary"> + For more installation methods, see the{' '} + <PMLink + href="https://docs.packmind.com/cli#installation" + target="_blank" + variant="active" + > + CLI documentation + </PMLink> + . + </PMText> + </PMVStack> + </PMAccordion.ItemContent> + </PMAccordion.Item> + + <PMAccordion.Item + value="step-2" + backgroundColor="background.primary" + mt={4} + p={2} + border={'solid 1px'} + borderColor={'border.tertiary'} + borderRadius={'md'} + _open={{ borderColor: 'blue.500' }} + > + <PMAccordion.ItemTrigger cursor="pointer"> + <PMAccordion.ItemIndicator /> + <AccordionItemHeader + stepNumber={2} + icon={LuSend} + title="Submit changes" + description="Detect local changes and submit them for review." + /> + </PMAccordion.ItemTrigger> + <PMAccordion.ItemContent p={6}> + <PMVStack align="flex-start" gap={4} width="full"> + <PMText as="p" color="secondary"> + Run the following command from your project directory to detect + differences between your local playbook files and the server, + then submit them as change proposals. + </PMText> + <PMBox width="1/2"> + <CopiableTextField + value={DIFF_SUBMIT_COMMAND} + readOnly + label="Terminal" + /> + </PMBox> + <PMAlert.Root status="info"> + <PMAlert.Indicator /> + <PMAlert.Content> + <PMAlert.Description> + You can also run <strong>packmind-cli diff</strong> without{' '} + <strong>--submit</strong> to preview changes before + submitting. + </PMAlert.Description> + </PMAlert.Content> + </PMAlert.Root> + </PMVStack> + </PMAccordion.ItemContent> + </PMAccordion.Item> + </PMAccordion.Root> + </PMVStack> + ); +}; diff --git a/apps/frontend/src/domain/change-proposals/components/ReviewChangesNavLink.tsx b/apps/frontend/src/domain/change-proposals/components/ReviewChangesNavLink.tsx new file mode 100644 index 000000000..efbe3bd0e --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ReviewChangesNavLink.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useGetGroupedChangeProposalsQuery } from '../api/queries/ChangeProposalsQueries'; +import { SidebarNavigationLink } from '../../organizations/components/SidebarNavigation'; +import { useGetSpaceBySlugQuery } from '../../spaces/api/queries/SpacesQueries'; +import { routes } from '../../../shared/utils/routes'; +import { LuGitPullRequestArrow } from 'react-icons/lu'; + +interface ReviewChangesNavLinkProps { + orgSlug: string; + spaceSlug: string; +} + +export function ReviewChangesNavLink({ + orgSlug, + spaceSlug, +}: ReviewChangesNavLinkProps): React.ReactElement { + const { data: space } = useGetSpaceBySlugQuery(spaceSlug); + const { data: groupedProposals } = useGetGroupedChangeProposalsQuery( + space?.id, + ); + + const totalArtefacts = groupedProposals + ? groupedProposals.standards.length + + groupedProposals.commands.length + + groupedProposals.skills.length + + groupedProposals.creations.length + : 0; + + return ( + <SidebarNavigationLink + url={routes.space.toReviewChanges(orgSlug, spaceSlug)} + label="Review changes" + icon={<LuGitPullRequestArrow />} + badge={ + totalArtefacts > 0 + ? { text: String(totalArtefacts), colorScheme: 'blue' } + : undefined + } + /> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/ReviewChangesSidebar.tsx b/apps/frontend/src/domain/change-proposals/components/ReviewChangesSidebar.tsx new file mode 100644 index 000000000..9d1530fd4 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/ReviewChangesSidebar.tsx @@ -0,0 +1,374 @@ +import React, { useMemo, useState } from 'react'; +import { NavLink, useParams, useSearchParams } from 'react-router'; +import { + PMBox, + PMCloseButton, + PMDialog, + PMFlex, + PMHStack, + PMIcon, + PMLink, + PMText, + PMTooltip, + PMVerticalNav, + PMVerticalNavSection, + PMVStack, +} from '@packmind/ui'; +import { LuInfo } from 'react-icons/lu'; +import { ReviewChangesBlankState } from './ReviewChangesBlankState'; +import { + ChangeProposalType, + CollectionItemAddPayload, + ListChangeProposalsBySpaceResponse, + SkillCreationProposalOverview, + SkillFile, + SkillId, +} from '@packmind/types'; +import { routes } from '../../../shared/utils/routes'; +import { useGetSkillWithFilesByIdQuery } from '../../skills/api/queries/SkillsQueries'; +import { useListChangeProposalsBySkillQuery } from '../api/queries/ChangeProposalsQueries'; +import { getFilePathsWithChanges } from '../utils/filterProposalsByFilePath'; +import { SkillFileFilterTree } from './SkillReviewDetail/SkillFileFilterTree'; + +const artefactTypeLabels: Record<string, string> = { + commands: 'Command', + standards: 'Standard', + skills: 'Skill', +}; + +function NavBadge({ + children, + minWidth, + px, +}: { + children: React.ReactNode; + minWidth?: string; + px?: number; +}) { + return ( + <PMFlex + alignItems="center" + justifyContent="center" + bg="yellow.800" + color="yellow.200" + borderRadius="full" + minWidth={minWidth} + px={px} + height="24px" + fontSize="xs" + fontWeight="bold" + flexShrink={0} + > + {children} + </PMFlex> + ); +} + +function SidebarNavLink({ + to, + name, + artefactType, + badge, +}: { + to: string; + name: string; + artefactType: string; + badge: React.ReactNode; +}) { + const typeLabel = artefactTypeLabels[artefactType] ?? artefactType; + + return ( + <NavLink to={to} prefetch="intent"> + {({ isActive }) => ( + <PMTooltip label={name} placement="bottom-start" openDelay={300}> + <PMLink + variant="navbar" + data-active={isActive ? 'true' : undefined} + as="span" + display="flex" + alignItems="center" + width="full" + py={2} + > + <PMHStack + width="full" + justifyContent="space-between" + gap={2} + minW={0} + > + <PMVStack gap={0} flex={1} minW={0} alignItems="flex-start"> + <PMText + fontSize="sm" + fontWeight={isActive ? 'bold' : 'medium'} + overflow="hidden" + textOverflow="ellipsis" + whiteSpace="nowrap" + maxW="100%" + > + {name} + </PMText> + <PMText fontSize="xs" opacity={0.5} fontWeight="normal"> + {typeLabel} + </PMText> + </PMVStack> + {badge} + </PMHStack> + </PMLink> + </PMTooltip> + )} + </NavLink> + ); +} + +interface ReviewChangesSidebarProps { + groupedProposals: ListChangeProposalsBySpaceResponse; +} + +export function ReviewChangesSidebar({ + groupedProposals, +}: ReviewChangesSidebarProps) { + const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false); + const { orgSlug, spaceSlug, artefactType, artefactId, proposalId } = + useParams<{ + orgSlug: string; + spaceSlug: string; + artefactType: string; + artefactId: string; + proposalId: string; + }>(); + const [searchParams, setSearchParams] = useSearchParams(); + + const isSkillSelected = artefactType === 'skills' && !!artefactId; + const isSkillCreationSelected = + artefactType === 'skills' && !artefactId && !!proposalId; + const skillId = isSkillSelected ? (artefactId as SkillId) : undefined; + + const { data: skillData } = useGetSkillWithFilesByIdQuery(skillId); + const { data: proposalsData } = useListChangeProposalsBySkillQuery(skillId); + + const files = skillData?.files ?? []; + const proposals = proposalsData?.changeProposals ?? []; + + const allFilePaths = useMemo(() => { + if (!isSkillSelected) return []; + + const existingPaths = files + .filter((f) => f.path !== 'SKILL.md') + .map((f) => f.path); + + const addFilePaths = proposals + .filter((p) => p.type === ChangeProposalType.addSkillFile) + .map((p) => { + const payload = p.payload as CollectionItemAddPayload< + Omit<SkillFile, 'id' | 'skillVersionId'> + >; + return payload.item.path; + }) + .filter((path) => path !== 'SKILL.md'); + + const pathSet = new Set([...existingPaths, ...addFilePaths]); + return Array.from(pathSet).sort((a, b) => a.localeCompare(b)); + }, [isSkillSelected, files, proposals]); + + const filePathsWithChanges = useMemo( + () => + isSkillSelected + ? getFilePathsWithChanges(proposals, files) + : new Set<string>(), + [isSkillSelected, proposals, files], + ); + + const skillCreation = useMemo(() => { + if (!isSkillCreationSelected) return undefined; + return groupedProposals.creations.find( + (c): c is SkillCreationProposalOverview => + c.id === proposalId && c.artefactType === 'skills', + ); + }, [isSkillCreationSelected, groupedProposals.creations, proposalId]); + + const creationFilePaths = useMemo(() => { + if (!skillCreation?.payload.files) return []; + return skillCreation.payload.files + .map((f) => f.path) + .filter((p) => p !== 'SKILL.md') + .sort((a, b) => a.localeCompare(b)); + }, [skillCreation]); + + const creationFilePathsWithChanges = useMemo( + () => new Set(creationFilePaths), + [creationFilePaths], + ); + + const selectedFilter = searchParams.get('file') ?? ''; + const handleFilterSelect = (filter: string) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (filter) { + next.set('file', filter); + } else { + next.delete('file'); + } + return next; + }, + { replace: true }, + ); + }; + + if (!orgSlug || !spaceSlug) { + return null; + } + + const allItems = [ + ...groupedProposals.commands.map((item) => ({ + ...item, + artefactType: 'commands' as const, + })), + ...groupedProposals.standards.map((item) => ({ + ...item, + artefactType: 'standards' as const, + })), + ...groupedProposals.skills.map((item) => ({ + ...item, + artefactType: 'skills' as const, + })), + ].sort((a, b) => b.lastContributedAt.localeCompare(a.lastContributedAt)); + + const sortedCreations = [...(groupedProposals.creations ?? [])].sort((a, b) => + b.lastContributedAt.localeCompare(a.lastContributedAt), + ); + + return ( + <PMVerticalNav logo={false} showLogoContainer={false} width="270px"> + <PMBox flex={1} minH={0} overflowY="auto"> + <PMVerticalNavSection + title="CHANGES TO REVIEW" + titleExtra={ + <> + <PMBox + display="inline-flex" + cursor="pointer" + onClick={() => setIsInfoDialogOpen(true)} + > + <PMIcon as={LuInfo} color="faded" /> + </PMBox> + <PMDialog.Root + open={isInfoDialogOpen} + onOpenChange={(details) => setIsInfoDialogOpen(details.open)} + size="xl" + scrollBehavior="inside" + > + <PMDialog.Backdrop /> + <PMDialog.Positioner> + <PMDialog.Content> + <PMDialog.Header> + <PMDialog.Title> + How to submit playbook changes + </PMDialog.Title> + <PMDialog.CloseTrigger asChild> + <PMCloseButton /> + </PMDialog.CloseTrigger> + </PMDialog.Header> + <PMDialog.Body> + <ReviewChangesBlankState /> + </PMDialog.Body> + </PMDialog.Content> + </PMDialog.Positioner> + </PMDialog.Root> + </> + } + navEntries={[ + ...allItems.map((item) => ( + <PMBox + key={`artefact-${item.artefactId}`} + borderBottom="1px solid" + borderColor="{colors.border.tertiary}" + > + <SidebarNavLink + to={routes.space.toReviewChangesArtefact( + orgSlug, + spaceSlug, + item.artefactType, + item.artefactId, + )} + name={item.name} + artefactType={item.artefactType} + badge={ + <NavBadge minWidth="24px"> + {item.changeProposalCount} + </NavBadge> + } + /> + </PMBox> + )), + ...sortedCreations.map((item) => ( + <PMBox + key={`creation-${item.id}`} + borderBottom="1px solid" + borderColor="{colors.border.tertiary}" + > + <SidebarNavLink + to={routes.space.toReviewChangesCreation( + orgSlug, + spaceSlug, + item.artefactType, + item.id, + )} + name={item.name} + artefactType={item.artefactType} + badge={<NavBadge px={2}>New</NavBadge>} + /> + </PMBox> + )), + ]} + /> + </PMBox> + + {isSkillSelected && allFilePaths.length > 0 && ( + <PMBox flex={1} minH={0} overflowY="auto" px={4}> + <PMText + fontSize="xs" + color="faded" + fontWeight="bold" + textTransform="uppercase" + mb={2} + overflow="hidden" + textOverflow="ellipsis" + whiteSpace="nowrap" + > + {skillData?.skill?.name ?? 'Skill'} + </PMText> + <SkillFileFilterTree + allFilePaths={allFilePaths} + filePathsWithChanges={filePathsWithChanges} + selectedFilter={selectedFilter} + onFilterSelect={handleFilterSelect} + /> + </PMBox> + )} + + {isSkillCreationSelected && creationFilePaths.length > 0 && ( + <PMBox flex={1} minH={0} overflowY="auto" px={4}> + <PMText + fontSize="xs" + color="faded" + fontWeight="bold" + textTransform="uppercase" + mb={2} + overflow="hidden" + textOverflow="ellipsis" + whiteSpace="nowrap" + > + {skillCreation?.name ?? 'Skill'} + </PMText> + <SkillFileFilterTree + allFilePaths={creationFilePaths} + filePathsWithChanges={creationFilePathsWithChanges} + selectedFilter={selectedFilter} + onFilterSelect={handleFilterSelect} + /> + </PMBox> + )} + </PMVerticalNav> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/FileItems/FileContent.tsx b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/FileItems/FileContent.tsx new file mode 100644 index 000000000..ad2df6053 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/FileItems/FileContent.tsx @@ -0,0 +1,66 @@ +import { + PMBox, + PMButton, + PMCodeMirror, + PMEmptyState, + PMMarkdownViewer, +} from '@packmind/ui'; +import { SkillFile } from '@packmind/types'; +import { + getFileLanguage, + getMimeType, +} from '../../../../skills/utils/fileTreeUtils'; + +const MARKDOWN_EXTENSIONS = ['.md', '.mdx', '.mdc']; + +export function isMarkdownPath(filePath: string): boolean { + return MARKDOWN_EXTENSIONS.some((ext) => + filePath.toLowerCase().endsWith(ext), + ); +} + +export function FileContent({ + file, +}: { + file: Pick<SkillFile, 'isBase64' | 'path' | 'content'>; +}) { + if (file.isBase64) { + const fileName = file.path.split('/').pop() ?? 'file'; + const handleDownload = () => { + const binaryString = atob(file.content); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const blob = new Blob([bytes], { type: getMimeType(file.path) }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); + }; + return ( + <PMEmptyState + title="Preview unavailable" + description="Binary file cannot be previewed" + > + <PMButton onClick={handleDownload}>Download file</PMButton> + </PMEmptyState> + ); + } + if (isMarkdownPath(file.path)) { + return ( + <PMBox p={4}> + <PMMarkdownViewer content={file.content} /> + </PMBox> + ); + } + return ( + <PMCodeMirror + value={file.content} + language={getFileLanguage(file.path)} + readOnly + /> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillContent/MetadataKeyValueDisplay.tsx b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillContent/MetadataKeyValueDisplay.tsx new file mode 100644 index 000000000..a92e3967d --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillContent/MetadataKeyValueDisplay.tsx @@ -0,0 +1,20 @@ +import { PMHStack, PMText, PMVStack } from '@packmind/ui'; + +export function MetadataKeyValueDisplay({ + metadata, +}: { + metadata: Record<string, string>; +}) { + return ( + <PMVStack gap={1} align="flex-start"> + {Object.entries(metadata).map(([key, value]) => ( + <PMHStack key={key} gap={2}> + <PMText fontSize="sm" fontWeight="bold"> + {key}: + </PMText> + <PMText fontSize="sm">{value}</PMText> + </PMHStack> + ))} + </PMVStack> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillContent/SkillOptionalField.tsx b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillContent/SkillOptionalField.tsx new file mode 100644 index 000000000..af589c68a --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillContent/SkillOptionalField.tsx @@ -0,0 +1,26 @@ +import { ReactNode } from 'react'; +import { PMText, PMVStack, PMVStackProps } from '@packmind/ui'; + +export function SkillOptionalField({ + label, + children, + ...rest +}: { + label: string; + children: ReactNode; +} & Omit<PMVStackProps, 'gap'>) { + return ( + <PMVStack gap={1} {...rest} align="flex-start"> + <PMText + fontSize="sm" + fontWeight="bold" + color="secondary" + width="full" + mb={2} + > + {label} + </PMText> + {children} + </PMVStack> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillContent/renderMarkdownDiffOrPreview.tsx b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillContent/renderMarkdownDiffOrPreview.tsx new file mode 100644 index 000000000..9602e2175 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillContent/renderMarkdownDiffOrPreview.tsx @@ -0,0 +1,42 @@ +import { PMBox, PMMarkdownViewer } from '@packmind/ui'; +import { ScalarUpdatePayload } from '@packmind/types'; +import { buildDiffHtml, markdownDiffCss } from '../../../utils/markdownDiff'; +import { + MarkdownEditor, + MarkdownEditorProvider, +} from '../../../../../shared/components/editor/MarkdownEditor'; + +export function renderMarkdownDiffOrPreview( + isDiff: boolean, + showPreview: boolean, + payload: ScalarUpdatePayload, + currentValue: string, + options?: { + diffBoxPadding?: string; + previewPaddingVariant?: 'none'; + defaultPaddingVariant?: 'none'; + }, +) { + if (isDiff && !showPreview) { + return ( + <PMBox padding={options?.diffBoxPadding} css={markdownDiffCss}> + <PMMarkdownViewer + htmlContent={buildDiffHtml(payload.oldValue, payload.newValue)} + /> + </PMBox> + ); + } + const value = isDiff ? payload.newValue : currentValue; + const paddingVariant = isDiff + ? options?.previewPaddingVariant + : options?.defaultPaddingVariant; + return ( + <MarkdownEditorProvider> + <MarkdownEditor + defaultValue={value} + readOnly + paddingVariant={paddingVariant} + /> + </MarkdownEditorProvider> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillDiffView.tsx b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillDiffView.tsx new file mode 100644 index 000000000..121905416 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillDiffView.tsx @@ -0,0 +1,243 @@ +import { useMemo } from 'react'; +import { PMBox, PMMarkdownViewer, PMText, PMVStack } from '@packmind/ui'; +import { ChangeProposalType, SkillFile } from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../../types'; +import { extractProposalDiffValues } from '../../utils/extractProposalDiffValues'; +import { buildDiffSections } from '../../utils/buildDiffSections'; +import { BinaryFilePlaceholder } from '../shared/BinaryFilePlaceholder'; +import { DiffBlock } from '../shared/DiffBlock'; +import { isMarkdownPath } from './FileItems/FileContent'; +import { + SCALAR_SKILL_TYPES, + SKILL_MD_MARKDOWN_TYPES, +} from '../../constants/skillProposalTypes'; +import { + getProposalFilePath, + isBinaryProposal, +} from '../../utils/groupSkillProposalsByFile'; + +interface SkillDiffViewProps { + proposal: ChangeProposalWithConflicts; + files: SkillFile[]; +} + +export function SkillDiffView({ + proposal, + files, +}: Readonly<SkillDiffViewProps>) { + const { oldValue, newValue } = extractProposalDiffValues(proposal); + const isScalar = SCALAR_SKILL_TYPES.has(proposal.type); + const isMarkdownField = SKILL_MD_MARKDOWN_TYPES.has(proposal.type); + + const diffSections = useMemo( + () => (isMarkdownField ? buildDiffSections(oldValue, newValue) : []), + [isMarkdownField, oldValue, newValue], + ); + + if (isScalar) { + return isMarkdownField ? ( + <RenderDiffSections + diffSections={diffSections} + isMarkdownField={true} + oldValue={oldValue} + newValue={newValue} + /> + ) : ( + <PMVStack gap={2} align="stretch"> + {oldValue && ( + <DiffBlock value={oldValue} variant="removed" isMarkdown={false} /> + )} + {newValue && ( + <DiffBlock value={newValue} variant="added" isMarkdown={false} /> + )} + </PMVStack> + ); + } + + return ( + <FileFocusedView + proposal={proposal} + files={files} + oldValue={oldValue} + newValue={newValue} + /> + ); +} + +function RenderDiffSections({ + diffSections, + isMarkdownField, + oldValue, + newValue, +}: Readonly<{ + diffSections: ReturnType<typeof buildDiffSections>; + isMarkdownField: boolean; + oldValue: string; + newValue: string; +}>) { + if (!isMarkdownField || diffSections.length === 0) { + return ( + <PMVStack gap={2} align="stretch"> + {oldValue && ( + <DiffBlock + value={oldValue} + variant="removed" + isMarkdown={isMarkdownField} + /> + )} + {newValue && ( + <DiffBlock + value={newValue} + variant="added" + isMarkdown={isMarkdownField} + /> + )} + </PMVStack> + ); + } + + return ( + <PMBox fontSize="sm"> + {diffSections.map((section, index) => + section.type === 'unchanged' ? ( + <PMMarkdownViewer key={index} content={section.value} /> + ) : ( + <PMBox + key={index} + borderRadius="md" + border="1px dashed" + borderColor="border.tertiary" + p={4} + my={2} + > + {section.oldValue && ( + <DiffBlock + value={section.oldValue} + variant="removed" + isMarkdown={true} + /> + )} + {section.newValue && ( + <PMBox mt={section.oldValue ? 2 : 0}> + <DiffBlock + value={section.newValue} + variant="added" + isMarkdown={true} + /> + </PMBox> + )} + </PMBox> + ), + )} + </PMBox> + ); +} + +function FileFocusedView({ + proposal, + files, + oldValue, + newValue, +}: Readonly<{ + proposal: ChangeProposalWithConflicts; + files: SkillFile[]; + oldValue: string; + newValue: string; +}>) { + const isAddFile = proposal.type === ChangeProposalType.addSkillFile; + const isDeleteFile = proposal.type === ChangeProposalType.deleteSkillFile; + const isUpdateContent = + proposal.type === ChangeProposalType.updateSkillFileContent; + const isUpdatePermissions = + proposal.type === ChangeProposalType.updateSkillFilePermissions; + + const filePath = useMemo( + () => getProposalFilePath(proposal, files), + [proposal, files], + ); + + const isBinary = isBinaryProposal(proposal); + const isMarkdown = isMarkdownPath(filePath); + + const diffSections = useMemo( + () => + isMarkdown && isUpdateContent + ? buildDiffSections(oldValue, newValue) + : [], + [isMarkdown, isUpdateContent, oldValue, newValue], + ); + + if (isBinary) { + return ( + <PMBox> + <PMText fontSize="xs" fontWeight="semibold" color="secondary" mb={2}> + {filePath} + </PMText> + <BinaryFilePlaceholder /> + </PMBox> + ); + } + + return ( + <PMBox> + <PMText fontSize="xs" fontWeight="semibold" color="secondary" mb={2}> + {filePath} + </PMText> + + {isAddFile && ( + <DiffBlock + value={newValue} + variant="added" + isMarkdown={isMarkdown} + filePath={filePath} + showIndicator={false} + /> + )} + + {isDeleteFile && ( + <DiffBlock + value={oldValue} + variant="removed" + isMarkdown={isMarkdown} + filePath={filePath} + showIndicator={false} + /> + )} + + {isUpdateContent && + (isMarkdown ? ( + <RenderDiffSections + diffSections={diffSections} + isMarkdownField={true} + oldValue={oldValue} + newValue={newValue} + /> + ) : ( + <PMVStack gap={2} align="stretch"> + <DiffBlock + value={oldValue} + variant="removed" + isMarkdown={false} + filePath={filePath} + /> + <DiffBlock + value={newValue} + variant="added" + isMarkdown={false} + filePath={filePath} + /> + </PMVStack> + ))} + + {isUpdatePermissions && ( + <PMVStack gap={2} align="stretch"> + <PMText fontSize="xs" color="secondary"> + Permissions change + </PMText> + <DiffBlock value={oldValue} variant="removed" isMarkdown={false} /> + <DiffBlock value={newValue} variant="added" isMarkdown={false} /> + </PMVStack> + )} + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillFileFilterTree.tsx b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillFileFilterTree.tsx new file mode 100644 index 000000000..a12a80a45 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillFileFilterTree.tsx @@ -0,0 +1,189 @@ +import { useMemo, useState } from 'react'; +import { LuFolder, LuFile, LuChevronRight, LuFiles } from 'react-icons/lu'; +import { + createFileTreeCollection, + PMBox, + PMIcon, + PMSeparator, + PMText, + PMTreeView, + PMTreeViewBranchIndentGuide, +} from '@packmind/ui'; +import { SKILL_MD_PATH } from '../../utils/groupSkillProposalsByFile'; + +interface SkillFileFilterTreeProps { + allFilePaths: string[]; + filePathsWithChanges: Set<string>; + selectedFilter: string; + onFilterSelect: (filter: string) => void; +} + +function ChangeIndicator() { + return ( + <PMBox bg="yellow.500" borderRadius="full" w="6px" h="6px" flexShrink={0} /> + ); +} + +function hasDescendantChanges( + dirPath: string, + filePathsWithChanges: Set<string>, +): boolean { + for (const path of filePathsWithChanges) { + if (path.startsWith(dirPath + '/')) return true; + } + return false; +} + +export function SkillFileFilterTree({ + allFilePaths, + filePathsWithChanges, + selectedFilter, + onFilterSelect, +}: Readonly<SkillFileFilterTreeProps>) { + const collection = useMemo( + () => createFileTreeCollection(allFilePaths), + [allFilePaths], + ); + + const [expandedValue, setExpandedValue] = useState(() => { + const dirs = new Set<string>(); + for (const filePath of allFilePaths) { + const parts = filePath.split('/'); + for (let i = 1; i < parts.length; i++) { + dirs.add(parts.slice(0, i).join('/')); + } + } + return Array.from(dirs); + }); + + const hasSkillMdChanges = filePathsWithChanges.has(SKILL_MD_PATH); + const isAllFilesSelected = selectedFilter === ''; + const isSkillMdSelected = selectedFilter === SKILL_MD_PATH; + + return ( + <PMBox> + {/* All Files item */} + <PMBox + px={3} + py={1.5} + cursor="pointer" + display="flex" + alignItems="center" + gap={2} + borderRadius="sm" + bg={isAllFilesSelected ? 'blue.900' : undefined} + _hover={{ bg: isAllFilesSelected ? 'blue.900' : 'blue.800' }} + onClick={() => onFilterSelect('')} + > + <PMIcon fontSize="sm"> + <LuFiles /> + </PMIcon> + <PMText + fontSize="sm" + fontWeight={isAllFilesSelected ? 'semibold' : 'normal'} + > + All Files + </PMText> + </PMBox> + + <PMSeparator borderColor="border.secondary" my={1} /> + + {/* SKILL.md item */} + <PMBox + px={3} + py={1.5} + cursor="pointer" + display="flex" + alignItems="center" + gap={2} + borderRadius="sm" + bg={isSkillMdSelected ? 'blue.900' : undefined} + _hover={{ bg: isSkillMdSelected ? 'blue.900' : 'blue.800' }} + onClick={() => onFilterSelect(SKILL_MD_PATH)} + > + <LuFile /> + <PMText + fontSize="sm" + fontWeight={isSkillMdSelected ? 'semibold' : 'normal'} + flex={1} + > + SKILL.md + </PMText> + {hasSkillMdChanges && <ChangeIndicator />} + </PMBox> + + {allFilePaths.length > 0 && ( + <> + <PMSeparator borderColor="border.secondary" my={1} /> + + {/* File tree */} + <PMTreeView.Root + collection={collection} + selectionMode="single" + selectedValue={selectedFilter ? [selectedFilter] : []} + onSelectionChange={(details: { selectedValue: string[] }) => { + const value = details.selectedValue[0]; + if (value) { + onFilterSelect(value); + } + }} + expandedValue={expandedValue} + onExpandedChange={(details: { expandedValue: string[] }) => { + setExpandedValue(details.expandedValue); + }} + width="full" + size="sm" + > + <PMTreeView.Tree> + <PMTreeView.Node + indentGuide={<PMTreeViewBranchIndentGuide />} + render={({ node, nodeState }) => { + if (nodeState.isBranch) { + const dirHasChanges = hasDescendantChanges( + node.value, + filePathsWithChanges, + ); + const isDirSelected = selectedFilter === node.value; + + return ( + <PMTreeView.Branch> + <PMTreeView.BranchControl + onClick={() => { + onFilterSelect(node.value); + }} + bg={isDirSelected ? 'blue.900' : undefined} + > + <PMTreeView.BranchIndicator> + <LuChevronRight /> + </PMTreeView.BranchIndicator> + <PMIcon> + <LuFolder /> + </PMIcon> + <PMTreeView.BranchText> + {node.label} + </PMTreeView.BranchText> + {dirHasChanges && <ChangeIndicator />} + </PMTreeView.BranchControl> + <PMTreeView.BranchContent /> + </PMTreeView.Branch> + ); + } + + const fileHasChanges = filePathsWithChanges.has(node.value); + + return ( + <PMTreeView.Item> + <LuFile /> + <PMTreeView.ItemText>{node.label}</PMTreeView.ItemText> + {fileHasChanges && <ChangeIndicator />} + </PMTreeView.Item> + ); + }} + /> + </PMTreeView.Tree> + </PMTreeView.Root> + </> + )} + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillGroupedAccordion.tsx b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillGroupedAccordion.tsx new file mode 100644 index 000000000..38199722e --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillGroupedAccordion.tsx @@ -0,0 +1,369 @@ +import { ReactNode, useMemo, useCallback } from 'react'; +import { LuSettings, LuFileText } from 'react-icons/lu'; +import { PMAccordion, PMBox, PMVStack } from '@packmind/ui'; +import { + ChangeProposalDecision, + ChangeProposalId, + ChangeProposalType, + SkillFile, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../../types'; +import { FRONTMATTER_SKILL_TYPES } from '../../constants/skillProposalTypes'; +import { ViewMode } from '../../hooks/useCardReviewState'; +import { + getProposalFilePath, + groupSkillProposalsByFile, + REMOVAL_GROUP_PATH, + SKILL_MD_PATH, +} from '../../utils/groupSkillProposalsByFile'; +import { ChangesSummaryBar } from '../shared/ChangesSummaryBar'; +import { ChangeProposalCard } from '../shared/ChangeProposalCard'; +import { ReviewedSectionDivider } from '../shared/ReviewedSectionDivider'; +import { FileGroupHeader } from '../shared/FileGroupHeader'; +import { SubSectionHeader } from '../shared/SubSectionHeader'; + +type PoolStatus = 'pending' | 'accepted' | 'dismissed'; + +interface SkillGroupedAccordionProps { + proposals: ChangeProposalWithConflicts[]; + files: SkillFile[]; + acceptedProposalIds: Set<ChangeProposalId>; + rejectedProposalIds: Set<ChangeProposalId>; + blockedByConflictIds: Set<ChangeProposalId>; + outdatedProposalIds: Set<ChangeProposalId>; + expandedCardIds: string[]; + showEditButton?: boolean | ((proposalType: ChangeProposalType) => boolean); + userLookup: Map<string, string>; + onToggleCard: (ids: string[]) => void; + getViewMode: (proposalId: ChangeProposalId) => ViewMode; + onViewModeChange: (proposalId: ChangeProposalId, mode: ViewMode) => void; + onEdit?: (proposalId: ChangeProposalId) => void; + onAccept: ( + proposalId: ChangeProposalId, + decision: ChangeProposalDecision, + ) => void; + onDismiss: (proposalId: ChangeProposalId) => void; + onUndo: (proposalId: ChangeProposalId) => void; + getDecisionForProposal?: ( + proposal: ChangeProposalWithConflicts, + ) => ChangeProposalDecision | undefined; + onExpandCard?: (id: string) => void; + renderExpandedView?: ( + viewMode: ViewMode, + proposal: ChangeProposalWithConflicts, + ) => ReactNode; +} + +function getPoolStatus( + proposalId: ChangeProposalId, + acceptedIds: Set<ChangeProposalId>, + rejectedIds: Set<ChangeProposalId>, +): PoolStatus { + if (acceptedIds.has(proposalId)) return 'accepted'; + if (rejectedIds.has(proposalId)) return 'dismissed'; + return 'pending'; +} + +export function SkillGroupedAccordion({ + proposals, + files, + acceptedProposalIds, + rejectedProposalIds, + blockedByConflictIds, + outdatedProposalIds, + expandedCardIds, + showEditButton, + userLookup, + onToggleCard, + getViewMode, + onViewModeChange, + onEdit, + onAccept, + onDismiss, + onUndo, + getDecisionForProposal, + onExpandCard, + renderExpandedView, +}: Readonly<SkillGroupedAccordionProps>) { + const proposalNumberMap = useMemo(() => { + const map = new Map<ChangeProposalId, number>(); + + const groupedByFile = new Map<string, ChangeProposalWithConflicts[]>(); + for (const p of proposals) { + const filePath = getProposalFilePath(p, files); + const list = groupedByFile.get(filePath) ?? []; + list.push(p); + groupedByFile.set(filePath, list); + } + + for (const [filePath, groupProposals] of groupedByFile) { + if (filePath === SKILL_MD_PATH) { + const frontmatter = groupProposals.filter((p) => + FRONTMATTER_SKILL_TYPES.has(p.type), + ); + const prompt = groupProposals.filter( + (p) => !FRONTMATTER_SKILL_TYPES.has(p.type), + ); + for (const subGroup of [frontmatter, prompt]) { + const sorted = [...subGroup].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + sorted.forEach((p, i) => map.set(p.id, i + 1)); + } + } else { + const sorted = [...groupProposals].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + sorted.forEach((p, i) => map.set(p.id, i + 1)); + } + } + + return map; + }, [proposals, files]); + + const fileGroups = useMemo( + () => + groupSkillProposalsByFile( + proposals, + files, + acceptedProposalIds, + rejectedProposalIds, + ), + [proposals, files, acceptedProposalIds, rejectedProposalIds], + ); + + const globalCounts = useMemo( + () => ({ + total: proposals.length, + pending: + proposals.length - acceptedProposalIds.size - rejectedProposalIds.size, + accepted: acceptedProposalIds.size, + dismissed: rejectedProposalIds.size, + }), + [proposals.length, acceptedProposalIds.size, rejectedProposalIds.size], + ); + + const handleValueChange = useCallback( + (details: { value: string[] }) => { + onToggleCard(details.value); + }, + [onToggleCard], + ); + + const { pendingFileGroups, reviewedFileGroups, totalReviewedCount } = + useMemo(() => { + const pending: { + filePath: string; + changeCount: number; + pendingCount: number; + proposals: ChangeProposalWithConflicts[]; + }[] = []; + const reviewed: { + filePath: string; + proposals: ChangeProposalWithConflicts[]; + }[] = []; + let reviewedCount = 0; + + for (const group of fileGroups) { + const pendingProposals = group.proposals.filter( + (p) => + getPoolStatus(p.id, acceptedProposalIds, rejectedProposalIds) === + 'pending', + ); + const reviewedProposals = group.proposals.filter( + (p) => + getPoolStatus(p.id, acceptedProposalIds, rejectedProposalIds) !== + 'pending', + ); + + if (pendingProposals.length > 0) { + pending.push({ + filePath: group.filePath, + changeCount: group.changeCount, + pendingCount: group.pendingCount, + proposals: pendingProposals, + }); + } + + if (reviewedProposals.length > 0) { + reviewed.push({ + filePath: group.filePath, + proposals: reviewedProposals, + }); + reviewedCount += reviewedProposals.length; + } + } + + return { + pendingFileGroups: pending, + reviewedFileGroups: reviewed, + totalReviewedCount: reviewedCount, + }; + }, [fileGroups, acceptedProposalIds, rejectedProposalIds]); + + const flatPending = useMemo( + () => pendingFileGroups.flatMap((g) => g.proposals), + [pendingFileGroups], + ); + + const expandNextPending = useCallback( + (proposalId: ChangeProposalId) => { + if (!onExpandCard) return; + const idx = flatPending.findIndex((p) => p.id === proposalId); + const next = flatPending + .slice(idx + 1) + .find((p) => !expandedCardIds.includes(p.id)); + if (next) onExpandCard(next.id); + }, + [onExpandCard, flatPending, expandedCardIds], + ); + + const handleAccept = useCallback( + (proposalId: ChangeProposalId, decision: ChangeProposalDecision) => { + onAccept(proposalId, decision); + expandNextPending(proposalId); + }, + [onAccept, expandNextPending], + ); + + const handleDismiss = useCallback( + (proposalId: ChangeProposalId) => { + onDismiss(proposalId); + expandNextPending(proposalId); + }, + [onDismiss, expandNextPending], + ); + + const resolveShowEditButton = useCallback( + (proposalType: ChangeProposalType): boolean | undefined => { + if (typeof showEditButton === 'function') { + return showEditButton(proposalType); + } + return showEditButton; + }, + [showEditButton], + ); + + const renderSkillMdSubSections = ( + proposals: ChangeProposalWithConflicts[], + ) => { + const frontmatter = proposals.filter((p) => + FRONTMATTER_SKILL_TYPES.has(p.type), + ); + const prompt = proposals.filter( + (p) => !FRONTMATTER_SKILL_TYPES.has(p.type), + ); + + return ( + <> + {frontmatter.length > 0 && ( + <PMVStack gap={3} width="full" align="stretch"> + <SubSectionHeader label="Frontmatter" icon={<LuSettings />} /> + {frontmatter.map(renderCard)} + </PMVStack> + )} + {prompt.length > 0 && ( + <PMVStack gap={3} width="full" align="stretch"> + <SubSectionHeader label="Prompt" icon={<LuFileText />} /> + {prompt.map(renderCard)} + </PMVStack> + )} + </> + ); + }; + + const renderCard = (proposal: ChangeProposalWithConflicts) => { + const poolStatus = getPoolStatus( + proposal.id, + acceptedProposalIds, + rejectedProposalIds, + ); + const viewMode = getViewMode(proposal.id); + const authorName = userLookup.get(proposal.createdBy) ?? 'Unknown'; + + return ( + <ChangeProposalCard + key={proposal.id} + proposal={proposal} + proposalNumber={proposalNumberMap.get(proposal.id) ?? 0} + poolStatus={poolStatus} + authorName={authorName} + viewMode={viewMode} + isOutdated={outdatedProposalIds.has(proposal.id)} + isBlockedByConflict={blockedByConflictIds.has(proposal.id)} + showToolbar={true} + showEditButton={resolveShowEditButton( + proposal.type as ChangeProposalType, + )} + decision={getDecisionForProposal?.(proposal)} + onViewModeChange={(mode) => onViewModeChange(proposal.id, mode)} + onEdit={() => onEdit?.(proposal.id)} + onAccept={(decision) => handleAccept(proposal.id, decision)} + onDismiss={() => handleDismiss(proposal.id)} + onUndo={() => onUndo(proposal.id)} + renderExpandedView={renderExpandedView} + /> + ); + }; + + return ( + <PMBox> + <ChangesSummaryBar + totalCount={globalCounts.total} + pendingCount={globalCounts.pending} + acceptedCount={globalCounts.accepted} + dismissedCount={globalCounts.dismissed} + /> + <PMBox px={6} pb={6}> + <PMAccordion.Root + collapsible + multiple + value={expandedCardIds} + onValueChange={handleValueChange} + > + <PMVStack gap={5} width="full"> + {pendingFileGroups.map((group) => ( + <PMVStack key={group.filePath} gap={3} width="full"> + {group.filePath !== REMOVAL_GROUP_PATH && ( + <FileGroupHeader + filePath={group.filePath} + changeCount={group.changeCount} + pendingCount={group.pendingCount} + /> + )} + {group.filePath === SKILL_MD_PATH + ? renderSkillMdSubSections(group.proposals) + : group.proposals.map(renderCard)} + </PMVStack> + ))} + {totalReviewedCount > 0 && ( + <> + <ReviewedSectionDivider count={totalReviewedCount} /> + {reviewedFileGroups.map((group) => ( + <PMVStack + key={`reviewed-${group.filePath}`} + gap={3} + width="full" + > + {group.filePath !== REMOVAL_GROUP_PATH && ( + <FileGroupHeader + filePath={group.filePath} + changeCount={group.proposals.length} + pendingCount={0} + /> + )} + {group.filePath === SKILL_MD_PATH + ? renderSkillMdSubSections(group.proposals) + : group.proposals.map(renderCard)} + </PMVStack> + ))} + </> + )} + </PMVStack> + </PMAccordion.Root> + </PMBox> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillInlineView.tsx b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillInlineView.tsx new file mode 100644 index 000000000..dfe28e62a --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillInlineView.tsx @@ -0,0 +1,223 @@ +import { useMemo } from 'react'; +import { + PMBox, + PMCodeMirror, + PMMarkdownViewer, + PMText, + PMVStack, +} from '@packmind/ui'; +import { ChangeProposalType, SkillFile } from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../../types'; +import { extractProposalDiffValues } from '../../utils/extractProposalDiffValues'; +import { renderDiffText } from '../../utils/renderDiffText'; +import { buildDiffSections } from '../../utils/buildDiffSections'; +import { BinaryFilePlaceholder } from '../shared/BinaryFilePlaceholder'; +import { isMarkdownPath } from './FileItems/FileContent'; +import { getFileLanguage } from '../../../skills/utils/fileTreeUtils'; +import { + SCALAR_SKILL_TYPES, + SKILL_MD_MARKDOWN_TYPES, +} from '../../constants/skillProposalTypes'; +import { + getProposalFilePath, + isBinaryProposal, +} from '../../utils/groupSkillProposalsByFile'; + +interface SkillInlineViewProps { + proposal: ChangeProposalWithConflicts; + files: SkillFile[]; +} + +export function SkillInlineView({ + proposal, + files, +}: Readonly<SkillInlineViewProps>) { + const { oldValue, newValue } = extractProposalDiffValues(proposal); + const isScalar = SCALAR_SKILL_TYPES.has(proposal.type); + const isMarkdownField = SKILL_MD_MARKDOWN_TYPES.has(proposal.type); + + if (isScalar) { + return isMarkdownField ? ( + <DescriptionInlineDiff oldValue={oldValue} newValue={newValue} /> + ) : ( + <PMBox + p={3} + bg="background.tertiary" + borderLeft="2px solid" + borderColor="border.tertiary" + borderRadius="md" + > + <PMText fontSize="sm">{renderDiffText(oldValue, newValue)}</PMText> + </PMBox> + ); + } + + return ( + <FileInlineView + proposal={proposal} + files={files} + oldValue={oldValue} + newValue={newValue} + /> + ); +} + +function DescriptionInlineDiff({ + oldValue, + newValue, +}: Readonly<{ + oldValue: string; + newValue: string; +}>) { + const sections = useMemo( + () => buildDiffSections(oldValue, newValue), + [oldValue, newValue], + ); + + return ( + <PMBox fontSize="sm"> + {sections.map((section, index) => + section.type === 'unchanged' ? ( + <PMMarkdownViewer key={index} content={section.value} /> + ) : ( + <PMBox + key={index} + borderRadius="md" + border="1px dashed" + borderColor="border.tertiary" + p={3} + my={2} + > + <PMText fontSize="sm" lineHeight="tall"> + {renderDiffText(section.oldValue, section.newValue)} + </PMText> + </PMBox> + ), + )} + </PMBox> + ); +} + +function FileInlineView({ + proposal, + files, + oldValue, + newValue, +}: Readonly<{ + proposal: ChangeProposalWithConflicts; + files: SkillFile[]; + oldValue: string; + newValue: string; +}>) { + const isAddFile = proposal.type === ChangeProposalType.addSkillFile; + const isDeleteFile = proposal.type === ChangeProposalType.deleteSkillFile; + const isUpdateContent = + proposal.type === ChangeProposalType.updateSkillFileContent; + const isUpdatePermissions = + proposal.type === ChangeProposalType.updateSkillFilePermissions; + + const filePath = useMemo( + () => getProposalFilePath(proposal, files), + [proposal, files], + ); + + const isBinary = isBinaryProposal(proposal); + const isMarkdown = isMarkdownPath(filePath); + + if (isBinary) { + return ( + <PMBox> + <PMText fontSize="xs" fontWeight="semibold" color="secondary" mb={2}> + {filePath} + </PMText> + <BinaryFilePlaceholder /> + </PMBox> + ); + } + + return ( + <PMBox> + <PMText fontSize="xs" fontWeight="semibold" color="secondary" mb={2}> + {filePath} + </PMText> + + {isAddFile && ( + <PMBox + p={3} + bg="green.500/10" + borderLeft="2px solid" + borderColor="green.500/30" + borderRadius="md" + fontSize="sm" + > + {isMarkdown ? ( + <PMMarkdownViewer content={newValue} /> + ) : ( + <PMCodeMirror + value={newValue} + language={getFileLanguage(filePath)} + readOnly + /> + )} + </PMBox> + )} + + {isDeleteFile && ( + <PMBox + p={3} + bg="red.500/10" + borderLeft="2px solid" + borderColor="red.500/30" + borderRadius="md" + fontSize="sm" + > + {isMarkdown ? ( + <PMBox opacity={0.7} textDecoration="line-through"> + <PMMarkdownViewer content={oldValue} /> + </PMBox> + ) : ( + <PMBox opacity={0.7}> + <PMCodeMirror + value={oldValue} + language={getFileLanguage(filePath)} + readOnly + /> + </PMBox> + )} + </PMBox> + )} + + {isUpdateContent && + (isMarkdown ? ( + <DescriptionInlineDiff oldValue={oldValue} newValue={newValue} /> + ) : ( + <PMBox + p={3} + bg="background.tertiary" + borderLeft="2px solid" + borderColor="border.tertiary" + borderRadius="md" + > + <PMText fontSize="sm">{renderDiffText(oldValue, newValue)}</PMText> + </PMBox> + ))} + + {isUpdatePermissions && ( + <PMVStack gap={2} align="stretch"> + <PMText fontSize="xs" color="secondary"> + Permissions change + </PMText> + <PMBox + p={3} + bg="background.tertiary" + borderLeft="2px solid" + borderColor="border.tertiary" + borderRadius="md" + > + <PMText fontSize="sm">{renderDiffText(oldValue, newValue)}</PMText> + </PMBox> + </PMVStack> + )} + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillOriginalTabContent.tsx b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillOriginalTabContent.tsx new file mode 100644 index 000000000..b3ec53e64 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillOriginalTabContent.tsx @@ -0,0 +1,97 @@ +import { useMemo } from 'react'; +import { PMBox, PMMarkdownViewer, PMText, PMVStack } from '@packmind/ui'; +import { Skill, SkillFile } from '@packmind/types'; +import { SKILL_MD_PATH } from '../../utils/groupSkillProposalsByFile'; +import { serializeSkillToMarkdown } from '../../utils/serializeArtifactToMarkdown'; +import { SkillFrontmatterInfo } from '../../../skills/components/SkillFrontmatterInfo'; +import { SkillFilePreview } from '../../../skills/components/SkillFilePreview'; +import { ArtifactResultFilePreview } from '../../../artifacts/components/ArtifactResultFilePreview'; + +interface SkillOriginalTabContentProps { + skill: Skill; + files: SkillFile[]; + filePathFilter?: string; +} + +function filterFiles(files: SkillFile[], filter: string): SkillFile[] { + return files.filter( + (f) => f.path === filter || f.path.startsWith(filter + '/'), + ); +} + +export function SkillOriginalTabContent({ + skill, + files, + filePathFilter = '', +}: Readonly<SkillOriginalTabContentProps>) { + const showScalarFields = !filePathFilter || filePathFilter === SKILL_MD_PATH; + const showFiles = !filePathFilter || filePathFilter !== SKILL_MD_PATH; + + const displayFiles = useMemo(() => { + const filtered = + filePathFilter && filePathFilter !== SKILL_MD_PATH + ? filterFiles(files, filePathFilter) + : files; + return [...filtered].sort((a, b) => a.path.localeCompare(b.path)); + }, [files, filePathFilter]); + + const markdown = useMemo(() => serializeSkillToMarkdown(skill), [skill]); + + const skillMdPreview = ( + <PMVStack align="stretch" gap={4}> + <SkillFrontmatterInfo skillVersion={skill} /> + <PMBox + border="solid 1px" + borderColor="border.primary" + borderRadius="md" + padding={4} + backgroundColor="background.primary" + > + <PMMarkdownViewer content={skill.prompt} /> + </PMBox> + </PMVStack> + ); + + return ( + <PMBox p={6}> + <PMText + fontSize="2xs" + fontWeight="medium" + textTransform="uppercase" + color="faded" + mb={6} + > + Original Version + </PMText> + + <PMVStack gap={6} align="stretch"> + {showScalarFields && ( + <ArtifactResultFilePreview + fileName={SKILL_MD_PATH} + markdown={markdown} + previewContent={skillMdPreview} + /> + )} + + {showFiles && displayFiles.length > 0 && ( + <PMVStack gap={4} align="stretch"> + <PMText fontSize="md" fontWeight="semibold"> + Files + </PMText> + {displayFiles.map((file) => ( + <PMBox + key={file.id} + border="solid 1px" + borderColor="border.tertiary" + borderRadius="md" + p={4} + > + <SkillFilePreview file={file} /> + </PMBox> + ))} + </PMVStack> + )} + </PMVStack> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillResultTabContent.tsx b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillResultTabContent.tsx new file mode 100644 index 000000000..9e3d0bd8f --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillResultTabContent.tsx @@ -0,0 +1,157 @@ +import { useCallback, useMemo } from 'react'; +import { PMBox, PMMarkdownViewer, PMText, PMVStack } from '@packmind/ui'; +import { + ChangeProposalId, + ChangeProposalType, + Skill, + SkillFile, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../../types'; +import { applySkillProposals } from '../../utils/applySkillProposals'; +import { PREVIEW_SKILL_VERSION_ID } from '../../utils/changeProposalHelpers'; +import { SKILL_MD_PATH } from '../../utils/groupSkillProposalsByFile'; +import { serializeSkillToMarkdown } from '../../utils/serializeArtifactToMarkdown'; +import { SkillFrontmatterInfo } from '../../../skills/components/SkillFrontmatterInfo'; +import { SkillFilePreview } from '../../../skills/components/SkillFilePreview'; +import { ArtifactResultFilePreview } from '../../../artifacts/components/ArtifactResultFilePreview'; + +interface SkillResultTabContentProps { + skill: Skill; + files: SkillFile[]; + proposals: ChangeProposalWithConflicts[]; + acceptedProposalIds: Set<ChangeProposalId>; + filePathFilter?: string; +} + +function filterFiles(files: SkillFile[], filter: string): SkillFile[] { + return files.filter( + (f) => f.path === filter || f.path.startsWith(filter + '/'), + ); +} + +export function SkillResultTabContent({ + skill, + files, + proposals, + acceptedProposalIds, + filePathFilter = '', +}: Readonly<SkillResultTabContentProps>) { + const applied = useMemo( + () => applySkillProposals(skill, files, proposals, acceptedProposalIds), + [skill, files, proposals, acceptedProposalIds], + ); + + const hasAccepted = acceptedProposalIds.size > 0; + + const hasAcceptedRemoval = proposals.some( + (p) => + acceptedProposalIds.has(p.id) && + p.type === ChangeProposalType.removeSkill, + ); + + const getPreviewCommand = useCallback( + () => ({ + recipeVersions: [], + standardVersions: [], + skillVersions: [ + { + id: PREVIEW_SKILL_VERSION_ID, + skillId: skill.id, + version: skill.version, + userId: skill.userId, + name: applied.name, + slug: skill.slug, + description: applied.description, + prompt: applied.prompt, + license: applied.license, + compatibility: applied.compatibility, + metadata: applied.metadata, + allowedTools: applied.allowedTools, + files: applied.files, + }, + ], + }), + [applied, skill], + ); + + const showScalarFields = !filePathFilter || filePathFilter === SKILL_MD_PATH; + const showFiles = !filePathFilter || filePathFilter !== SKILL_MD_PATH; + + const sortedFiles = useMemo(() => { + const filtered = + filePathFilter && filePathFilter !== SKILL_MD_PATH + ? filterFiles(applied.files, filePathFilter) + : applied.files; + return [...filtered].sort((a, b) => a.path.localeCompare(b.path)); + }, [applied.files, filePathFilter]); + + const markdown = useMemo(() => serializeSkillToMarkdown(applied), [applied]); + + const skillMdPreview = ( + <PMVStack align="stretch" gap={4}> + <SkillFrontmatterInfo skillVersion={applied} /> + <PMBox + border="solid 1px" + borderColor="border.primary" + borderRadius="md" + padding={4} + backgroundColor="background.primary" + > + <PMMarkdownViewer content={applied.prompt} /> + </PMBox> + </PMVStack> + ); + + return ( + <PMBox p={6}> + <PMText + fontSize="2xs" + fontWeight="medium" + textTransform="uppercase" + color="faded" + mb={6} + > + Version with accepted changes + </PMText> + + {hasAccepted ? ( + <PMVStack gap={6} align="stretch"> + {showScalarFields && ( + <ArtifactResultFilePreview + fileName={SKILL_MD_PATH} + markdown={markdown} + previewContent={skillMdPreview} + hideActions={hasAcceptedRemoval} + getPreviewCommand={getPreviewCommand} + /> + )} + + {showFiles && sortedFiles.length > 0 && ( + <PMVStack gap={4} align="stretch"> + <PMText fontSize="md" fontWeight="semibold"> + Files + </PMText> + {sortedFiles.map((file) => ( + <PMBox + key={file.id} + border="solid 1px" + borderColor="border.tertiary" + borderRadius="md" + p={4} + > + <SkillFilePreview file={file} /> + </PMBox> + ))} + </PMVStack> + )} + </PMVStack> + ) : ( + <PMBox py={12} textAlign="center"> + <PMText color="faded" fontStyle="italic"> + No accepted changes yet + </PMText> + </PMBox> + )} + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillReviewDetail.tsx b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillReviewDetail.tsx new file mode 100644 index 000000000..9769e90f3 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/SkillReviewDetail.tsx @@ -0,0 +1,398 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { PMAlertDialog, PMBox, PMSpinner } from '@packmind/ui'; +import { + AcceptedChangeProposal, + ChangeProposalDecision, + ChangeProposalId, + ChangeProposalStatus, + OrganizationId, + SkillId, + SpaceId, +} from '@packmind/types'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; +import { useCurrentSpace } from '../../../spaces/hooks/useCurrentSpace'; +import { useGetSkillWithFilesByIdQuery } from '../../../skills/api/queries/SkillsQueries'; +import { + useApplySkillChangeProposalsMutation, + useListChangeProposalsBySkillQuery, +} from '../../api/queries/ChangeProposalsQueries'; +import { + GET_CHANGE_PROPOSALS_BY_SKILL_KEY, + GET_GROUPED_CHANGE_PROPOSALS_KEY, +} from '../../api/queryKeys'; +import { + getSkillByIdKey, + getSkillBySlugKey, +} from '../../../skills/api/queryKeys'; +import { useUserLookup } from '../../hooks/useUserLookup'; +import { useChangeProposalPool } from '../../hooks/useChangeProposalPool'; +import { useNavigateAfterApply } from '../../hooks/useNavigateAfterApply'; +import { + useCardReviewState, + ReviewTab, + ViewMode, +} from '../../hooks/useCardReviewState'; +import { ChangeProposalWithConflicts } from '../../types'; +import { + computeRemovalOutdatedIds, + computeSkillOutdatedIds, + mergeOutdatedIds, +} from '../../utils/computeOutdatedProposalIds'; +import { + filterProposalsByFilePath, + getFilePathsWithChanges, + hasChangesForFilter, +} from '../../utils/filterProposalsByFilePath'; +import { ReviewHeader } from '../shared/ReviewHeader'; +import { SkillGroupedAccordion } from './SkillGroupedAccordion'; +import { extractProposalDiffValues } from '../../utils/extractProposalDiffValues'; +import { + getProposalFilePath, + isBinaryProposal, + SKILL_MD_PATH, +} from '../../utils/groupSkillProposalsByFile'; +import { isMarkdownPath } from './FileItems/FileContent'; +import { BinaryFilePlaceholder } from '../shared/BinaryFilePlaceholder'; +import { FocusedView } from '../shared/FocusedView'; +import { SkillDiffView } from './SkillDiffView'; +import { SkillInlineView } from './SkillInlineView'; +import { SkillOriginalTabContent } from './SkillOriginalTabContent'; +import { SkillResultTabContent } from './SkillResultTabContent'; +import { useBlocker, useBeforeUnload, useSearchParams } from 'react-router'; +import { routes } from '../../../../shared/utils/routes'; +import { SKILL_MD_MARKDOWN_TYPES } from '../../constants/skillProposalTypes'; +import { isEditableProposalType } from '../../utils/editableProposalTypes'; + +interface SkillReviewDetailProps { + artefactId: string; + orgSlug?: string; + spaceSlug?: string; +} + +export function SkillReviewDetail({ + artefactId, + orgSlug, + spaceSlug, +}: Readonly<SkillReviewDetailProps>) { + const skillId = artefactId as SkillId; + const { organization, user } = useAuthContext(); + const { spaceId } = useCurrentSpace(); + const queryClient = useQueryClient(); + const userLookup = useUserLookup(); + + const organizationId = organization?.id; + + const { data: selectedSkillData } = useGetSkillWithFilesByIdQuery(skillId); + const selectedSkill = selectedSkillData ?? undefined; + const skill = selectedSkill?.skill; + const files = selectedSkill?.files ?? []; + const skillSlug = skill?.slug; + + const applySkillChangeProposalsMutation = + useApplySkillChangeProposalsMutation({ orgSlug, spaceSlug, skillSlug }); + + const { data: selectedSkillProposalsData, isLoading: isLoadingProposals } = + useListChangeProposalsBySkillQuery(skillId); + + const selectedSkillProposals = + selectedSkillProposalsData?.changeProposals ?? []; + const currentPackageIds = selectedSkillProposalsData?.currentPackageIds ?? []; + + const pool = useChangeProposalPool(selectedSkillProposals); + const navigateToNextArtifact = useNavigateAfterApply(artefactId); + + const reviewState = useCardReviewState(); + + const hasInitiallyExpanded = useRef(false); + useEffect(() => { + if (hasInitiallyExpanded.current) return; + if (selectedSkillProposals.length === 0) return; + + const sorted = [...selectedSkillProposals].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + reviewState.toggleCard([sorted[0].id]); + hasInitiallyExpanded.current = true; + }, [selectedSkillProposals, reviewState.toggleCard]); + + const handleAcceptAndCollapse = useCallback( + (proposalId: ChangeProposalId, decision: ChangeProposalDecision) => { + pool.handlePoolAccept(proposalId, decision); + reviewState.collapseCard(proposalId); + }, + [pool.handlePoolAccept, reviewState.collapseCard], + ); + + const handleDismissAndCollapse = useCallback( + (proposalId: ChangeProposalId) => { + pool.handlePoolReject(proposalId); + reviewState.collapseCard(proposalId); + }, + [pool.handlePoolReject, reviewState.collapseCard], + ); + + const [searchParams] = useSearchParams(); + const filePathFilter = searchParams.get('file') ?? ''; + + const filePathsWithChanges = useMemo( + () => getFilePathsWithChanges(selectedSkillProposals, files), + [selectedSkillProposals, files], + ); + + const filteredProposals = useMemo( + () => + filterProposalsByFilePath(selectedSkillProposals, files, filePathFilter), + [selectedSkillProposals, files, filePathFilter], + ); + + const filterHasChanges = useMemo( + () => hasChangesForFilter(filePathsWithChanges, filePathFilter), + [filePathsWithChanges, filePathFilter], + ); + + const disabledTabs: ReviewTab[] = useMemo( + () => + !selectedSkillProposalsData || filterHasChanges + ? [] + : ['changes', 'result'], + [selectedSkillProposalsData, filterHasChanges], + ); + + useEffect(() => { + if (!selectedSkillProposalsData) return; + reviewState.setActiveTab(filterHasChanges ? 'changes' : 'original'); + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only auto-switch when file filter changes + }, [filePathFilter]); + + const outdatedProposalIds = useMemo( + () => + mergeOutdatedIds( + computeSkillOutdatedIds(selectedSkillProposals, skill, files), + computeRemovalOutdatedIds(selectedSkillProposals, currentPackageIds), + ), + [selectedSkillProposals, skill, files, currentPackageIds], + ); + + useBeforeUnload( + useCallback( + (event) => { + if (pool.hasPooledDecisions) { + event.preventDefault(); + } + }, + [pool.hasPooledDecisions], + ), + ); + + const blocker = useBlocker(pool.hasPooledDecisions); + + const handleSave = useCallback(async () => { + if (!organizationId || !spaceId) return; + if (!pool.hasPooledDecisions) return; + + const accepted = selectedSkillProposals.reduce((acc, changeProposal) => { + if (pool.acceptedProposalIds.has(changeProposal.id)) { + acc.push({ + ...changeProposal, + status: ChangeProposalStatus.applied, + decision: pool.getDecisionForChangeProposal(changeProposal), + }); + } + return acc; + }, [] as AcceptedChangeProposal[]); + + try { + await applySkillChangeProposalsMutation.mutateAsync({ + organizationId: organizationId as OrganizationId, + spaceId: spaceId as SpaceId, + artefactId: skillId, + accepted, + rejected: Array.from(pool.rejectedProposalIds), + }); + + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: GET_GROUPED_CHANGE_PROPOSALS_KEY, + }), + queryClient.invalidateQueries({ + queryKey: [...GET_CHANGE_PROPOSALS_BY_SKILL_KEY, skillId], + }), + queryClient.invalidateQueries({ + queryKey: getSkillByIdKey(spaceId, skillId), + }), + queryClient.invalidateQueries({ + queryKey: getSkillBySlugKey(spaceId, skillSlug), + }), + ]); + + pool.resetPool(); + navigateToNextArtifact(); + } catch { + // Errors are handled by the mutation onError callbacks + } + }, [ + organizationId, + spaceId, + pool.acceptedProposalIds, + pool.rejectedProposalIds, + pool.hasPooledDecisions, + applySkillChangeProposalsMutation, + queryClient, + pool.resetPool, + skillId, + navigateToNextArtifact, + ]); + + const renderExpandedView = useCallback( + (viewMode: ViewMode, proposal: ChangeProposalWithConflicts) => { + if (!skill) return null; + + if (viewMode === 'focused') { + if (isBinaryProposal(proposal)) { + return <BinaryFilePlaceholder />; + } + const filePath = getProposalFilePath(proposal, files); + const { oldValue, newValue } = extractProposalDiffValues(proposal); + const isMarkdownContent = + filePath === SKILL_MD_PATH + ? SKILL_MD_MARKDOWN_TYPES.has(proposal.type) + : isMarkdownPath(filePath); + return ( + <FocusedView + oldValue={oldValue} + newValue={newValue} + isMarkdownContent={isMarkdownContent} + filePath={filePath !== SKILL_MD_PATH ? filePath : undefined} + /> + ); + } + if (viewMode === 'diff') + return <SkillDiffView proposal={proposal} files={files} />; + if (viewMode === 'inline') + return <SkillInlineView proposal={proposal} files={files} />; + return null; + }, + [skill, files], + ); + + const latestProposal = useMemo(() => { + if (selectedSkillProposals.length === 0) return null; + return [...selectedSkillProposals].sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0]; + }, [selectedSkillProposals]); + + const latestAuthor = latestProposal + ? (userLookup.get(latestProposal.createdBy) ?? 'Unknown') + : ''; + const latestTime = latestProposal?.createdAt ?? new Date(); + + if (isLoadingProposals) { + return ( + <PMBox gridColumn="span 2" display="flex" justifyContent="center" p={8}> + <PMSpinner /> + </PMBox> + ); + } + + if (!skill) { + return null; + } + + const artefactLink = + orgSlug && spaceSlug && skillSlug + ? routes.space.toSkill(orgSlug, spaceSlug, skillSlug) + : undefined; + + return ( + <> + <PMBox + gridColumn="span 2" + display="flex" + flexDirection="column" + height="full" + overflowY="auto" + > + <ReviewHeader + artefactName={skill.name} + artefactVersion={skill.version} + latestAuthor={latestAuthor} + latestTime={latestTime} + activeTab={reviewState.activeTab} + onTabChange={reviewState.setActiveTab} + acceptedCount={pool.acceptedProposalIds.size} + dismissedCount={pool.rejectedProposalIds.size} + pendingCount={ + selectedSkillProposals.length - + pool.acceptedProposalIds.size - + pool.rejectedProposalIds.size + } + hasPooledDecisions={pool.hasPooledDecisions} + isSaving={applySkillChangeProposalsMutation.isPending} + onSave={handleSave} + disabledTabs={disabledTabs} + artefactLink={artefactLink} + /> + + {reviewState.activeTab === 'changes' && ( + <SkillGroupedAccordion + proposals={filteredProposals} + files={files} + acceptedProposalIds={pool.acceptedProposalIds} + rejectedProposalIds={pool.rejectedProposalIds} + blockedByConflictIds={pool.blockedByConflictIds} + outdatedProposalIds={outdatedProposalIds} + expandedCardIds={reviewState.expandedCardIds} + showEditButton={isEditableProposalType} + userLookup={userLookup} + onToggleCard={reviewState.toggleCard} + getViewMode={reviewState.getViewMode} + onViewModeChange={reviewState.setViewMode} + onAccept={handleAcceptAndCollapse} + onDismiss={handleDismissAndCollapse} + onUndo={pool.handleUndoPool} + getDecisionForProposal={pool.getDecisionForChangeProposal} + onExpandCard={reviewState.expandCard} + renderExpandedView={renderExpandedView} + /> + )} + + {reviewState.activeTab === 'original' && ( + <SkillOriginalTabContent + skill={skill} + files={files} + filePathFilter={filePathFilter} + /> + )} + + {reviewState.activeTab === 'result' && ( + <SkillResultTabContent + skill={skill} + files={files} + proposals={pool.proposalsWithDecisions} + acceptedProposalIds={pool.acceptedProposalIds} + filePathFilter={filePathFilter} + /> + )} + </PMBox> + + <PMAlertDialog + open={blocker.state === 'blocked'} + onOpenChange={(details) => { + if (!details.open) { + blocker.reset?.(); + } + }} + title="Unsaved changes" + message="You have unsaved changes. If you leave this page, your changes will be lost." + confirmText="Leave" + cancelText="Stay" + confirmColorScheme="red" + onConfirm={() => blocker.proceed?.()} + /> + </> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/index.ts b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/index.ts new file mode 100644 index 000000000..a4404c87c --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/SkillReviewDetail/index.ts @@ -0,0 +1 @@ +export { SkillReviewDetail } from './SkillReviewDetail'; diff --git a/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/StandardDiffView.tsx b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/StandardDiffView.tsx new file mode 100644 index 000000000..d9611030d --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/StandardDiffView.tsx @@ -0,0 +1,256 @@ +import { useMemo } from 'react'; +import { + PMBox, + PMHeading, + PMMarkdownViewer, + PMText, + PMVStack, +} from '@packmind/ui'; +import { + ChangeProposalType, + CollectionItemDeletePayload, + CollectionItemUpdatePayload, + Rule, + RuleId, + Standard, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../../types'; +import { extractProposalDiffValues } from '../../utils/extractProposalDiffValues'; +import { buildDiffSections } from '../../utils/buildDiffSections'; +import { DiffBlock } from '../shared/DiffBlock'; + +interface StandardDiffViewProps { + proposal: ChangeProposalWithConflicts; + standard: Standard; + rules: Rule[]; +} + +const standardProposalTypes = new Set<ChangeProposalType>([ + ChangeProposalType.updateStandardName, + ChangeProposalType.updateStandardDescription, + ChangeProposalType.updateStandardScope, + ChangeProposalType.addRule, + ChangeProposalType.updateRule, + ChangeProposalType.deleteRule, +]); + +function getTargetRuleId( + proposal: ChangeProposalWithConflicts, +): RuleId | undefined { + if ( + proposal.type === ChangeProposalType.updateRule || + proposal.type === ChangeProposalType.deleteRule + ) { + const payload = proposal.payload as + | CollectionItemUpdatePayload<RuleId> + | CollectionItemDeletePayload<{ id: string; content: string }>; + return payload.targetId as RuleId; + } + return undefined; +} + +export function StandardDiffView({ + proposal, + standard, + rules, +}: Readonly<StandardDiffViewProps>) { + const { oldValue, newValue } = extractProposalDiffValues(proposal); + + const isStandardProposal = standardProposalTypes.has(proposal.type); + const isNameChange = proposal.type === ChangeProposalType.updateStandardName; + const isDescriptionChange = + proposal.type === ChangeProposalType.updateStandardDescription; + const isScopeChange = + proposal.type === ChangeProposalType.updateStandardScope; + const isAddRule = proposal.type === ChangeProposalType.addRule; + const isUpdateRule = proposal.type === ChangeProposalType.updateRule; + const isDeleteRule = proposal.type === ChangeProposalType.deleteRule; + + const contextOpacity = 0.5; + + const targetRuleId = getTargetRuleId(proposal); + + const sortedRules = useMemo( + () => + [...rules].sort((a, b) => + a.content.toLowerCase().localeCompare(b.content.toLowerCase()), + ), + [rules], + ); + + const descriptionSections = useMemo( + () => (isDescriptionChange ? buildDiffSections(oldValue, newValue) : []), + [isDescriptionChange, oldValue, newValue], + ); + + if (!isStandardProposal) { + return null; + } + + return ( + <PMBox> + {/* Name section */} + <PMBox mb={4}> + {isNameChange ? ( + <PMVStack gap={2} align="stretch"> + <PMText fontSize="xs" fontWeight="semibold" color="secondary"> + Name change + </PMText> + <DiffBlock value={oldValue} variant="removed" isMarkdown={false} /> + <DiffBlock value={newValue} variant="added" isMarkdown={false} /> + </PMVStack> + ) : ( + <PMHeading size="h5" color="primary" opacity={contextOpacity}> + {standard.name} + </PMHeading> + )} + </PMBox> + + {/* Scope section */} + {(standard.scope || isScopeChange) && ( + <PMBox mb={4}> + {isScopeChange ? ( + <PMVStack gap={2} align="stretch"> + <PMText fontSize="xs" fontWeight="semibold" color="secondary"> + Scope change + </PMText> + {oldValue && ( + <DiffBlock + value={oldValue} + variant="removed" + isMarkdown={false} + /> + )} + {newValue && ( + <DiffBlock + value={newValue} + variant="added" + isMarkdown={false} + /> + )} + </PMVStack> + ) : ( + <PMBox opacity={contextOpacity}> + <PMText as="p" fontSize="md" fontWeight="semibold"> + Scope + </PMText> + <PMText fontSize="sm" color="faded" mt={1}> + {standard.scope} + </PMText> + </PMBox> + )} + </PMBox> + )} + + {/* Description section */} + <PMBox mb={4}> + {isDescriptionChange ? ( + <PMBox fontSize="sm"> + {descriptionSections.map((section, index) => + section.type === 'unchanged' ? ( + <PMMarkdownViewer key={index} content={section.value} /> + ) : ( + <PMBox + key={index} + borderRadius="md" + border="1px dashed" + borderColor="border.tertiary" + p={4} + my={2} + > + {section.oldValue && ( + <DiffBlock + value={section.oldValue} + variant="removed" + isMarkdown={true} + /> + )} + {section.newValue && ( + <PMBox mt={section.oldValue ? 2 : 0}> + <DiffBlock + value={section.newValue} + variant="added" + isMarkdown={true} + /> + </PMBox> + )} + </PMBox> + ), + )} + </PMBox> + ) : ( + <PMBox opacity={contextOpacity} fontSize="sm"> + <PMMarkdownViewer content={standard.description} /> + </PMBox> + )} + </PMBox> + + {/* Rules section */} + {(sortedRules.length > 0 || isAddRule) && ( + <PMVStack gap={2} align="stretch" marginTop={2}> + <PMText fontSize="md" fontWeight="semibold"> + Rules + </PMText> + + {sortedRules.map((rule) => { + const isTargetRule = rule.id === targetRuleId; + + if (isTargetRule && isUpdateRule) { + return ( + <PMBox key={rule.id}> + <DiffBlock + value={oldValue} + variant="removed" + isMarkdown={false} + /> + <PMBox mt={2}> + <DiffBlock + value={newValue} + variant="added" + isMarkdown={false} + /> + </PMBox> + </PMBox> + ); + } + + if (isTargetRule && isDeleteRule) { + return ( + <PMBox key={rule.id}> + <DiffBlock + value={rule.content} + variant="removed" + isMarkdown={false} + showIndicator={false} + /> + </PMBox> + ); + } + + return ( + <PMBox + key={rule.id} + p={3} + bg="background.tertiary" + opacity={contextOpacity} + > + <PMText fontSize="sm" color="primary"> + {rule.content} + </PMText> + </PMBox> + ); + })} + + {isAddRule && ( + <DiffBlock + value={newValue} + variant="added" + isMarkdown={false} + showIndicator={false} + /> + )} + </PMVStack> + )} + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/StandardInlineView.tsx b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/StandardInlineView.tsx new file mode 100644 index 000000000..7c18f85d0 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/StandardInlineView.tsx @@ -0,0 +1,244 @@ +import { useMemo } from 'react'; +import { + PMBox, + PMHeading, + PMMarkdownViewer, + PMText, + PMVStack, +} from '@packmind/ui'; +import { + ChangeProposalType, + CollectionItemDeletePayload, + CollectionItemUpdatePayload, + Rule, + RuleId, + Standard, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../../types'; +import { extractProposalDiffValues } from '../../utils/extractProposalDiffValues'; +import { renderDiffText } from '../../utils/renderDiffText'; +import { buildDiffSections } from '../../utils/buildDiffSections'; + +interface StandardInlineViewProps { + proposal: ChangeProposalWithConflicts; + standard: Standard; + rules: Rule[]; +} + +const standardProposalTypes = new Set([ + ChangeProposalType.updateStandardName, + ChangeProposalType.updateStandardDescription, + ChangeProposalType.updateStandardScope, + ChangeProposalType.addRule, + ChangeProposalType.updateRule, + ChangeProposalType.deleteRule, +]); + +function isStandardProposal(type: ChangeProposalType): boolean { + return standardProposalTypes.has(type); +} + +export function StandardInlineView({ + proposal, + standard, + rules, +}: Readonly<StandardInlineViewProps>) { + const { oldValue, newValue } = extractProposalDiffValues(proposal); + + const sortedRules = useMemo( + () => + [...rules].sort((a, b) => + a.content.toLowerCase().localeCompare(b.content.toLowerCase()), + ), + [rules], + ); + + const targetRuleId = useMemo(() => { + if (proposal.type === ChangeProposalType.updateRule) { + return (proposal.payload as CollectionItemUpdatePayload<RuleId>).targetId; + } + if (proposal.type === ChangeProposalType.deleteRule) { + return ( + proposal.payload as CollectionItemDeletePayload<{ + id: string; + content: string; + }> + ).targetId as RuleId; + } + return null; + }, [proposal]); + + if (!isStandardProposal(proposal.type)) { + return null; + } + + const isNameChange = proposal.type === ChangeProposalType.updateStandardName; + const isDescriptionChange = + proposal.type === ChangeProposalType.updateStandardDescription; + const isScopeChange = + proposal.type === ChangeProposalType.updateStandardScope; + const isAddRule = proposal.type === ChangeProposalType.addRule; + const isUpdateRule = proposal.type === ChangeProposalType.updateRule; + const isDeleteRule = proposal.type === ChangeProposalType.deleteRule; + const isRuleChange = isAddRule || isUpdateRule || isDeleteRule; + + return ( + <PMBox> + {/* Name section */} + <PMBox mb={4}> + {isNameChange ? ( + <PMHeading size="h5">{renderDiffText(oldValue, newValue)}</PMHeading> + ) : ( + <PMHeading size="h5" color={isRuleChange ? 'faded' : undefined}> + {standard.name} + </PMHeading> + )} + </PMBox> + + {/* Description section */} + <PMBox + mb={4} + fontSize="sm" + opacity={isDescriptionChange ? 1 : isRuleChange ? 0.5 : 0.7} + > + {isDescriptionChange ? ( + <DescriptionInlineDiff oldValue={oldValue} newValue={newValue} /> + ) : ( + <PMMarkdownViewer content={standard.description} /> + )} + </PMBox> + + {/* Scope section */} + {(standard.scope || isScopeChange) && ( + <PMBox mb={4}> + <PMText as="p" fontSize="md" fontWeight="semibold" mb={1}> + Scope + </PMText> + {isScopeChange ? ( + <PMText fontSize="sm">{renderDiffText(oldValue, newValue)}</PMText> + ) : ( + <PMText fontSize="sm" color="faded"> + {standard.scope} + </PMText> + )} + </PMBox> + )} + + {/* Rules section */} + {(sortedRules.length > 0 || isAddRule) && ( + <PMVStack gap={2} align="stretch" marginTop={2}> + <PMText fontSize="md" fontWeight="semibold"> + Rules + </PMText> + + {sortedRules.map((rule) => { + if (isDeleteRule && rule.id === targetRuleId) { + return ( + <PMBox + key={rule.id} + p={3} + bg="red.500/10" + borderLeft="2px solid" + borderColor="red.500/30" + borderRadius="md" + > + <PMText + fontSize="sm" + textDecoration="line-through" + color="error" + > + {rule.content} + </PMText> + </PMBox> + ); + } + + if (isUpdateRule && rule.id === targetRuleId) { + return ( + <PMBox + key={rule.id} + p={3} + bg="background.tertiary" + borderLeft="2px solid" + borderColor="border.tertiary" + borderRadius="md" + > + <PMText fontSize="sm"> + {renderDiffText(oldValue, newValue)} + </PMText> + </PMBox> + ); + } + + return ( + <PMBox key={rule.id} p={3} bg="background.tertiary"> + <PMText + fontSize="sm" + color={isRuleChange ? 'faded' : 'primary'} + > + {rule.content} + </PMText> + </PMBox> + ); + })} + + {isAddRule && ( + <PMBox + p={3} + bg="green.500/10" + borderLeft="2px solid" + borderColor="green.500/30" + borderRadius="md" + > + <PMText fontSize="sm" color="success"> + {newValue} + </PMText> + </PMBox> + )} + </PMVStack> + )} + </PMBox> + ); +} + +/** + * Renders markdown description with inline word-level diffs. + * Uses buildDiffSections to find changed regions, then renders + * unchanged sections as normal markdown and changed sections with + * word-level diff highlighting via renderDiffText. + */ +function DescriptionInlineDiff({ + oldValue, + newValue, +}: Readonly<{ + oldValue: string; + newValue: string; +}>) { + const sections = useMemo( + () => buildDiffSections(oldValue, newValue), + [oldValue, newValue], + ); + + return ( + <PMBox fontSize="sm"> + {sections.map((section, index) => + section.type === 'unchanged' ? ( + <PMMarkdownViewer key={index} content={section.value} /> + ) : ( + <PMBox + key={index} + borderRadius="md" + border="1px dashed" + borderColor="border.tertiary" + p={3} + my={2} + > + <PMText fontSize="sm" lineHeight="tall"> + {renderDiffText(section.oldValue, section.newValue)} + </PMText> + </PMBox> + ), + )} + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/StandardOriginalTabContent.tsx b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/StandardOriginalTabContent.tsx new file mode 100644 index 000000000..506f5cf7a --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/StandardOriginalTabContent.tsx @@ -0,0 +1,44 @@ +import { PMBox, PMText } from '@packmind/ui'; +import { useMemo } from 'react'; +import { Rule, Standard } from '@packmind/types'; +import { serializeStandardToMarkdown } from '../../utils/serializeArtifactToMarkdown'; +import { ArtifactResultFilePreview } from '../../../artifacts/components/ArtifactResultFilePreview'; + +interface StandardOriginalTabContentProps { + standard: Standard; + rules: Rule[]; +} + +export function StandardOriginalTabContent({ + standard, + rules, +}: Readonly<StandardOriginalTabContentProps>) { + const markdown = useMemo( + () => + serializeStandardToMarkdown({ + name: standard.name, + scope: standard.scope ?? '', + description: standard.description, + rules, + }), + [standard, rules], + ); + + return ( + <PMBox p={6}> + <PMText + fontSize="2xs" + fontWeight="medium" + textTransform="uppercase" + color="faded" + mb={6} + > + Original Version + </PMText> + <ArtifactResultFilePreview + fileName={`standard-${standard.slug}.md`} + markdown={markdown} + /> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/StandardResultTabContent.tsx b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/StandardResultTabContent.tsx new file mode 100644 index 000000000..b274c3517 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/StandardResultTabContent.tsx @@ -0,0 +1,94 @@ +import { PMBox, PMText } from '@packmind/ui'; +import { useCallback, useMemo } from 'react'; +import { + ChangeProposalId, + ChangeProposalType, + Rule, + Standard, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../../types'; +import { applyStandardProposals } from '../../utils/applyStandardProposals'; +import { PREVIEW_STANDARD_VERSION_ID } from '../../utils/changeProposalHelpers'; +import { serializeStandardToMarkdown } from '../../utils/serializeArtifactToMarkdown'; +import { ArtifactResultFilePreview } from '../../../artifacts/components/ArtifactResultFilePreview'; + +interface StandardResultTabContentProps { + standard: Standard; + rules: Rule[]; + proposals: ChangeProposalWithConflicts[]; + acceptedProposalIds: Set<ChangeProposalId>; +} + +export function StandardResultTabContent({ + standard, + rules, + proposals, + acceptedProposalIds, +}: Readonly<StandardResultTabContentProps>) { + const applied = useMemo( + () => + applyStandardProposals(standard, rules, proposals, acceptedProposalIds), + [standard, rules, proposals, acceptedProposalIds], + ); + + const hasAccepted = acceptedProposalIds.size > 0; + + const hasAcceptedRemoval = proposals.some( + (p) => + acceptedProposalIds.has(p.id) && + p.type === ChangeProposalType.removeStandard, + ); + + const getPreviewCommand = useCallback( + () => ({ + recipeVersions: [], + standardVersions: [ + { + id: PREVIEW_STANDARD_VERSION_ID, + standardId: standard.id, + name: applied.name, + slug: standard.slug, + description: applied.description, + version: standard.version, + scope: applied.scope, + rules: applied.rules, + }, + ], + skillVersions: [], + }), + [applied, standard], + ); + + const markdown = useMemo( + () => serializeStandardToMarkdown(applied), + [applied], + ); + + return ( + <PMBox p={6}> + <PMText + fontSize="2xs" + fontWeight="medium" + textTransform="uppercase" + color="faded" + mb={6} + > + Version with accepted changes + </PMText> + {hasAccepted ? ( + <ArtifactResultFilePreview + fileName={`standard-${standard.slug}.md`} + markdown={markdown} + hideActions={hasAcceptedRemoval} + getPreviewCommand={getPreviewCommand} + /> + ) : ( + <PMBox py={12} textAlign="center"> + <PMText color="faded" fontStyle="italic"> + No accepted changes yet + </PMText> + </PMBox> + )} + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/StandardReviewDetail.tsx b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/StandardReviewDetail.tsx new file mode 100644 index 000000000..e6c9e70c1 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/StandardReviewDetail.tsx @@ -0,0 +1,341 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { PMAlertDialog, PMBox, PMSpinner } from '@packmind/ui'; +import { + AcceptedChangeProposal, + ChangeProposalDecision, + ChangeProposalId, + ChangeProposalStatus, + OrganizationId, + SpaceId, + StandardId, +} from '@packmind/types'; +import { isEditableProposalType } from '../../utils/editableProposalTypes'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; +import { useCurrentSpace } from '../../../spaces/hooks/useCurrentSpace'; +import { + useGetRulesByStandardIdQuery, + useGetStandardByIdQuery, +} from '../../../standards/api/queries/StandardsQueries'; +import { + useApplyStandardChangeProposalsMutation, + useListChangeProposalsByStandardQuery, +} from '../../api/queries/ChangeProposalsQueries'; +import { + GET_CHANGE_PROPOSALS_BY_STANDARD_KEY, + GET_GROUPED_CHANGE_PROPOSALS_KEY, +} from '../../api/queryKeys'; +import { useUserLookup } from '../../hooks/useUserLookup'; +import { useChangeProposalPool } from '../../hooks/useChangeProposalPool'; +import { useNavigateAfterApply } from '../../hooks/useNavigateAfterApply'; +import { useCardReviewState, ViewMode } from '../../hooks/useCardReviewState'; +import { ChangeProposalWithConflicts } from '../../types'; +import { getStandardByIdKey } from '../../../standards/api/queryKeys'; +import { + computeRemovalOutdatedIds, + computeStandardOutdatedIds, + mergeOutdatedIds, +} from '../../utils/computeOutdatedProposalIds'; +import { ChangeProposalAccordion } from '../shared/ChangeProposalAccordion'; +import { ReviewHeader } from '../shared/ReviewHeader'; +import { StandardDiffView } from './StandardDiffView'; +import { StandardInlineView } from './StandardInlineView'; +import { StandardOriginalTabContent } from './StandardOriginalTabContent'; +import { StandardResultTabContent } from './StandardResultTabContent'; +import { useBeforeUnload, useBlocker } from 'react-router'; +import { routes } from '../../../../shared/utils/routes'; + +interface StandardReviewDetailProps { + artefactId: string; + orgSlug?: string; + spaceSlug?: string; +} + +export function StandardReviewDetail({ + artefactId, + orgSlug, + spaceSlug, +}: Readonly<StandardReviewDetailProps>) { + const standardId = artefactId as StandardId; + const { organization, user } = useAuthContext(); + const { spaceId } = useCurrentSpace(); + const queryClient = useQueryClient(); + const userLookup = useUserLookup(); + + const organizationId = organization?.id; + + const applyStandardChangeProposalsMutation = + useApplyStandardChangeProposalsMutation({ orgSlug, spaceSlug }); + const { data: selectedStandardProposalsData, isLoading: isLoadingProposals } = + useListChangeProposalsByStandardQuery(standardId); + + const selectedStandardProposals = + selectedStandardProposalsData?.changeProposals ?? []; + const currentPackageIds = + selectedStandardProposalsData?.currentPackageIds ?? []; + + const { data: selectedStandardData } = useGetStandardByIdQuery(standardId); + const selectedStandard = selectedStandardData?.standard ?? undefined; + + const { data: rulesData, isLoading: isLoadingRules } = + useGetRulesByStandardIdQuery( + organizationId as OrganizationId, + spaceId as SpaceId, + standardId, + ); + const rules = rulesData ?? []; + + const pool = useChangeProposalPool(selectedStandardProposals); + const navigateToNextArtifact = useNavigateAfterApply(artefactId); + + const reviewState = useCardReviewState(); + + const hasInitiallyExpanded = useRef(false); + useEffect(() => { + if (hasInitiallyExpanded.current) return; + if (selectedStandardProposals.length === 0) return; + + const sorted = [...selectedStandardProposals].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + reviewState.toggleCard([sorted[0].id]); + hasInitiallyExpanded.current = true; + }, [selectedStandardProposals, reviewState.toggleCard]); + + const handleAcceptAndCollapse = useCallback( + (proposalId: ChangeProposalId, decision: ChangeProposalDecision) => { + pool.handlePoolAccept(proposalId, decision); + reviewState.collapseCard(proposalId); + }, + [pool.handlePoolAccept, reviewState.collapseCard], + ); + + const handleDismissAndCollapse = useCallback( + (proposalId: ChangeProposalId) => { + pool.handlePoolReject(proposalId); + reviewState.collapseCard(proposalId); + }, + [pool.handlePoolReject, reviewState.collapseCard], + ); + + const outdatedProposalIds = useMemo( + () => + mergeOutdatedIds( + computeStandardOutdatedIds( + selectedStandardProposals, + selectedStandard, + rules, + ), + computeRemovalOutdatedIds(selectedStandardProposals, currentPackageIds), + ), + [selectedStandardProposals, selectedStandard, rules, currentPackageIds], + ); + + useBeforeUnload( + useCallback( + (event) => { + if (pool.hasPooledDecisions) { + event.preventDefault(); + } + }, + [pool.hasPooledDecisions], + ), + ); + + const blocker = useBlocker(pool.hasPooledDecisions); + + const handleSave = useCallback(async () => { + if (!organizationId || !spaceId) return; + if (!pool.hasPooledDecisions) return; + + const accepted = selectedStandardProposals.reduce((acc, changeProposal) => { + if (pool.acceptedProposalIds.has(changeProposal.id)) { + acc.push({ + ...changeProposal, + status: ChangeProposalStatus.applied, + decision: pool.getDecisionForChangeProposal(changeProposal), + }); + } + return acc; + }, [] as AcceptedChangeProposal[]); + + try { + await applyStandardChangeProposalsMutation.mutateAsync({ + organizationId: organizationId as OrganizationId, + spaceId: spaceId as SpaceId, + artefactId: standardId, + accepted, + rejected: Array.from(pool.rejectedProposalIds), + }); + + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: GET_GROUPED_CHANGE_PROPOSALS_KEY, + }), + queryClient.invalidateQueries({ + queryKey: [...GET_CHANGE_PROPOSALS_BY_STANDARD_KEY, standardId], + }), + queryClient.invalidateQueries({ + queryKey: getStandardByIdKey(spaceId, standardId), + }), + ]); + + pool.resetPool(); + navigateToNextArtifact(); + } catch { + // Errors are handled by the mutation onError callbacks + } + }, [ + organizationId, + spaceId, + pool.acceptedProposalIds, + pool.rejectedProposalIds, + pool.hasPooledDecisions, + applyStandardChangeProposalsMutation, + queryClient, + pool.resetPool, + standardId, + navigateToNextArtifact, + ]); + + const renderExpandedView = useCallback( + (viewMode: ViewMode, proposal: ChangeProposalWithConflicts) => { + if (!selectedStandard) return null; + + if (viewMode === 'diff') + return ( + <StandardDiffView + proposal={proposal} + standard={selectedStandard} + rules={rules} + /> + ); + if (viewMode === 'inline') + return ( + <StandardInlineView + proposal={proposal} + standard={selectedStandard} + rules={rules} + /> + ); + return null; + }, + [selectedStandard, rules], + ); + + const latestProposal = useMemo(() => { + if (selectedStandardProposals.length === 0) return null; + return [...selectedStandardProposals].sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0]; + }, [selectedStandardProposals]); + + const latestAuthor = latestProposal + ? (userLookup.get(latestProposal.createdBy) ?? 'Unknown') + : ''; + const latestTime = latestProposal?.createdAt ?? new Date(); + + if (isLoadingProposals || isLoadingRules) { + return ( + <PMBox gridColumn="span 2" display="flex" justifyContent="center" p={8}> + <PMSpinner /> + </PMBox> + ); + } + + if (!selectedStandard) { + return null; + } + + const artefactLink = + orgSlug && spaceSlug + ? routes.space.toStandard(orgSlug, spaceSlug, artefactId) + : undefined; + + return ( + <> + <PMBox + gridColumn="span 2" + display="flex" + flexDirection="column" + height="full" + overflowY="auto" + > + <ReviewHeader + artefactName={selectedStandard.name} + artefactVersion={selectedStandard.version} + latestAuthor={latestAuthor} + latestTime={latestTime} + activeTab={reviewState.activeTab} + onTabChange={reviewState.setActiveTab} + acceptedCount={pool.acceptedProposalIds.size} + dismissedCount={pool.rejectedProposalIds.size} + pendingCount={ + selectedStandardProposals.length - + pool.acceptedProposalIds.size - + pool.rejectedProposalIds.size + } + hasPooledDecisions={pool.hasPooledDecisions} + isSaving={applyStandardChangeProposalsMutation.isPending} + onSave={handleSave} + artefactLink={artefactLink} + /> + + {reviewState.activeTab === 'changes' && ( + <ChangeProposalAccordion + proposals={selectedStandardProposals} + acceptedProposalIds={pool.acceptedProposalIds} + rejectedProposalIds={pool.rejectedProposalIds} + blockedByConflictIds={pool.blockedByConflictIds} + outdatedProposalIds={outdatedProposalIds} + expandedCardIds={reviewState.expandedCardIds} + showEditButton={isEditableProposalType} + userLookup={userLookup} + onToggleCard={reviewState.toggleCard} + getViewMode={reviewState.getViewMode} + onViewModeChange={reviewState.setViewMode} + onAccept={handleAcceptAndCollapse} + onDismiss={handleDismissAndCollapse} + onUndo={pool.handleUndoPool} + getDecisionForProposal={pool.getDecisionForChangeProposal} + onExpandCard={reviewState.expandCard} + renderExpandedView={renderExpandedView} + /> + )} + + {reviewState.activeTab === 'original' && ( + <StandardOriginalTabContent + standard={selectedStandard} + rules={rules} + /> + )} + + {reviewState.activeTab === 'result' && ( + <StandardResultTabContent + standard={selectedStandard} + rules={rules} + proposals={pool.proposalsWithDecisions} + acceptedProposalIds={pool.acceptedProposalIds} + /> + )} + </PMBox> + + <PMAlertDialog + open={blocker.state === 'blocked'} + onOpenChange={(details) => { + if (!details.open) { + blocker.reset?.(); + } + }} + title="Unsaved changes" + message="You have unsaved changes. If you leave this page, your changes will be lost." + confirmText="Leave" + cancelText="Stay" + confirmColorScheme="red" + onConfirm={() => blocker.proceed?.()} + /> + </> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/index.ts b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/index.ts new file mode 100644 index 000000000..e64b67037 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/index.ts @@ -0,0 +1 @@ +export { StandardReviewDetail } from './StandardReviewDetail'; diff --git a/apps/frontend/src/domain/change-proposals/components/SubmissionBanner.tsx b/apps/frontend/src/domain/change-proposals/components/SubmissionBanner.tsx new file mode 100644 index 000000000..db4824318 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/SubmissionBanner.tsx @@ -0,0 +1,45 @@ +import { Link } from 'react-router'; +import { PMAlert, PMLink } from '@packmind/ui'; +import { SubmittedState } from '../types'; + +interface SubmissionBannerProps { + submittedState: SubmittedState; + artefactLabel: string; +} + +export function SubmissionBanner({ + submittedState, + artefactLabel, +}: SubmissionBannerProps) { + const capitalised = + artefactLabel.charAt(0).toUpperCase() + artefactLabel.slice(1); + + if (submittedState.type === 'accepted') { + return ( + <PMAlert.Root status="success"> + <PMAlert.Indicator /> + <PMAlert.Content> + <PMAlert.Title>{capitalised} created</PMAlert.Title> + <PMAlert.Description> + The new {artefactLabel} has been added to your space.{' '} + <PMLink asChild> + <Link to={submittedState.artefactUrl}>View {artefactLabel}</Link> + </PMLink> + </PMAlert.Description> + </PMAlert.Content> + </PMAlert.Root> + ); + } + + return ( + <PMAlert.Root status="info"> + <PMAlert.Indicator /> + <PMAlert.Content> + <PMAlert.Title>Proposal rejected</PMAlert.Title> + <PMAlert.Description> + This creation proposal has been rejected. + </PMAlert.Description> + </PMAlert.Content> + </PMAlert.Root> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ApplyButton.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ApplyButton.tsx new file mode 100644 index 000000000..3901c0281 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ApplyButton.tsx @@ -0,0 +1,30 @@ +import { ApplyConfirmationPopover } from './ApplyConfirmationPopover'; + +interface ApplyButtonProps { + acceptedCount: number; + dismissedCount: number; + pendingCount: number; + hasPooledDecisions: boolean; + isSaving: boolean; + onSave: () => void; +} + +export function ApplyButton({ + acceptedCount, + dismissedCount, + pendingCount, + hasPooledDecisions, + isSaving, + onSave, +}: Readonly<ApplyButtonProps>) { + return ( + <ApplyConfirmationPopover + acceptedCount={acceptedCount} + dismissedCount={dismissedCount} + pendingCount={pendingCount} + hasPooledDecisions={hasPooledDecisions} + isSaving={isSaving} + onSave={onSave} + /> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ApplyConfirmationPopover.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ApplyConfirmationPopover.tsx new file mode 100644 index 000000000..127866874 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ApplyConfirmationPopover.tsx @@ -0,0 +1,116 @@ +import { + PMPopover, + PMButton, + PMText, + PMBox, + PMHStack, + PMVStack, + PMAlert, +} from '@packmind/ui'; +import { StatusDot } from './StatusDot'; + +interface ApplyConfirmationPopoverProps { + acceptedCount: number; + dismissedCount: number; + pendingCount: number; + hasPooledDecisions: boolean; + isSaving: boolean; + onSave: () => void; +} + +export function ApplyConfirmationPopover({ + acceptedCount, + dismissedCount, + pendingCount, + hasPooledDecisions, + isSaving, + onSave, +}: Readonly<ApplyConfirmationPopoverProps>) { + return ( + <PMPopover.Root + positioning={{ placement: 'bottom-end', offset: { mainAxis: 9 } }} + > + <PMPopover.Trigger asChild> + <PMButton + size="sm" + variant="primary" + disabled={!hasPooledDecisions || isSaving} + loading={isSaving} + loadingText="Applying..." + > + Apply ({acceptedCount}) + </PMButton> + </PMPopover.Trigger> + <PMPopover.Positioner> + <PMPopover.Content width="400px" bg="background.tertiary"> + <PMPopover.Arrow> + <PMPopover.ArrowTip /> + </PMPopover.Arrow> + <PMPopover.Body p={5}> + <PMBox mb={4}> + <PMText fontWeight="semibold">Review summary</PMText> + </PMBox> + + <PMVStack gap={2} mb={4} align="stretch"> + <PMHStack justifyContent="space-between"> + <PMHStack gap={2} alignItems="center"> + <StatusDot status="accepted" /> + <PMText>Accepted</PMText> + </PMHStack> + <PMText fontWeight="semibold">{acceptedCount}</PMText> + </PMHStack> + + <PMHStack justifyContent="space-between"> + <PMHStack gap={2} alignItems="center"> + <StatusDot status="dismissed" /> + <PMText>Dismissed</PMText> + </PMHStack> + <PMText fontWeight="semibold">{dismissedCount}</PMText> + </PMHStack> + + <PMHStack justifyContent="space-between"> + <PMHStack gap={2} alignItems="center"> + <StatusDot status="pending" /> + <PMText>Still pending</PMText> + </PMHStack> + <PMText fontWeight="semibold">{pendingCount}</PMText> + </PMHStack> + </PMVStack> + + {pendingCount > 0 && ( + <PMBox mb={4}> + <PMAlert.Root status="warning" size="sm"> + <PMAlert.Indicator /> + <PMAlert.Title> + {pendingCount} change(s) still pending. Submitting now will + leave them unreviewed. + </PMAlert.Title> + </PMAlert.Root> + </PMBox> + )} + + <PMText mb={4}> + {acceptedCount} change(s) will be applied and {dismissedCount}{' '} + will be dismissed. + </PMText> + + <PMBox mt={5}> + <PMHStack justifyContent="flex-end" gap={2}> + <PMPopover.CloseTrigger asChild> + <PMButton size="sm" variant="outline"> + Cancel + </PMButton> + </PMPopover.CloseTrigger> + <PMPopover.CloseTrigger asChild> + <PMButton size="sm" variant="primary" onClick={onSave}> + Confirm + </PMButton> + </PMPopover.CloseTrigger> + </PMHStack> + </PMBox> + </PMPopover.Body> + </PMPopover.Content> + </PMPopover.Positioner> + </PMPopover.Root> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ArtefactInfo.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ArtefactInfo.tsx new file mode 100644 index 000000000..c94c0e8f2 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ArtefactInfo.tsx @@ -0,0 +1,45 @@ +import { PMBadge, PMHStack, PMIconButton, PMText } from '@packmind/ui'; +import { Link } from 'react-router'; +import { LuArrowUpRight } from 'react-icons/lu'; +import { RelativeTime } from './RelativeTime'; + +interface ArtefactInfoProps { + artefactName: string; + artefactVersion: number; + latestAuthor: string; + latestTime: Date; + artefactLink?: string; +} + +export function ArtefactInfo({ + artefactName, + artefactVersion, + latestAuthor, + latestTime, + artefactLink, +}: Readonly<ArtefactInfoProps>) { + return ( + <PMHStack gap={2} alignItems="center"> + <PMText fontWeight="bold" fontSize="lg"> + {artefactName} + </PMText> + {artefactLink && ( + <Link to={artefactLink}> + <PMIconButton + aria-label="Go to artifact page" + size="xs" + variant="ghost" + > + <LuArrowUpRight /> + </PMIconButton> + </Link> + )} + <PMBadge size="sm" colorPalette="gray"> + Base v{artefactVersion} + </PMBadge> + <PMText fontSize="sm" color="secondary"> + {latestAuthor} · <RelativeTime date={latestTime} /> + </PMText> + </PMHStack> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/BinaryFilePlaceholder.tsx b/apps/frontend/src/domain/change-proposals/components/shared/BinaryFilePlaceholder.tsx new file mode 100644 index 000000000..46cb980a5 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/BinaryFilePlaceholder.tsx @@ -0,0 +1,15 @@ +import { PMIcon, PMText, PMVStack } from '@packmind/ui'; +import { LuEyeOff } from 'react-icons/lu'; + +export function BinaryFilePlaceholder() { + return ( + <PMVStack align="center" gap={2} py={8}> + <PMIcon color="text.faded" boxSize={8}> + <LuEyeOff /> + </PMIcon> + <PMText fontSize="sm" color="faded"> + Binary file — cannot display diff + </PMText> + </PMVStack> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/CardActions.tsx b/apps/frontend/src/domain/change-proposals/components/shared/CardActions.tsx new file mode 100644 index 000000000..41ec85a72 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/CardActions.tsx @@ -0,0 +1,320 @@ +import React, { useEffect, useState } from 'react'; +import { + PMButton, + PMButtonGroup, + PMCheckbox, + PMCloseButton, + PMDialog, + PMHStack, + PMMenu, + PMPortal, + PMText, + PMTooltip, + PMVStack, +} from '@packmind/ui'; +import { + ChangeProposalDecision, + ChangeProposalType, + PackageId, + RemoveArtefactDecision, + SpaceId, + getItemTypeFromChangeProposalType, +} from '@packmind/types'; +import { LuCheck, LuChevronDown, LuPencil, LuUndo2, LuX } from 'react-icons/lu'; +import { useAuthContext } from '../../../accounts/hooks'; +import { useListPackagesBySpaceQuery } from '../../../deployments/api/queries/DeploymentsQueries'; + +type PoolStatus = 'pending' | 'accepted' | 'dismissed'; + +interface CardActionsProps { + poolStatus: PoolStatus; + proposalType: ChangeProposalType; + packageIds: PackageId[]; + spaceId: SpaceId; + isOutdated: boolean; + isBlockedByConflict: boolean; + showEditButton?: boolean; + onEdit: () => void; + onAccept: (decision?: ChangeProposalDecision) => void; + onDismiss: () => void; + onUndo: () => void; +} + +function isRemoveProposal(type: ChangeProposalType): boolean { + return ( + type === ChangeProposalType.removeStandard || + type === ChangeProposalType.removeCommand || + type === ChangeProposalType.removeSkill + ); +} + +function RemoveFromPackagesModal({ + proposalType, + packageIds, + spaceId, + onAccept, + open, + onOpenChange, +}: { + proposalType: ChangeProposalType; + packageIds: PackageId[]; + spaceId: SpaceId; + onAccept: (decision: RemoveArtefactDecision) => void; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { organization } = useAuthContext(); + const artefactType = getItemTypeFromChangeProposalType(proposalType); + + const { data: packagesResponse } = useListPackagesBySpaceQuery( + spaceId, + organization?.id, + ); + + const packageMap = new Map( + packagesResponse?.packages?.map((pkg) => [pkg.id, pkg.name]) ?? [], + ); + + const [selectedPackageIds, setSelectedPackageIds] = useState<Set<PackageId>>( + new Set(), + ); + + useEffect(() => { + if (open) setSelectedPackageIds(new Set()); + }, [open]); + + const handleCheckedChange = (packageId: PackageId, checked: boolean) => { + setSelectedPackageIds((prev) => { + const next = new Set(prev); + if (checked) next.add(packageId); + else next.delete(packageId); + return next; + }); + }; + + return ( + <PMDialog.Root + open={open} + onOpenChange={(details) => onOpenChange(details.open)} + placement="center" + > + <PMPortal> + <PMDialog.Backdrop /> + <PMDialog.Positioner> + <PMDialog.Content> + <PMDialog.Header> + <PMDialog.Title>Remove {artefactType}:</PMDialog.Title> + <PMDialog.CloseTrigger asChild> + <PMCloseButton /> + </PMDialog.CloseTrigger> + </PMDialog.Header> + <PMDialog.Body> + <PMVStack align="flex-start" gap={4}> + <PMText fontWeight="semibold"> + Select from which packages the {artefactType} will be removed: + </PMText> + + <PMVStack align="flex-start" gap={2}> + {packageIds.map((packageId) => ( + <PMCheckbox + key={packageId} + checked={selectedPackageIds.has(packageId)} + onChange={(e) => + handleCheckedChange( + packageId, + (e.target as HTMLInputElement).checked, + ) + } + > + {packageMap.get(packageId) ?? 'Deleted package'} + </PMCheckbox> + ))} + </PMVStack> + </PMVStack> + </PMDialog.Body> + <PMDialog.Footer> + <PMButtonGroup size={'sm'}> + <PMDialog.Trigger asChild> + <PMButton variant="outline" size="sm"> + Cancel + </PMButton> + </PMDialog.Trigger> + <PMButton + size="sm" + colorPalette="blue" + disabled={selectedPackageIds.size === 0} + onClick={() => + onAccept({ + delete: false, + removeFromPackages: [...selectedPackageIds], + }) + } + > + Accept + </PMButton> + </PMButtonGroup> + </PMDialog.Footer> + </PMDialog.Content> + </PMDialog.Positioner> + </PMPortal> + </PMDialog.Root> + ); +} + +function ResolveButton({ + proposalType, + packageIds, + spaceId, + isOutdated, + onDismiss, + onAccept, +}: { + proposalType: ChangeProposalType; + packageIds: PackageId[]; + spaceId: SpaceId; + isOutdated: boolean; + onDismiss: () => void; + onAccept: (decision: RemoveArtefactDecision) => void; +}) { + const [isModalOpen, setIsModalOpen] = useState(false); + + const menu = ( + <PMMenu.Root> + <PMMenu.Trigger asChild> + <PMButton size="xs" variant="outline" disabled={isOutdated}> + Resolve <LuChevronDown size={12} /> + </PMButton> + </PMMenu.Trigger> + <PMPortal> + <PMMenu.Positioner> + <PMMenu.Content> + <PMMenu.Item value="dismiss" cursor="pointer" onClick={onDismiss}> + Dismiss + </PMMenu.Item> + <PMMenu.Separator borderColor={'border.tertiary'} /> + <PMMenu.Item + value="remove-from-packages" + cursor="pointer" + onClick={() => setIsModalOpen(true)} + > + Remove from packages... + </PMMenu.Item> + </PMMenu.Content> + </PMMenu.Positioner> + </PMPortal> + </PMMenu.Root> + ); + + return ( + <PMHStack gap={2}> + {isOutdated ? ( + <PMTooltip label="This proposal is based on an outdated version"> + {menu} + </PMTooltip> + ) : ( + menu + )} + <PMButton + size="xs" + variant="outline" + onClick={onDismiss} + color="red.300" + borderColor="red.300" + > + <LuX /> + Dismiss + </PMButton> + <RemoveFromPackagesModal + proposalType={proposalType} + packageIds={packageIds} + spaceId={spaceId} + onAccept={onAccept} + open={isModalOpen} + onOpenChange={setIsModalOpen} + /> + </PMHStack> + ); +} + +export function CardActions({ + poolStatus, + proposalType, + packageIds, + spaceId, + isOutdated, + isBlockedByConflict, + showEditButton = true, + onEdit, + onAccept, + onDismiss, + onUndo, +}: Readonly<CardActionsProps>) { + if (poolStatus !== 'pending') { + return ( + <PMButton size="sm" variant="ghost" onClick={onUndo}> + <LuUndo2 /> + Undo + </PMButton> + ); + } + + if (isRemoveProposal(proposalType)) { + return ( + <ResolveButton + proposalType={proposalType} + packageIds={packageIds} + spaceId={spaceId} + isOutdated={isOutdated} + onDismiss={onDismiss} + onAccept={(decision) => onAccept(decision)} + /> + ); + } + + const acceptDisabled = isOutdated || isBlockedByConflict; + const acceptTooltip = isOutdated + ? 'This proposal is outdated and cannot be accepted' + : isBlockedByConflict + ? 'A conflicting proposal has already been accepted' + : undefined; + + const acceptButton = ( + <PMButton + size="xs" + variant="outline" + disabled={acceptDisabled} + onClick={() => onAccept()} + color="green.300" + borderColor="green.300" + > + <LuCheck /> + Accept + </PMButton> + ); + + return ( + <PMHStack gap={2}> + {showEditButton && ( + <PMButton size="xs" variant="outline" onClick={onEdit}> + <LuPencil /> + Edit + </PMButton> + )} + {acceptTooltip ? ( + <PMTooltip label={acceptTooltip}>{acceptButton}</PMTooltip> + ) : ( + acceptButton + )} + <PMButton + size="xs" + variant="outline" + onClick={onDismiss} + color="red.300" + borderColor="red.300" + > + <LuX /> + Dismiss + </PMButton> + </PMHStack> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/CardToolbar.tsx b/apps/frontend/src/domain/change-proposals/components/shared/CardToolbar.tsx new file mode 100644 index 000000000..ed3e719b2 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/CardToolbar.tsx @@ -0,0 +1,95 @@ +import { PMButton, PMHStack } from '@packmind/ui'; +import { + ChangeProposalDecision, + ChangeProposalType, + PackageId, + SpaceId, +} from '@packmind/types'; +import { LuArrowUpRight, LuMinimize2 } from 'react-icons/lu'; +import { ViewMode } from '../../hooks/useCardReviewState'; +import { CardActions } from './CardActions'; +import { ViewModeSelector } from './ViewModeSelector'; + +type PoolStatus = 'pending' | 'accepted' | 'dismissed'; + +interface CardToolbarProps { + poolStatus: PoolStatus; + proposalType: ChangeProposalType; + packageIds: PackageId[]; + spaceId: SpaceId; + isOutdated: boolean; + isBlockedByConflict: boolean; + viewMode: ViewMode; + showEditButton?: boolean; + onViewModeChange: (mode: ViewMode) => void; + onEdit: () => void; + onAccept: (decision?: ChangeProposalDecision) => void; + onDismiss: () => void; + onUndo: () => void; +} + +function isRemoveProposal(type: ChangeProposalType): boolean { + return ( + type === ChangeProposalType.removeStandard || + type === ChangeProposalType.removeCommand || + type === ChangeProposalType.removeSkill + ); +} + +export function CardToolbar({ + poolStatus, + proposalType, + packageIds, + spaceId, + isOutdated, + isBlockedByConflict, + viewMode, + showEditButton, + onViewModeChange, + onEdit, + onAccept, + onDismiss, + onUndo, +}: Readonly<CardToolbarProps>) { + const isFocused = viewMode === 'focused'; + const isExpanded = !isFocused; + const isRemove = isRemoveProposal(proposalType); + + return ( + <PMHStack justifyContent="space-between" alignItems="center"> + {isRemove ? ( + <div /> + ) : ( + <PMHStack gap={2} alignItems="center"> + <PMButton + size="sm" + variant={'secondary'} + onClick={() => onViewModeChange(isFocused ? 'diff' : 'focused')} + > + {isFocused ? <LuArrowUpRight /> : <LuMinimize2 />} + {isFocused ? 'Show in file' : 'Focused'} + </PMButton> + {isExpanded && ( + <ViewModeSelector + viewMode={viewMode} + onViewModeChange={onViewModeChange} + /> + )} + </PMHStack> + )} + <CardActions + poolStatus={poolStatus} + proposalType={proposalType} + packageIds={packageIds} + spaceId={spaceId} + isOutdated={isOutdated} + isBlockedByConflict={isBlockedByConflict} + showEditButton={showEditButton} + onEdit={onEdit} + onAccept={onAccept} + onDismiss={onDismiss} + onUndo={onUndo} + /> + </PMHStack> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ChangeProposalAccordion.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ChangeProposalAccordion.tsx new file mode 100644 index 000000000..31781679c --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ChangeProposalAccordion.tsx @@ -0,0 +1,225 @@ +import { ReactNode, useMemo, useCallback } from 'react'; +import { PMAccordion, PMBox, PMVStack } from '@packmind/ui'; +import { + ChangeProposalDecision, + ChangeProposalId, + ChangeProposalType, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../../types'; +import { ViewMode } from '../../hooks/useCardReviewState'; +import { buildProposalNumberMap } from '../../utils/changeProposalHelpers'; +import { ChangesSummaryBar } from './ChangesSummaryBar'; +import { ChangeProposalCard } from './ChangeProposalCard'; +import { ReviewedSectionDivider } from './ReviewedSectionDivider'; + +type PoolStatus = 'pending' | 'accepted' | 'dismissed'; + +interface ChangeProposalAccordionProps { + proposals: ChangeProposalWithConflicts[]; + acceptedProposalIds: Set<ChangeProposalId>; + rejectedProposalIds: Set<ChangeProposalId>; + blockedByConflictIds: Set<ChangeProposalId>; + outdatedProposalIds: Set<ChangeProposalId>; + expandedCardIds: string[]; + showEditButton?: boolean | ((proposalType: ChangeProposalType) => boolean); + userLookup: Map<string, string>; + onToggleCard: (ids: string[]) => void; + getViewMode: (proposalId: ChangeProposalId) => ViewMode; + onViewModeChange: (proposalId: ChangeProposalId, mode: ViewMode) => void; + onEdit?: (proposalId: ChangeProposalId) => void; + onAccept: ( + proposalId: ChangeProposalId, + decision: ChangeProposalDecision, + ) => void; + onDismiss: (proposalId: ChangeProposalId) => void; + onUndo: (proposalId: ChangeProposalId) => void; + getDecisionForProposal?: ( + proposal: ChangeProposalWithConflicts, + ) => ChangeProposalDecision | undefined; + onExpandCard?: (id: string) => void; + renderExpandedView?: ( + viewMode: ViewMode, + proposal: ChangeProposalWithConflicts, + ) => ReactNode; +} + +function getPoolStatus( + proposalId: ChangeProposalId, + acceptedIds: Set<ChangeProposalId>, + rejectedIds: Set<ChangeProposalId>, +): PoolStatus { + if (acceptedIds.has(proposalId)) return 'accepted'; + if (rejectedIds.has(proposalId)) return 'dismissed'; + return 'pending'; +} + +export function ChangeProposalAccordion({ + proposals, + acceptedProposalIds, + rejectedProposalIds, + blockedByConflictIds, + outdatedProposalIds, + expandedCardIds, + showEditButton, + userLookup, + onToggleCard, + getViewMode, + onViewModeChange, + onEdit, + onAccept, + onDismiss, + onUndo, + getDecisionForProposal, + onExpandCard, + renderExpandedView, +}: Readonly<ChangeProposalAccordionProps>) { + const proposalNumberMap = useMemo( + () => buildProposalNumberMap(proposals), + [proposals], + ); + + const { pending, reviewed } = useMemo(() => { + const sorted = [...proposals].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + const pendingList: ChangeProposalWithConflicts[] = []; + const reviewedList: ChangeProposalWithConflicts[] = []; + for (const p of sorted) { + const status = getPoolStatus( + p.id, + acceptedProposalIds, + rejectedProposalIds, + ); + if (status === 'pending') { + pendingList.push(p); + } else { + reviewedList.push(p); + } + } + return { pending: pendingList, reviewed: reviewedList }; + }, [proposals, acceptedProposalIds, rejectedProposalIds]); + + const counts = useMemo( + () => ({ + total: proposals.length, + pending: pending.length, + accepted: acceptedProposalIds.size, + dismissed: rejectedProposalIds.size, + }), + [ + proposals.length, + pending.length, + acceptedProposalIds.size, + rejectedProposalIds.size, + ], + ); + + const expandNextPending = useCallback( + (proposalId: ChangeProposalId) => { + if (!onExpandCard) return; + const idx = pending.findIndex((p) => p.id === proposalId); + const next = pending + .slice(idx + 1) + .find((p) => !expandedCardIds.includes(p.id)); + if (next) onExpandCard(next.id); + }, + [onExpandCard, pending, expandedCardIds], + ); + + const handleAccept = useCallback( + (proposalId: ChangeProposalId, decision: ChangeProposalDecision) => { + onAccept(proposalId, decision); + expandNextPending(proposalId); + }, + [onAccept, expandNextPending], + ); + + const handleDismiss = useCallback( + (proposalId: ChangeProposalId) => { + onDismiss(proposalId); + expandNextPending(proposalId); + }, + [onDismiss, expandNextPending], + ); + + const handleValueChange = useCallback( + (details: { value: string[] }) => { + onToggleCard(details.value); + }, + [onToggleCard], + ); + + const resolveShowEditButton = useCallback( + (proposalType: ChangeProposalType): boolean | undefined => { + if (typeof showEditButton === 'function') { + return showEditButton(proposalType); + } + return showEditButton; + }, + [showEditButton], + ); + + const renderCard = (proposal: ChangeProposalWithConflicts) => { + const poolStatus = getPoolStatus( + proposal.id, + acceptedProposalIds, + rejectedProposalIds, + ); + const viewMode = getViewMode(proposal.id); + const authorName = userLookup.get(proposal.createdBy) ?? 'Unknown'; + + return ( + <ChangeProposalCard + key={proposal.id} + proposal={proposal} + proposalNumber={proposalNumberMap.get(proposal.id) ?? 0} + poolStatus={poolStatus} + authorName={authorName} + viewMode={viewMode} + isOutdated={outdatedProposalIds.has(proposal.id)} + isBlockedByConflict={blockedByConflictIds.has(proposal.id)} + showToolbar={true} + showEditButton={resolveShowEditButton( + proposal.type as ChangeProposalType, + )} + decision={getDecisionForProposal?.(proposal)} + onViewModeChange={(mode) => onViewModeChange(proposal.id, mode)} + onEdit={() => onEdit?.(proposal.id)} + onAccept={(decision) => handleAccept(proposal.id, decision)} + onDismiss={() => handleDismiss(proposal.id)} + onUndo={() => onUndo(proposal.id)} + renderExpandedView={renderExpandedView} + /> + ); + }; + + return ( + <PMBox> + <ChangesSummaryBar + totalCount={counts.total} + pendingCount={counts.pending} + acceptedCount={counts.accepted} + dismissedCount={counts.dismissed} + /> + <PMBox px={6} pb={6}> + <PMAccordion.Root + collapsible + multiple + value={expandedCardIds} + onValueChange={handleValueChange} + > + <PMVStack gap={3} width="full"> + {pending.map(renderCard)} + {reviewed.length > 0 && ( + <> + <ReviewedSectionDivider count={reviewed.length} /> + {reviewed.map(renderCard)} + </> + )} + </PMVStack> + </PMAccordion.Root> + </PMBox> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ChangeProposalCard.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ChangeProposalCard.tsx new file mode 100644 index 000000000..f8cc721b9 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ChangeProposalCard.tsx @@ -0,0 +1,114 @@ +import { ReactNode } from 'react'; +import { PMAccordion } from '@packmind/ui'; +import { + ChangeProposalDecision, + ChangeProposalType, + ScalarUpdatePayload, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../../types'; +import { ViewMode } from '../../hooks/useCardReviewState'; +import { isEditableProposalType } from '../../utils/editableProposalTypes'; +import { ChangeProposalCardHeader } from './ChangeProposalCardHeader'; +import { ChangeProposalCardBody } from './ChangeProposalCardBody'; + +function isRemoveProposal(type: ChangeProposalType): boolean { + return ( + type === ChangeProposalType.removeStandard || + type === ChangeProposalType.removeCommand || + type === ChangeProposalType.removeSkill + ); +} + +type PoolStatus = 'pending' | 'accepted' | 'dismissed'; + +interface ChangeProposalCardProps { + proposal: ChangeProposalWithConflicts; + proposalNumber: number; + poolStatus: PoolStatus; + authorName: string; + viewMode: ViewMode; + isOutdated: boolean; + isBlockedByConflict: boolean; + showToolbar?: boolean; + showEditButton?: boolean; + filePath?: string; + decision?: ChangeProposalDecision | null; + onViewModeChange: (mode: ViewMode) => void; + onEdit: () => void; + onAccept: (decision: ChangeProposalDecision) => void; + onDismiss: () => void; + onUndo: () => void; + renderExpandedView?: ( + viewMode: ViewMode, + proposal: ChangeProposalWithConflicts, + ) => ReactNode; +} + +export function ChangeProposalCard({ + proposal, + proposalNumber, + poolStatus, + authorName, + viewMode, + isOutdated, + isBlockedByConflict, + showToolbar, + showEditButton, + filePath, + decision, + onViewModeChange, + onEdit, + onAccept, + onDismiss, + onUndo, + renderExpandedView, +}: Readonly<ChangeProposalCardProps>) { + const isRemoval = isRemoveProposal(proposal.type as ChangeProposalType); + const proposalType = proposal.type as ChangeProposalType; + const isEdited = + isEditableProposalType(proposalType) && + decision != null && + (decision as ScalarUpdatePayload).newValue !== + (proposal.payload as ScalarUpdatePayload).newValue; + + return ( + <PMAccordion.Item + value={proposal.id} + border="1px solid" + borderColor={isRemoval ? 'red.800' : 'border.tertiary'} + borderRadius="md" + width="full" + > + <ChangeProposalCardHeader + proposalNumber={proposalNumber} + proposalType={proposalType} + poolStatus={poolStatus} + isOutdated={isOutdated} + isEdited={isEdited} + isConflicting={isBlockedByConflict} + authorName={authorName} + createdAt={proposal.createdAt} + artefactVersion={proposal.artefactVersion} + filePath={filePath} + /> + <PMAccordion.ItemContent> + <ChangeProposalCardBody + proposal={proposal} + viewMode={viewMode} + poolStatus={poolStatus} + isOutdated={isOutdated} + isBlockedByConflict={isBlockedByConflict} + showToolbar={showToolbar} + showEditButton={showEditButton} + decision={decision} + onViewModeChange={onViewModeChange} + onEdit={onEdit} + onAccept={onAccept} + onDismiss={onDismiss} + onUndo={onUndo} + renderExpandedView={renderExpandedView} + /> + </PMAccordion.ItemContent> + </PMAccordion.Item> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ChangeProposalCardBody.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ChangeProposalCardBody.tsx new file mode 100644 index 000000000..786987886 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ChangeProposalCardBody.tsx @@ -0,0 +1,382 @@ +import { ReactNode, useCallback, useState } from 'react'; +import { + PMBadge, + PMButton, + PMHStack, + PMInput, + PMSeparator, + PMText, + PMTextArea, + PMVStack, +} from '@packmind/ui'; +import { LuCheck, LuChevronDown, LuChevronUp, LuX } from 'react-icons/lu'; +import { LuFolder, LuGitBranch, LuPackage } from 'react-icons/lu'; +import { Collapsible, useCollapsibleContext } from '@chakra-ui/react'; +import { + ChangeProposalDecision, + ChangeProposalType, + PackageId, + RemoveArtefactDecision, + RemoveArtefactPayload, + ScalarUpdatePayload, + SpaceId, + TargetId, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../../types'; +import { ViewMode } from '../../hooks/useCardReviewState'; +import { extractProposalDiffValues } from '../../utils/extractProposalDiffValues'; +import { isMarkdownContent } from '../../utils/isMarkdownContent'; +import { + isEditableProposalType, + isSingleLineProposalType, +} from '../../utils/editableProposalTypes'; +import { ProposalMessage } from './ProposalMessage'; +import { CardToolbar } from './CardToolbar'; +import { FocusedView } from './FocusedView'; +import { useAuthContext } from '../../../accounts/hooks'; +import { + useGetTargetsByOrganizationQuery, + useListPackagesBySpaceQuery, +} from '../../../deployments/api/queries/DeploymentsQueries'; + +const CollapsibleChevron = () => { + const { open } = useCollapsibleContext(); + return open ? <LuChevronUp size={12} /> : <LuChevronDown size={12} />; +}; + +type PoolStatus = 'pending' | 'accepted' | 'dismissed'; + +interface ChangeProposalCardBodyProps { + proposal: ChangeProposalWithConflicts; + viewMode: ViewMode; + poolStatus: PoolStatus; + isOutdated: boolean; + isBlockedByConflict: boolean; + showToolbar?: boolean; + showEditButton?: boolean; + decision?: ChangeProposalDecision | null; + onViewModeChange: (mode: ViewMode) => void; + onEdit: () => void; + onAccept: (decision: ChangeProposalDecision) => void; + onDismiss: () => void; + onUndo: () => void; + renderExpandedView?: ( + viewMode: ViewMode, + proposal: ChangeProposalWithConflicts, + ) => ReactNode; +} + +function RemoveProposalContent({ + spaceId, + targetId, + packageIds, + poolStatus, + decision, +}: { + spaceId: SpaceId; + targetId: TargetId | undefined; + packageIds: PackageId[]; + poolStatus: PoolStatus; + decision: RemoveArtefactDecision | null; +}) { + const { organization } = useAuthContext(); + const { data: packagesResponse } = useListPackagesBySpaceQuery( + spaceId, + organization?.id, + ); + const { data: targets } = useGetTargetsByOrganizationQuery(); + + const packageMap = new Map<PackageId, string>( + packagesResponse?.packages?.map((pkg) => [pkg.id, pkg.name]) ?? [], + ); + + const target = targetId ? targets?.find((t) => t.id === targetId) : undefined; + const repoLabel = target + ? `${target.repository.owner}/${target.repository.repo}` + : null; + const targetPath = target && target.path !== '/' ? target.path : null; + + const removedPackageIds = + poolStatus === 'accepted' && decision && !decision.delete + ? decision.removeFromPackages + : []; + + const distributedPackages = packageIds + .map((id) => ({ id, name: packageMap.get(id) })) + .filter((pkg) => pkg.name !== undefined); + + return ( + <PMVStack align="stretch" gap={4}> + <PMHStack gap={2} alignItems="center" flexWrap="wrap"> + <PMText fontSize="sm" color="secondary"> + Removed from repository + </PMText> + {repoLabel && ( + <PMBadge size="sm"> + <LuGitBranch /> + {repoLabel} + </PMBadge> + )} + {targetPath && ( + <> + <PMText fontSize="sm" color="secondary"> + in + </PMText> + <PMBadge size="sm" colorPalette="gray"> + <LuFolder /> + {targetPath} + </PMBadge> + </> + )} + </PMHStack> + + {removedPackageIds.length > 0 ? ( + <PMHStack gap={2} alignItems="center" flexWrap="wrap"> + <PMText fontSize="sm" color="secondary"> + Will be removed from packages + </PMText> + {removedPackageIds.map((id) => { + const name = packageMap.get(id); + return ( + <PMBadge key={id} size="sm" colorPalette={name ? 'red' : 'gray'}> + <LuPackage /> + {name ?? 'Deleted package'} + </PMBadge> + ); + })} + </PMHStack> + ) : distributedPackages.length > 0 ? ( + <PMHStack gap={2} alignItems="center" flexWrap="wrap"> + <PMText fontSize="sm" color="secondary"> + Distributed in + </PMText> + {distributedPackages.map((pkg) => ( + <PMBadge key={pkg.id} size="sm" colorPalette="gray"> + <LuPackage /> + {pkg.name} + </PMBadge> + ))} + </PMHStack> + ) : null} + </PMVStack> + ); +} + +export function ChangeProposalCardBody({ + proposal, + viewMode, + poolStatus, + isOutdated, + isBlockedByConflict, + showToolbar = true, + showEditButton, + decision, + onViewModeChange, + onEdit, + onAccept, + onDismiss, + onUndo, + renderExpandedView, +}: Readonly<ChangeProposalCardBodyProps>) { + const { oldValue, newValue } = extractProposalDiffValues(proposal); + const markdown = isMarkdownContent(proposal.type); + const removePayload = proposal.payload as RemoveArtefactPayload; + const packageIds = removePayload?.packageIds ?? []; + const removeDecision = (decision ?? null) as RemoveArtefactDecision | null; + + const isRemoveType = + proposal.type === ChangeProposalType.removeStandard || + proposal.type === ChangeProposalType.removeCommand || + proposal.type === ChangeProposalType.removeSkill; + + const editable = isEditableProposalType(proposal.type); + const singleLine = isSingleLineProposalType(proposal.type); + const isEdited = + editable && + decision != null && + (decision as ScalarUpdatePayload).newValue !== newValue; + const editedNewValue = isEdited + ? (decision as ScalarUpdatePayload).newValue + : null; + const [isEditing, setIsEditing] = useState(false); + const [editedValue, setEditedValue] = useState(newValue); + const isEditValid = editedValue.trim().length > 0; + + const handleAccept = useCallback( + (decision?: ChangeProposalDecision) => { + const finalDecision = + decision ?? + (!isRemoveType + ? (proposal.payload as ChangeProposalDecision) + : undefined); + if (finalDecision !== undefined) { + onAccept(finalDecision); + } + }, + [isRemoveType, proposal.payload, onAccept], + ); + + const handleEditToggle = useCallback(() => { + setEditedValue(newValue); + setIsEditing(true); + onEdit(); + }, [newValue, onEdit]); + + const handleCancelEdit = useCallback(() => { + setEditedValue(newValue); + setIsEditing(false); + }, [newValue]); + + const handleAcceptEdit = useCallback(() => { + const scalarPayload = proposal.payload as ScalarUpdatePayload; + const editDecision: ScalarUpdatePayload = { + oldValue: scalarPayload.oldValue, + newValue: editedValue, + }; + setIsEditing(false); + onAccept(editDecision as ChangeProposalDecision); + }, [proposal.payload, editedValue, onAccept]); + + const resolvedShowEditButton = showEditButton ?? editable; + + return ( + <PMVStack gap={0} alignItems="stretch"> + {showToolbar && !isEditing && ( + <> + <PMSeparator borderColor="border.tertiary" /> + <PMVStack p={4} alignItems="stretch"> + <CardToolbar + poolStatus={poolStatus} + proposalType={proposal.type} + packageIds={packageIds} + spaceId={proposal.spaceId} + isOutdated={isOutdated} + isBlockedByConflict={isBlockedByConflict} + viewMode={viewMode} + showEditButton={resolvedShowEditButton} + onViewModeChange={onViewModeChange} + onEdit={handleEditToggle} + onAccept={handleAccept} + onDismiss={onDismiss} + onUndo={onUndo} + /> + </PMVStack> + </> + )} + + {proposal.message && ( + <> + <PMSeparator borderColor="border.tertiary" /> + <PMVStack p={4} alignItems="stretch"> + <ProposalMessage message={proposal.message} /> + </PMVStack> + </> + )} + + <PMSeparator borderColor="border.tertiary" /> + <PMVStack p={4} alignItems="stretch"> + {isEditing ? ( + <PMVStack gap={3} alignItems="stretch"> + {singleLine ? ( + <PMInput + value={editedValue} + onChange={(e) => setEditedValue(e.target.value)} + size="sm" + autoFocus + /> + ) : ( + <PMTextArea + value={editedValue} + onChange={(e) => setEditedValue(e.target.value)} + size="sm" + rows={10} + autoFocus + /> + )} + <PMHStack gap={2} justifyContent="flex-end"> + <PMButton size="xs" variant="outline" onClick={handleCancelEdit}> + <LuX /> + Cancel + </PMButton> + <PMButton + size="xs" + variant="outline" + onClick={handleAcceptEdit} + disabled={!isEditValid} + color="green.300" + borderColor="green.300" + opacity={isEditValid ? 1 : 0.5} + > + <LuCheck /> + Save & Accept + </PMButton> + </PMHStack> + </PMVStack> + ) : isRemoveType ? ( + <RemoveProposalContent + spaceId={proposal.spaceId} + targetId={proposal.targetId} + packageIds={packageIds} + poolStatus={poolStatus} + decision={removeDecision} + /> + ) : isEdited ? ( + <PMVStack gap={4} alignItems="stretch"> + <Collapsible.Root> + <PMHStack alignItems="center" justifyContent="space-between"> + <Collapsible.Trigger cursor="pointer" textAlign="left"> + <PMHStack gap={1} alignItems="center"> + <PMText fontSize="xs" color="secondary" fontWeight="medium"> + Original proposal + </PMText> + <CollapsibleChevron /> + </PMHStack> + </Collapsible.Trigger> + <PMButton + size="xs" + variant="ghost" + color="blue.400" + onClick={onUndo} + > + Reset to original + </PMButton> + </PMHStack> + <Collapsible.Content> + <PMVStack gap={1} alignItems="stretch" opacity={0.6} pt={2}> + <FocusedView + oldValue={oldValue} + newValue={newValue} + isMarkdownContent={markdown} + /> + </PMVStack> + </Collapsible.Content> + </Collapsible.Root> + <PMSeparator borderColor="border.tertiary" /> + <PMVStack gap={1} alignItems="stretch"> + <PMText fontSize="xs" color="secondary" fontWeight="medium"> + Edited + </PMText> + <FocusedView + oldValue={oldValue} + newValue={editedNewValue!} + isMarkdownContent={markdown} + /> + </PMVStack> + </PMVStack> + ) : !showToolbar && renderExpandedView ? ( + renderExpandedView(viewMode, proposal) + ) : viewMode === 'focused' ? ( + (renderExpandedView?.(viewMode, proposal) ?? ( + <FocusedView + oldValue={oldValue} + newValue={newValue} + isMarkdownContent={markdown} + /> + )) + ) : renderExpandedView ? ( + renderExpandedView(viewMode, proposal) + ) : null} + </PMVStack> + </PMVStack> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ChangeProposalCardHeader.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ChangeProposalCardHeader.tsx new file mode 100644 index 000000000..2f3cc6689 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ChangeProposalCardHeader.tsx @@ -0,0 +1,137 @@ +import { + PMAccordion, + PMBadge, + PMHStack, + PMIcon, + PMText, + PMTooltip, +} from '@packmind/ui'; +import { + ChangeProposalType, + getItemTypeFromChangeProposalType, +} from '@packmind/types'; +import { LuCircleAlert, LuGitBranch, LuPencil, LuTrash2 } from 'react-icons/lu'; +import { ProposalLabel } from './ProposalLabel'; +import { StatusDot } from './StatusDot'; +import { ProposalMeta } from './ProposalMeta'; +import { RelativeTime } from './RelativeTime'; + +function isRemoveProposal(type: ChangeProposalType): boolean { + return ( + type === ChangeProposalType.removeStandard || + type === ChangeProposalType.removeCommand || + type === ChangeProposalType.removeSkill + ); +} + +type PoolStatus = 'pending' | 'accepted' | 'dismissed'; + +interface ChangeProposalCardHeaderProps { + proposalNumber: number; + proposalType: ChangeProposalType; + poolStatus: PoolStatus; + isOutdated: boolean; + isEdited: boolean; + isConflicting: boolean; + authorName: string; + createdAt: Date; + artefactVersion: number; + filePath?: string; +} + +export function ChangeProposalCardHeader({ + proposalNumber, + proposalType, + poolStatus, + isOutdated, + isEdited, + isConflicting, + authorName, + createdAt, + artefactVersion, + filePath, +}: Readonly<ChangeProposalCardHeaderProps>) { + const isRemoval = isRemoveProposal(proposalType); + const artefactTypeLabel = isRemoval + ? (() => { + const itemType = getItemTypeFromChangeProposalType(proposalType); + return itemType.charAt(0).toUpperCase() + itemType.slice(1); + })() + : undefined; + + return ( + <PMAccordion.ItemTrigger + px={4} + py={3} + _hover={{ cursor: 'pointer' }} + {...(isRemoval && { bg: 'red.950/30' })} + > + <PMHStack flex={1} gap={3} alignItems="center"> + <PMAccordion.ItemIndicator /> + {isRemoval && ( + <PMIcon color="red.400" fontSize="md"> + <LuTrash2 /> + </PMIcon> + )} + <ProposalLabel + proposalNumber={proposalNumber} + proposalType={proposalType} + /> + <StatusDot status={poolStatus} /> + {isOutdated && ( + <PMTooltip label="This proposal was made on an outdated version"> + <PMBadge colorPalette="orange" variant="subtle" size="sm"> + <PMIcon> + <LuCircleAlert /> + </PMIcon> + Outdated + </PMBadge> + </PMTooltip> + )} + {isEdited && ( + <PMTooltip label="This proposal was edited before acceptance"> + <PMBadge colorPalette="blue" variant="subtle" size="sm"> + <PMIcon> + <LuPencil /> + </PMIcon> + Edited + </PMBadge> + </PMTooltip> + )} + {isConflicting && ( + <PMTooltip label="This proposal conflicts with another accepted proposal"> + <PMBadge colorPalette="yellow" variant="subtle" size="sm"> + <PMIcon> + <LuGitBranch /> + </PMIcon> + Conflicting + </PMBadge> + </PMTooltip> + )} + {filePath && ( + <PMBadge variant="outline" size="sm" fontFamily="mono"> + {filePath} + </PMBadge> + )} + <PMHStack flex={1} justifyContent="flex-end"> + {isRemoval ? ( + <PMHStack gap={2} alignItems="center"> + <PMText fontSize="xs" color="secondary"> + {authorName} · <RelativeTime date={createdAt} /> · + </PMText> + <PMBadge size="sm" colorPalette="red" variant="subtle"> + {artefactTypeLabel} + </PMBadge> + </PMHStack> + ) : ( + <ProposalMeta + authorName={authorName} + createdAt={createdAt} + artefactVersion={artefactVersion} + /> + )} + </PMHStack> + </PMHStack> + </PMAccordion.ItemTrigger> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ChangesSummaryBar.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ChangesSummaryBar.tsx new file mode 100644 index 000000000..f8a16f460 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ChangesSummaryBar.tsx @@ -0,0 +1,62 @@ +import { PMBox, PMHStack, PMText } from '@packmind/ui'; +import { MultiSegmentProgressBar } from './MultiSegmentProgressBar'; + +interface ChangesSummaryBarProps { + totalCount: number; + pendingCount: number; + acceptedCount: number; + dismissedCount: number; +} + +export function ChangesSummaryBar({ + totalCount, + pendingCount, + acceptedCount, + dismissedCount, +}: Readonly<ChangesSummaryBarProps>) { + const segments = [ + { count: acceptedCount, color: 'green.400' }, + { count: dismissedCount, color: 'red.400' }, + { count: pendingCount, color: 'yellow.400' }, + ]; + + return ( + <PMBox px={6} py={3} my={2}> + <PMHStack justifyContent="space-between" mb={2}> + <PMHStack gap={3} fontSize="sm" color="fg.subtle"> + <PMText> + {totalCount} change{totalCount !== 1 ? 's' : ''} + </PMText> + <PMHStack gap={1} alignItems="center"> + <PMBox + width="8px" + height="8px" + borderRadius="full" + bg="yellow.400" + /> + <PMText>{pendingCount} pending</PMText> + </PMHStack> + <PMHStack gap={1} alignItems="center"> + <PMBox + width="8px" + height="8px" + borderRadius="full" + bg="green.400" + /> + <PMText>{acceptedCount} accepted</PMText> + </PMHStack> + <PMHStack gap={1} alignItems="center"> + <PMBox width="8px" height="8px" borderRadius="full" bg="red.400" /> + <PMText>{dismissedCount} dismissed</PMText> + </PMHStack> + </PMHStack> + {pendingCount > 0 && ( + <PMText fontSize="sm" color="secondary"> + {pendingCount} pending review + </PMText> + )} + </PMHStack> + <MultiSegmentProgressBar segments={segments} /> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ConfirmCreationDecisionDialog.test.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ConfirmCreationDecisionDialog.test.tsx new file mode 100644 index 000000000..448f2409f --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ConfirmCreationDecisionDialog.test.tsx @@ -0,0 +1,160 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { UIProvider } from '@packmind/ui'; +import { ConfirmCreationDecisionDialog } from './ConfirmCreationDecisionDialog'; + +const renderWithProviders = (ui: React.ReactElement) => + render(<UIProvider>{ui}</UIProvider>); + +const baseProps = { + open: true, + artefactLabel: 'skill' as const, + artefactName: 'Generate React component', + isPending: false, + onConfirm: jest.fn(), + onOpenChange: jest.fn(), +}; + +describe('ConfirmCreationDecisionDialog', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when decision is "accept"', () => { + it('renders the accept title with the artefact label', async () => { + renderWithProviders( + <ConfirmCreationDecisionDialog {...baseProps} decision="accept" />, + ); + expect( + await screen.findByText('Accept this skill proposal?'), + ).toBeInTheDocument(); + }); + + it('renders the artefact name in the body', async () => { + renderWithProviders( + <ConfirmCreationDecisionDialog {...baseProps} decision="accept" />, + ); + expect( + await screen.findByText(/Generate React component/), + ).toBeInTheDocument(); + }); + + it('renders an "Accept" confirm button', async () => { + renderWithProviders( + <ConfirmCreationDecisionDialog {...baseProps} decision="accept" />, + ); + expect( + await screen.findByRole('button', { name: 'Accept' }), + ).toBeInTheDocument(); + }); + + it('calls onConfirm when the confirm button is clicked', async () => { + const onConfirm = jest.fn(); + renderWithProviders( + <ConfirmCreationDecisionDialog + {...baseProps} + decision="accept" + onConfirm={onConfirm} + />, + ); + const button = await screen.findByRole('button', { name: 'Accept' }); + fireEvent.click(button); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + }); + + describe('when decision is "dismiss"', () => { + it('renders the dismiss title with the artefact label', async () => { + renderWithProviders( + <ConfirmCreationDecisionDialog {...baseProps} decision="dismiss" />, + ); + expect( + await screen.findByText('Dismiss this skill proposal?'), + ).toBeInTheDocument(); + }); + + it('renders a "Dismiss" confirm button', async () => { + renderWithProviders( + <ConfirmCreationDecisionDialog {...baseProps} decision="dismiss" />, + ); + expect( + await screen.findByRole('button', { name: 'Dismiss' }), + ).toBeInTheDocument(); + }); + + it('calls onConfirm when the confirm button is clicked', async () => { + const onConfirm = jest.fn(); + renderWithProviders( + <ConfirmCreationDecisionDialog + {...baseProps} + decision="dismiss" + onConfirm={onConfirm} + />, + ); + const button = await screen.findByRole('button', { name: 'Dismiss' }); + fireEvent.click(button); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + }); + + describe('artefact label substitution', () => { + it('renders "command" in the accept title when label is "command"', async () => { + renderWithProviders( + <ConfirmCreationDecisionDialog + {...baseProps} + decision="accept" + artefactLabel="command" + />, + ); + expect( + await screen.findByText('Accept this command proposal?'), + ).toBeInTheDocument(); + }); + + it('renders "standard" in the dismiss title when label is "standard"', async () => { + renderWithProviders( + <ConfirmCreationDecisionDialog + {...baseProps} + decision="dismiss" + artefactLabel="standard" + />, + ); + expect( + await screen.findByText('Dismiss this standard proposal?'), + ).toBeInTheDocument(); + }); + }); + + describe('when isPending is true', () => { + it('does not call onConfirm when the confirm button is clicked', async () => { + const onConfirm = jest.fn(); + renderWithProviders( + <ConfirmCreationDecisionDialog + {...baseProps} + decision="accept" + isPending={true} + onConfirm={onConfirm} + />, + ); + const buttons = await screen.findAllByRole('button'); + const confirmButton = buttons[buttons.length - 2]; + fireEvent.click(confirmButton); + expect(onConfirm).not.toHaveBeenCalled(); + }); + }); + + describe('when not open', () => { + it('does not render the dialog content', () => { + renderWithProviders( + <ConfirmCreationDecisionDialog + {...baseProps} + open={false} + decision="accept" + />, + ); + expect( + screen.queryByText('Accept this skill proposal?'), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ConfirmCreationDecisionDialog.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ConfirmCreationDecisionDialog.tsx new file mode 100644 index 000000000..b248c103c --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ConfirmCreationDecisionDialog.tsx @@ -0,0 +1,65 @@ +import { PMAlertDialog } from '@packmind/ui'; + +export type CreationDecision = 'accept' | 'dismiss'; + +export type CreationArtefactLabel = 'skill' | 'command' | 'standard'; + +export interface ConfirmCreationDecisionDialogProps { + open: boolean; + decision: CreationDecision; + artefactLabel: CreationArtefactLabel; + artefactName: string; + isPending: boolean; + onConfirm: () => void; + onOpenChange: (open: boolean) => void; +} + +const COPY: Record< + CreationDecision, + { + title: (label: string) => string; + body: (label: string, name: string) => string; + confirmText: string; + confirmColorScheme: 'green' | 'red'; + } +> = { + accept: { + title: (label) => `Accept this ${label} proposal?`, + body: (label, name) => + `This will create the ${label} "${name}" in your space and mark the proposal as applied.`, + confirmText: 'Accept', + confirmColorScheme: 'green', + }, + dismiss: { + title: (label) => `Dismiss this ${label} proposal?`, + body: (label, name) => + `The ${label} "${name}" won't be created and the proposal will be discarded.`, + confirmText: 'Dismiss', + confirmColorScheme: 'red', + }, +}; + +export function ConfirmCreationDecisionDialog({ + open, + decision, + artefactLabel, + artefactName, + isPending, + onConfirm, + onOpenChange, +}: Readonly<ConfirmCreationDecisionDialogProps>) { + const copy = COPY[decision]; + + return ( + <PMAlertDialog + open={open} + onOpenChange={(details) => onOpenChange(details.open)} + title={copy.title(artefactLabel)} + message={copy.body(artefactLabel, artefactName)} + confirmText={copy.confirmText} + confirmColorScheme={copy.confirmColorScheme} + isLoading={isPending} + onConfirm={onConfirm} + /> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/CreationReviewHeader.tsx b/apps/frontend/src/domain/change-proposals/components/shared/CreationReviewHeader.tsx new file mode 100644 index 000000000..6f3bda8ac --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/CreationReviewHeader.tsx @@ -0,0 +1,72 @@ +import { PMBadge, PMBox, PMHStack, PMText } from '@packmind/ui'; +import { PreviewArtifactRenderingCommand } from '@packmind/types'; +import { RelativeTime } from './RelativeTime'; +import { ReviewActionButtons } from '../ReviewActionButtons'; +import { DownloadAsAgentButton } from '../../../artifacts/components/DownloadAsAgentButton'; + +interface CreationReviewHeaderProps { + artefactName: string; + latestAuthor: string; + latestTime: Date; + onAccept: () => void; + onDismiss: () => void; + isPending: boolean; + isSubmitted: boolean; + getPreviewCommand?: () => Omit< + PreviewArtifactRenderingCommand, + 'codingAgent' + >; +} + +export function CreationReviewHeader({ + artefactName, + latestAuthor, + latestTime, + onAccept, + onDismiss, + isPending, + isSubmitted, + getPreviewCommand, +}: Readonly<CreationReviewHeaderProps>) { + return ( + <PMBox + position="sticky" + top={0} + zIndex={10} + bg="bg.panel" + borderBottom="1px solid" + borderColor="border.tertiary" + px={6} + py={3} + > + <PMHStack justifyContent="space-between" alignItems="center"> + <PMHStack gap={2} alignItems="center"> + <PMText fontWeight="bold" fontSize="lg"> + {artefactName} + </PMText> + <PMBadge size="sm" colorPalette="gray"> + New + </PMBadge> + <PMText fontSize="sm" color="secondary"> + {latestAuthor} · <RelativeTime date={latestTime} /> + </PMText> + </PMHStack> + {!isSubmitted && ( + <PMHStack gap={2}> + {getPreviewCommand && ( + <DownloadAsAgentButton + getPreviewCommand={getPreviewCommand} + size="xs" + /> + )} + <ReviewActionButtons + onAccept={onAccept} + onDismiss={onDismiss} + isPending={isPending} + /> + </PMHStack> + )} + </PMHStack> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/DiffBlock.tsx b/apps/frontend/src/domain/change-proposals/components/shared/DiffBlock.tsx new file mode 100644 index 000000000..8b834e056 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/DiffBlock.tsx @@ -0,0 +1,80 @@ +import { + PMBox, + PMCodeMirror, + PMHStack, + PMMarkdownViewer, + PMText, +} from '@packmind/ui'; +import { getFileLanguage } from '../../../skills/utils/fileTreeUtils'; + +interface DiffBlockProps { + value: string; + variant: 'removed' | 'added'; + isMarkdown: boolean; + showIndicator?: boolean; + filePath?: string; +} + +const variantConfig = { + removed: { + indicator: '\u2212', + indicatorColor: 'error' as const, + bg: 'red.500/10', + borderColor: 'red.500/30', + }, + added: { + indicator: '+', + indicatorColor: 'success' as const, + bg: 'green.500/10', + borderColor: 'green.500/30', + }, +}; + +export function DiffBlock({ + value, + variant, + isMarkdown, + showIndicator = true, + filePath, +}: Readonly<DiffBlockProps>) { + const config = variantConfig[variant]; + + return ( + <PMHStack gap={2} alignItems="stretch"> + {showIndicator && ( + <PMText + fontWeight="bold" + fontSize="sm" + color={config.indicatorColor} + fontFamily="mono" + pt={2} + flexShrink={0} + > + {config.indicator} + </PMText> + )} + <PMBox + flex={1} + bg={config.bg} + borderLeft="2px solid" + borderColor={config.borderColor} + borderRadius="md" + p={3} + fontSize="sm" + css={{ '& p:last-child': { marginBottom: 0 } }} + > + {isMarkdown ? ( + <PMMarkdownViewer content={value} /> + ) : filePath ? ( + <PMCodeMirror + value={value} + language={getFileLanguage(filePath)} + readOnly + /> + ) : ( + <PMText fontSize="sm">{value}</PMText> + )} + </PMBox> + </PMHStack> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/DiffSectionSeparator.tsx b/apps/frontend/src/domain/change-proposals/components/shared/DiffSectionSeparator.tsx new file mode 100644 index 000000000..c61e683a2 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/DiffSectionSeparator.tsx @@ -0,0 +1,17 @@ +import { PMHStack, PMText } from '@packmind/ui'; + +export function DiffSectionSeparator() { + return ( + <PMHStack justify="center" py={1}> + <PMText + fontSize="sm" + color="faded" + fontFamily="mono" + letterSpacing="wider" + userSelect="none" + > + ··· + </PMText> + </PMHStack> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/FileGroupHeader.tsx b/apps/frontend/src/domain/change-proposals/components/shared/FileGroupHeader.tsx new file mode 100644 index 000000000..5ababa6f1 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/FileGroupHeader.tsx @@ -0,0 +1,43 @@ +import { LuFile } from 'react-icons/lu'; +import { PMBadge, PMBox, PMHStack, PMIcon, PMText } from '@packmind/ui'; + +interface FileGroupHeaderProps { + filePath: string; + changeCount: number; + pendingCount: number; +} + +export function FileGroupHeader({ + filePath, + changeCount, + pendingCount, +}: Readonly<FileGroupHeaderProps>) { + return ( + <PMBox + width="full" + bg="bg.panel" + borderRadius="md" + px={4} + py={2} + borderBottom="1px solid" + borderColor="border.tertiary" + > + <PMHStack gap={3} alignItems="center" justifyContent="flex-start"> + <PMIcon color="text.faded"> + <LuFile /> + </PMIcon> + <PMText fontSize="sm" fontWeight="semibold" color="faded"> + {filePath} + </PMText> + <PMText fontSize="xs" color="faded"> + {changeCount} change{changeCount !== 1 ? 's' : ''} + </PMText> + {pendingCount > 0 && ( + <PMBadge colorPalette="yellow" variant="subtle" size="sm"> + {pendingCount} pending + </PMBadge> + )} + </PMHStack> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/FocusedView.tsx b/apps/frontend/src/domain/change-proposals/components/shared/FocusedView.tsx new file mode 100644 index 000000000..56afb23cc --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/FocusedView.tsx @@ -0,0 +1,73 @@ +import { type ReactNode, useMemo } from 'react'; +import { PMBox, PMVStack } from '@packmind/ui'; +import { buildDiffSections } from '../../utils/buildDiffSections'; +import { DiffBlock } from './DiffBlock'; +import { DiffSectionSeparator } from './DiffSectionSeparator'; + +interface FocusedViewProps { + oldValue: string; + newValue: string; + isMarkdownContent: boolean; + filePath?: string; +} + +export function FocusedView({ + oldValue, + newValue, + isMarkdownContent, + filePath, +}: Readonly<FocusedViewProps>) { + const sections = useMemo( + () => buildDiffSections(oldValue, newValue), + [oldValue, newValue], + ); + + const elements = useMemo(() => { + const result: ReactNode[] = []; + let hadUnchangedBefore = false; + + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + if (section.type === 'unchanged') { + hadUnchangedBefore = true; + continue; + } + + if (hadUnchangedBefore && result.length > 0) { + result.push(<DiffSectionSeparator key={`sep-${i}`} />); + } + hadUnchangedBefore = false; + + result.push( + <PMBox key={i}> + {section.oldValue && ( + <DiffBlock + value={section.oldValue} + variant="removed" + isMarkdown={isMarkdownContent} + filePath={filePath} + /> + )} + {section.newValue && ( + <PMBox mt={section.oldValue ? 2 : 0}> + <DiffBlock + value={section.newValue} + variant="added" + isMarkdown={isMarkdownContent} + filePath={filePath} + /> + </PMBox> + )} + </PMBox>, + ); + } + + return result; + }, [sections, isMarkdownContent, filePath]); + + return ( + <PMVStack gap={3} alignItems="stretch"> + {elements} + </PMVStack> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/MultiSegmentProgressBar.tsx b/apps/frontend/src/domain/change-proposals/components/shared/MultiSegmentProgressBar.tsx new file mode 100644 index 000000000..182061b31 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/MultiSegmentProgressBar.tsx @@ -0,0 +1,40 @@ +import { PMBox } from '@packmind/ui'; + +interface Segment { + count: number; + color: string; +} + +interface MultiSegmentProgressBarProps { + segments: Segment[]; +} + +export function MultiSegmentProgressBar({ + segments, +}: Readonly<MultiSegmentProgressBarProps>) { + const total = segments.reduce((sum, s) => sum + s.count, 0); + if (total === 0) return null; + + return ( + <PMBox + display="flex" + height="2px" + borderRadius="full" + overflow="hidden" + width="full" + bg="bg.subtle" + > + {segments.map( + (segment, i) => + segment.count > 0 && ( + <PMBox + key={i} + width={`${(segment.count / total) * 100}%`} + height="100%" + bg={segment.color} + /> + ), + )} + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ProposalLabel.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ProposalLabel.tsx new file mode 100644 index 000000000..64cec0aae --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ProposalLabel.tsx @@ -0,0 +1,32 @@ +import { PMText } from '@packmind/ui'; +import { ChangeProposalType } from '@packmind/types'; +import { getChangeProposalFieldLabel } from '../../utils/changeProposalHelpers'; + +interface ProposalLabelProps { + proposalNumber: number; + proposalType: ChangeProposalType; +} + +export function ProposalLabel({ + proposalNumber, + proposalType, +}: Readonly<ProposalLabelProps>) { + const fieldLabel = getChangeProposalFieldLabel(proposalType); + const isRemoval = + proposalType === ChangeProposalType.removeStandard || + proposalType === ChangeProposalType.removeCommand || + proposalType === ChangeProposalType.removeSkill; + + return ( + <PMText fontWeight="medium" fontSize="sm"> + #{proposalNumber} —{' '} + {isRemoval ? ( + <PMText as="span" color="error"> + {fieldLabel} + </PMText> + ) : ( + fieldLabel + )} + </PMText> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ProposalMessage.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ProposalMessage.tsx new file mode 100644 index 000000000..b76c40471 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ProposalMessage.tsx @@ -0,0 +1,15 @@ +import { PMText } from '@packmind/ui'; + +interface ProposalMessageProps { + message: string | undefined; +} + +export function ProposalMessage({ message }: Readonly<ProposalMessageProps>) { + if (!message) return null; + + return ( + <PMText fontSize="sm" fontStyle="italic" color="secondary"> + {message} + </PMText> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ProposalMeta.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ProposalMeta.tsx new file mode 100644 index 000000000..60b19e72f --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ProposalMeta.tsx @@ -0,0 +1,25 @@ +import { PMBadge, PMHStack, PMText } from '@packmind/ui'; +import { RelativeTime } from './RelativeTime'; + +interface ProposalMetaProps { + authorName: string; + createdAt: Date; + artefactVersion: number; +} + +export function ProposalMeta({ + authorName, + createdAt, + artefactVersion, +}: Readonly<ProposalMetaProps>) { + return ( + <PMHStack gap={2} alignItems="center"> + <PMText fontSize="xs" color="secondary"> + {authorName} · <RelativeTime date={createdAt} /> · + </PMText> + <PMBadge size="sm" colorPalette="gray"> + base v{artefactVersion} + </PMBadge> + </PMHStack> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/RelativeTime.tsx b/apps/frontend/src/domain/change-proposals/components/shared/RelativeTime.tsx new file mode 100644 index 000000000..b992d5d64 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/RelativeTime.tsx @@ -0,0 +1,25 @@ +import { PMText, PMTextColors, PMTooltip } from '@packmind/ui'; +import { + formatExactDate, + formatRelativeTime, +} from '../../utils/formatRelativeTime'; + +interface RelativeTimeProps { + date: Date; + fontSize?: string; + color?: PMTextColors; +} + +export function RelativeTime({ + date, + fontSize, + color, +}: Readonly<RelativeTimeProps>) { + return ( + <PMTooltip label={formatExactDate(date)}> + <PMText as="span" fontSize={fontSize} color={color} py={1} my={-1}> + {formatRelativeTime(date)} + </PMText> + </PMTooltip> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ReviewHeader.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ReviewHeader.tsx new file mode 100644 index 000000000..818235731 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ReviewHeader.tsx @@ -0,0 +1,77 @@ +import { PMBox, PMHStack } from '@packmind/ui'; +import { ArtefactInfo } from './ArtefactInfo'; +import { ViewTabSelector } from './ViewTabSelector'; +import { ApplyButton } from './ApplyButton'; +import { ReviewTab } from '../../hooks/useCardReviewState'; + +interface ReviewHeaderProps { + artefactName: string; + artefactVersion: number; + latestAuthor: string; + latestTime: Date; + activeTab: ReviewTab; + onTabChange: (tab: ReviewTab) => void; + acceptedCount: number; + dismissedCount: number; + pendingCount: number; + hasPooledDecisions: boolean; + isSaving: boolean; + onSave: () => void; + disabledTabs?: ReviewTab[]; + artefactLink?: string; +} + +export function ReviewHeader({ + artefactName, + artefactVersion, + latestAuthor, + latestTime, + activeTab, + onTabChange, + acceptedCount, + dismissedCount, + pendingCount, + hasPooledDecisions, + isSaving, + onSave, + disabledTabs, + artefactLink, +}: Readonly<ReviewHeaderProps>) { + return ( + <PMBox + position="sticky" + top={0} + zIndex={10} + bg="bg.panel" + borderBottom="1px solid" + borderColor="border.tertiary" + px={6} + py={3} + > + <PMHStack justifyContent="space-between" alignItems="center"> + <ArtefactInfo + artefactName={artefactName} + artefactVersion={artefactVersion} + latestAuthor={latestAuthor} + latestTime={latestTime} + artefactLink={artefactLink} + /> + <PMHStack gap={4} alignItems="center"> + <ViewTabSelector + activeTab={activeTab} + onTabChange={onTabChange} + disabledTabs={disabledTabs} + /> + <ApplyButton + acceptedCount={acceptedCount} + dismissedCount={dismissedCount} + pendingCount={pendingCount} + hasPooledDecisions={hasPooledDecisions} + isSaving={isSaving} + onSave={onSave} + /> + </PMHStack> + </PMHStack> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ReviewedSectionDivider.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ReviewedSectionDivider.tsx new file mode 100644 index 000000000..673411bd9 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ReviewedSectionDivider.tsx @@ -0,0 +1,24 @@ +import { PMBox, PMText } from '@packmind/ui'; + +interface ReviewedSectionDividerProps { + count: number; +} + +export function ReviewedSectionDivider({ + count, +}: Readonly<ReviewedSectionDividerProps>) { + return ( + <PMBox display="flex" alignItems="center" gap={3} py={2}> + <PMBox flex={1} height="1px" bg="border.tertiary" /> + <PMText + fontSize="xs" + fontWeight="semibold" + textTransform="uppercase" + color="secondary" + > + Reviewed ({count}) + </PMText> + <PMBox flex={1} height="1px" bg="border.tertiary" /> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/StatusDot.tsx b/apps/frontend/src/domain/change-proposals/components/shared/StatusDot.tsx new file mode 100644 index 000000000..df3758ba5 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/StatusDot.tsx @@ -0,0 +1,25 @@ +import { PMBox } from '@packmind/ui'; + +type PoolStatus = 'pending' | 'accepted' | 'dismissed'; + +const colorByStatus: Record<PoolStatus, string> = { + pending: 'yellow.400', + accepted: 'green.400', + dismissed: 'red.400', +}; + +interface StatusDotProps { + status: PoolStatus; +} + +export function StatusDot({ status }: Readonly<StatusDotProps>) { + return ( + <PMBox + width="10px" + height="10px" + borderRadius="full" + flexShrink={0} + bg={colorByStatus[status]} + /> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/SubSectionHeader.tsx b/apps/frontend/src/domain/change-proposals/components/shared/SubSectionHeader.tsx new file mode 100644 index 000000000..66a87752b --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/SubSectionHeader.tsx @@ -0,0 +1,30 @@ +import { ReactNode } from 'react'; +import { PMBox, PMHStack, PMIcon, PMText } from '@packmind/ui'; + +interface SubSectionHeaderProps { + label: string; + icon: ReactNode; +} + +export function SubSectionHeader({ + label, + icon, +}: Readonly<SubSectionHeaderProps>) { + return ( + <PMBox pl={3} borderLeft="2px solid" borderColor="border.secondary"> + <PMHStack gap={2} alignItems="center"> + <PMIcon fontSize="xs" color="text.faded"> + {icon} + </PMIcon> + <PMText + fontSize="xs" + fontWeight="semibold" + textTransform="uppercase" + color="secondary" + > + {label} + </PMText> + </PMHStack> + </PMBox> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ViewModeSelector.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ViewModeSelector.tsx new file mode 100644 index 000000000..b460ba7ee --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ViewModeSelector.tsx @@ -0,0 +1,37 @@ +import { PMSegmentGroup } from '@packmind/ui'; +import { ViewMode } from '../../hooks/useCardReviewState'; + +interface ViewModeSelectorProps { + viewMode: ViewMode; + onViewModeChange: (mode: ViewMode) => void; +} + +const viewModeItems: { label: string; value: ViewMode }[] = [ + { label: 'Diff', value: 'diff' }, + { label: 'Inline', value: 'inline' }, +]; + +export function ViewModeSelector({ + viewMode, + onViewModeChange, +}: Readonly<ViewModeSelectorProps>) { + return ( + <PMSegmentGroup.Root + size="sm" + value={viewMode} + onValueChange={(e) => onViewModeChange(e.value as ViewMode)} + > + <PMSegmentGroup.Indicator bg="background.tertiary" /> + {viewModeItems.map((item) => ( + <PMSegmentGroup.Item + key={item.value} + value={item.value} + _checked={{ color: 'text.primary' }} + > + <PMSegmentGroup.ItemText>{item.label}</PMSegmentGroup.ItemText> + <PMSegmentGroup.ItemHiddenInput /> + </PMSegmentGroup.Item> + ))} + </PMSegmentGroup.Root> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/ViewTabSelector.tsx b/apps/frontend/src/domain/change-proposals/components/shared/ViewTabSelector.tsx new file mode 100644 index 000000000..66b888568 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/ViewTabSelector.tsx @@ -0,0 +1,50 @@ +import { PMHStack, PMIcon, PMTabs } from '@packmind/ui'; +import { LuRepeat2, LuEye, LuSparkles } from 'react-icons/lu'; +import { ReviewTab } from '../../hooks/useCardReviewState'; +import { type ComponentType } from 'react'; + +interface ViewTabSelectorProps { + activeTab: ReviewTab; + onTabChange: (tab: ReviewTab) => void; + disabledTabs?: ReviewTab[]; +} + +const tabDefinitions: { + label: string; + value: ReviewTab; + icon: ComponentType; +}[] = [ + { label: 'Changes', value: 'changes', icon: LuRepeat2 }, + { label: 'Original', value: 'original', icon: LuEye }, + { label: 'Result', value: 'result', icon: LuSparkles }, +]; + +export function ViewTabSelector({ + activeTab, + onTabChange, + disabledTabs, +}: Readonly<ViewTabSelectorProps>) { + const tabs = tabDefinitions.map((tab) => ({ + value: tab.value, + triggerLabel: ( + <PMHStack gap={1} alignItems="center"> + <PMIcon> + <tab.icon /> + </PMIcon> + {tab.label} + </PMHStack> + ), + disabled: disabledTabs?.includes(tab.value), + })); + + return ( + <PMTabs + tabs={tabs} + defaultValue={activeTab} + value={activeTab} + onValueChange={(details) => onTabChange(details.value as ReviewTab)} + variant="enclosed" + size="sm" + /> + ); +} diff --git a/apps/frontend/src/domain/change-proposals/components/shared/index.ts b/apps/frontend/src/domain/change-proposals/components/shared/index.ts new file mode 100644 index 000000000..3e69686c7 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/shared/index.ts @@ -0,0 +1,22 @@ +export { DiffBlock } from './DiffBlock'; +export { DiffSectionSeparator } from './DiffSectionSeparator'; +export { StatusDot } from './StatusDot'; +export { ProposalLabel } from './ProposalLabel'; +export { ProposalMeta } from './ProposalMeta'; +export { ProposalMessage } from './ProposalMessage'; +export { ChangeProposalCardHeader } from './ChangeProposalCardHeader'; +export { CardActions } from './CardActions'; +export { ViewModeSelector } from './ViewModeSelector'; +export { CardToolbar } from './CardToolbar'; +export { ChangesSummaryBar } from './ChangesSummaryBar'; +export { MultiSegmentProgressBar } from './MultiSegmentProgressBar'; +export { ReviewedSectionDivider } from './ReviewedSectionDivider'; +export { ArtefactInfo } from './ArtefactInfo'; +export { ViewTabSelector } from './ViewTabSelector'; +export { ApplyButton } from './ApplyButton'; +export { ReviewHeader } from './ReviewHeader'; +export { FocusedView } from './FocusedView'; +export { ChangeProposalCardBody } from './ChangeProposalCardBody'; +export { ChangeProposalCard } from './ChangeProposalCard'; +export { ChangeProposalAccordion } from './ChangeProposalAccordion'; +export { ApplyConfirmationPopover } from './ApplyConfirmationPopover'; diff --git a/apps/frontend/src/domain/change-proposals/constants/skillProposalTypes.ts b/apps/frontend/src/domain/change-proposals/constants/skillProposalTypes.ts new file mode 100644 index 000000000..4fa7f606c --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/constants/skillProposalTypes.ts @@ -0,0 +1,27 @@ +import { ChangeProposalType } from '@packmind/types'; + +export const SCALAR_SKILL_TYPES = new Set<ChangeProposalType>([ + ChangeProposalType.updateSkillName, + ChangeProposalType.updateSkillDescription, + ChangeProposalType.updateSkillPrompt, + ChangeProposalType.updateSkillMetadata, + ChangeProposalType.updateSkillLicense, + ChangeProposalType.updateSkillCompatibility, + ChangeProposalType.updateSkillAllowedTools, + ChangeProposalType.updateSkillAdditionalProperty, +]); + +export const FRONTMATTER_SKILL_TYPES = new Set<ChangeProposalType>([ + ChangeProposalType.updateSkillName, + ChangeProposalType.updateSkillDescription, + ChangeProposalType.updateSkillMetadata, + ChangeProposalType.updateSkillLicense, + ChangeProposalType.updateSkillCompatibility, + ChangeProposalType.updateSkillAllowedTools, + ChangeProposalType.updateSkillAdditionalProperty, +]); + +export const SKILL_MD_MARKDOWN_TYPES = new Set<ChangeProposalType>([ + ChangeProposalType.updateSkillDescription, + ChangeProposalType.updateSkillPrompt, +]); diff --git a/apps/frontend/src/domain/change-proposals/hooks/useCardReviewState.ts b/apps/frontend/src/domain/change-proposals/hooks/useCardReviewState.ts new file mode 100644 index 000000000..18de063eb --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/hooks/useCardReviewState.ts @@ -0,0 +1,52 @@ +import { useCallback, useState } from 'react'; +import { ChangeProposalId } from '@packmind/types'; + +export type ViewMode = 'diff' | 'focused' | 'inline'; +export type ReviewTab = 'changes' | 'original' | 'result'; + +export function useCardReviewState() { + const [activeTab, setActiveTab] = useState<ReviewTab>('changes'); + const [expandedCardIds, setExpandedCardIds] = useState<string[]>([]); + const [viewModeByProposal, setViewModeByProposal] = useState< + Map<ChangeProposalId, ViewMode> + >(new Map()); + const toggleCard = useCallback((ids: string[]) => { + setExpandedCardIds(ids); + }, []); + + const collapseCard = useCallback((id: string) => { + setExpandedCardIds((prev) => prev.filter((cardId) => cardId !== id)); + }, []); + + const expandCard = useCallback((id: string) => { + setExpandedCardIds((prev) => (prev.includes(id) ? prev : [...prev, id])); + }, []); + + const getViewMode = useCallback( + (proposalId: ChangeProposalId): ViewMode => + viewModeByProposal.get(proposalId) ?? 'focused', + [viewModeByProposal], + ); + + const setViewMode = useCallback( + (proposalId: ChangeProposalId, mode: ViewMode) => { + setViewModeByProposal((prev) => { + const next = new Map(prev); + next.set(proposalId, mode); + return next; + }); + }, + [], + ); + + return { + activeTab, + setActiveTab, + expandedCardIds, + toggleCard, + collapseCard, + expandCard, + getViewMode, + setViewMode, + }; +} diff --git a/apps/frontend/src/domain/change-proposals/hooks/useChangeProposalPool.ts b/apps/frontend/src/domain/change-proposals/hooks/useChangeProposalPool.ts new file mode 100644 index 000000000..6133f60a0 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/hooks/useChangeProposalPool.ts @@ -0,0 +1,186 @@ +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { useQuery, keepPreviousData } from '@tanstack/react-query'; +import { + ChangeProposal, + ChangeProposalDecision, + ChangeProposalId, + ChangeProposalType, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../types'; +import { useAuthContext } from '../../accounts/hooks/useAuthContext'; +import { changeProposalsGateway } from '../api/gateways'; +import { RECOMPUTE_CONFLICTS_KEY } from '../api/queryKeys'; + +export function useChangeProposalPool( + proposals: ChangeProposalWithConflicts[], +) { + const { organization } = useAuthContext(); + + const [reviewingProposalId, setReviewingProposalId] = + useState<ChangeProposalId | null>(null); + const [acceptedProposalIds, setAcceptedProposalIds] = useState< + Set<ChangeProposalId> + >(new Set()); + const [rejectedProposalIds, setRejectedProposalIds] = useState< + Set<ChangeProposalId> + >(new Set()); + const [decisionsTaken, setDecisionsTaken] = useState< + Record<ChangeProposalId, ChangeProposalDecision> + >({}); + + useEffect(() => { + const currentIds = new Set(proposals.map((p) => p.id)); + + setAcceptedProposalIds((prev) => { + const next = new Set([...prev].filter((id) => currentIds.has(id))); + return next.size === prev.size ? prev : next; + }); + setRejectedProposalIds((prev) => { + const next = new Set([...prev].filter((id) => currentIds.has(id))); + return next.size === prev.size ? prev : next; + }); + setReviewingProposalId((prev) => + prev && !currentIds.has(prev) ? null : prev, + ); + }, [proposals]); + + const hasDecisions = Object.keys(decisionsTaken).length > 0; + const artefactId = proposals[0]?.artefactId; + const spaceId = proposals[0]?.spaceId; + + const conflictsQuery = useQuery({ + queryKey: [...RECOMPUTE_CONFLICTS_KEY, artefactId, decisionsTaken], + queryFn: () => + changeProposalsGateway.recomputeConflicts({ + organizationId: organization!.id, + spaceId: spaceId!, + artefactId: artefactId!, + decisions: decisionsTaken, + }), + enabled: hasDecisions && !!organization?.id && !!artefactId && !!spaceId, + placeholderData: keepPreviousData, + }); + + const blockedByConflictIds = useMemo(() => { + const recomputedConflicts = hasDecisions + ? conflictsQuery.data?.conflicts + : undefined; + + const blocked = new Set<ChangeProposalId>(); + + for (const proposal of proposals) { + if (acceptedProposalIds.has(proposal.id)) continue; + + const conflictsWith = + recomputedConflicts?.[proposal.id] ?? proposal.conflictsWith; + + for (const conflictId of conflictsWith) { + if (acceptedProposalIds.has(conflictId)) { + blocked.add(proposal.id); + break; + } + } + } + + return blocked; + }, [hasDecisions, conflictsQuery.data, proposals, acceptedProposalIds]); + + const hasPooledDecisions = + acceptedProposalIds.size > 0 || rejectedProposalIds.size > 0; + + const handleSelectProposal = useCallback((proposalId: ChangeProposalId) => { + setReviewingProposalId((prev) => (prev === proposalId ? null : proposalId)); + }, []); + + const handlePoolAccept = useCallback( + (proposalId: ChangeProposalId, decision: ChangeProposalDecision) => { + setAcceptedProposalIds((prev) => { + const next = new Set(prev); + next.add(proposalId); + return next; + }); + setRejectedProposalIds((prev) => { + const next = new Set(prev); + next.delete(proposalId); + return next; + }); + setDecisionsTaken((prev) => ({ ...prev, [proposalId]: decision })); + }, + [], + ); + + const handlePoolReject = useCallback((proposalId: ChangeProposalId) => { + setRejectedProposalIds((prev) => { + const next = new Set(prev); + next.add(proposalId); + return next; + }); + setAcceptedProposalIds((prev) => { + const next = new Set(prev); + next.delete(proposalId); + return next; + }); + setDecisionsTaken((prev) => { + const { [proposalId]: _, ...rest } = prev; + return rest as Record<ChangeProposalId, ChangeProposalDecision>; + }); + }, []); + + const handleUndoPool = useCallback((proposalId: ChangeProposalId) => { + setAcceptedProposalIds((prev) => { + const next = new Set(prev); + next.delete(proposalId); + return next; + }); + setRejectedProposalIds((prev) => { + const next = new Set(prev); + next.delete(proposalId); + return next; + }); + setDecisionsTaken((prev) => { + const { [proposalId]: _, ...rest } = prev; + return rest as Record<ChangeProposalId, ChangeProposalDecision>; + }); + }, []); + + const resetPool = useCallback(() => { + setAcceptedProposalIds(new Set()); + setRejectedProposalIds(new Set()); + setReviewingProposalId(null); + setDecisionsTaken({}); + }, []); + + const getDecisionForChangeProposal = <T extends ChangeProposalType>( + changeProposal: ChangeProposal<T>, + ) => { + return decisionsTaken[ + changeProposal.id + ] as unknown as ChangeProposalDecision<T>; + }; + + const proposalsWithDecisions = useMemo( + () => + proposals.map((p) => { + const decision = decisionsTaken[p.id]; + return decision + ? ({ ...p, decision } as ChangeProposalWithConflicts) + : p; + }), + [proposals, decisionsTaken], + ); + + return { + reviewingProposalId, + acceptedProposalIds, + rejectedProposalIds, + blockedByConflictIds, + hasPooledDecisions, + proposalsWithDecisions, + getDecisionForChangeProposal, + handleSelectProposal, + handlePoolAccept, + handlePoolReject, + handleUndoPool, + resetPool, + }; +} diff --git a/apps/frontend/src/domain/change-proposals/hooks/useCreationProposalCache.ts b/apps/frontend/src/domain/change-proposals/hooks/useCreationProposalCache.ts new file mode 100644 index 000000000..c02ab4dbc --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/hooks/useCreationProposalCache.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; +import { SubmittedState } from '../types'; + +export function useCreationProposalCache<T>(proposal: T | undefined): { + displayedProposal: T | undefined; + submittedState: SubmittedState | null; + setSubmittedState: (state: SubmittedState) => void; +} { + const [cachedProposal, setCachedProposal] = useState<T | undefined>(proposal); + const [submittedState, setSubmittedState] = useState<SubmittedState | null>( + null, + ); + + useEffect(() => { + if (proposal) setCachedProposal(proposal); + }, [proposal]); + + return { + displayedProposal: cachedProposal ?? proposal, + submittedState, + setSubmittedState, + }; +} diff --git a/apps/frontend/src/domain/change-proposals/hooks/useCreationReviewDetail.ts b/apps/frontend/src/domain/change-proposals/hooks/useCreationReviewDetail.ts new file mode 100644 index 000000000..0bc5639e5 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/hooks/useCreationReviewDetail.ts @@ -0,0 +1,129 @@ +import { useCallback } from 'react'; +import { useParams } from 'react-router'; +import type { + ApplyCreationChangeProposalsResponse, + ChangeProposalDecision, + CreationProposalOverview, +} from '@packmind/types'; +import { + ChangeProposalId, + ChangeProposalStatus, + OrganizationId, + SpaceId, +} from '@packmind/types'; +import { useAuthContext } from '../../accounts/hooks/useAuthContext'; +import { useCurrentSpace } from '../../spaces/hooks/useCurrentSpace'; +import { + useApplyCreationChangeProposalsMutation, + useGetGroupedChangeProposalsQuery, +} from '../api/queries/ChangeProposalsQueries'; +import { useCreationProposalCache } from './useCreationProposalCache'; + +interface UseCreationReviewDetailOptions<T extends CreationProposalOverview> { + proposalId: ChangeProposalId; + orgSlugProp?: string; + spaceSlugProp?: string; + filter: (proposal: CreationProposalOverview) => proposal is T; + getAcceptUrl: ( + response: ApplyCreationChangeProposalsResponse, + orgSlug: string, + spaceSlug: string, + ) => string; +} + +export function useCreationReviewDetail<T extends CreationProposalOverview>({ + proposalId, + orgSlugProp, + spaceSlugProp, + filter, + getAcceptUrl, +}: UseCreationReviewDetailOptions<T>) { + const { organization } = useAuthContext(); + const { spaceId, space } = useCurrentSpace(); + const { orgSlug: orgSlugParam } = useParams<{ orgSlug: string }>(); + + const orgSlug = orgSlugProp ?? orgSlugParam; + const spaceSlug = spaceSlugProp ?? space?.slug; + + const { data: groupedProposals, isLoading } = + useGetGroupedChangeProposalsQuery(); + + const applyMutation = useApplyCreationChangeProposalsMutation({ + orgSlug, + spaceSlug, + }); + + const proposal = groupedProposals?.creations.find( + (c): c is T => c.id === proposalId && filter(c), + ); + + const { displayedProposal, submittedState, setSubmittedState } = + useCreationProposalCache<T>(proposal); + + const handleAccept = useCallback( + async (decision: ChangeProposalDecision<T['type']>) => { + if ( + !organization?.id || + !spaceId || + !orgSlug || + !spaceSlug || + !displayedProposal + ) + return; + try { + const response = await applyMutation.mutateAsync({ + organizationId: organization.id as OrganizationId, + spaceId: spaceId as SpaceId, + accepted: [ + { + ...displayedProposal, + status: ChangeProposalStatus.applied, + decision, + }, + ], + rejected: [], + }); + setSubmittedState({ + type: 'accepted', + artefactUrl: getAcceptUrl(response, orgSlug, spaceSlug), + }); + } catch { + // error handled by mutation onError callback + } + }, + [ + organization?.id, + spaceId, + proposalId, + orgSlug, + spaceSlug, + applyMutation, + setSubmittedState, + getAcceptUrl, + ], + ); + + const handleReject = useCallback(async () => { + if (!organization?.id || !spaceId) return; + try { + await applyMutation.mutateAsync({ + organizationId: organization.id as OrganizationId, + spaceId: spaceId as SpaceId, + accepted: [], + rejected: [proposalId as ChangeProposalId], + }); + setSubmittedState({ type: 'rejected' }); + } catch { + // error handled by mutation onError callback + } + }, [organization?.id, spaceId, proposalId, applyMutation, setSubmittedState]); + + return { + displayedProposal, + submittedState, + handleAccept, + handleReject, + isPending: applyMutation.isPending, + isLoading, + }; +} diff --git a/apps/frontend/src/domain/change-proposals/hooks/useDiffNavigation.test.ts b/apps/frontend/src/domain/change-proposals/hooks/useDiffNavigation.test.ts new file mode 100644 index 000000000..36be5ab9b --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/hooks/useDiffNavigation.test.ts @@ -0,0 +1,291 @@ +import { renderHook, act } from '@testing-library/react'; +import { createChangeProposalId } from '@packmind/types'; +import { groupByProximity, useDiffNavigation } from './useDiffNavigation'; + +describe('groupByProximity', () => { + describe('with an empty list', () => { + it('returns an empty array', () => { + const result = groupByProximity([]); + + expect(result).toEqual([]); + }); + }); + + describe('with a single element', () => { + it('returns one group containing the element', () => { + const parent = document.createElement('div'); + const el = document.createElement('ins'); + parent.appendChild(el); + + const result = groupByProximity([el]); + + expect(result).toEqual([[el]]); + }); + }); + + describe('when two diff elements are direct siblings', () => { + it('groups them together', () => { + const parent = document.createElement('div'); + const del = document.createElement('del'); + const ins = document.createElement('ins'); + parent.appendChild(del); + parent.appendChild(ins); + + const result = groupByProximity([del, ins]); + + expect(result).toEqual([[del, ins]]); + }); + }); + + describe('when two diff elements are separated by a text node', () => { + it('creates separate groups', () => { + const parent = document.createElement('div'); + const del = document.createElement('del'); + const text = document.createTextNode(' some text '); + const ins = document.createElement('ins'); + parent.appendChild(del); + parent.appendChild(text); + parent.appendChild(ins); + + const result = groupByProximity([del, ins]); + + expect(result).toEqual([[del], [ins]]); + }); + }); + + describe('when two diff elements are separated by a non-diff element', () => { + it('creates separate groups', () => { + const parent = document.createElement('div'); + const del = document.createElement('del'); + const span = document.createElement('span'); + const ins = document.createElement('ins'); + parent.appendChild(del); + parent.appendChild(span); + parent.appendChild(ins); + + const result = groupByProximity([del, ins]); + + expect(result).toEqual([[del], [ins]]); + }); + }); + + describe('when multiple diff elements are consecutive siblings', () => { + it('groups all into one group', () => { + const parent = document.createElement('div'); + const del1 = document.createElement('del'); + const ins1 = document.createElement('ins'); + const del2 = document.createElement('del'); + const ins2 = document.createElement('ins'); + parent.appendChild(del1); + parent.appendChild(ins1); + parent.appendChild(del2); + parent.appendChild(ins2); + + const result = groupByProximity([del1, ins1, del2, ins2]); + + expect(result).toEqual([[del1, ins1, del2, ins2]]); + }); + }); + + describe('when elements have different parents', () => { + it('creates a separate group per parent', () => { + const parent1 = document.createElement('div'); + const parent2 = document.createElement('div'); + const del = document.createElement('del'); + const ins = document.createElement('ins'); + parent1.appendChild(del); + parent2.appendChild(ins); + + const result = groupByProximity([del, ins]); + + expect(result).toEqual([[del], [ins]]); + }); + }); +}); + +describe('useDiffNavigation', () => { + let mutationCallbacks: MutationCallback[]; + let observeTargets: Node[]; + const OriginalMutationObserver = window.MutationObserver; + + beforeEach(() => { + jest.useFakeTimers({ + doNotFake: ['requestAnimationFrame', 'cancelAnimationFrame'], + }); + mutationCallbacks = []; + observeTargets = []; + window.MutationObserver = class MockMutationObserver { + private callback: MutationCallback; + constructor(callback: MutationCallback) { + this.callback = callback; + mutationCallbacks.push(callback); + } + observe(target: Node) { + observeTargets.push(target); + } + disconnect() { + // no-op mock + } + takeRecords() { + return []; + } + } as unknown as typeof MutationObserver; + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0); + return 0; + }); + }); + + afterEach(() => { + (window.requestAnimationFrame as jest.Mock).mockRestore(); + window.MutationObserver = OriginalMutationObserver; + document.body.innerHTML = ''; + jest.useRealTimers(); + }); + + describe('when no diff sections exist initially', () => { + it('observes document.body for mutations', () => { + const proposalId = createChangeProposalId('proposal-1'); + + renderHook(() => useDiffNavigation(proposalId)); + + expect(observeTargets).toContain(document.body); + }); + + it('detects changes once sections appear in the DOM', () => { + const proposalId = createChangeProposalId('proposal-1'); + + const { result } = renderHook(() => useDiffNavigation(proposalId)); + + const section = document.createElement('div'); + section.setAttribute('data-diff-section', ''); + const change = document.createElement('div'); + change.setAttribute('data-diff-change', ''); + section.appendChild(change); + document.body.appendChild(section); + + act(() => { + mutationCallbacks[0]([], {} as MutationObserver); + jest.runAllTimers(); + }); + + expect(result.current.totalChanges).toBe(1); + }); + }); + + describe('when diff sections already exist in DOM', () => { + it('detects changes on the first frame scan', () => { + const section = document.createElement('div'); + section.setAttribute('data-diff-section', ''); + const change = document.createElement('ins'); + section.appendChild(change); + document.body.appendChild(section); + + const proposalId = createChangeProposalId('proposal-1'); + + const { result } = renderHook(() => useDiffNavigation(proposalId)); + + act(() => { + jest.runAllTimers(); + }); + + expect(result.current.totalChanges).toBe(1); + }); + }); + + describe('when switching from one proposal to another', () => { + it('re-detects changes for the new proposal', () => { + const section = document.createElement('div'); + section.setAttribute('data-diff-section', ''); + const change1 = document.createElement('ins'); + const change2 = document.createElement('del'); + section.appendChild(change1); + section.appendChild(change2); + document.body.appendChild(section); + + const proposalId1 = createChangeProposalId('proposal-1'); + const proposalId2 = createChangeProposalId('proposal-2'); + + const { result, rerender } = renderHook( + ({ id }) => useDiffNavigation(id), + { initialProps: { id: proposalId1 } }, + ); + + // Replace DOM content for the new proposal + section.innerHTML = ''; + const newChange = document.createElement('div'); + newChange.setAttribute('data-diff-change', ''); + section.appendChild(newChange); + + rerender({ id: proposalId2 }); + + act(() => { + jest.runAllTimers(); + }); + + expect(result.current.totalChanges).toBe(1); + }); + }); + + describe('when switching proposals with same change count', () => { + it('removes data-diff-active from old element', () => { + const section = document.createElement('div'); + section.setAttribute('data-diff-section', ''); + const change1 = document.createElement('ins'); + section.appendChild(change1); + document.body.appendChild(section); + + const proposalId1 = createChangeProposalId('proposal-1'); + const proposalId2 = createChangeProposalId('proposal-2'); + + const { rerender } = renderHook(({ id }) => useDiffNavigation(id), { + initialProps: { id: proposalId1 }, + }); + + act(() => { + jest.runAllTimers(); + }); + + // Replace with a different element for the second proposal + section.innerHTML = ''; + const change2 = document.createElement('del'); + section.appendChild(change2); + + rerender({ id: proposalId2 }); + + act(() => { + jest.runAllTimers(); + }); + + expect(change1.hasAttribute('data-diff-active')).toBe(false); + }); + + it('applies data-diff-active on the new element', () => { + const section = document.createElement('div'); + section.setAttribute('data-diff-section', ''); + const change1 = document.createElement('ins'); + section.appendChild(change1); + document.body.appendChild(section); + + const proposalId1 = createChangeProposalId('proposal-1'); + const proposalId2 = createChangeProposalId('proposal-2'); + + const { rerender } = renderHook(({ id }) => useDiffNavigation(id), { + initialProps: { id: proposalId1 }, + }); + + // Replace with a different element for the second proposal + section.innerHTML = ''; + const change2 = document.createElement('del'); + section.appendChild(change2); + + rerender({ id: proposalId2 }); + + act(() => { + jest.runAllTimers(); + }); + + expect(change2.hasAttribute('data-diff-active')).toBe(true); + }); + }); +}); diff --git a/apps/frontend/src/domain/change-proposals/hooks/useDiffNavigation.ts b/apps/frontend/src/domain/change-proposals/hooks/useDiffNavigation.ts new file mode 100644 index 000000000..40edb3552 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/hooks/useDiffNavigation.ts @@ -0,0 +1,202 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { ChangeProposalId } from '@packmind/types'; + +const ACCORDION_ANIMATION_DELAY_MS = 250; + +const CHANGE_SELECTOR = + '[data-diff-section] ins, [data-diff-section] del, [data-diff-section] [data-diff-change], [data-diff-section] .diff-ins, [data-diff-section] .diff-del, [data-diff-section] .milkdown-diff-highlight'; + +function findScrollableAncestor(element: HTMLElement): HTMLElement | null { + let parent = element.parentElement; + while (parent) { + const { overflowY, overflow } = window.getComputedStyle(parent); + if ( + (overflowY === 'auto' || + overflowY === 'scroll' || + overflow === 'auto' || + overflow === 'scroll') && + parent.scrollHeight > parent.clientHeight + ) { + return parent; + } + parent = parent.parentElement; + } + return null; +} + +function scrollToElement(element: HTMLElement) { + const container = findScrollableAncestor(element); + if (!container) return; + const targetRect = element.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const offset = targetRect.top - containerRect.top + container.scrollTop; + const center = offset - container.clientHeight / 2 + targetRect.height / 2; + container.scrollTo({ top: center, behavior: 'smooth' }); +} + +function areAdjacent( + a: HTMLElement, + b: HTMLElement, + diffSet: Set<HTMLElement>, +): boolean { + if (a.parentNode !== b.parentNode) return false; + let node = a.nextSibling; + while (node !== null && node !== b) { + if ( + node.nodeType !== Node.ELEMENT_NODE || + !diffSet.has(node as HTMLElement) + ) { + return false; + } + node = node.nextSibling; + } + return node === b; +} + +export function groupByProximity(elements: HTMLElement[]): HTMLElement[][] { + if (elements.length === 0) return []; + const diffSet = new Set(elements); + const groups: HTMLElement[][] = [[elements[0]]]; + for (let i = 1; i < elements.length; i++) { + if (areAdjacent(elements[i - 1], elements[i], diffSet)) { + groups[groups.length - 1].push(elements[i]); + } else { + groups.push([elements[i]]); + } + } + return groups; +} + +function checkHasScroll(elements: HTMLElement[]): boolean { + if (elements.length === 0) return false; + return findScrollableAncestor(elements[0]) !== null; +} + +export function useDiffNavigation( + reviewingProposalId: ChangeProposalId | null, +) { + const [currentIndex, setCurrentIndex] = useState(0); + const [totalChanges, setTotalChanges] = useState(0); + const [hasScroll, setHasScroll] = useState(false); + const groupsRef = useRef<HTMLElement[][]>([]); + + useEffect(() => { + if (!reviewingProposalId) { + groupsRef.current + .flat() + .forEach((el) => el.removeAttribute('data-diff-active')); + groupsRef.current = []; + setTotalChanges(0); + setCurrentIndex(0); + setHasScroll(false); + return; + } + + let cancelled = false; + + // Reset immediately — prevent stale data from previous proposal + groupsRef.current + .flat() + .forEach((el) => el.removeAttribute('data-diff-active')); + groupsRef.current = []; + setTotalChanges(0); + setCurrentIndex(0); + setHasScroll(false); + + function applyGroups(groups: HTMLElement[][]) { + if (cancelled) return; + groupsRef.current = groups; + setTotalChanges(groups.length); + setCurrentIndex(0); + setHasScroll(checkHasScroll(groups.flat())); + } + + let observer: MutationObserver | null = null; + let timeoutId: ReturnType<typeof setTimeout> | null = null; + let animDelayId: ReturnType<typeof setTimeout> | null = null; + + const scanForChanges = () => { + const els = Array.from( + document.querySelectorAll<HTMLElement>(CHANGE_SELECTOR), + ); + if (els.length > 0) { + observer?.disconnect(); + if (timeoutId) clearTimeout(timeoutId); + // Delay applying groups to let accordion animations complete + // before getBoundingClientRect() is called for scrolling + animDelayId = setTimeout(() => { + applyGroups(groupByProximity(els)); + }, ACCORDION_ANIMATION_DELAY_MS); + } + }; + + // Watch body for DOM mutations immediately (before rAF) + observer = new MutationObserver(scanForChanges); + observer.observe(document.body, { childList: true, subtree: true }); + + // Explicit scan on next frame for elements already in DOM + const frameId = requestAnimationFrame(() => scanForChanges()); + + // Safety timeout + timeoutId = setTimeout(() => observer?.disconnect(), 2000); + + return () => { + cancelled = true; + cancelAnimationFrame(frameId); + observer?.disconnect(); + if (timeoutId) clearTimeout(timeoutId); + if (animDelayId) clearTimeout(animDelayId); + groupsRef.current + .flat() + .forEach((el) => el.removeAttribute('data-diff-active')); + }; + }, [reviewingProposalId]); + + useEffect(() => { + if (totalChanges === 0) return; + + const elements = Array.from( + document.querySelectorAll<HTMLElement>(CHANGE_SELECTOR), + ); + const groups = groupByProximity(elements); + groupsRef.current = groups; + setHasScroll(checkHasScroll(elements)); + + elements.forEach((el) => el.removeAttribute('data-diff-active')); + + const current = groups[currentIndex]; + if (!current) return; + + current.forEach((el) => el.setAttribute('data-diff-active', '')); + scrollToElement(current[0]); + }, [currentIndex, totalChanges, reviewingProposalId]); + + const goToNext = useCallback(() => { + setCurrentIndex((prev) => { + if (prev >= groupsRef.current.length - 1) return prev; + return prev + 1; + }); + }, []); + + const goToPrevious = useCallback(() => { + setCurrentIndex((prev) => { + if (prev <= 0) return prev; + return prev - 1; + }); + }, []); + + const scrollToCurrent = useCallback(() => { + const groups = groupsRef.current; + const target = groups[currentIndex]?.[0]; + if (target) scrollToElement(target); + }, [currentIndex]); + + return { + currentIndex, + totalChanges, + hasScroll, + goToNext, + goToPrevious, + scrollToCurrent, + }; +} diff --git a/apps/frontend/src/domain/change-proposals/hooks/useNavigateAfterApply.ts b/apps/frontend/src/domain/change-proposals/hooks/useNavigateAfterApply.ts new file mode 100644 index 000000000..bc6bd2488 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/hooks/useNavigateAfterApply.ts @@ -0,0 +1,75 @@ +import { useCallback } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useQueryClient } from '@tanstack/react-query'; +import { ListChangeProposalsBySpaceResponse } from '@packmind/types'; +import { GET_GROUPED_CHANGE_PROPOSALS_KEY } from '../api/queryKeys'; +import { routes } from '../../../shared/utils/routes'; + +export function useNavigateAfterApply(currentArtefactId: string) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { orgSlug, spaceSlug } = useParams<{ + orgSlug: string; + spaceSlug: string; + }>(); + + return useCallback(() => { + if (!orgSlug || !spaceSlug) return; + + const groupedProposals = + queryClient.getQueryData<ListChangeProposalsBySpaceResponse>( + GET_GROUPED_CHANGE_PROPOSALS_KEY, + ); + if (!groupedProposals) return; + + const allItems = [ + ...groupedProposals.commands.map((item) => ({ + ...item, + artefactType: 'commands' as const, + })), + ...groupedProposals.standards.map((item) => ({ + ...item, + artefactType: 'standards' as const, + })), + ...groupedProposals.skills.map((item) => ({ + ...item, + artefactType: 'skills' as const, + })), + ].sort((a, b) => b.lastContributedAt.localeCompare(a.lastContributedAt)); + + const currentStillExists = allItems.some( + (item) => item.artefactId === currentArtefactId, + ); + + if (currentStillExists) return; + + if (allItems.length > 0) { + const first = allItems[0]; + navigate( + routes.space.toReviewChangesArtefact( + orgSlug, + spaceSlug, + first.artefactType, + first.artefactId, + ), + ); + return; + } + + const creations = groupedProposals.creations ?? []; + if (creations.length > 0) { + const sorted = [...creations].sort((a, b) => + b.lastContributedAt.localeCompare(a.lastContributedAt), + ); + const first = sorted[0]; + navigate( + routes.space.toReviewChangesCreation( + orgSlug, + spaceSlug, + first.artefactType, + first.id, + ), + ); + } + }, [queryClient, navigate, orgSlug, spaceSlug, currentArtefactId]); +} diff --git a/apps/frontend/src/domain/change-proposals/hooks/useUserLookup.ts b/apps/frontend/src/domain/change-proposals/hooks/useUserLookup.ts new file mode 100644 index 000000000..7a7a1328e --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/hooks/useUserLookup.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; +import { UserId } from '@packmind/types'; +import { useGetUsersInMyOrganizationQuery } from '../../accounts/api/queries/UserQueries'; + +export function useUserLookup(): Map<UserId, string> { + const { data } = useGetUsersInMyOrganizationQuery(); + + return useMemo(() => { + const map = new Map<UserId, string>(); + if (data?.users) { + for (const user of data.users) { + map.set(user.userId, user.displayName); + } + } + return map; + }, [data]); +} diff --git a/apps/frontend/src/domain/change-proposals/plugins/diffDecorationPlugin.ts b/apps/frontend/src/domain/change-proposals/plugins/diffDecorationPlugin.ts new file mode 100644 index 000000000..02eeae466 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/plugins/diffDecorationPlugin.ts @@ -0,0 +1,197 @@ +import { Plugin, PluginKey } from '@milkdown/kit/prose/state'; +import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'; +import { buildUnifiedMarkdownDiff } from '../utils/buildUnifiedMarkdownDiff'; +import { mapMarkdownDiffToPositions } from '../utils/mapMarkdownDiffToPositions'; +import { + DiffDecorationPluginOptions, + DiffPluginState, + DiffRegion, +} from '../types/diffTypes'; +import { createRoot, Root } from 'react-dom/client'; +import { PMBox } from '@packmind/ui'; +import { Tooltip } from '@chakra-ui/react'; +import { markdownDiffCss } from '../utils/markdownDiff'; +import React from 'react'; + +const DIFF_PLUGIN_KEY = new PluginKey<DiffPluginState>('diffDecoration'); + +/** + * Creates a tooltip DOM element for a diff region. + * The tooltip is rendered using React Portal with Chakra UI components. + */ +function createTooltipWidget( + region: DiffRegion, + proposalNumbers: number[], +): HTMLElement { + const tooltipContainer = document.createElement('div'); + tooltipContainer.className = 'milkdown-diff-tooltip'; + tooltipContainer.style.display = 'inline'; + tooltipContainer.style.position = 'relative'; + + // Create React root and render tooltip + const root: Root = createRoot(tooltipContainer); + + const proposalText = + proposalNumbers.length === 1 + ? `Changed by proposal #${proposalNumbers[0]}` + : `Changed by proposals ${proposalNumbers.map((n) => `#${n}`).join(', ')}`; + + root.render( + React.createElement( + Tooltip.Root, + { openDelay: 100, closeDelay: 0, positioning: { placement: 'top' } }, + React.createElement( + Tooltip.Trigger, + { asChild: true }, + React.createElement('span', { + style: { + borderBottom: '2px dotted var(--chakra-colors-yellow-emphasis)', + cursor: 'help', + }, + }), + ), + React.createElement( + Tooltip.Content, + null, + React.createElement( + PMBox, + { + css: { + maxWidth: '500px', + padding: '8px', + }, + }, + React.createElement( + PMBox, + { + css: { + fontWeight: 'bold', + marginBottom: '4px', + fontSize: '12px', + }, + }, + proposalText, + ), + React.createElement(PMBox, { + css: { + fontSize: '14px', + ...markdownDiffCss, + }, + dangerouslySetInnerHTML: { __html: region.diffHtml }, + }), + ), + ), + ), + ); + + return tooltipContainer; +} + +/** + * Creates a ProseMirror plugin that adds decorations for diff highlighting and tooltips. + * + * @param options - Plugin configuration including old/new values and proposal numbers + * @returns ProseMirror plugin instance + */ +export function createDiffDecorationPlugin( + options: DiffDecorationPluginOptions, +): Plugin<DiffPluginState> { + const { oldValue, newValue, proposalNumbers, doc } = options; + + // Compute diff regions once during initialization + const blocks = buildUnifiedMarkdownDiff(oldValue, newValue); + const diffRegions = mapMarkdownDiffToPositions(blocks, newValue, doc); + + return new Plugin<DiffPluginState>({ + key: DIFF_PLUGIN_KEY, + + state: { + init: (): DiffPluginState => ({ + diffRegions, + hoveredRegion: null, + }), + + apply: (tr, state): DiffPluginState => { + // State remains constant (diffs don't change after initialization) + return state; + }, + }, + + props: { + decorations: (state): DecorationSet => { + const pluginState = DIFF_PLUGIN_KEY.getState(state); + if (!pluginState) return DecorationSet.empty; + + const decorations: Decoration[] = []; + + // Create inline decorations for each diff region + for (const region of pluginState.diffRegions) { + // Add highlight decoration + decorations.push( + Decoration.inline(region.from, region.to, { + class: 'milkdown-diff-highlight', + 'data-diff-from': String(region.from), + 'data-diff-to': String(region.to), + }), + ); + } + + return DecorationSet.create(state.doc, decorations); + }, + + handleDOMEvents: { + mouseover: (view, event) => { + const target = event.target as HTMLElement; + + // Check if we're hovering over a diff highlight + const diffElement = target.closest('.milkdown-diff-highlight'); + if (!diffElement) return false; + + const from = parseInt( + diffElement.getAttribute('data-diff-from') || '0', + 10, + ); + const to = parseInt( + diffElement.getAttribute('data-diff-to') || '0', + 10, + ); + + const pluginState = DIFF_PLUGIN_KEY.getState(view.state); + if (!pluginState) return false; + + // Find the matching region + const region = pluginState.diffRegions.find( + (r) => r.from === from && r.to === to, + ); + + if (region && region !== pluginState.hoveredRegion) { + // Show tooltip for this region + const tooltipWidget = createTooltipWidget(region, proposalNumbers); + + // Append tooltip to diff element + if (!diffElement.querySelector('.milkdown-diff-tooltip')) { + diffElement.appendChild(tooltipWidget); + } + } + + return false; + }, + + mouseout: (view, event) => { + const target = event.target as HTMLElement; + const diffElement = target.closest('.milkdown-diff-highlight'); + + if (diffElement) { + // Remove tooltip + const tooltip = diffElement.querySelector('.milkdown-diff-tooltip'); + if (tooltip) { + tooltip.remove(); + } + } + + return false; + }, + }, + }, + }); +} diff --git a/apps/frontend/src/domain/change-proposals/types.ts b/apps/frontend/src/domain/change-proposals/types.ts new file mode 100644 index 000000000..7016eace1 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/types.ts @@ -0,0 +1,9 @@ +import { ChangeProposal, ChangeProposalId } from '@packmind/types'; + +export type ChangeProposalWithConflicts = ChangeProposal & { + conflictsWith: ChangeProposalId[]; +}; + +export type SubmittedState = + | { type: 'accepted'; artefactUrl: string } + | { type: 'rejected' }; diff --git a/apps/frontend/src/domain/change-proposals/types/diffTypes.ts b/apps/frontend/src/domain/change-proposals/types/diffTypes.ts new file mode 100644 index 000000000..af2ab7cf9 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/types/diffTypes.ts @@ -0,0 +1,42 @@ +import { Node as ProseMirrorNode } from '@milkdown/kit/prose/model'; + +/** + * Represents a region of text in the ProseMirror document that has changed. + * Used by the diff decoration plugin to highlight changes and show tooltips. + */ +export interface DiffRegion { + /** Start position in the ProseMirror document */ + from: number; + /** End position in the ProseMirror document */ + to: number; + /** HTML content showing the diff (with <ins> and <del> tags) */ + diffHtml: string; + /** Original text content before the change */ + oldValue: string; + /** New text content after the change */ + newValue: string; +} + +/** + * Options passed to the diff decoration plugin. + */ +export interface DiffDecorationPluginOptions { + /** Original markdown content before changes */ + oldValue: string; + /** New markdown content after changes */ + newValue: string; + /** Array of proposal numbers that made changes (for tooltip header) */ + proposalNumbers: number[]; + /** ProseMirror document node (provided by the editor instance) */ + doc: ProseMirrorNode; +} + +/** + * Internal state maintained by the diff decoration plugin. + */ +export interface DiffPluginState { + /** Computed diff regions (calculated once during plugin initialization) */ + diffRegions: DiffRegion[]; + /** Currently hovered diff region (for tooltip display) */ + hoveredRegion: DiffRegion | null; +} diff --git a/apps/frontend/src/domain/change-proposals/utils/applyRecipeProposals.test.ts b/apps/frontend/src/domain/change-proposals/utils/applyRecipeProposals.test.ts new file mode 100644 index 000000000..e37938f81 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/applyRecipeProposals.test.ts @@ -0,0 +1,336 @@ +import { v4 as uuidv4 } from 'uuid'; +import { + ChangeProposal, + ChangeProposalCaptureMode, + ChangeProposalStatus, + ChangeProposalType, + Recipe, + createChangeProposalId, + createRecipeId, + createSpaceId, + createUserId, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../types'; +import { applyRecipeProposals } from './applyRecipeProposals'; + +const recipeFactory = (recipe?: Partial<Recipe>): Recipe => ({ + id: createRecipeId(uuidv4()), + name: 'Test Recipe', + slug: 'test-recipe', + content: 'Test recipe content', + version: 1, + gitCommit: undefined, + userId: createUserId(uuidv4()), + spaceId: createSpaceId(uuidv4()), + ...recipe, +}); + +const changeProposalFactory = ( + proposal?: Partial<ChangeProposal<ChangeProposalType>>, +): ChangeProposal<ChangeProposalType> => + ({ + id: createChangeProposalId(uuidv4()), + type: ChangeProposalType.updateCommandName, + artefactId: createRecipeId(uuidv4()), + artefactVersion: 1, + spaceId: createSpaceId(uuidv4()), + payload: { oldValue: 'Old Name', newValue: 'New Name' }, + captureMode: ChangeProposalCaptureMode.commit, + status: ChangeProposalStatus.pending, + createdBy: createUserId(uuidv4()), + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...proposal, + }) as ChangeProposal<ChangeProposalType>; + +describe('applyRecipeProposals', () => { + const recipeId = createRecipeId(uuidv4()); + const recipe = recipeFactory({ + id: recipeId, + name: 'Original Recipe', + content: 'Original content', + }); + + describe('with no proposals', () => { + const proposals: ChangeProposalWithConflicts[] = []; + const acceptedIds = new Set<string>(); + + let result: ReturnType<typeof applyRecipeProposals>; + + beforeEach(() => { + result = applyRecipeProposals(recipe, proposals, acceptedIds); + }); + + it('returns the original recipe name', () => { + expect(result.name).toBe('Original Recipe'); + }); + + it('returns the original recipe content', () => { + expect(result.content).toBe('Original content'); + }); + }); + + describe('with no accepted proposals', () => { + const proposalId = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + payload: { oldValue: 'Original Recipe', newValue: 'New Name' }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set<string>(); + + let result: ReturnType<typeof applyRecipeProposals>; + + beforeEach(() => { + result = applyRecipeProposals(recipe, proposals, acceptedIds); + }); + + it('returns the original recipe name', () => { + expect(result.name).toBe('Original Recipe'); + }); + }); + + describe('when updating recipe name', () => { + const proposalId = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + payload: { oldValue: 'Original Recipe', newValue: 'Updated Name' }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set([proposalId]); + + let result: ReturnType<typeof applyRecipeProposals>; + + beforeEach(() => { + result = applyRecipeProposals(recipe, proposals, acceptedIds); + }); + + it('applies the new name', () => { + expect(result.name).toBe('Updated Name'); + }); + }); + + describe('when updating recipe content', () => { + const proposalId = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + payload: { + oldValue: 'Original content', + newValue: 'Updated content', + }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set([proposalId]); + + let result: ReturnType<typeof applyRecipeProposals>; + + beforeEach(() => { + result = applyRecipeProposals(recipe, proposals, acceptedIds); + }); + + it('applies the new content', () => { + expect(result.content).toBe('Updated content'); + }); + }); + + describe('when applying multiple proposals in chronological order', () => { + const proposal1Id = createChangeProposalId(uuidv4()); + const proposal2Id = createChangeProposalId(uuidv4()); + const proposal3Id = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: proposal1Id, + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + payload: { + oldValue: 'Original Recipe', + newValue: 'First Update', + }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: proposal2Id, + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + payload: { oldValue: 'First Update', newValue: 'Second Update' }, + createdAt: new Date('2024-01-02'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: proposal3Id, + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + payload: { oldValue: 'Second Update', newValue: 'Final Update' }, + createdAt: new Date('2024-01-03'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set([proposal1Id, proposal2Id, proposal3Id]); + + let result: ReturnType<typeof applyRecipeProposals>; + + beforeEach(() => { + result = applyRecipeProposals(recipe, proposals, acceptedIds); + }); + + it('applies changes in chronological order', () => { + expect(result.name).toBe('Final Update'); + }); + }); + + describe('when applying proposals in non-chronological order', () => { + const earlierProposalId = createChangeProposalId(uuidv4()); + const laterProposalId = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: laterProposalId, + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + payload: { + oldValue: 'First Update', + newValue: 'Second Update', + }, + createdAt: new Date('2024-01-02'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: earlierProposalId, + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + payload: { + oldValue: 'Original Recipe', + newValue: 'First Update', + }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set([earlierProposalId, laterProposalId]); + + let result: ReturnType<typeof applyRecipeProposals>; + + beforeEach(() => { + result = applyRecipeProposals(recipe, proposals, acceptedIds); + }); + + it('sorts and applies proposals by createdAt date', () => { + expect(result.name).toBe('Second Update'); + }); + }); + + describe('when a content proposal conflicts with current value', () => { + const proposalId = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + payload: { + oldValue: 'Value that does not match current content', + newValue: 'Updated content', + }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set([proposalId]); + + let result: ReturnType<typeof applyRecipeProposals>; + + beforeEach(() => { + result = applyRecipeProposals(recipe, proposals, acceptedIds); + }); + + it('returns the original recipe name', () => { + expect(result.name).toBe('Original Recipe'); + }); + + it('returns the original recipe content', () => { + expect(result.content).toBe('Original content'); + }); + }); + + describe('when applying mixed proposal types', () => { + const nameProposalId = createChangeProposalId(uuidv4()); + const contentProposalId = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: nameProposalId, + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + payload: { + oldValue: 'Original Recipe', + newValue: 'Updated Recipe', + }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: contentProposalId, + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + payload: { + oldValue: 'Original content', + newValue: 'Updated content', + }, + createdAt: new Date('2024-01-02'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set([nameProposalId, contentProposalId]); + + let result: ReturnType<typeof applyRecipeProposals>; + + beforeEach(() => { + result = applyRecipeProposals(recipe, proposals, acceptedIds); + }); + + it('applies the name change', () => { + expect(result.name).toBe('Updated Recipe'); + }); + + it('applies the content change', () => { + expect(result.content).toBe('Updated content'); + }); + }); +}); diff --git a/apps/frontend/src/domain/change-proposals/utils/applyRecipeProposals.ts b/apps/frontend/src/domain/change-proposals/utils/applyRecipeProposals.ts new file mode 100644 index 000000000..00c30eeaa --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/applyRecipeProposals.ts @@ -0,0 +1,60 @@ +import { + ChangeProposalConflictError, + ChangeProposalId, + Recipe, + RecipeVersion, + CommandChangeProposalApplier, + DiffService, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../types'; +import { PREVIEW_RECIPE_VERSION_ID } from './changeProposalHelpers'; + +export interface AppliedRecipe { + name: string; + content: string; +} + +/** + * Applies all accepted change proposals sequentially to a recipe. + * + * Uses the shared CommandChangeProposalApplier for computing the + * final state, including diff-based merging for content changes. + */ +export function applyRecipeProposals( + recipe: Recipe, + proposals: ChangeProposalWithConflicts[], + acceptedIds: Set<ChangeProposalId>, +): AppliedRecipe { + const acceptedProposals = proposals.filter((p) => acceptedIds.has(p.id)); + + const sortedProposals = [...acceptedProposals].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + if (sortedProposals.length === 0) { + return { name: recipe.name, content: recipe.content }; + } + + // Build a RecipeVersion for the shared applier + const sourceVersion: RecipeVersion = { + id: PREVIEW_RECIPE_VERSION_ID, + recipeId: recipe.id, + name: recipe.name, + slug: recipe.slug, + content: recipe.content, + version: recipe.version, + userId: recipe.userId, + }; + + const applier = new CommandChangeProposalApplier(new DiffService()); + + try { + const result = applier.applyChangeProposals(sourceVersion, sortedProposals); + return result.version; + } catch (error) { + if (error instanceof ChangeProposalConflictError) { + return { name: recipe.name, content: recipe.content }; + } + throw error; + } +} diff --git a/apps/frontend/src/domain/change-proposals/utils/applySkillProposals.test.ts b/apps/frontend/src/domain/change-proposals/utils/applySkillProposals.test.ts new file mode 100644 index 000000000..8d830fe26 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/applySkillProposals.test.ts @@ -0,0 +1,728 @@ +import { v4 as uuidv4 } from 'uuid'; +import { + ChangeProposal, + ChangeProposalCaptureMode, + ChangeProposalStatus, + ChangeProposalType, + Skill, + SkillFile, + createChangeProposalId, + createSkillFileId, + createSkillId, + createSkillVersionId, + createSpaceId, + createUserId, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../types'; +import { applySkillProposals } from './applySkillProposals'; + +const skillFactory = (overrides?: Partial<Skill>): Skill => ({ + id: createSkillId(uuidv4()), + name: 'Test Skill', + slug: 'test-skill', + description: 'Test description', + prompt: '# Test prompt', + version: 1, + license: 'MIT', + compatibility: 'Claude Code', + allowedTools: 'Bash, Read', + metadata: { key1: 'value1' }, + userId: createUserId(uuidv4()), + spaceId: createSpaceId(uuidv4()), + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}); + +const skillFileFactory = (overrides?: Partial<SkillFile>): SkillFile => ({ + id: createSkillFileId(uuidv4()), + skillVersionId: createSkillVersionId(uuidv4()), + path: 'src/index.ts', + content: 'console.log("hello");', + permissions: 'read', + isBase64: false, + ...overrides, +}); + +const changeProposalFactory = ( + proposal?: Partial<ChangeProposal<ChangeProposalType>>, +): ChangeProposal<ChangeProposalType> => + ({ + id: createChangeProposalId(uuidv4()), + type: ChangeProposalType.updateSkillName, + artefactId: createSkillId(uuidv4()), + artefactVersion: 1, + spaceId: createSpaceId(uuidv4()), + payload: { oldValue: 'Old', newValue: 'New' }, + captureMode: ChangeProposalCaptureMode.commit, + status: ChangeProposalStatus.pending, + createdBy: createUserId(uuidv4()), + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...proposal, + }) as ChangeProposal<ChangeProposalType>; + +const withConflicts = ( + proposal: ChangeProposal<ChangeProposalType>, +): ChangeProposalWithConflicts => ({ + ...proposal, + conflictsWith: [], +}); + +describe('applySkillProposals', () => { + const skillId = createSkillId(uuidv4()); + const skill = skillFactory({ id: skillId }); + const file1 = skillFileFactory({ + id: createSkillFileId('file-1'), + path: 'src/main.ts', + content: 'main content', + permissions: 'read', + }); + const file2 = skillFileFactory({ + id: createSkillFileId('file-2'), + path: 'src/utils.ts', + content: 'utils content', + permissions: 'read-write', + }); + const files = [file1, file2]; + + describe('with no proposals', () => { + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + result = applySkillProposals(skill, files, [], new Set()); + }); + + it('returns the original skill name', () => { + expect(result.name).toBe('Test Skill'); + }); + + it('returns the original description', () => { + expect(result.description).toBe('Test description'); + }); + + it('returns the original prompt', () => { + expect(result.prompt).toBe('# Test prompt'); + }); + + it('returns the original files', () => { + expect(result.files).toEqual(files); + }); + }); + + describe('when updating skill name', () => { + const proposalId = createChangeProposalId(uuidv4()); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateSkillName, + payload: { oldValue: 'Test Skill', newValue: 'Updated Skill' }, + createdAt: new Date('2024-01-01'), + }), + ), + ]; + result = applySkillProposals( + skill, + files, + proposals, + new Set([proposalId]), + ); + }); + + it('applies the new name', () => { + expect(result.name).toBe('Updated Skill'); + }); + }); + + describe('when updating skill description', () => { + const proposalId = createChangeProposalId(uuidv4()); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateSkillDescription, + payload: { + oldValue: 'Test description', + newValue: 'New description', + }, + createdAt: new Date('2024-01-01'), + }), + ), + ]; + result = applySkillProposals( + skill, + files, + proposals, + new Set([proposalId]), + ); + }); + + it('applies the new description', () => { + expect(result.description).toBe('New description'); + }); + }); + + describe('when updating skill prompt', () => { + const proposalId = createChangeProposalId(uuidv4()); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateSkillPrompt, + payload: { oldValue: '# Test prompt', newValue: '# New prompt' }, + createdAt: new Date('2024-01-01'), + }), + ), + ]; + result = applySkillProposals( + skill, + files, + proposals, + new Set([proposalId]), + ); + }); + + it('applies the new prompt', () => { + expect(result.prompt).toBe('# New prompt'); + }); + }); + + describe('when updating skill license', () => { + const proposalId = createChangeProposalId(uuidv4()); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateSkillLicense, + payload: { oldValue: 'MIT', newValue: 'Apache-2.0' }, + createdAt: new Date('2024-01-01'), + }), + ), + ]; + result = applySkillProposals( + skill, + files, + proposals, + new Set([proposalId]), + ); + }); + + it('applies the new license', () => { + expect(result.license).toBe('Apache-2.0'); + }); + }); + + describe('when updating skill compatibility', () => { + const proposalId = createChangeProposalId(uuidv4()); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateSkillCompatibility, + payload: { oldValue: 'Claude Code', newValue: 'Cursor' }, + createdAt: new Date('2024-01-01'), + }), + ), + ]; + result = applySkillProposals( + skill, + files, + proposals, + new Set([proposalId]), + ); + }); + + it('applies the new compatibility', () => { + expect(result.compatibility).toBe('Cursor'); + }); + }); + + describe('when updating skill allowedTools', () => { + const proposalId = createChangeProposalId(uuidv4()); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateSkillAllowedTools, + payload: { oldValue: 'Bash, Read', newValue: 'Bash, Read, Write' }, + createdAt: new Date('2024-01-01'), + }), + ), + ]; + result = applySkillProposals( + skill, + files, + proposals, + new Set([proposalId]), + ); + }); + + it('applies the new allowedTools', () => { + expect(result.allowedTools).toBe('Bash, Read, Write'); + }); + }); + + describe('when updating skill metadata', () => { + const proposalId = createChangeProposalId(uuidv4()); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateSkillMetadata, + payload: { + oldValue: JSON.stringify({ key1: 'value1' }), + newValue: JSON.stringify({ key1: 'updated', key2: 'new' }), + }, + createdAt: new Date('2024-01-01'), + }), + ), + ]; + result = applySkillProposals( + skill, + files, + proposals, + new Set([proposalId]), + ); + }); + + it('applies the new metadata', () => { + expect(result.metadata).toEqual({ key1: 'updated', key2: 'new' }); + }); + }); + + describe('when adding a skill file', () => { + const proposalId = createChangeProposalId(uuidv4()); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.addSkillFile, + payload: { + item: { + path: 'src/new-file.ts', + content: 'new file content', + permissions: 'read', + isBase64: false, + }, + }, + createdAt: new Date('2024-01-01'), + }), + ), + ]; + result = applySkillProposals( + skill, + files, + proposals, + new Set([proposalId]), + ); + }); + + it('adds the new file', () => { + expect(result.files).toHaveLength(3); + }); + + it('includes the new file with correct content', () => { + const newFile = result.files.find((f) => f.path === 'src/new-file.ts'); + expect(newFile?.content).toBe('new file content'); + }); + }); + + describe('when updating file content', () => { + const proposalId = createChangeProposalId(uuidv4()); + const targetId = createSkillFileId('file-1'); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId, + oldValue: 'main content', + newValue: 'updated main content', + }, + createdAt: new Date('2024-01-01'), + }), + ), + ]; + result = applySkillProposals( + skill, + files, + proposals, + new Set([proposalId]), + ); + }); + + it('updates the file content', () => { + const updatedFile = result.files.find((f) => f.id === targetId); + expect(updatedFile?.content).toBe('updated main content'); + }); + }); + + describe('when updating file permissions', () => { + const proposalId = createChangeProposalId(uuidv4()); + const targetId = createSkillFileId('file-1'); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateSkillFilePermissions, + payload: { + targetId, + oldValue: 'read', + newValue: 'read-write', + }, + createdAt: new Date('2024-01-01'), + }), + ), + ]; + result = applySkillProposals( + skill, + files, + proposals, + new Set([proposalId]), + ); + }); + + it('updates the file permissions', () => { + const updatedFile = result.files.find((f) => f.id === targetId); + expect(updatedFile?.permissions).toBe('read-write'); + }); + }); + + describe('when deleting a skill file', () => { + const proposalId = createChangeProposalId(uuidv4()); + const targetId = createSkillFileId('file-1'); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.deleteSkillFile, + payload: { + targetId, + item: { + id: targetId, + path: 'src/main.ts', + content: 'main content', + permissions: 'read', + isBase64: false, + }, + }, + createdAt: new Date('2024-01-01'), + }), + ), + ]; + result = applySkillProposals( + skill, + files, + proposals, + new Set([proposalId]), + ); + }); + + it('removes the file', () => { + expect(result.files).toHaveLength(1); + }); + + it('does not include the deleted file', () => { + expect(result.files.find((f) => f.id === targetId)).toBeUndefined(); + }); + }); + + describe('when applying proposals in chronological order', () => { + const proposal1Id = createChangeProposalId(uuidv4()); + const proposal2Id = createChangeProposalId(uuidv4()); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + // Proposals listed in reverse order, should still apply oldest first + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: proposal2Id, + type: ChangeProposalType.updateSkillName, + payload: { oldValue: 'First', newValue: 'Second' }, + createdAt: new Date('2024-01-02'), + }), + ), + withConflicts( + changeProposalFactory({ + id: proposal1Id, + type: ChangeProposalType.updateSkillName, + payload: { oldValue: 'Test Skill', newValue: 'First' }, + createdAt: new Date('2024-01-01'), + }), + ), + ]; + result = applySkillProposals( + skill, + files, + proposals, + new Set([proposal1Id, proposal2Id]), + ); + }); + + it('applies proposals sorted by createdAt', () => { + expect(result.name).toBe('Second'); + }); + }); + + describe('when adding then deleting the same file (cancellation)', () => { + const addProposalId = createChangeProposalId(uuidv4()); + const deleteProposalId = createChangeProposalId(uuidv4()); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: addProposalId, + type: ChangeProposalType.addSkillFile, + payload: { + item: { + path: 'src/temp.ts', + content: 'temp content', + permissions: 'read', + isBase64: false, + }, + }, + createdAt: new Date('2024-01-01'), + }), + ), + withConflicts( + changeProposalFactory({ + id: deleteProposalId, + type: ChangeProposalType.deleteSkillFile, + payload: { + targetId: createSkillFileId(''), + item: { + id: createSkillFileId(''), + path: 'src/temp.ts', + content: 'temp content', + permissions: 'read', + isBase64: false, + }, + }, + createdAt: new Date('2024-01-02'), + }), + ), + ]; + + // First apply just the add to get the generated file ID + const addResult = applySkillProposals( + skill, + files, + [proposals[0]], + new Set([addProposalId]), + ); + const addedFile = addResult.files.find((f) => f.path === 'src/temp.ts'); + + // Update the delete proposal to target the correct file ID + if (addedFile) { + (proposals[1] as ChangeProposalWithConflicts).payload = { + targetId: addedFile.id, + item: { + id: addedFile.id, + path: 'src/temp.ts', + content: 'temp content', + permissions: 'read', + isBase64: false, + }, + }; + } + + result = applySkillProposals( + skill, + files, + proposals, + new Set([addProposalId, deleteProposalId]), + ); + }); + + it('cancels both add and delete', () => { + expect(result.files).toHaveLength(2); + }); + }); + + describe('when applying mixed proposal types', () => { + const nameProposalId = createChangeProposalId(uuidv4()); + const promptProposalId = createChangeProposalId(uuidv4()); + const addFileProposalId = createChangeProposalId(uuidv4()); + const updateContentProposalId = createChangeProposalId(uuidv4()); + const targetFileId = createSkillFileId('file-1'); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: nameProposalId, + type: ChangeProposalType.updateSkillName, + payload: { oldValue: 'Test Skill', newValue: 'Mixed Skill' }, + createdAt: new Date('2024-01-01'), + }), + ), + withConflicts( + changeProposalFactory({ + id: promptProposalId, + type: ChangeProposalType.updateSkillPrompt, + payload: { oldValue: '# Test prompt', newValue: '# New prompt' }, + createdAt: new Date('2024-01-02'), + }), + ), + withConflicts( + changeProposalFactory({ + id: addFileProposalId, + type: ChangeProposalType.addSkillFile, + payload: { + item: { + path: 'src/added.ts', + content: 'added', + permissions: 'read', + isBase64: false, + }, + }, + createdAt: new Date('2024-01-03'), + }), + ), + withConflicts( + changeProposalFactory({ + id: updateContentProposalId, + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: targetFileId, + oldValue: 'main content', + newValue: 'updated main', + }, + createdAt: new Date('2024-01-04'), + }), + ), + ]; + result = applySkillProposals( + skill, + files, + proposals, + new Set([ + nameProposalId, + promptProposalId, + addFileProposalId, + updateContentProposalId, + ]), + ); + }); + + it('applies the name change', () => { + expect(result.name).toBe('Mixed Skill'); + }); + + it('applies the prompt change', () => { + expect(result.prompt).toBe('# New prompt'); + }); + + it('adds the new file', () => { + expect(result.files).toHaveLength(3); + }); + + it('updates the file content', () => { + const updated = result.files.find((f) => f.id === targetFileId); + expect(updated?.content).toBe('updated main'); + }); + }); + + describe('when a prompt proposal conflicts with current value', () => { + const proposalId = createChangeProposalId(uuidv4()); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateSkillPrompt, + payload: { + oldValue: 'Value that does not match current prompt', + newValue: '# New prompt', + }, + createdAt: new Date('2024-01-01'), + }), + ), + ]; + result = applySkillProposals( + skill, + files, + proposals, + new Set([proposalId]), + ); + }); + + it('returns the original skill name', () => { + expect(result.name).toBe('Test Skill'); + }); + + it('returns the original skill prompt', () => { + expect(result.prompt).toBe('# Test prompt'); + }); + + it('returns the original files', () => { + expect(result.files).toEqual(files); + }); + }); + + describe('with non-accepted proposals', () => { + const proposalId = createChangeProposalId(uuidv4()); + let result: ReturnType<typeof applySkillProposals>; + + beforeEach(() => { + const proposals: ChangeProposalWithConflicts[] = [ + withConflicts( + changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateSkillName, + payload: { oldValue: 'Test Skill', newValue: 'Should Not Apply' }, + createdAt: new Date('2024-01-01'), + }), + ), + ]; + result = applySkillProposals(skill, files, proposals, new Set()); + }); + + it('does not apply non-accepted proposals', () => { + expect(result.name).toBe('Test Skill'); + }); + }); +}); diff --git a/apps/frontend/src/domain/change-proposals/utils/applySkillProposals.ts b/apps/frontend/src/domain/change-proposals/utils/applySkillProposals.ts new file mode 100644 index 000000000..38413514f --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/applySkillProposals.ts @@ -0,0 +1,93 @@ +import { + ChangeProposalConflictError, + ChangeProposalId, + Skill, + SkillFile, + SkillChangeProposalApplier, + SkillVersionWithFiles, + DiffService, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../types'; +import { PREVIEW_SKILL_VERSION_ID } from './changeProposalHelpers'; + +export interface AppliedSkill { + name: string; + description: string; + prompt: string; + license: string | undefined; + compatibility: string | undefined; + allowedTools: string | undefined; + metadata: Record<string, string> | undefined; + additionalProperties: Record<string, unknown> | undefined; + files: SkillFile[]; +} + +/** + * Applies all accepted change proposals sequentially to a skill. + * + * Uses the shared SkillChangeProposalApplier for all field computation + * (scalars + files). + */ +export function applySkillProposals( + skill: Skill, + files: SkillFile[], + proposals: ChangeProposalWithConflicts[], + acceptedIds: Set<ChangeProposalId>, +): AppliedSkill { + const acceptedProposals = proposals.filter((p) => acceptedIds.has(p.id)); + + const sortedProposals = [...acceptedProposals].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + // Use shared applier for scalar field computation + const sourceVersion: SkillVersionWithFiles = { + id: files[0]?.skillVersionId ?? PREVIEW_SKILL_VERSION_ID, + skillId: skill.id, + version: skill.version, + userId: skill.userId, + name: skill.name, + slug: skill.slug, + description: skill.description, + prompt: skill.prompt, + license: skill.license, + compatibility: skill.compatibility, + metadata: skill.metadata, + allowedTools: skill.allowedTools, + additionalProperties: skill.additionalProperties, + files: [...files], + }; + + const applier = new SkillChangeProposalApplier(new DiffService()); + + try { + const appliedResult = applier.applyChangeProposals( + sourceVersion, + sortedProposals, + ); + + return { + ...appliedResult.version, + license: appliedResult.version.license, + compatibility: appliedResult.version.compatibility, + allowedTools: appliedResult.version.allowedTools, + metadata: appliedResult.version.metadata, + additionalProperties: appliedResult.version.additionalProperties, + }; + } catch (error) { + if (error instanceof ChangeProposalConflictError) { + return { + name: skill.name, + description: skill.description, + prompt: skill.prompt, + license: skill.license, + compatibility: skill.compatibility, + allowedTools: skill.allowedTools, + metadata: skill.metadata, + additionalProperties: skill.additionalProperties, + files: [...files], + }; + } + throw error; + } +} diff --git a/apps/frontend/src/domain/change-proposals/utils/applyStandardProposals.test.ts b/apps/frontend/src/domain/change-proposals/utils/applyStandardProposals.test.ts new file mode 100644 index 000000000..0af9e2baf --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/applyStandardProposals.test.ts @@ -0,0 +1,732 @@ +import { v4 as uuidv4 } from 'uuid'; +import { + ChangeProposal, + ChangeProposalCaptureMode, + ChangeProposalStatus, + ChangeProposalType, + Rule, + Standard, + createChangeProposalId, + createRuleId, + createSpaceId, + createStandardId, + createStandardVersionId, + createUserId, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../types'; +import { + applyStandardProposals, + getProposalNumbers, +} from './applyStandardProposals'; + +// Local test factories to avoid module boundary violations +const standardFactory = (standard?: Partial<Standard>): Standard => ({ + id: createStandardId(uuidv4()), + name: 'Test Standard', + slug: 'test-standard', + description: 'Test standard description', + version: 1, + gitCommit: undefined, + userId: createUserId(uuidv4()), + scope: null, + spaceId: createSpaceId(uuidv4()), + ...standard, +}); + +const ruleFactory = (rule?: Partial<Rule>): Rule => ({ + id: createRuleId(uuidv4()), + content: 'Test rule content describing a coding standard', + standardVersionId: createStandardVersionId(uuidv4()), + ...rule, +}); + +const changeProposalFactory = ( + proposal?: Partial<ChangeProposal<ChangeProposalType>>, +): ChangeProposal<ChangeProposalType> => + ({ + id: createChangeProposalId(uuidv4()), + type: ChangeProposalType.updateStandardName, + artefactId: createStandardId(uuidv4()), + artefactVersion: 1, + spaceId: createSpaceId(uuidv4()), + payload: { oldValue: 'Old Name', newValue: 'New Name' }, + captureMode: ChangeProposalCaptureMode.commit, + status: ChangeProposalStatus.pending, + createdBy: createUserId(uuidv4()), + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...proposal, + }) as ChangeProposal<ChangeProposalType>; + +describe('applyStandardProposals', () => { + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + const standard = standardFactory({ + id: standardId, + name: 'Original Standard', + description: 'Original description', + }); + const rules = [ + ruleFactory({ + id: createRuleId('rule-1'), + content: 'Rule 1 content', + standardVersionId, + }), + ruleFactory({ + id: createRuleId('rule-2'), + content: 'Rule 2 content', + standardVersionId, + }), + ]; + + describe('with no proposals', () => { + const proposals: ChangeProposalWithConflicts[] = []; + const acceptedIds = new Set<string>(); + + let result: ReturnType<typeof applyStandardProposals>; + + beforeEach(() => { + result = applyStandardProposals(standard, rules, proposals, acceptedIds); + }); + + it('returns the original standard name', () => { + expect(result.name).toBe('Original Standard'); + }); + + it('returns the original standard description', () => { + expect(result.description).toBe('Original description'); + }); + + it('returns the original rules', () => { + expect(result.rules).toEqual(rules); + }); + }); + + describe('with no accepted proposals', () => { + const proposalId = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + payload: { oldValue: 'Original Standard', newValue: 'New Name' }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set<string>(); + + let result: ReturnType<typeof applyStandardProposals>; + + beforeEach(() => { + result = applyStandardProposals(standard, rules, proposals, acceptedIds); + }); + + it('returns the original standard name', () => { + expect(result.name).toBe('Original Standard'); + }); + }); + + describe('when updating standard name', () => { + const proposalId = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + payload: { oldValue: 'Original Standard', newValue: 'Updated Name' }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set([proposalId]); + + let result: ReturnType<typeof applyStandardProposals>; + + beforeEach(() => { + result = applyStandardProposals(standard, rules, proposals, acceptedIds); + }); + + it('applies the new name', () => { + expect(result.name).toBe('Updated Name'); + }); + }); + + describe('when updating standard description', () => { + const proposalId = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateStandardDescription, + artefactId: standardId, + payload: { + oldValue: 'Original description', + newValue: 'Updated description', + }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set([proposalId]); + + let result: ReturnType<typeof applyStandardProposals>; + + beforeEach(() => { + result = applyStandardProposals(standard, rules, proposals, acceptedIds); + }); + + it('applies the new description', () => { + expect(result.description).toBe('Updated description'); + }); + }); + + describe('when adding a rule', () => { + const proposalId = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.addRule, + artefactId: standardId, + payload: { item: { content: 'New rule content' } }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set([proposalId]); + + let result: ReturnType<typeof applyStandardProposals>; + + beforeEach(() => { + result = applyStandardProposals(standard, rules, proposals, acceptedIds); + }); + + it('adds the new rule', () => { + expect(result.rules).toHaveLength(3); + }); + + it('includes the new rule content', () => { + expect(result.rules[2].content).toBe('New rule content'); + }); + }); + + describe('when updating a rule', () => { + const targetRuleId = createRuleId('rule-1'); + const proposalId = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateRule, + artefactId: standardId, + payload: { + targetId: targetRuleId, + oldValue: 'Rule 1 content', + newValue: 'Updated rule 1 content', + }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set([proposalId]); + + let result: ReturnType<typeof applyStandardProposals>; + + beforeEach(() => { + result = applyStandardProposals(standard, rules, proposals, acceptedIds); + }); + + it('updates the rule content', () => { + const updatedRule = result.rules.find((r) => r.id === targetRuleId); + expect(updatedRule?.content).toBe('Updated rule 1 content'); + }); + + it('keeps the same number of rules', () => { + expect(result.rules).toHaveLength(2); + }); + }); + + describe('when deleting a rule', () => { + const targetRuleId = createRuleId('rule-1'); + const proposalId = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.deleteRule, + artefactId: standardId, + payload: { + targetId: targetRuleId, + item: { id: targetRuleId, content: 'Rule 1 content' }, + }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set([proposalId]); + + let result: ReturnType<typeof applyStandardProposals>; + + beforeEach(() => { + result = applyStandardProposals(standard, rules, proposals, acceptedIds); + }); + + it('removes the rule', () => { + expect(result.rules).toHaveLength(1); + }); + + it('does not include the deleted rule', () => { + const deletedRule = result.rules.find((r) => r.id === targetRuleId); + expect(deletedRule).toBeUndefined(); + }); + }); + + describe('when applying multiple proposals in chronological order', () => { + const proposal1Id = createChangeProposalId(uuidv4()); + const proposal2Id = createChangeProposalId(uuidv4()); + const proposal3Id = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: proposal1Id, + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + payload: { + oldValue: 'Original Standard', + newValue: 'First Update', + }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: proposal2Id, + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + payload: { oldValue: 'First Update', newValue: 'Second Update' }, + createdAt: new Date('2024-01-02'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: proposal3Id, + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + payload: { oldValue: 'Second Update', newValue: 'Final Update' }, + createdAt: new Date('2024-01-03'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set([proposal1Id, proposal2Id, proposal3Id]); + + let result: ReturnType<typeof applyStandardProposals>; + + beforeEach(() => { + result = applyStandardProposals(standard, rules, proposals, acceptedIds); + }); + + it('applies changes in chronological order', () => { + expect(result.name).toBe('Final Update'); + }); + }); + + describe('when applying proposals in non-chronological order', () => { + const earlierProposalId = createChangeProposalId(uuidv4()); + const laterProposalId = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: laterProposalId, + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + payload: { + oldValue: 'First Update', + newValue: 'Second Update', + }, + createdAt: new Date('2024-01-02'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: earlierProposalId, + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + payload: { + oldValue: 'Original Standard', + newValue: 'First Update', + }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set([earlierProposalId, laterProposalId]); + + let result: ReturnType<typeof applyStandardProposals>; + + beforeEach(() => { + result = applyStandardProposals(standard, rules, proposals, acceptedIds); + }); + + it('sorts and applies proposals by createdAt date', () => { + expect(result.name).toBe('Second Update'); + }); + }); + + describe('when deleting a previously added rule', () => { + const addProposalId = createChangeProposalId(uuidv4()); + + let result: ReturnType<typeof applyStandardProposals>; + + beforeEach(() => { + // First apply the add proposal to get the rule ID + const addProposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: addProposalId, + type: ChangeProposalType.addRule, + artefactId: standardId, + payload: { item: { content: 'Temporarily added rule' } }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + ]; + const addAcceptedIds = new Set([addProposalId]); + + const addResult = applyStandardProposals( + standard, + rules, + addProposals, + addAcceptedIds, + ); + const addedRule = addResult.rules.find( + (r) => r.content === 'Temporarily added rule', + ); + + if (!addedRule) { + throw new Error('Added rule not found'); + } + + // Now apply both add and delete proposals together + const deleteProposalId = createChangeProposalId(uuidv4()); + const allProposals: ChangeProposalWithConflicts[] = [ + ...addProposals, + { + ...changeProposalFactory({ + id: deleteProposalId, + type: ChangeProposalType.deleteRule, + artefactId: standardId, + payload: { + targetId: addedRule.id, + item: { id: addedRule.id, content: 'Temporarily added rule' }, + }, + createdAt: new Date('2024-01-02'), + }), + conflictsWith: [], + }, + ]; + const allAcceptedIds = new Set([addProposalId, deleteProposalId]); + + result = applyStandardProposals( + standard, + rules, + allProposals, + allAcceptedIds, + ); + }); + + it('removes the temporarily added rule', () => { + const tempRule = result.rules.find( + (r) => r.content === 'Temporarily added rule', + ); + expect(tempRule).toBeUndefined(); + }); + + it('returns to the original number of rules', () => { + expect(result.rules).toHaveLength(2); + }); + }); + + describe('when a description proposal conflicts with current value', () => { + const proposalId = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.updateStandardDescription, + artefactId: standardId, + payload: { + oldValue: 'Value that does not match current description', + newValue: 'Updated description', + }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set([proposalId]); + + let result: ReturnType<typeof applyStandardProposals>; + + beforeEach(() => { + result = applyStandardProposals(standard, rules, proposals, acceptedIds); + }); + + it('returns the original standard name', () => { + expect(result.name).toBe('Original Standard'); + }); + + it('returns the original standard description', () => { + expect(result.description).toBe('Original description'); + }); + + it('returns the original rules', () => { + expect(result.rules).toEqual(rules); + }); + }); + + describe('when applying mixed proposal types', () => { + const nameProposalId = createChangeProposalId(uuidv4()); + const descProposalId = createChangeProposalId(uuidv4()); + const addRuleProposalId = createChangeProposalId(uuidv4()); + const updateRuleProposalId = createChangeProposalId(uuidv4()); + const deleteRuleProposalId = createChangeProposalId(uuidv4()); + const targetUpdateRuleId = createRuleId('rule-1'); + const targetDeleteRuleId = createRuleId('rule-2'); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: nameProposalId, + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + payload: { + oldValue: 'Original Standard', + newValue: 'Mixed Updates', + }, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: descProposalId, + type: ChangeProposalType.updateStandardDescription, + artefactId: standardId, + payload: { + oldValue: 'Original description', + newValue: 'New description', + }, + createdAt: new Date('2024-01-02'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: addRuleProposalId, + type: ChangeProposalType.addRule, + artefactId: standardId, + payload: { item: { content: 'Brand new rule' } }, + createdAt: new Date('2024-01-03'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: updateRuleProposalId, + type: ChangeProposalType.updateRule, + artefactId: standardId, + payload: { + targetId: targetUpdateRuleId, + oldValue: 'Rule 1 content', + newValue: 'Modified rule 1', + }, + createdAt: new Date('2024-01-04'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: deleteRuleProposalId, + type: ChangeProposalType.deleteRule, + artefactId: standardId, + payload: { + targetId: targetDeleteRuleId, + item: { id: targetDeleteRuleId, content: 'Rule 2 content' }, + }, + createdAt: new Date('2024-01-05'), + }), + conflictsWith: [], + }, + ]; + const acceptedIds = new Set([ + nameProposalId, + descProposalId, + addRuleProposalId, + updateRuleProposalId, + deleteRuleProposalId, + ]); + + let result: ReturnType<typeof applyStandardProposals>; + + beforeEach(() => { + result = applyStandardProposals(standard, rules, proposals, acceptedIds); + }); + + it('applies the name change', () => { + expect(result.name).toBe('Mixed Updates'); + }); + + it('applies the description change', () => { + expect(result.description).toBe('New description'); + }); + + it('has the correct number of rules after all operations', () => { + expect(result.rules).toHaveLength(2); + }); + + it('includes the added rule', () => { + const addedRule = result.rules.find( + (r) => r.content === 'Brand new rule', + ); + expect(addedRule).toBeDefined(); + }); + + it('includes the updated rule with new content', () => { + const updatedRule = result.rules.find((r) => r.id === targetUpdateRuleId); + expect(updatedRule?.content).toBe('Modified rule 1'); + }); + + it('does not include the deleted rule', () => { + const deletedRule = result.rules.find((r) => r.id === targetDeleteRuleId); + expect(deletedRule).toBeUndefined(); + }); + }); +}); + +describe('getProposalNumbers', () => { + describe('with proposals in chronological order', () => { + const id1 = createChangeProposalId(uuidv4()); + const id2 = createChangeProposalId(uuidv4()); + const id3 = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: id1, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: id2, + createdAt: new Date('2024-01-02'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: id3, + createdAt: new Date('2024-01-03'), + }), + conflictsWith: [], + }, + ]; + + let result: number[]; + + beforeEach(() => { + result = getProposalNumbers([id1, id2, id3], proposals); + }); + + it('returns sequential numbers starting from 1', () => { + expect(result).toEqual([1, 2, 3]); + }); + }); + + describe('with proposals in non-chronological order', () => { + const id1 = createChangeProposalId(uuidv4()); + const id2 = createChangeProposalId(uuidv4()); + const id3 = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: id3, + createdAt: new Date('2024-01-03'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: id1, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: id2, + createdAt: new Date('2024-01-02'), + }), + conflictsWith: [], + }, + ]; + + let result: number[]; + + beforeEach(() => { + result = getProposalNumbers([id3, id1, id2], proposals); + }); + + it('returns numbers based on chronological order', () => { + expect(result).toEqual([3, 1, 2]); + }); + }); + + describe('with missing proposal IDs', () => { + const id1 = createChangeProposalId(uuidv4()); + const id2 = createChangeProposalId(uuidv4()); + const missingId = createChangeProposalId(uuidv4()); + const proposals: ChangeProposalWithConflicts[] = [ + { + ...changeProposalFactory({ + id: id1, + createdAt: new Date('2024-01-01'), + }), + conflictsWith: [], + }, + { + ...changeProposalFactory({ + id: id2, + createdAt: new Date('2024-01-02'), + }), + conflictsWith: [], + }, + ]; + + let result: number[]; + + beforeEach(() => { + result = getProposalNumbers([id1, missingId, id2], proposals); + }); + + it('filters out missing IDs', () => { + expect(result).toEqual([1, 2]); + }); + }); +}); diff --git a/apps/frontend/src/domain/change-proposals/utils/applyStandardProposals.ts b/apps/frontend/src/domain/change-proposals/utils/applyStandardProposals.ts new file mode 100644 index 000000000..4c2303506 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/applyStandardProposals.ts @@ -0,0 +1,92 @@ +import { + ChangeProposalConflictError, + ChangeProposalId, + Rule, + Standard, + StandardVersion, + StandardChangeProposalApplier, + DiffService, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../types'; +import { + buildProposalNumberMap, + PREVIEW_STANDARD_VERSION_ID, +} from './changeProposalHelpers'; + +export interface AppliedStandard { + name: string; + scope: string; + description: string; + rules: Rule[]; +} + +/** + * Applies all accepted change proposals sequentially to a standard. + * + * Uses the shared StandardChangeProposalApplier for all field computation + * (scalars + rules). + */ +export function applyStandardProposals( + standard: Standard, + rules: Rule[], + proposals: ChangeProposalWithConflicts[], + acceptedIds: Set<ChangeProposalId>, +): AppliedStandard { + // Filter to only accepted proposals + const acceptedProposals = proposals.filter((p) => acceptedIds.has(p.id)); + + // Sort by createdAt (oldest first) to apply in chronological order + const sortedProposals = [...acceptedProposals].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + // Use shared applier for scalar field computation (name, scope, description) + const sourceVersion: StandardVersion = { + id: rules[0]?.standardVersionId ?? PREVIEW_STANDARD_VERSION_ID, + standardId: standard.id, + name: standard.name, + slug: standard.slug, + description: standard.description, + version: standard.version, + scope: standard.scope, + rules: [...rules], + }; + + const applier = new StandardChangeProposalApplier(new DiffService()); + + try { + const appliedResult = applier.applyChangeProposals( + sourceVersion, + sortedProposals, + ); + + return { + ...appliedResult.version, + scope: appliedResult.version.scope ?? '', + rules: appliedResult.version.rules ?? [], + }; + } catch (error) { + if (error instanceof ChangeProposalConflictError) { + return { + name: standard.name, + scope: standard.scope ?? '', + description: standard.description, + rules: [...rules], + }; + } + throw error; + } +} + +/** + * Helper to get proposal numbers for display in tooltips + */ +export function getProposalNumbers( + proposalIds: ChangeProposalId[], + proposals: ChangeProposalWithConflicts[], +): number[] { + const numberMap = buildProposalNumberMap(proposals); + return proposalIds + .map((id) => numberMap.get(id)) + .filter((num): num is number => num !== undefined); +} diff --git a/apps/frontend/src/domain/change-proposals/utils/buildDiffSections.ts b/apps/frontend/src/domain/change-proposals/utils/buildDiffSections.ts new file mode 100644 index 000000000..4f5e39ce1 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/buildDiffSections.ts @@ -0,0 +1,42 @@ +import { diffLines, Change } from 'diff'; + +export type DiffSection = + | { type: 'unchanged'; value: string } + | { type: 'changed'; oldValue: string; newValue: string }; + +export function buildDiffSections( + oldValue: string, + newValue: string, +): DiffSection[] { + const changes: Change[] = diffLines(oldValue, newValue); + const sections: DiffSection[] = []; + let i = 0; + while (i < changes.length) { + const change = changes[i]; + if (!change.added && !change.removed) { + sections.push({ type: 'unchanged', value: change.value }); + i++; + } else if (change.removed) { + const next = changes[i + 1]; + if (next && next.added) { + sections.push({ + type: 'changed', + oldValue: change.value, + newValue: next.value, + }); + i += 2; + } else { + sections.push({ + type: 'changed', + oldValue: change.value, + newValue: '', + }); + i++; + } + } else if (change.added) { + sections.push({ type: 'changed', oldValue: '', newValue: change.value }); + i++; + } + } + return sections; +} diff --git a/apps/frontend/src/domain/change-proposals/utils/buildUnifiedMarkdownDiff.ts b/apps/frontend/src/domain/change-proposals/utils/buildUnifiedMarkdownDiff.ts new file mode 100644 index 000000000..41e8d3c91 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/buildUnifiedMarkdownDiff.ts @@ -0,0 +1,115 @@ +import { marked, Token } from 'marked'; +import { diffArrays } from 'diff'; +import { buildDiffHtml } from './markdownDiff'; + +export interface MarkdownBlock { + html: string; + isChanged: boolean; + diffHtml?: string; // HTML showing the diff for this block + type: 'paragraph' | 'heading' | 'list' | 'code' | 'other'; +} + +/** + * Builds a list of markdown blocks with change tracking for unified view. + * Each block knows if it was changed and includes the diff HTML for tooltip display. + */ +export function buildUnifiedMarkdownDiff( + oldValue: string, + newValue: string, +): MarkdownBlock[] { + marked.setOptions({ breaks: true, gfm: true }); + + const oldTokens = marked.lexer(oldValue).filter((t) => t.type !== 'space'); + const newTokens = marked.lexer(newValue).filter((t) => t.type !== 'space'); + + const oldRaws = oldTokens.map((t) => t.raw); + const newRaws = newTokens.map((t) => t.raw); + const changes = diffArrays(oldRaws, newRaws); + + let oldIdx = 0; + let newIdx = 0; + const blocks: MarkdownBlock[] = []; + + for (let ci = 0; ci < changes.length; ci++) { + const change = changes[ci]; + const count = change.count ?? change.value.length; + + if (!change.added && !change.removed) { + // Unchanged blocks + for (let i = 0; i < count; i++) { + const token = newTokens[newIdx++]; + blocks.push({ + html: renderToken(token), + isChanged: false, + type: getTokenType(token), + }); + oldIdx++; + } + } else if (change.removed) { + const next = changes[ci + 1]; + if (next && next.added) { + // Modified blocks + const nextCount = next.count ?? next.value.length; + if (count === 1 && nextCount === 1) { + // Single token modified + const oldToken = oldTokens[oldIdx++]; + const newToken = newTokens[newIdx++]; + blocks.push({ + html: renderToken(newToken), + isChanged: true, + diffHtml: buildDiffHtml(oldToken.raw, newToken.raw), + type: getTokenType(newToken), + }); + } else { + // Multiple tokens deleted and added + // Treat all added tokens as changed + oldIdx += count; // Skip deleted tokens + for (let i = 0; i < nextCount; i++) { + const newToken = newTokens[newIdx++]; + blocks.push({ + html: renderToken(newToken), + isChanged: true, + diffHtml: buildDiffHtml('', newToken.raw), // Show as pure addition + type: getTokenType(newToken), + }); + } + } + ci++; // Skip the next (added) change + } else { + // Pure deletion - skip in unified view (don't show deleted content) + for (let i = 0; i < count; i++) { + oldIdx++; + } + } + } else { + // Pure additions + for (let i = 0; i < count; i++) { + const newToken = newTokens[newIdx++]; + blocks.push({ + html: renderToken(newToken), + isChanged: true, + diffHtml: buildDiffHtml('', newToken.raw), // Show as pure addition + type: getTokenType(newToken), + }); + } + } + } + + return blocks; +} + +function renderToken(token: Token): string { + const list = [token] as Token[] & { links: Record<string, never> }; + list.links = {}; + return marked.parser(list); +} + +function getTokenType( + token: Token, +): 'paragraph' | 'heading' | 'list' | 'code' | 'other' { + if (token.type === 'paragraph') return 'paragraph'; + if (token.type === 'heading') return 'heading'; + if (token.type === 'list') return 'list'; + if (token.type === 'code') return 'code'; + return 'other'; +} diff --git a/apps/frontend/src/domain/change-proposals/utils/changeProposalHelpers.ts b/apps/frontend/src/domain/change-proposals/utils/changeProposalHelpers.ts new file mode 100644 index 000000000..b37f59114 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/changeProposalHelpers.ts @@ -0,0 +1,58 @@ +import { + CHANGE_PROPOSAL_TYPE_LABELS, + ChangeProposalId, + ChangeProposalStatus, + ChangeProposalType, + createRecipeVersionId, + createSkillVersionId, + createStandardVersionId, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../types'; + +export const PREVIEW_RECIPE_VERSION_ID = createRecipeVersionId('preview'); +export const PREVIEW_SKILL_VERSION_ID = createSkillVersionId('preview'); +export const PREVIEW_STANDARD_VERSION_ID = createStandardVersionId('preview'); + +export function getChangeProposalFieldLabel(type: ChangeProposalType): string { + return CHANGE_PROPOSAL_TYPE_LABELS[type]; +} + +export function buildProposalNumberMap( + proposals: { id: ChangeProposalId; createdAt: Date }[], +): Map<ChangeProposalId, number> { + const sorted = [...proposals].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + return new Map(sorted.map((p, i) => [p.id, i + 1])); +} + +export function buildBlockedByAcceptedMap( + proposals: ChangeProposalWithConflicts[], + acceptedProposalIds: Set<ChangeProposalId>, +): Map<ChangeProposalId, ChangeProposalId[]> { + const map = new Map<ChangeProposalId, ChangeProposalId[]>(); + for (const acceptedId of acceptedProposalIds) { + const accepted = proposals.find((p) => p.id === acceptedId); + if (!accepted) continue; + for (const conflictId of accepted.conflictsWith) { + const existing = map.get(conflictId) ?? []; + existing.push(acceptedId); + map.set(conflictId, existing); + } + } + return map; +} + +export function getStatusBadgeProps(status: ChangeProposalStatus): { + label: string; + colorPalette: string; +} { + switch (status) { + case ChangeProposalStatus.pending: + return { label: 'Pending', colorPalette: 'green' }; + case ChangeProposalStatus.applied: + return { label: 'Accepted', colorPalette: 'green' }; + case ChangeProposalStatus.rejected: + return { label: 'Dismissed', colorPalette: 'red' }; + } +} diff --git a/apps/frontend/src/domain/change-proposals/utils/computeOutdatedProposalIds.spec.ts b/apps/frontend/src/domain/change-proposals/utils/computeOutdatedProposalIds.spec.ts new file mode 100644 index 000000000..9e0ce4144 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/computeOutdatedProposalIds.spec.ts @@ -0,0 +1,1188 @@ +import { + ChangeProposal, + ChangeProposalCaptureMode, + ChangeProposalId, + ChangeProposalStatus, + ChangeProposalType, + PackageId, + Recipe, + Rule, + Skill, + SkillFile, + Standard, +} from '@packmind/types'; + +import { + computeSkillOutdatedIds, + computeStandardOutdatedIds, + computeCommandOutdatedIds, + computeRemovalOutdatedIds, +} from './computeOutdatedProposalIds'; + +// --------------------------------------------------------------------------- +// Helpers – factory functions to reduce boilerplate +// --------------------------------------------------------------------------- + +let idCounter = 0; + +function nextId(prefix = 'id'): string { + return `${prefix}-${++idCounter}`; +} + +function makeProposal( + overrides: Partial<ChangeProposal> & { + type: ChangeProposalType; + payload: unknown; + }, +): ChangeProposal { + return { + id: nextId('proposal') as ChangeProposalId, + artefactId: nextId('artefact') as never, + artefactVersion: 1, + spaceId: 'space-1' as never, + captureMode: ChangeProposalCaptureMode.commit, + status: ChangeProposalStatus.pending, + createdBy: 'user-1' as never, + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as ChangeProposal; +} + +function makeSkill(overrides: Partial<Skill> = {}): Skill { + return { + id: 'skill-1' as never, + spaceId: 'space-1' as never, + userId: 'user-1' as never, + name: 'My Skill', + slug: 'my-skill', + version: 2, + description: 'A description', + prompt: 'A prompt', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makeSkillFile(overrides: Partial<SkillFile> = {}): SkillFile { + return { + id: 'file-1' as never, + skillVersionId: 'sv-1' as never, + path: 'src/index.ts', + content: 'console.log("hello")', + permissions: '755', + isBase64: false, + ...overrides, + }; +} + +function makeStandard(overrides: Partial<Standard> = {}): Standard { + return { + id: 'std-1' as never, + name: 'My Standard', + slug: 'my-standard', + description: 'A description', + version: 2, + userId: 'user-1' as never, + scope: 'all files', + spaceId: 'space-1' as never, + ...overrides, + }; +} + +function makeRule(overrides: Partial<Rule> = {}): Rule { + return { + id: 'rule-1' as never, + content: 'Always use const', + standardVersionId: 'sv-1' as never, + ...overrides, + }; +} + +function makeRecipe(overrides: Partial<Recipe> = {}): Recipe { + return { + id: 'recipe-1' as never, + name: 'My Command', + slug: 'my-command', + content: 'Step 1: do this', + version: 2, + userId: 'user-1' as never, + spaceId: 'space-1' as never, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeEach(() => { + idCounter = 0; +}); + +describe('computeSkillOutdatedIds', () => { + describe('when skill is undefined', () => { + it('returns an empty set', () => { + const result = computeSkillOutdatedIds([], undefined, []); + expect(result.size).toBe(0); + }); + }); + + describe('same artefact version (optimization short-circuit)', () => { + it('never marks proposal as outdated regardless of payload mismatch', () => { + const skill = makeSkill({ version: 5 }); + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillName, + artefactVersion: 5, + payload: { oldValue: 'WRONG', newValue: 'something' }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.size).toBe(0); + }); + }); + + describe('scalar updates', () => { + const scalarCases: { + label: string; + type: ChangeProposalType; + field: keyof Skill; + currentValue: string; + }[] = [ + { + label: 'updateSkillName', + type: ChangeProposalType.updateSkillName, + field: 'name', + currentValue: 'My Skill', + }, + { + label: 'updateSkillDescription', + type: ChangeProposalType.updateSkillDescription, + field: 'description', + currentValue: 'A description', + }, + { + label: 'updateSkillPrompt', + type: ChangeProposalType.updateSkillPrompt, + field: 'prompt', + currentValue: 'A prompt', + }, + { + label: 'updateSkillLicense', + type: ChangeProposalType.updateSkillLicense, + field: 'license', + currentValue: 'MIT', + }, + { + label: 'updateSkillCompatibility', + type: ChangeProposalType.updateSkillCompatibility, + field: 'compatibility', + currentValue: 'Node 18+', + }, + { + label: 'updateSkillAllowedTools', + type: ChangeProposalType.updateSkillAllowedTools, + field: 'allowedTools', + currentValue: 'Bash,Read', + }, + ]; + + describe.each(scalarCases)('$label', ({ type, field, currentValue }) => { + describe('when oldValue matches current value', () => { + it('is NOT outdated', () => { + const skill = makeSkill({ [field]: currentValue }); + const proposal = makeProposal({ + type, + artefactVersion: 1, + payload: { oldValue: currentValue, newValue: 'new' }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.size).toBe(0); + }); + }); + + describe('when oldValue does not match current value', () => { + it('IS outdated', () => { + const skill = makeSkill({ [field]: currentValue }); + const proposal = makeProposal({ + type, + artefactVersion: 1, + payload: { oldValue: 'MISMATCHED', newValue: 'new' }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.has(proposal.id)).toBe(true); + }); + }); + }); + + describe('updateSkillMetadata', () => { + describe('when oldValue matches serialized metadata', () => { + it('is NOT outdated', () => { + const metadata = { bar: 'b', foo: 'a' }; + const skill = makeSkill({ metadata }); + // Serialized with sorted keys: {"bar":"b","foo":"a"} + const serialized = JSON.stringify({ bar: 'b', foo: 'a' }); + + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillMetadata, + artefactVersion: 1, + payload: { oldValue: serialized, newValue: '{}' }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.size).toBe(0); + }); + }); + + describe('when oldValue does not match serialized metadata', () => { + it('IS outdated', () => { + const skill = makeSkill({ metadata: { foo: 'a' } }); + + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillMetadata, + artefactVersion: 1, + payload: { oldValue: '{"foo":"WRONG"}', newValue: '{}' }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.has(proposal.id)).toBe(true); + }); + }); + + it('serializes metadata with sorted keys for deterministic comparison', () => { + // Keys inserted in reverse order should still serialize identically + const skill = makeSkill({ metadata: { zebra: 'z', apple: 'a' } }); + const sortedSerialized = JSON.stringify({ apple: 'a', zebra: 'z' }); + + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillMetadata, + artefactVersion: 1, + payload: { oldValue: sortedSerialized, newValue: '{}' }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.size).toBe(0); + }); + + describe('when metadata is undefined', () => { + it('uses "{}"', () => { + const skill = makeSkill({ metadata: undefined }); + + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillMetadata, + artefactVersion: 1, + payload: { oldValue: '{}', newValue: '{"a":"1"}' }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.size).toBe(0); + }); + }); + }); + + describe('optional fields default to empty string', () => { + it('uses empty string for undefined license', () => { + const skill = makeSkill({ license: undefined }); + + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillLicense, + artefactVersion: 1, + payload: { oldValue: '', newValue: 'MIT' }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.size).toBe(0); + }); + + it('uses empty string for undefined compatibility', () => { + const skill = makeSkill({ compatibility: undefined }); + + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillCompatibility, + artefactVersion: 1, + payload: { oldValue: '', newValue: 'Node 18+' }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.size).toBe(0); + }); + + it('uses empty string for undefined allowedTools', () => { + const skill = makeSkill({ allowedTools: undefined }); + + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillAllowedTools, + artefactVersion: 1, + payload: { oldValue: '', newValue: 'Bash' }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.size).toBe(0); + }); + }); + }); + + describe('collection update – updateSkillFileContent', () => { + describe('when oldValue matches current file content', () => { + it('is NOT outdated', () => { + const file = makeSkillFile({ content: 'current content' }); + const skill = makeSkill(); + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillFileContent, + artefactVersion: 1, + payload: { + targetId: file.id, + oldValue: 'current content', + newValue: 'new content', + }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, [file]); + expect(result.size).toBe(0); + }); + }); + + describe('when oldValue does not match current file content', () => { + it('IS outdated', () => { + const file = makeSkillFile({ content: 'current content' }); + const skill = makeSkill(); + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillFileContent, + artefactVersion: 1, + payload: { + targetId: file.id, + oldValue: 'STALE content', + newValue: 'new content', + }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, [file]); + expect(result.has(proposal.id)).toBe(true); + }); + }); + + describe('when the target file no longer exists', () => { + it('IS outdated', () => { + const skill = makeSkill(); + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillFileContent, + artefactVersion: 1, + payload: { + targetId: 'file-missing' as never, + oldValue: 'anything', + newValue: 'new', + }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.has(proposal.id)).toBe(true); + }); + }); + }); + + describe('collection update – updateSkillFilePermissions', () => { + describe('when oldValue matches current permissions', () => { + it('is NOT outdated', () => { + const file = makeSkillFile({ permissions: '755' }); + const skill = makeSkill(); + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillFilePermissions, + artefactVersion: 1, + payload: { + targetId: file.id, + oldValue: '755', + newValue: '644', + }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, [file]); + expect(result.size).toBe(0); + }); + }); + + describe('when oldValue does not match current permissions', () => { + it('IS outdated', () => { + const file = makeSkillFile({ permissions: '755' }); + const skill = makeSkill(); + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillFilePermissions, + artefactVersion: 1, + payload: { + targetId: file.id, + oldValue: '644', + newValue: '700', + }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, [file]); + expect(result.has(proposal.id)).toBe(true); + }); + }); + + describe('when the target file no longer exists', () => { + it('IS outdated', () => { + const skill = makeSkill(); + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillFilePermissions, + artefactVersion: 1, + payload: { + targetId: 'file-missing' as never, + oldValue: '755', + newValue: '644', + }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.has(proposal.id)).toBe(true); + }); + }); + }); + + describe('collection add – addSkillFile', () => { + it('is never outdated', () => { + const skill = makeSkill(); + const proposal = makeProposal({ + type: ChangeProposalType.addSkillFile, + artefactVersion: 1, + payload: { + item: { + path: 'src/new.ts', + content: 'new file', + permissions: '644', + isBase64: false, + }, + }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.size).toBe(0); + }); + }); + + describe('collection delete – deleteSkillFile', () => { + describe('when file exists and content matches snapshot', () => { + it('is NOT outdated', () => { + const file = makeSkillFile({ content: 'original content' }); + const skill = makeSkill(); + const proposal = makeProposal({ + type: ChangeProposalType.deleteSkillFile, + artefactVersion: 1, + payload: { + targetId: file.id, + item: { + id: file.id, + path: file.path, + content: 'original content', + permissions: file.permissions, + isBase64: file.isBase64, + }, + }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, [file]); + expect(result.size).toBe(0); + }); + }); + + describe('when file content has changed since snapshot', () => { + it('IS outdated', () => { + const file = makeSkillFile({ content: 'UPDATED content' }); + const skill = makeSkill(); + const proposal = makeProposal({ + type: ChangeProposalType.deleteSkillFile, + artefactVersion: 1, + payload: { + targetId: file.id, + item: { + id: file.id, + path: file.path, + content: 'original content', + permissions: file.permissions, + isBase64: file.isBase64, + }, + }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, [file]); + expect(result.has(proposal.id)).toBe(true); + }); + }); + + describe('when the target file no longer exists', () => { + it('IS outdated', () => { + const skill = makeSkill(); + const proposal = makeProposal({ + type: ChangeProposalType.deleteSkillFile, + artefactVersion: 1, + payload: { + targetId: 'file-gone' as never, + item: { + id: 'file-gone', + path: 'src/gone.ts', + content: 'old', + permissions: '755', + isBase64: false, + }, + }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.has(proposal.id)).toBe(true); + }); + }); + }); + + describe('updateSkillAdditionalProperty', () => { + describe('when oldValue matches current serialized value', () => { + it('is NOT outdated', () => { + const skill = makeSkill({ + additionalProperties: { model: 'opus' }, + }); + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillAdditionalProperty, + artefactVersion: 1, + payload: { + targetId: 'model', + oldValue: '"opus"', + newValue: '"sonnet"', + }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.size).toBe(0); + }); + }); + + describe('when oldValue does not match current serialized value', () => { + it('IS outdated', () => { + const skill = makeSkill({ + additionalProperties: { model: 'opus' }, + }); + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillAdditionalProperty, + artefactVersion: 1, + payload: { + targetId: 'model', + oldValue: '"haiku"', + newValue: '"sonnet"', + }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.has(proposal.id)).toBe(true); + }); + }); + + describe('when property does not exist on skill', () => { + describe('when oldValue is "null"', () => { + it('matches', () => { + const skill = makeSkill({ additionalProperties: undefined }); + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillAdditionalProperty, + artefactVersion: 1, + payload: { + targetId: 'model', + oldValue: 'null', + newValue: '"opus"', + }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.size).toBe(0); + }); + }); + }); + + describe('when oldValue is empty string', () => { + it('uses empty string as-is with ?? operator', () => { + const skill = makeSkill({ + additionalProperties: { model: null }, + }); + const proposal = makeProposal({ + type: ChangeProposalType.updateSkillAdditionalProperty, + artefactVersion: 1, + payload: { + targetId: 'model', + oldValue: '', + newValue: '"opus"', + }, + }); + + const result = computeSkillOutdatedIds([proposal], skill, []); + expect(result.has(proposal.id)).toBe(true); + }); + }); + }); + + describe('multiple proposals', () => { + let result: Set<ChangeProposalId>; + let fresh: ChangeProposal; + let stale: ChangeProposal; + + beforeEach(() => { + const skill = makeSkill({ + name: 'Current Name', + description: 'Current Desc', + }); + fresh = makeProposal({ + type: ChangeProposalType.updateSkillName, + artefactVersion: 1, + payload: { oldValue: 'Current Name', newValue: 'New Name' }, + }); + stale = makeProposal({ + type: ChangeProposalType.updateSkillDescription, + artefactVersion: 1, + payload: { oldValue: 'OLD Desc', newValue: 'New Desc' }, + }); + + result = computeSkillOutdatedIds([fresh, stale], skill, []); + }); + + it('contains exactly one outdated proposal', () => { + expect(result.size).toBe(1); + }); + + it('includes the stale proposal', () => { + expect(result.has(stale.id)).toBe(true); + }); + + it('excludes the fresh proposal', () => { + expect(result.has(fresh.id)).toBe(false); + }); + }); +}); + +describe('computeStandardOutdatedIds', () => { + describe('when standard is undefined', () => { + it('returns an empty set', () => { + const result = computeStandardOutdatedIds([], undefined, []); + expect(result.size).toBe(0); + }); + }); + + describe('same artefact version (optimization short-circuit)', () => { + it('never marks proposal as outdated regardless of payload mismatch', () => { + const standard = makeStandard({ version: 3 }); + const proposal = makeProposal({ + type: ChangeProposalType.updateStandardName, + artefactVersion: 3, + payload: { oldValue: 'WRONG', newValue: 'new' }, + }); + + const result = computeStandardOutdatedIds([proposal], standard, []); + expect(result.size).toBe(0); + }); + }); + + describe('scalar updates', () => { + const scalarCases: { + label: string; + type: ChangeProposalType; + field: keyof Standard; + currentValue: string; + }[] = [ + { + label: 'updateStandardName', + type: ChangeProposalType.updateStandardName, + field: 'name', + currentValue: 'My Standard', + }, + { + label: 'updateStandardDescription', + type: ChangeProposalType.updateStandardDescription, + field: 'description', + currentValue: 'A description', + }, + { + label: 'updateStandardScope', + type: ChangeProposalType.updateStandardScope, + field: 'scope', + currentValue: 'all files', + }, + ]; + + describe.each(scalarCases)('$label', ({ type, field, currentValue }) => { + describe('when oldValue matches current value', () => { + it('is NOT outdated', () => { + const standard = makeStandard({ [field]: currentValue }); + const proposal = makeProposal({ + type, + artefactVersion: 1, + payload: { oldValue: currentValue, newValue: 'new' }, + }); + + const result = computeStandardOutdatedIds([proposal], standard, []); + expect(result.size).toBe(0); + }); + }); + + describe('when oldValue does not match current value', () => { + it('IS outdated', () => { + const standard = makeStandard({ [field]: currentValue }); + const proposal = makeProposal({ + type, + artefactVersion: 1, + payload: { oldValue: 'MISMATCHED', newValue: 'new' }, + }); + + const result = computeStandardOutdatedIds([proposal], standard, []); + expect(result.has(proposal.id)).toBe(true); + }); + }); + }); + + describe('optional scope defaults to empty string', () => { + it('uses empty string for null scope', () => { + const standard = makeStandard({ scope: null }); + + const proposal = makeProposal({ + type: ChangeProposalType.updateStandardScope, + artefactVersion: 1, + payload: { oldValue: '', newValue: 'new scope' }, + }); + + const result = computeStandardOutdatedIds([proposal], standard, []); + expect(result.size).toBe(0); + }); + }); + }); + + describe('collection update – updateRule', () => { + describe('when oldValue matches current rule content', () => { + it('is NOT outdated', () => { + const rule = makeRule({ content: 'Always use const' }); + const standard = makeStandard(); + const proposal = makeProposal({ + type: ChangeProposalType.updateRule, + artefactVersion: 1, + payload: { + targetId: rule.id, + oldValue: 'Always use const', + newValue: 'Always use let', + }, + }); + + const result = computeStandardOutdatedIds([proposal], standard, [rule]); + expect(result.size).toBe(0); + }); + }); + + describe('when oldValue does not match current rule content', () => { + it('IS outdated', () => { + const rule = makeRule({ content: 'Always use const' }); + const standard = makeStandard(); + const proposal = makeProposal({ + type: ChangeProposalType.updateRule, + artefactVersion: 1, + payload: { + targetId: rule.id, + oldValue: 'STALE content', + newValue: 'new content', + }, + }); + + const result = computeStandardOutdatedIds([proposal], standard, [rule]); + expect(result.has(proposal.id)).toBe(true); + }); + }); + + describe('when the target rule no longer exists', () => { + it('IS outdated', () => { + const standard = makeStandard(); + const proposal = makeProposal({ + type: ChangeProposalType.updateRule, + artefactVersion: 1, + payload: { + targetId: 'rule-missing' as never, + oldValue: 'anything', + newValue: 'new', + }, + }); + + const result = computeStandardOutdatedIds([proposal], standard, []); + expect(result.has(proposal.id)).toBe(true); + }); + }); + }); + + describe('collection add – addRule', () => { + it('is never outdated', () => { + const standard = makeStandard(); + const proposal = makeProposal({ + type: ChangeProposalType.addRule, + artefactVersion: 1, + payload: { + item: { content: 'new rule' }, + }, + }); + + const result = computeStandardOutdatedIds([proposal], standard, []); + expect(result.size).toBe(0); + }); + }); + + describe('collection delete – deleteRule', () => { + describe('when rule exists and content matches snapshot', () => { + it('is NOT outdated', () => { + const rule = makeRule({ content: 'original' }); + const standard = makeStandard(); + const proposal = makeProposal({ + type: ChangeProposalType.deleteRule, + artefactVersion: 1, + payload: { + targetId: rule.id, + item: { id: rule.id, content: 'original' }, + }, + }); + + const result = computeStandardOutdatedIds([proposal], standard, [rule]); + expect(result.size).toBe(0); + }); + }); + + describe('when rule content has changed since snapshot', () => { + it('IS outdated', () => { + const rule = makeRule({ content: 'UPDATED' }); + const standard = makeStandard(); + const proposal = makeProposal({ + type: ChangeProposalType.deleteRule, + artefactVersion: 1, + payload: { + targetId: rule.id, + item: { id: rule.id, content: 'original' }, + }, + }); + + const result = computeStandardOutdatedIds([proposal], standard, [rule]); + expect(result.has(proposal.id)).toBe(true); + }); + }); + + describe('when the target rule no longer exists', () => { + it('IS outdated', () => { + const standard = makeStandard(); + const proposal = makeProposal({ + type: ChangeProposalType.deleteRule, + artefactVersion: 1, + payload: { + targetId: 'rule-gone' as never, + item: { id: 'rule-gone', content: 'old' }, + }, + }); + + const result = computeStandardOutdatedIds([proposal], standard, []); + expect(result.has(proposal.id)).toBe(true); + }); + }); + }); + + describe('multiple proposals', () => { + let result: Set<ChangeProposalId>; + let fresh: ChangeProposal; + let stale: ChangeProposal; + let addProposal: ChangeProposal; + + beforeEach(() => { + const standard = makeStandard({ + name: 'Current Name', + description: 'Current Desc', + }); + const rule = makeRule({ content: 'Current Rule' }); + + fresh = makeProposal({ + type: ChangeProposalType.updateStandardName, + artefactVersion: 1, + payload: { oldValue: 'Current Name', newValue: 'New' }, + }); + stale = makeProposal({ + type: ChangeProposalType.updateRule, + artefactVersion: 1, + payload: { + targetId: rule.id, + oldValue: 'OLD Rule', + newValue: 'New Rule', + }, + }); + addProposal = makeProposal({ + type: ChangeProposalType.addRule, + artefactVersion: 1, + payload: { item: { content: 'new rule' } }, + }); + + result = computeStandardOutdatedIds( + [fresh, stale, addProposal], + standard, + [rule], + ); + }); + + it('contains exactly one outdated proposal', () => { + expect(result.size).toBe(1); + }); + + it('includes the stale proposal', () => { + expect(result.has(stale.id)).toBe(true); + }); + + it('excludes the fresh proposal', () => { + expect(result.has(fresh.id)).toBe(false); + }); + + it('excludes the add proposal', () => { + expect(result.has(addProposal.id)).toBe(false); + }); + }); +}); + +describe('computeCommandOutdatedIds', () => { + describe('when recipe is undefined', () => { + it('returns an empty set', () => { + const result = computeCommandOutdatedIds([], undefined); + expect(result.size).toBe(0); + }); + }); + + describe('same artefact version (optimization short-circuit)', () => { + it('never marks proposal as outdated regardless of payload mismatch', () => { + const recipe = makeRecipe({ version: 4 }); + const proposal = makeProposal({ + type: ChangeProposalType.updateCommandName, + artefactVersion: 4, + payload: { oldValue: 'WRONG', newValue: 'new' }, + }); + + const result = computeCommandOutdatedIds([proposal], recipe); + expect(result.size).toBe(0); + }); + }); + + describe('scalar updates', () => { + describe('updateCommandName', () => { + describe('when oldValue matches current name', () => { + it('is NOT outdated', () => { + const recipe = makeRecipe({ name: 'My Command' }); + const proposal = makeProposal({ + type: ChangeProposalType.updateCommandName, + artefactVersion: 1, + payload: { oldValue: 'My Command', newValue: 'Renamed' }, + }); + + const result = computeCommandOutdatedIds([proposal], recipe); + expect(result.size).toBe(0); + }); + }); + + describe('when oldValue does not match current name', () => { + it('IS outdated', () => { + const recipe = makeRecipe({ name: 'My Command' }); + const proposal = makeProposal({ + type: ChangeProposalType.updateCommandName, + artefactVersion: 1, + payload: { oldValue: 'STALE name', newValue: 'Renamed' }, + }); + + const result = computeCommandOutdatedIds([proposal], recipe); + expect(result.has(proposal.id)).toBe(true); + }); + }); + }); + + describe('updateCommandDescription', () => { + describe('when oldValue matches current content', () => { + it('is NOT outdated', () => { + const recipe = makeRecipe({ content: 'Step 1: do this' }); + const proposal = makeProposal({ + type: ChangeProposalType.updateCommandDescription, + artefactVersion: 1, + payload: { oldValue: 'Step 1: do this', newValue: 'Updated' }, + }); + + const result = computeCommandOutdatedIds([proposal], recipe); + expect(result.size).toBe(0); + }); + }); + + describe('when oldValue does not match current content', () => { + it('IS outdated', () => { + const recipe = makeRecipe({ content: 'Step 1: do this' }); + const proposal = makeProposal({ + type: ChangeProposalType.updateCommandDescription, + artefactVersion: 1, + payload: { oldValue: 'STALE content', newValue: 'Updated' }, + }); + + const result = computeCommandOutdatedIds([proposal], recipe); + expect(result.has(proposal.id)).toBe(true); + }); + }); + }); + }); + + describe('unknown proposal types are ignored', () => { + it('does not mark unknown types as outdated', () => { + const recipe = makeRecipe(); + // addRule is not a command type, should be ignored + const proposal = makeProposal({ + type: ChangeProposalType.addRule, + artefactVersion: 1, + payload: { item: { content: 'something' } }, + }); + + const result = computeCommandOutdatedIds([proposal], recipe); + expect(result.size).toBe(0); + }); + }); + + describe('multiple proposals', () => { + let result: Set<ChangeProposalId>; + let fresh: ChangeProposal; + let stale: ChangeProposal; + + beforeEach(() => { + const recipe = makeRecipe({ name: 'Current', content: 'Current desc' }); + + fresh = makeProposal({ + type: ChangeProposalType.updateCommandName, + artefactVersion: 1, + payload: { oldValue: 'Current', newValue: 'Renamed' }, + }); + stale = makeProposal({ + type: ChangeProposalType.updateCommandDescription, + artefactVersion: 1, + payload: { oldValue: 'OLD desc', newValue: 'New desc' }, + }); + + result = computeCommandOutdatedIds([fresh, stale], recipe); + }); + + it('contains exactly one outdated proposal', () => { + expect(result.size).toBe(1); + }); + + it('includes the stale proposal', () => { + expect(result.has(stale.id)).toBe(true); + }); + + it('excludes the fresh proposal', () => { + expect(result.has(fresh.id)).toBe(false); + }); + }); +}); + +describe('computeRemovalOutdatedIds', () => { + afterEach(() => jest.clearAllMocks()); + + describe('when no removal proposals exist', () => { + it('returns an empty set', () => { + const proposals = [ + makeProposal({ + type: ChangeProposalType.updateSkillName, + payload: { oldValue: 'old', newValue: 'new' }, + }), + ]; + + const result = computeRemovalOutdatedIds(proposals, [ + 'pkg-1' as PackageId, + ]); + expect(result.size).toBe(0); + }); + }); + + describe('when a packageId is not in currentPackageIds', () => { + it('marks removal proposal as outdated', () => { + const proposal = makeProposal({ + type: ChangeProposalType.removeStandard, + payload: { packageIds: ['pkg-removed' as PackageId] }, + }); + + const result = computeRemovalOutdatedIds( + [proposal], + ['pkg-1' as PackageId], + ); + expect(result.has(proposal.id)).toBe(true); + }); + }); + + describe('when all packageIds are in currentPackageIds', () => { + it('does not mark removal proposal as outdated', () => { + const proposal = makeProposal({ + type: ChangeProposalType.removeCommand, + payload: { packageIds: ['pkg-1' as PackageId, 'pkg-2' as PackageId] }, + }); + + const result = computeRemovalOutdatedIds( + [proposal], + ['pkg-1' as PackageId, 'pkg-2' as PackageId], + ); + expect(result.size).toBe(0); + }); + }); + + describe('when payload has empty packageIds', () => { + it('does not mark proposal as outdated', () => { + const proposal = makeProposal({ + type: ChangeProposalType.removeStandard, + payload: { packageIds: [] }, + }); + + const result = computeRemovalOutdatedIds( + [proposal], + ['pkg-1' as PackageId], + ); + expect(result.size).toBe(0); + }); + }); + + describe('when currentPackageIds is empty', () => { + it('marks removal proposal as outdated', () => { + const proposal = makeProposal({ + type: ChangeProposalType.removeSkill, + payload: { packageIds: ['pkg-1' as PackageId] }, + }); + + const result = computeRemovalOutdatedIds([proposal], []); + expect(result.has(proposal.id)).toBe(true); + }); + }); + + describe('when proposals contain non-removal types', () => { + it('ignores non-removal-type proposals', () => { + const removalProposal = makeProposal({ + type: ChangeProposalType.removeSkill, + payload: { packageIds: ['pkg-gone' as PackageId] }, + }); + const updateProposal = makeProposal({ + type: ChangeProposalType.updateStandardName, + payload: { oldValue: 'old', newValue: 'new' }, + }); + + const result = computeRemovalOutdatedIds( + [removalProposal, updateProposal], + ['pkg-1' as PackageId], + ); + expect(result.has(removalProposal.id)).toBe(true); + }); + + it('counts only removal-type proposals as outdated', () => { + const removalProposal = makeProposal({ + type: ChangeProposalType.removeSkill, + payload: { packageIds: ['pkg-gone' as PackageId] }, + }); + const updateProposal = makeProposal({ + type: ChangeProposalType.updateStandardName, + payload: { oldValue: 'old', newValue: 'new' }, + }); + + const result = computeRemovalOutdatedIds( + [removalProposal, updateProposal], + ['pkg-1' as PackageId], + ); + expect(result.size).toBe(1); + }); + }); +}); diff --git a/apps/frontend/src/domain/change-proposals/utils/computeOutdatedProposalIds.ts b/apps/frontend/src/domain/change-proposals/utils/computeOutdatedProposalIds.ts new file mode 100644 index 000000000..c10a31ffa --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/computeOutdatedProposalIds.ts @@ -0,0 +1,279 @@ +import { + ChangeProposal, + ChangeProposalId, + ChangeProposalType, + CollectionItemDeletePayload, + CollectionItemUpdatePayload, + PackageId, + Recipe, + RemoveArtefactPayload, + Rule, + ScalarUpdatePayload, + Skill, + SkillFile, + SkillFileId, + Standard, + canonicalJsonStringify, +} from '@packmind/types'; + +// --- Skill field mappings (mirrors backend SkillChangeProposalValidator) --- + +type ScalarSkillType = + | ChangeProposalType.updateSkillName + | ChangeProposalType.updateSkillDescription + | ChangeProposalType.updateSkillPrompt + | ChangeProposalType.updateSkillMetadata + | ChangeProposalType.updateSkillLicense + | ChangeProposalType.updateSkillCompatibility + | ChangeProposalType.updateSkillAllowedTools; + +const SKILL_FIELD_BY_TYPE: Record<ScalarSkillType, (skill: Skill) => string> = { + [ChangeProposalType.updateSkillName]: (skill) => skill.name, + [ChangeProposalType.updateSkillDescription]: (skill) => skill.description, + [ChangeProposalType.updateSkillPrompt]: (skill) => skill.prompt, + [ChangeProposalType.updateSkillMetadata]: (skill) => + skill.metadata != null ? canonicalJsonStringify(skill.metadata) : '{}', + [ChangeProposalType.updateSkillLicense]: (skill) => skill.license ?? '', + [ChangeProposalType.updateSkillCompatibility]: (skill) => + skill.compatibility ?? '', + [ChangeProposalType.updateSkillAllowedTools]: (skill) => + skill.allowedTools ?? '', +}; + +const SCALAR_SKILL_TYPES = new Set<ChangeProposalType>([ + ChangeProposalType.updateSkillName, + ChangeProposalType.updateSkillDescription, + ChangeProposalType.updateSkillPrompt, + ChangeProposalType.updateSkillMetadata, + ChangeProposalType.updateSkillLicense, + ChangeProposalType.updateSkillCompatibility, + ChangeProposalType.updateSkillAllowedTools, +]); + +// --- Standard field mappings (mirrors backend StandardChangeProposalValidator) --- + +type ScalarStandardType = + | ChangeProposalType.updateStandardName + | ChangeProposalType.updateStandardDescription + | ChangeProposalType.updateStandardScope; + +const STANDARD_FIELD_BY_TYPE: Record< + ScalarStandardType, + (standard: Standard) => string +> = { + [ChangeProposalType.updateStandardName]: (standard) => standard.name, + [ChangeProposalType.updateStandardDescription]: (standard) => + standard.description, + [ChangeProposalType.updateStandardScope]: (standard) => standard.scope ?? '', +}; + +const SCALAR_STANDARD_TYPES = new Set<ChangeProposalType>([ + ChangeProposalType.updateStandardName, + ChangeProposalType.updateStandardDescription, + ChangeProposalType.updateStandardScope, +]); + +// --- Command field mappings (mirrors backend CommandChangeProposalValidator) --- + +type ScalarCommandType = + | ChangeProposalType.updateCommandName + | ChangeProposalType.updateCommandDescription; + +const RECIPE_FIELD_BY_TYPE: Record< + ScalarCommandType, + (recipe: Recipe) => string +> = { + [ChangeProposalType.updateCommandName]: (recipe) => recipe.name, + [ChangeProposalType.updateCommandDescription]: (recipe) => recipe.content, +}; + +const SCALAR_COMMAND_TYPES = new Set<ChangeProposalType>([ + ChangeProposalType.updateCommandName, + ChangeProposalType.updateCommandDescription, +]); + +// --- Compute functions --- + +export function computeSkillOutdatedIds( + proposals: ChangeProposal[], + skill: Skill | undefined, + files: SkillFile[], +): Set<ChangeProposalId> { + const outdated = new Set<ChangeProposalId>(); + if (!skill) return outdated; + + for (const proposal of proposals) { + if (proposal.artefactVersion === skill.version) continue; + + if (SCALAR_SKILL_TYPES.has(proposal.type)) { + const payload = proposal.payload as ScalarUpdatePayload; + const currentValue = + SKILL_FIELD_BY_TYPE[proposal.type as ScalarSkillType](skill); + if (payload.oldValue !== currentValue) { + outdated.add(proposal.id); + } + continue; + } + + if (proposal.type === ChangeProposalType.updateSkillFileContent) { + const payload = + proposal.payload as CollectionItemUpdatePayload<SkillFileId>; + const file = files.find((f) => f.id === payload.targetId); + if (!file || payload.oldValue !== file.content) { + outdated.add(proposal.id); + } + continue; + } + + if (proposal.type === ChangeProposalType.updateSkillFilePermissions) { + const payload = + proposal.payload as CollectionItemUpdatePayload<SkillFileId>; + const file = files.find((f) => f.id === payload.targetId); + if (!file || payload.oldValue !== file.permissions) { + outdated.add(proposal.id); + } + continue; + } + + if (proposal.type === ChangeProposalType.deleteSkillFile) { + const payload = proposal.payload as CollectionItemDeletePayload< + Omit<SkillFile, 'skillVersionId'> + >; + const file = files.find((f) => f.id === payload.targetId); + if (!file || file.content !== payload.item.content) { + outdated.add(proposal.id); + } + continue; + } + + if (proposal.type === ChangeProposalType.updateSkillAdditionalProperty) { + const payload = proposal.payload as CollectionItemUpdatePayload<string>; + const currentValue = canonicalJsonStringify( + skill.additionalProperties?.[payload.targetId] ?? null, + ); + const expectedOld = payload.oldValue ?? 'null'; + if (expectedOld !== currentValue) { + outdated.add(proposal.id); + } + continue; + } + + // addSkillFile → never outdated + } + + return outdated; +} + +export function computeStandardOutdatedIds( + proposals: ChangeProposal[], + standard: Standard | undefined, + rules: Rule[], +): Set<ChangeProposalId> { + const outdated = new Set<ChangeProposalId>(); + if (!standard) return outdated; + + for (const proposal of proposals) { + if (proposal.artefactVersion === standard.version) continue; + + if (SCALAR_STANDARD_TYPES.has(proposal.type)) { + const payload = proposal.payload as ScalarUpdatePayload; + const currentValue = + STANDARD_FIELD_BY_TYPE[proposal.type as ScalarStandardType](standard); + if (payload.oldValue !== currentValue) { + outdated.add(proposal.id); + } + continue; + } + + if (proposal.type === ChangeProposalType.updateRule) { + const payload = proposal.payload as CollectionItemUpdatePayload<string>; + const rule = rules.find((r) => r.id === payload.targetId); + if (!rule || payload.oldValue !== rule.content) { + outdated.add(proposal.id); + } + continue; + } + + if (proposal.type === ChangeProposalType.deleteRule) { + const payload = proposal.payload as CollectionItemDeletePayload< + Omit<Rule, 'standardVersionId'> + >; + const rule = rules.find((r) => r.id === payload.targetId); + if (!rule || rule.content !== payload.item.content) { + outdated.add(proposal.id); + } + continue; + } + + // addRule → never outdated + } + + return outdated; +} + +export function computeCommandOutdatedIds( + proposals: ChangeProposal[], + recipe: Recipe | undefined, +): Set<ChangeProposalId> { + const outdated = new Set<ChangeProposalId>(); + if (!recipe) return outdated; + + for (const proposal of proposals) { + if (proposal.artefactVersion === recipe.version) continue; + if (!SCALAR_COMMAND_TYPES.has(proposal.type)) continue; + + const payload = proposal.payload as ScalarUpdatePayload; + const currentValue = + RECIPE_FIELD_BY_TYPE[proposal.type as ScalarCommandType](recipe); + if (payload.oldValue !== currentValue) { + outdated.add(proposal.id); + } + } + + return outdated; +} + +// --- Merge helper --- + +export function mergeOutdatedIds( + ...sets: Set<ChangeProposalId>[] +): Set<ChangeProposalId> { + const merged = new Set<ChangeProposalId>(); + for (const set of sets) { + for (const id of set) { + merged.add(id); + } + } + return merged; +} + +// --- Removal outdated detection --- + +const REMOVAL_TYPES = new Set<ChangeProposalType>([ + ChangeProposalType.removeStandard, + ChangeProposalType.removeCommand, + ChangeProposalType.removeSkill, +]); + +export function computeRemovalOutdatedIds( + proposals: ChangeProposal[], + currentPackageIds: PackageId[], +): Set<ChangeProposalId> { + const outdated = new Set<ChangeProposalId>(); + const currentSet = new Set(currentPackageIds); + + for (const proposal of proposals) { + if (!REMOVAL_TYPES.has(proposal.type)) continue; + + const payload = proposal.payload as RemoveArtefactPayload; + const hasOutdatedPackage = payload.packageIds.some( + (id) => !currentSet.has(id), + ); + + if (hasOutdatedPackage) { + outdated.add(proposal.id); + } + } + + return outdated; +} diff --git a/apps/frontend/src/domain/change-proposals/utils/editableProposalTypes.ts b/apps/frontend/src/domain/change-proposals/utils/editableProposalTypes.ts new file mode 100644 index 000000000..cd261f851 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/editableProposalTypes.ts @@ -0,0 +1,33 @@ +import { ChangeProposalType } from '@packmind/types'; + +/** + * Proposal types that support inline editing of the proposed newValue + * before accepting. These are all ScalarUpdatePayload types for + * name, description, and prompt fields. + */ +const editableProposalTypes = new Set<ChangeProposalType>([ + ChangeProposalType.updateStandardName, + ChangeProposalType.updateStandardDescription, + ChangeProposalType.updateCommandName, + ChangeProposalType.updateCommandDescription, + ChangeProposalType.updateSkillName, + ChangeProposalType.updateSkillDescription, + ChangeProposalType.updateSkillPrompt, +]); + +export function isEditableProposalType(type: ChangeProposalType): boolean { + return editableProposalTypes.has(type); +} + +/** + * Single-line types use a text input; multi-line types use a textarea. + */ +const singleLineProposalTypes = new Set<ChangeProposalType>([ + ChangeProposalType.updateStandardName, + ChangeProposalType.updateCommandName, + ChangeProposalType.updateSkillName, +]); + +export function isSingleLineProposalType(type: ChangeProposalType): boolean { + return singleLineProposalTypes.has(type); +} diff --git a/apps/frontend/src/domain/change-proposals/utils/extractProposalDiffValues.spec.ts b/apps/frontend/src/domain/change-proposals/utils/extractProposalDiffValues.spec.ts new file mode 100644 index 000000000..aef41a7c6 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/extractProposalDiffValues.spec.ts @@ -0,0 +1,176 @@ +import { ChangeProposal, ChangeProposalType } from '@packmind/types'; +import { extractProposalDiffValues } from './extractProposalDiffValues'; + +const makeProposal = ( + type: ChangeProposalType, + payload: unknown, +): ChangeProposal => + ({ + id: 'proposal-1', + type, + payload, + }) as unknown as ChangeProposal; + +describe('extractProposalDiffValues', () => { + describe('scalar update types', () => { + it('returns oldValue and newValue directly', () => { + const proposal = makeProposal(ChangeProposalType.updateSkillName, { + oldValue: 'Old Name', + newValue: 'New Name', + }); + + expect(extractProposalDiffValues(proposal)).toEqual({ + oldValue: 'Old Name', + newValue: 'New Name', + }); + }); + }); + + describe('updateSkillAdditionalProperty', () => { + it('prepends the property key to old and new values', () => { + const proposal = makeProposal( + ChangeProposalType.updateSkillAdditionalProperty, + { + targetId: 'userInvocable', + oldValue: 'false', + newValue: 'true', + }, + ); + + expect(extractProposalDiffValues(proposal)).toEqual({ + oldValue: 'user-invocable: false', + newValue: 'user-invocable: true', + }); + }); + + it('converts disableModelInvocation to kebab-case', () => { + const proposal = makeProposal( + ChangeProposalType.updateSkillAdditionalProperty, + { + targetId: 'disableModelInvocation', + oldValue: 'null', + newValue: 'true', + }, + ); + + expect(extractProposalDiffValues(proposal)).toEqual({ + oldValue: '', + newValue: 'disable-model-invocation: true', + }); + }); + + describe('when value is empty (property added)', () => { + it('returns empty string for oldValue', () => { + const proposal = makeProposal( + ChangeProposalType.updateSkillAdditionalProperty, + { + targetId: 'model', + oldValue: '', + newValue: 'sonnet', + }, + ); + + expect(extractProposalDiffValues(proposal)).toEqual({ + oldValue: '', + newValue: 'model: sonnet', + }); + }); + }); + + describe('when old value is null sentinel (property added)', () => { + it('returns empty string for oldValue', () => { + const proposal = makeProposal( + ChangeProposalType.updateSkillAdditionalProperty, + { targetId: 'model', oldValue: 'null', newValue: 'sonnet' }, + ); + expect(extractProposalDiffValues(proposal)).toEqual({ + oldValue: '', + newValue: 'model: sonnet', + }); + }); + }); + + describe('when new value is null sentinel (property removed)', () => { + it('returns empty string for newValue', () => { + const proposal = makeProposal( + ChangeProposalType.updateSkillAdditionalProperty, + { targetId: 'model', oldValue: 'sonnet', newValue: 'null' }, + ); + expect(extractProposalDiffValues(proposal)).toEqual({ + oldValue: 'model: sonnet', + newValue: '', + }); + }); + }); + + describe('when new value is empty (property removed)', () => { + it('returns empty string for newValue', () => { + const proposal = makeProposal( + ChangeProposalType.updateSkillAdditionalProperty, + { + targetId: 'context', + oldValue: 'some-context', + newValue: '', + }, + ); + + expect(extractProposalDiffValues(proposal)).toEqual({ + oldValue: 'context: some-context', + newValue: '', + }); + }); + }); + }); + + describe('collection update types', () => { + it('returns oldValue and newValue without targetId', () => { + const proposal = makeProposal(ChangeProposalType.updateRule, { + targetId: 'rule-1', + oldValue: 'old rule text', + newValue: 'new rule text', + }); + + expect(extractProposalDiffValues(proposal)).toEqual({ + oldValue: 'old rule text', + newValue: 'new rule text', + }); + }); + }); + + describe('collection add types', () => { + it('returns empty oldValue and item content as newValue', () => { + const proposal = makeProposal(ChangeProposalType.addRule, { + item: { content: 'new rule content' }, + }); + + expect(extractProposalDiffValues(proposal)).toEqual({ + oldValue: '', + newValue: 'new rule content', + }); + }); + }); + + describe('collection delete types', () => { + it('returns item content as oldValue and empty newValue', () => { + const proposal = makeProposal(ChangeProposalType.deleteRule, { + item: { id: 'rule-1', content: 'deleted rule content' }, + }); + + expect(extractProposalDiffValues(proposal)).toEqual({ + oldValue: 'deleted rule content', + newValue: '', + }); + }); + }); + + describe('unknown type', () => { + it('returns empty values', () => { + const proposal = makeProposal('unknownType' as ChangeProposalType, {}); + + expect(extractProposalDiffValues(proposal)).toEqual({ + oldValue: '', + newValue: '', + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/change-proposals/utils/extractProposalDiffValues.ts b/apps/frontend/src/domain/change-proposals/utils/extractProposalDiffValues.ts new file mode 100644 index 000000000..0fbc519cd --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/extractProposalDiffValues.ts @@ -0,0 +1,104 @@ +import { + CAMEL_TO_YAML_KEY, + ChangeProposal, + ChangeProposalType, + ScalarUpdatePayload, + CollectionItemUpdatePayload, + CollectionItemAddPayload, + CollectionItemDeletePayload, + camelToKebab, +} from '@packmind/types'; + +export interface DiffValues { + oldValue: string; + newValue: string; +} + +const scalarUpdateTypes = new Set<ChangeProposalType>([ + ChangeProposalType.updateCommandName, + ChangeProposalType.updateCommandDescription, + ChangeProposalType.updateStandardName, + ChangeProposalType.updateStandardDescription, + ChangeProposalType.updateStandardScope, + ChangeProposalType.updateSkillName, + ChangeProposalType.updateSkillDescription, + ChangeProposalType.updateSkillPrompt, + ChangeProposalType.updateSkillMetadata, + ChangeProposalType.updateSkillLicense, + ChangeProposalType.updateSkillCompatibility, + ChangeProposalType.updateSkillAllowedTools, +]); + +const collectionUpdateTypes = new Set<ChangeProposalType>([ + ChangeProposalType.updateRule, + ChangeProposalType.updateSkillFileContent, + ChangeProposalType.updateSkillFilePermissions, +]); + +const collectionAddTypes = new Set<ChangeProposalType>([ + ChangeProposalType.addRule, + ChangeProposalType.addSkillFile, +]); + +const collectionDeleteTypes = new Set<ChangeProposalType>([ + ChangeProposalType.deleteRule, + ChangeProposalType.deleteSkillFile, +]); + +/** + * Normalizes any proposal type to a simple { oldValue, newValue } pair + * suitable for diff rendering. + */ +export function extractProposalDiffValues( + proposal: ChangeProposal, +): DiffValues { + const { type, payload } = proposal; + + if (scalarUpdateTypes.has(type)) { + const scalarPayload = payload as ScalarUpdatePayload; + return { + oldValue: scalarPayload.oldValue, + newValue: scalarPayload.newValue, + }; + } + + if (type === ChangeProposalType.updateSkillAdditionalProperty) { + const updatePayload = payload as CollectionItemUpdatePayload<string>; + const displayKey = + CAMEL_TO_YAML_KEY[updatePayload.targetId] ?? + camelToKebab(updatePayload.targetId); + return { + oldValue: + updatePayload.oldValue && updatePayload.oldValue !== 'null' + ? `${displayKey}: ${updatePayload.oldValue}` + : '', + newValue: + updatePayload.newValue && updatePayload.newValue !== 'null' + ? `${displayKey}: ${updatePayload.newValue}` + : '', + }; + } + + if (collectionUpdateTypes.has(type)) { + const updatePayload = payload as CollectionItemUpdatePayload<unknown>; + return { + oldValue: updatePayload.oldValue, + newValue: updatePayload.newValue, + }; + } + + if (collectionAddTypes.has(type)) { + const addPayload = payload as CollectionItemAddPayload<{ content: string }>; + return { oldValue: '', newValue: addPayload.item.content }; + } + + if (collectionDeleteTypes.has(type)) { + const deletePayload = payload as CollectionItemDeletePayload<{ + id: string; + content: string; + }>; + return { oldValue: deletePayload.item.content, newValue: '' }; + } + + return { oldValue: '', newValue: '' }; +} diff --git a/apps/frontend/src/domain/change-proposals/utils/filterProposalsByFilePath.spec.ts b/apps/frontend/src/domain/change-proposals/utils/filterProposalsByFilePath.spec.ts new file mode 100644 index 000000000..0944f29e5 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/filterProposalsByFilePath.spec.ts @@ -0,0 +1,274 @@ +import { v4 as uuidv4 } from 'uuid'; +import { + ChangeProposal, + ChangeProposalCaptureMode, + ChangeProposalStatus, + ChangeProposalType, + SkillFile, + createChangeProposalId, + createSkillFileId, + createSkillId, + createSkillVersionId, + createSpaceId, + createUserId, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../types'; +import { SKILL_MD_PATH } from './groupSkillProposalsByFile'; +import { + getFilePathsWithChanges, + filterProposalsByFilePath, + hasChangesForFilter, +} from './filterProposalsByFilePath'; + +const skillFileFactory = (overrides?: Partial<SkillFile>): SkillFile => ({ + id: createSkillFileId(uuidv4()), + skillVersionId: createSkillVersionId(uuidv4()), + path: 'src/index.ts', + content: 'console.log("hello");', + permissions: 'read', + isBase64: false, + ...overrides, +}); + +const changeProposalFactory = ( + proposal?: Partial<ChangeProposal<ChangeProposalType>>, +): ChangeProposalWithConflicts => + ({ + id: createChangeProposalId(uuidv4()), + type: ChangeProposalType.updateSkillName, + artefactId: createSkillId(uuidv4()), + artefactVersion: 1, + payload: { oldValue: 'Old', newValue: 'New' }, + captureMode: ChangeProposalCaptureMode.commit, + status: ChangeProposalStatus.pending, + createdBy: createUserId(uuidv4()), + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + conflictsWith: [], + message: '', + ...proposal, + }) as ChangeProposalWithConflicts; + +describe('getFilePathsWithChanges', () => { + const file1 = skillFileFactory({ + id: createSkillFileId('file-1'), + path: 'src/main.ts', + }); + const files = [file1]; + + describe('when there are scalar and file proposals', () => { + it('includes SKILL.md for scalar proposals', () => { + const proposals = [ + changeProposalFactory({ type: ChangeProposalType.updateSkillName }), + ]; + expect(getFilePathsWithChanges(proposals, files).has(SKILL_MD_PATH)).toBe( + true, + ); + }); + + it('includes the file path for file proposals', () => { + const proposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: createSkillFileId('file-1'), + oldValue: 'old', + newValue: 'new', + }, + }), + ]; + expect(getFilePathsWithChanges(proposals, files).has('src/main.ts')).toBe( + true, + ); + }); + + it('includes the path for addSkillFile proposals', () => { + const proposals = [ + changeProposalFactory({ + type: ChangeProposalType.addSkillFile, + payload: { + item: { + path: 'src/new-file.ts', + content: 'content', + permissions: 'read', + isBase64: false, + }, + }, + }), + ]; + expect( + getFilePathsWithChanges(proposals, files).has('src/new-file.ts'), + ).toBe(true); + }); + + it('deduplicates paths from multiple proposals on the same file', () => { + const proposals = [ + changeProposalFactory({ type: ChangeProposalType.updateSkillName }), + changeProposalFactory({ + type: ChangeProposalType.updateSkillDescription, + }), + ]; + expect(getFilePathsWithChanges(proposals, files).size).toBe(1); + }); + }); + + describe('when there are no proposals', () => { + it('returns an empty set', () => { + expect(getFilePathsWithChanges([], files).size).toBe(0); + }); + }); +}); + +describe('filterProposalsByFilePath', () => { + const file1 = skillFileFactory({ + id: createSkillFileId('file-1'), + path: 'src/main.ts', + }); + const file2 = skillFileFactory({ + id: createSkillFileId('file-2'), + path: 'src/utils/helper.ts', + }); + const files = [file1, file2]; + + const nameProposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillName, + }); + const fileContentProposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: createSkillFileId('file-1'), + oldValue: 'old', + newValue: 'new', + }, + }); + const helperProposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: createSkillFileId('file-2'), + oldValue: 'old', + newValue: 'new', + }, + }); + const addFileProposal = changeProposalFactory({ + type: ChangeProposalType.addSkillFile, + payload: { + item: { + path: 'docs/readme.md', + content: '# README', + permissions: 'read', + isBase64: false, + }, + }, + }); + + const allProposals = [ + nameProposal, + fileContentProposal, + helperProposal, + addFileProposal, + ]; + + describe('when filter is empty string', () => { + it('returns all proposals', () => { + expect(filterProposalsByFilePath(allProposals, files, '')).toHaveLength( + 4, + ); + }); + }); + + describe('when filter is an exact file path', () => { + it('returns only proposals matching SKILL.md', () => { + expect( + filterProposalsByFilePath(allProposals, files, SKILL_MD_PATH), + ).toHaveLength(1); + }); + + it('returns only proposals matching src/main.ts', () => { + expect( + filterProposalsByFilePath(allProposals, files, 'src/main.ts'), + ).toHaveLength(1); + }); + + it('returns only proposals matching docs/readme.md', () => { + expect( + filterProposalsByFilePath(allProposals, files, 'docs/readme.md'), + ).toHaveLength(1); + }); + }); + + describe('when filter is a directory prefix', () => { + it('returns proposals for all files under src/', () => { + expect( + filterProposalsByFilePath(allProposals, files, 'src'), + ).toHaveLength(2); + }); + + it('returns proposals for files under src/utils/', () => { + expect( + filterProposalsByFilePath(allProposals, files, 'src/utils'), + ).toHaveLength(1); + }); + }); + + describe('when filter matches no proposals', () => { + it('returns an empty array', () => { + expect( + filterProposalsByFilePath(allProposals, files, 'nonexistent'), + ).toHaveLength(0); + }); + }); +}); + +describe('hasChangesForFilter', () => { + const filePathsWithChanges = new Set([ + SKILL_MD_PATH, + 'src/main.ts', + 'src/utils/helper.ts', + ]); + + describe('when filter is empty string', () => { + it('returns true if there are any changes', () => { + expect(hasChangesForFilter(filePathsWithChanges, '')).toBe(true); + }); + + it('returns false if there are no changes', () => { + expect(hasChangesForFilter(new Set(), '')).toBe(false); + }); + }); + + describe('when filter is an exact file match', () => { + it('returns true for a file with changes', () => { + expect(hasChangesForFilter(filePathsWithChanges, 'src/main.ts')).toBe( + true, + ); + }); + + it('returns false for a file without changes', () => { + expect(hasChangesForFilter(filePathsWithChanges, 'config.json')).toBe( + false, + ); + }); + }); + + describe('when filter is a directory prefix', () => { + describe('when descendants have changes', () => { + it('returns true', () => { + expect(hasChangesForFilter(filePathsWithChanges, 'src')).toBe(true); + }); + + it('returns true for a nested directory', () => { + expect(hasChangesForFilter(filePathsWithChanges, 'src/utils')).toBe( + true, + ); + }); + }); + + describe('when no descendants have changes', () => { + it('returns false', () => { + expect(hasChangesForFilter(filePathsWithChanges, 'docs')).toBe(false); + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/change-proposals/utils/filterProposalsByFilePath.ts b/apps/frontend/src/domain/change-proposals/utils/filterProposalsByFilePath.ts new file mode 100644 index 000000000..edb04e936 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/filterProposalsByFilePath.ts @@ -0,0 +1,41 @@ +import { SkillFile } from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../types'; +import { getProposalFilePath } from './groupSkillProposalsByFile'; + +export function getFilePathsWithChanges( + proposals: ChangeProposalWithConflicts[], + files: SkillFile[], +): Set<string> { + const paths = new Set<string>(); + for (const proposal of proposals) { + paths.add(getProposalFilePath(proposal, files)); + } + return paths; +} + +export function filterProposalsByFilePath( + proposals: ChangeProposalWithConflicts[], + files: SkillFile[], + filter: string, +): ChangeProposalWithConflicts[] { + if (!filter) return proposals; + + return proposals.filter((proposal) => { + const filePath = getProposalFilePath(proposal, files); + if (filePath === filter) return true; + return filePath.startsWith(filter + '/'); + }); +} + +export function hasChangesForFilter( + filePathsWithChanges: Set<string>, + filter: string, +): boolean { + if (!filter) return filePathsWithChanges.size > 0; + + for (const path of filePathsWithChanges) { + if (path === filter) return true; + if (path.startsWith(filter + '/')) return true; + } + return false; +} diff --git a/apps/frontend/src/domain/change-proposals/utils/formatRelativeTime.ts b/apps/frontend/src/domain/change-proposals/utils/formatRelativeTime.ts new file mode 100644 index 000000000..d6998b52a --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/formatRelativeTime.ts @@ -0,0 +1,9 @@ +import { format, formatDistanceToNowStrict } from 'date-fns'; + +export function formatRelativeTime(date: Date): string { + return `${formatDistanceToNowStrict(new Date(date))} ago`; +} + +export function formatExactDate(date: Date): string { + return format(new Date(date), 'MMM d, yyyy, h:mm a'); +} diff --git a/apps/frontend/src/domain/change-proposals/utils/groupSkillProposalsByFile.spec.ts b/apps/frontend/src/domain/change-proposals/utils/groupSkillProposalsByFile.spec.ts new file mode 100644 index 000000000..3cb9cb503 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/groupSkillProposalsByFile.spec.ts @@ -0,0 +1,501 @@ +import { v4 as uuidv4 } from 'uuid'; +import { + ChangeProposal, + ChangeProposalCaptureMode, + ChangeProposalStatus, + ChangeProposalType, + SkillFile, + createChangeProposalId, + createSkillFileId, + createSkillId, + createSkillVersionId, + createSpaceId, + createUserId, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../types'; +import { + SKILL_MD_PATH, + UNKNOWN_FILE_PATH, + getProposalFilePath, + groupSkillProposalsByFile, + isBinaryProposal, +} from './groupSkillProposalsByFile'; + +const skillFileFactory = (overrides?: Partial<SkillFile>): SkillFile => ({ + id: createSkillFileId(uuidv4()), + skillVersionId: createSkillVersionId(uuidv4()), + path: 'src/index.ts', + content: 'console.log("hello");', + permissions: 'read', + isBase64: false, + ...overrides, +}); + +const changeProposalFactory = ( + proposal?: Partial<ChangeProposal<ChangeProposalType>>, +): ChangeProposalWithConflicts => + ({ + id: createChangeProposalId(uuidv4()), + type: ChangeProposalType.updateSkillName, + artefactId: createSkillId(uuidv4()), + artefactVersion: 1, + spaceId: createSpaceId(uuidv4()), + payload: { oldValue: 'Old', newValue: 'New' }, + captureMode: ChangeProposalCaptureMode.commit, + status: ChangeProposalStatus.pending, + createdBy: createUserId(uuidv4()), + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + conflictsWith: [], + message: '', + ...proposal, + }) as ChangeProposalWithConflicts; + +describe('getProposalFilePath', () => { + const file1 = skillFileFactory({ + id: createSkillFileId('file-1'), + path: 'src/main.ts', + }); + const file2 = skillFileFactory({ + id: createSkillFileId('file-2'), + path: 'config.json', + }); + const files = [file1, file2]; + + it('maps updateSkillName to SKILL.md', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillName, + }); + expect(getProposalFilePath(proposal, files)).toBe(SKILL_MD_PATH); + }); + + it('maps updateSkillDescription to SKILL.md', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillDescription, + }); + expect(getProposalFilePath(proposal, files)).toBe(SKILL_MD_PATH); + }); + + it('maps updateSkillPrompt to SKILL.md', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillPrompt, + }); + expect(getProposalFilePath(proposal, files)).toBe(SKILL_MD_PATH); + }); + + it('maps updateSkillMetadata to SKILL.md', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillMetadata, + }); + expect(getProposalFilePath(proposal, files)).toBe(SKILL_MD_PATH); + }); + + it('maps updateSkillLicense to SKILL.md', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillLicense, + }); + expect(getProposalFilePath(proposal, files)).toBe(SKILL_MD_PATH); + }); + + it('maps updateSkillCompatibility to SKILL.md', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillCompatibility, + }); + expect(getProposalFilePath(proposal, files)).toBe(SKILL_MD_PATH); + }); + + it('maps updateSkillAllowedTools to SKILL.md', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillAllowedTools, + }); + expect(getProposalFilePath(proposal, files)).toBe(SKILL_MD_PATH); + }); + + it('extracts path from addSkillFile payload', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.addSkillFile, + payload: { + item: { + path: 'src/new-file.ts', + content: 'content', + permissions: 'read', + isBase64: false, + }, + }, + }); + expect(getProposalFilePath(proposal, files)).toBe('src/new-file.ts'); + }); + + it('extracts path from deleteSkillFile payload', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.deleteSkillFile, + payload: { + targetId: file1.id, + item: { + id: file1.id, + path: 'src/main.ts', + content: 'content', + permissions: 'read', + isBase64: false, + }, + }, + }); + expect(getProposalFilePath(proposal, files)).toBe('src/main.ts'); + }); + + it('looks up file path for updateSkillFileContent by targetId', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: createSkillFileId('file-2'), + oldValue: 'old', + newValue: 'new', + }, + }); + expect(getProposalFilePath(proposal, files)).toBe('config.json'); + }); + + it('looks up file path for updateSkillFilePermissions by targetId', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFilePermissions, + payload: { + targetId: createSkillFileId('file-1'), + oldValue: 'read', + newValue: 'read-write', + }, + }); + expect(getProposalFilePath(proposal, files)).toBe('src/main.ts'); + }); + + describe('when file is not found', () => { + it('falls back to unknown file path', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: createSkillFileId('nonexistent'), + oldValue: 'old', + newValue: 'new', + }, + }); + expect(getProposalFilePath(proposal, files)).toBe(UNKNOWN_FILE_PATH); + }); + }); +}); + +describe('groupSkillProposalsByFile', () => { + const file1 = skillFileFactory({ + id: createSkillFileId('file-1'), + path: 'src/main.ts', + }); + const file2 = skillFileFactory({ + id: createSkillFileId('file-2'), + path: 'config.json', + }); + const files = [file1, file2]; + + describe('when grouping scalar and file proposals', () => { + const nameProposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillName, + createdAt: new Date('2024-01-01'), + }); + const descProposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillDescription, + createdAt: new Date('2024-01-02'), + }); + const fileContentProposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: createSkillFileId('file-1'), + oldValue: 'old', + newValue: 'new', + }, + createdAt: new Date('2024-01-03'), + }); + const addFileProposal = changeProposalFactory({ + type: ChangeProposalType.addSkillFile, + payload: { + item: { + path: 'docs/readme.md', + content: '# README', + permissions: 'read', + isBase64: false, + }, + }, + createdAt: new Date('2024-01-04'), + }); + + const proposals = [ + addFileProposal, + fileContentProposal, + nameProposal, + descProposal, + ]; + + let result: ReturnType<typeof groupSkillProposalsByFile>; + + beforeEach(() => { + result = groupSkillProposalsByFile( + proposals, + files, + new Set(), + new Set(), + ); + }); + + it('creates three groups', () => { + expect(result).toHaveLength(3); + }); + + it('places SKILL.md group first', () => { + expect(result[0].filePath).toBe(SKILL_MD_PATH); + }); + + it('places docs/readme.md second alphabetically', () => { + expect(result[1].filePath).toBe('docs/readme.md'); + }); + + it('places src/main.ts third alphabetically', () => { + expect(result[2].filePath).toBe('src/main.ts'); + }); + + it('groups two scalar proposals under SKILL.md', () => { + expect(result[0].proposals).toHaveLength(2); + }); + + it('reports correct change count for SKILL.md group', () => { + expect(result[0].changeCount).toBe(2); + }); + + it('sorts proposals within SKILL.md group oldest first', () => { + expect(result[0].proposals[0].id).toBe(nameProposal.id); + }); + + it('sorts proposals within SKILL.md group newest last', () => { + expect(result[0].proposals[1].id).toBe(descProposal.id); + }); + }); + + describe('pending count calculation', () => { + const p1 = changeProposalFactory({ + type: ChangeProposalType.updateSkillName, + }); + const p2 = changeProposalFactory({ + type: ChangeProposalType.updateSkillDescription, + }); + const p3 = changeProposalFactory({ + type: ChangeProposalType.updateSkillPrompt, + }); + + describe('when no decisions are made', () => { + it('counts all as pending', () => { + const result = groupSkillProposalsByFile( + [p1, p2, p3], + files, + new Set(), + new Set(), + ); + expect(result[0].pendingCount).toBe(3); + }); + }); + + describe('when one proposal is accepted', () => { + it('excludes it from pending count', () => { + const result = groupSkillProposalsByFile( + [p1, p2, p3], + files, + new Set([p1.id]), + new Set(), + ); + expect(result[0].pendingCount).toBe(2); + }); + }); + + describe('when one proposal is rejected', () => { + it('excludes it from pending count', () => { + const result = groupSkillProposalsByFile( + [p1, p2, p3], + files, + new Set(), + new Set([p2.id]), + ); + expect(result[0].pendingCount).toBe(2); + }); + }); + + describe('when proposals are both accepted and rejected', () => { + it('excludes both from pending count', () => { + const result = groupSkillProposalsByFile( + [p1, p2, p3], + files, + new Set([p1.id]), + new Set([p2.id]), + ); + expect(result[0].pendingCount).toBe(1); + }); + }); + }); + + describe('when there are no proposals', () => { + it('returns an empty array', () => { + const result = groupSkillProposalsByFile([], files, new Set(), new Set()); + expect(result).toEqual([]); + }); + }); + + describe('when all proposals target the same file', () => { + const p1 = changeProposalFactory({ + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: createSkillFileId('file-1'), + oldValue: 'old1', + newValue: 'new1', + }, + createdAt: new Date('2024-01-02'), + }); + const p2 = changeProposalFactory({ + type: ChangeProposalType.updateSkillFilePermissions, + payload: { + targetId: createSkillFileId('file-1'), + oldValue: 'read', + newValue: 'read-write', + }, + createdAt: new Date('2024-01-01'), + }); + + let result: ReturnType<typeof groupSkillProposalsByFile>; + + beforeEach(() => { + result = groupSkillProposalsByFile([p1, p2], files, new Set(), new Set()); + }); + + it('creates a single group', () => { + expect(result).toHaveLength(1); + }); + + it('uses the correct file path', () => { + expect(result[0].filePath).toBe('src/main.ts'); + }); + + it('includes both proposals in the group', () => { + expect(result[0].changeCount).toBe(2); + }); + + it('sorts older proposal first', () => { + expect(result[0].proposals[0].id).toBe(p2.id); + }); + + it('sorts newer proposal last', () => { + expect(result[0].proposals[1].id).toBe(p1.id); + }); + }); +}); + +describe('isBinaryProposal', () => { + describe('when proposal is addSkillFile with isBase64 true', () => { + it('returns true', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.addSkillFile, + payload: { + item: { + path: 'icon.png', + content: 'base64content', + permissions: 'rw-r--r--', + isBase64: true, + }, + }, + }); + expect(isBinaryProposal(proposal)).toBe(true); + }); + }); + + describe('when proposal is addSkillFile with isBase64 false', () => { + it('returns false', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.addSkillFile, + payload: { + item: { + path: 'readme.md', + content: '# Hello', + permissions: 'rw-r--r--', + isBase64: false, + }, + }, + }); + expect(isBinaryProposal(proposal)).toBe(false); + }); + }); + + describe('when proposal is deleteSkillFile with isBase64 true', () => { + it('returns true', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.deleteSkillFile, + payload: { + targetId: createSkillFileId('file-1'), + item: { + id: createSkillFileId('file-1'), + path: 'doc.pdf', + content: 'base64content', + permissions: 'rw-r--r--', + isBase64: true, + }, + }, + }); + expect(isBinaryProposal(proposal)).toBe(true); + }); + }); + + describe('when proposal is updateSkillFileContent with isBase64 true', () => { + it('returns true', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: createSkillFileId('file-1'), + oldValue: 'old-base64', + newValue: 'new-base64', + isBase64: true, + }, + }); + expect(isBinaryProposal(proposal)).toBe(true); + }); + }); + + describe('when proposal is updateSkillFileContent without isBase64', () => { + it('returns false', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: createSkillFileId('file-1'), + oldValue: 'old text', + newValue: 'new text', + }, + }); + expect(isBinaryProposal(proposal)).toBe(false); + }); + }); + + describe('when proposal is updateSkillFilePermissions', () => { + it('returns false', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFilePermissions, + payload: { + targetId: createSkillFileId('file-1'), + oldValue: 'read', + newValue: 'read-write', + }, + }); + expect(isBinaryProposal(proposal)).toBe(false); + }); + }); + + describe('when proposal is a scalar type', () => { + it('returns false', () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillName, + payload: { oldValue: 'Old', newValue: 'New' }, + }); + expect(isBinaryProposal(proposal)).toBe(false); + }); + }); +}); diff --git a/apps/frontend/src/domain/change-proposals/utils/groupSkillProposalsByFile.ts b/apps/frontend/src/domain/change-proposals/utils/groupSkillProposalsByFile.ts new file mode 100644 index 000000000..ee6fd0dd7 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/groupSkillProposalsByFile.ts @@ -0,0 +1,136 @@ +import { + ChangeProposalId, + ChangeProposalType, + CollectionItemAddPayload, + CollectionItemDeletePayload, + CollectionItemUpdatePayload, + SkillFile, + SkillFileContentUpdatePayload, + SkillFileId, +} from '@packmind/types'; +import { ChangeProposalWithConflicts } from '../types'; +import { SCALAR_SKILL_TYPES } from '../constants/skillProposalTypes'; + +export const REMOVAL_GROUP_PATH = '__removal__'; +export const SKILL_MD_PATH = '/SKILL.md'; +export const UNKNOWN_FILE_PATH = '/unknown.md'; + +export interface FileGroup { + filePath: string; + proposals: ChangeProposalWithConflicts[]; + changeCount: number; + pendingCount: number; +} + +export function getProposalFilePath( + proposal: ChangeProposalWithConflicts, + files: SkillFile[], +): string { + if (proposal.type === ChangeProposalType.removeSkill) { + return REMOVAL_GROUP_PATH; + } + + if (SCALAR_SKILL_TYPES.has(proposal.type)) { + return SKILL_MD_PATH; + } + + if (proposal.type === ChangeProposalType.addSkillFile) { + const payload = proposal.payload as CollectionItemAddPayload< + Omit<SkillFile, 'id' | 'skillVersionId'> + >; + return payload.item.path; + } + + if (proposal.type === ChangeProposalType.deleteSkillFile) { + const payload = proposal.payload as CollectionItemDeletePayload< + Omit<SkillFile, 'skillVersionId'> + >; + return payload.item.path; + } + + if ( + proposal.type === ChangeProposalType.updateSkillFileContent || + proposal.type === ChangeProposalType.updateSkillFilePermissions + ) { + const payload = + proposal.payload as CollectionItemUpdatePayload<SkillFileId>; + const file = files.find((f) => f.id === payload.targetId); + return file?.path ?? UNKNOWN_FILE_PATH; + } + + return UNKNOWN_FILE_PATH; +} + +export function isBinaryProposal( + proposal: ChangeProposalWithConflicts, +): boolean { + if (proposal.type === ChangeProposalType.addSkillFile) { + const payload = proposal.payload as CollectionItemAddPayload< + Omit<SkillFile, 'id' | 'skillVersionId'> + >; + return payload.item.isBase64; + } + + if (proposal.type === ChangeProposalType.deleteSkillFile) { + const payload = proposal.payload as CollectionItemDeletePayload< + Omit<SkillFile, 'skillVersionId'> + >; + return payload.item.isBase64; + } + + if (proposal.type === ChangeProposalType.updateSkillFileContent) { + const payload = proposal.payload as SkillFileContentUpdatePayload; + return payload.isBase64 ?? false; + } + + return false; +} + +export function groupSkillProposalsByFile( + proposals: ChangeProposalWithConflicts[], + files: SkillFile[], + acceptedIds: Set<ChangeProposalId>, + rejectedIds: Set<ChangeProposalId>, +): FileGroup[] { + const groupMap = new Map<string, ChangeProposalWithConflicts[]>(); + + for (const proposal of proposals) { + const filePath = getProposalFilePath(proposal, files); + const existing = groupMap.get(filePath); + if (existing) { + existing.push(proposal); + } else { + groupMap.set(filePath, [proposal]); + } + } + + const groups: FileGroup[] = []; + + for (const [filePath, groupProposals] of groupMap) { + const sorted = [...groupProposals].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + const pendingCount = sorted.filter( + (p) => !acceptedIds.has(p.id) && !rejectedIds.has(p.id), + ).length; + + groups.push({ + filePath, + proposals: sorted, + changeCount: sorted.length, + pendingCount, + }); + } + + groups.sort((a, b) => { + if (a.filePath === REMOVAL_GROUP_PATH) return -1; + if (b.filePath === REMOVAL_GROUP_PATH) return 1; + if (a.filePath === SKILL_MD_PATH) return -1; + if (b.filePath === SKILL_MD_PATH) return 1; + return a.filePath.localeCompare(b.filePath); + }); + + return groups; +} diff --git a/apps/frontend/src/domain/change-proposals/utils/isMarkdownContent.ts b/apps/frontend/src/domain/change-proposals/utils/isMarkdownContent.ts new file mode 100644 index 000000000..89051ae99 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/isMarkdownContent.ts @@ -0,0 +1,15 @@ +import { ChangeProposalType } from '@packmind/types'; + +const markdownContentTypes = new Set<ChangeProposalType>([ + ChangeProposalType.updateCommandDescription, + ChangeProposalType.updateStandardDescription, + ChangeProposalType.updateSkillDescription, +]); + +/** + * Returns true when the proposal type carries markdown content + * that should be rendered with a markdown viewer in diffs. + */ +export function isMarkdownContent(type: ChangeProposalType): boolean { + return markdownContentTypes.has(type); +} diff --git a/apps/frontend/src/domain/change-proposals/utils/mapMarkdownDiffToPositions.ts b/apps/frontend/src/domain/change-proposals/utils/mapMarkdownDiffToPositions.ts new file mode 100644 index 000000000..f5fb517d7 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/mapMarkdownDiffToPositions.ts @@ -0,0 +1,140 @@ +import { Node as ProseMirrorNode } from '@milkdown/kit/prose/model'; +import { MarkdownBlock } from './buildUnifiedMarkdownDiff'; +import { DiffRegion } from '../types/diffTypes'; + +/** + * Extracts plain text content from HTML, removing all tags. + * This is used to match HTML block content to ProseMirror text nodes. + */ +function extractTextFromHtml(html: string): string { + // Remove HTML tags but preserve content + const text = html + .replace(/<[^>]+>/g, ' ') // Replace tags with spaces + .replace(/\s+/g, ' ') // Normalize whitespace + .trim(); + return text; +} + +/** + * Finds all text positions in a ProseMirror document that match the given search text. + * Returns an array of {from, to, text} for each match. + */ +function findTextPositions( + doc: ProseMirrorNode, + searchText: string, +): Array<{ from: number; to: number; text: string }> { + const positions: Array<{ from: number; to: number; text: string }> = []; + const normalizedSearch = searchText.toLowerCase().replace(/\s+/g, ' ').trim(); + + if (!normalizedSearch) return positions; + + // Get the full document text + const fullText = doc.textContent; + const normalizedFullText = fullText.toLowerCase(); + + // Find all occurrences of the search text + let startIndex = 0; + while (true) { + const index = normalizedFullText.indexOf(normalizedSearch, startIndex); + if (index === -1) break; + + // Convert text index to ProseMirror positions + // We need to walk the document to find the actual positions + const currentPos = 1; // Start at position 1 (after doc node) + let textIndex = 0; + let found = false; + + doc.descendants((node, pos) => { + if (found) return false; + + if (node.isText) { + const nodeText = node.text || ''; + const nodeLength = nodeText.length; + + if (textIndex <= index && textIndex + nodeLength > index) { + // This node contains the start of our match + const offsetInNode = index - textIndex; + const matchLength = normalizedSearch.length; + + // The actual positions in ProseMirror + const from = pos + offsetInNode; + const to = from + matchLength; + + positions.push({ + from, + to, + text: fullText.substring(index, index + matchLength), + }); + + found = true; + return false; + } + + textIndex += nodeLength; + } + + return true; + }); + + startIndex = index + 1; + } + + return positions; +} + +/** + * Maps markdown diff blocks to ProseMirror document positions. + * Takes the output from buildUnifiedMarkdownDiff and converts it to + * DiffRegion objects with ProseMirror positions for decoration. + * + * @param blocks - Output from buildUnifiedMarkdownDiff + * @param newValue - The new markdown content (used for context) + * @param doc - ProseMirror document node + * @returns Array of DiffRegion objects with positions and diff HTML + */ +export function mapMarkdownDiffToPositions( + blocks: MarkdownBlock[], + newValue: string, + doc: ProseMirrorNode, +): DiffRegion[] { + const diffRegions: DiffRegion[] = []; + const usedPositions = new Set<string>(); // Track used positions to avoid duplicates + + for (const block of blocks) { + if (!block.isChanged || !block.diffHtml) { + continue; // Skip unchanged blocks + } + + // Extract text content from the block HTML + const blockText = extractTextFromHtml(block.html); + + if (!blockText) { + continue; // Skip empty blocks + } + + // Find all positions in the document that match this block's text + const positions = findTextPositions(doc, blockText); + + for (const pos of positions) { + const posKey = `${pos.from}-${pos.to}`; + + // Avoid duplicate regions + if (usedPositions.has(posKey)) { + continue; + } + + usedPositions.add(posKey); + + // Create a diff region for this position + diffRegions.push({ + from: pos.from, + to: pos.to, + diffHtml: block.diffHtml, + oldValue: '', // We don't have the old value at this granularity + newValue: pos.text, + }); + } + } + + return diffRegions; +} diff --git a/apps/frontend/src/domain/change-proposals/utils/markdownDiff.test.ts b/apps/frontend/src/domain/change-proposals/utils/markdownDiff.test.ts new file mode 100644 index 000000000..572cacd49 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/markdownDiff.test.ts @@ -0,0 +1,365 @@ +import { buildDiffHtml } from './markdownDiff'; + +describe('buildDiffHtml', () => { + describe('with unchanged content', () => { + const content = '## Heading\n\nSome paragraph.'; + let result: string; + + beforeEach(() => { + result = buildDiffHtml(content, content); + }); + + it('renders the heading', () => { + expect(result).toContain('<h2>Heading</h2>'); + }); + + it('renders the paragraph', () => { + expect(result).toContain('<p>Some paragraph.</p>'); + }); + + it('does not contain del tags', () => { + expect(result).not.toContain('<del>'); + }); + + it('does not contain ins tags', () => { + expect(result).not.toContain('<ins>'); + }); + }); + + describe('with deleted list items', () => { + const oldValue = [ + '## When to Use', + '', + '* When adding a new service', + '* When refactoring existing code', + '* When reviewing code', + ].join('\n'); + const newValue = '## When to Use'; + let result: string; + + beforeEach(() => { + result = buildDiffHtml(oldValue, newValue); + }); + + it('wraps first deleted list item in del inside li', () => { + expect(result).toContain('<li><del>When adding a new service</del></li>'); + }); + + it('wraps second deleted list item in del inside li', () => { + expect(result).toContain( + '<li><del>When refactoring existing code</del></li>', + ); + }); + + it('wraps third deleted list item in del inside li', () => { + expect(result).toContain('<li><del>When reviewing code</del></li>'); + }); + + describe('when list is fully replaced', () => { + const replacedOldValue = '* Item one\n* Item two\n* Item three'; + const replacedNewValue = 'Replaced with paragraph.'; + let replacedResult: string; + + beforeEach(() => { + replacedResult = buildDiffHtml(replacedOldValue, replacedNewValue); + }); + + it('does not produce del tags spanning into li', () => { + expect(replacedResult).not.toMatch(/<del>[^<]*<li>/); + }); + + it('does not produce del tags spanning across li boundaries', () => { + expect(replacedResult).not.toMatch(/<del>[^<]*<\/li>[^<]*<li>/); + }); + }); + }); + + describe('with added list items', () => { + const oldValue = '## Section'; + const newValue = [ + '## Section', + '', + '* New item one', + '* New item two', + ].join('\n'); + let result: string; + + beforeEach(() => { + result = buildDiffHtml(oldValue, newValue); + }); + + it('wraps first added list item in ins inside li', () => { + expect(result).toContain('<li><ins>New item one</ins></li>'); + }); + + it('wraps second added list item in ins inside li', () => { + expect(result).toContain('<li><ins>New item two</ins></li>'); + }); + }); + + describe('with mixed list changes', () => { + const oldValue = '* Keep this\n* Remove this\n* Also keep'; + const newValue = '* Keep this\n* Added new\n* Also keep'; + let result: string; + + beforeEach(() => { + result = buildDiffHtml(oldValue, newValue); + }); + + it('keeps unchanged items without markers', () => { + expect(result).toContain('<li>Keep this</li>'); + }); + + it('keeps second unchanged item without markers', () => { + expect(result).toContain('<li>Also keep</li>'); + }); + + it('marks removed items with del', () => { + expect(result).toContain('<li><del>Remove this</del></li>'); + }); + + it('marks added items with ins', () => { + expect(result).toContain('<li><ins>Added new</ins></li>'); + }); + }); + + describe('with paragraph text changes', () => { + const oldValue = 'The quick brown fox'; + const newValue = 'The slow brown cat'; + let result: string; + + beforeEach(() => { + result = buildDiffHtml(oldValue, newValue); + }); + + it('contains del tags for removed words', () => { + expect(result).toContain('<del>'); + }); + + it('contains ins tags for added words', () => { + expect(result).toContain('<ins>'); + }); + + it('preserves unchanged words', () => { + expect(result).toContain('brown'); + }); + }); + + describe('with heading changes', () => { + const oldValue = '## Old Title'; + const newValue = '## New Title'; + let result: string; + + beforeEach(() => { + result = buildDiffHtml(oldValue, newValue); + }); + + it('renders as h2 element', () => { + expect(result).toContain('<h2>'); + }); + + it('contains del tags for removed text', () => { + expect(result).toContain('<del>'); + }); + + it('contains ins tags for added text', () => { + expect(result).toContain('<ins>'); + }); + }); + + describe('with code blocks', () => { + describe('when unchanged', () => { + const content = '```js\nconst x = 1;\n```'; + let result: string; + + beforeEach(() => { + result = buildDiffHtml(content, content); + }); + + it('renders code element', () => { + expect(result).toContain('<code'); + }); + + it('does not contain diff-del markers', () => { + expect(result).not.toContain('diff-del'); + }); + + it('does not contain diff-ins markers', () => { + expect(result).not.toContain('diff-ins'); + }); + }); + + describe('when modified', () => { + const oldValue = '```js\nconst x = 1;\n```'; + const newValue = '```js\nconst x = 2;\n```'; + let result: string; + + beforeEach(() => { + result = buildDiffHtml(oldValue, newValue); + }); + + it('renders a single pre element', () => { + const preCount = (result.match(/<pre>/g) || []).length; + expect(preCount).toBe(1); + }); + + it('uses span with diff-del class for removed code', () => { + expect(result).toContain('<span class="diff-del">'); + }); + + it('uses span with diff-ins class for added code', () => { + expect(result).toContain('<span class="diff-ins">'); + }); + + it('includes language class', () => { + expect(result).toContain('class="language-js"'); + }); + }); + + describe('when lines are added', () => { + const oldValue = '```ts\nconst a = 1;\nconst b = 2;\n```'; + const newValue = '```ts\nconst a = 1;\n// new line\nconst b = 2;\n```'; + let result: string; + + beforeEach(() => { + result = buildDiffHtml(oldValue, newValue); + }); + + it('uses span with diff-ins class for added lines', () => { + expect(result).toContain('<span class="diff-ins">'); + }); + + it('does not use raw ins tags inside code block', () => { + expect(result).not.toMatch(/<code[^>]*>[\s\S]*<ins>/); + }); + }); + + describe('when containing HTML special characters', () => { + const oldValue = '```\nconst a = x < y && z > 0;\n```'; + const newValue = '```\nconst a = x < y || z > 0;\n```'; + let result: string; + + beforeEach(() => { + result = buildDiffHtml(oldValue, newValue); + }); + + it('escapes less-than signs', () => { + expect(result).toContain('<'); + }); + + it('escapes greater-than signs', () => { + expect(result).toContain('>'); + }); + + it('escapes ampersands', () => { + expect(result).toContain('&'); + }); + + it('does not contain unescaped HTML in code blocks', () => { + expect(result).not.toMatch(/<code[^>]*>.*[^&]<[^/dis]/); + }); + }); + }); + + describe('with task list items', () => { + const oldValue = '* [ ] Unchecked task\n* [x] Checked task'; + const newValue = ''; + let result: string; + + beforeEach(() => { + result = buildDiffHtml(oldValue, newValue); + }); + + it('wraps deleted task list items in del', () => { + expect(result).toContain('<del>'); + }); + + it('includes checkbox for task list items', () => { + expect(result).toContain('checkbox'); + }); + }); + + describe('with tight-to-loose list transition', () => { + const oldValue = + '* [ ] Use `PackmindErrorHandler.throwError`\n* Follow logging guidelines'; + const newValue = + '* [ ] Use `PackmindErrorHandler.throwError`\n\n* Follow logging guidelines\n\n* New item added'; + let result: string; + + beforeEach(() => { + result = buildDiffHtml(oldValue, newValue); + }); + + it('does not mark unchanged checkbox item as deleted', () => { + expect(result).not.toContain( + '<del><input type="checkbox" disabled > Use <code>PackmindErrorHandler.throwError</code></del>', + ); + }); + + it('does not mark unchanged checkbox item as added', () => { + expect(result).not.toContain( + '<ins><input type="checkbox" disabled > Use <code>PackmindErrorHandler.throwError</code></ins>', + ); + }); + + it('does not mark unchanged plain item as deleted', () => { + expect(result).not.toContain('<del>Follow logging guidelines</del>'); + }); + + it('does not mark unchanged plain item as added', () => { + expect(result).not.toContain('<ins>Follow logging guidelines</ins>'); + }); + + it('marks the truly new item as added', () => { + expect(result).toContain('<ins>New item added</ins>'); + }); + }); + + describe('with complex mixed content', () => { + const oldValue = [ + '## When to Use', + '', + '* When adding a new service, controller, or API endpoint', + '* When refactoring existing try/catch blocks', + '* When reviewing code that lacks structured error responses', + ].join('\n'); + const newValue = [ + '## When to Use', + '', + 'Use this pattern in all new code.', + ].join('\n'); + let result: string; + + beforeEach(() => { + result = buildDiffHtml(oldValue, newValue); + }); + + it('preserves unchanged heading', () => { + expect(result).toContain('<h2>When to Use</h2>'); + }); + + it('contains del tags for removed content', () => { + expect(result).toContain('<del>'); + }); + + it('contains ins tags for added content', () => { + expect(result).toContain('<ins>'); + }); + + it('includes first deleted list item text', () => { + expect(result).toContain( + 'When adding a new service, controller, or API endpoint', + ); + }); + + it('includes second deleted list item text', () => { + expect(result).toContain('When refactoring existing try/catch blocks'); + }); + + it('includes third deleted list item text', () => { + expect(result).toContain( + 'When reviewing code that lacks structured error responses', + ); + }); + }); +}); diff --git a/apps/frontend/src/domain/change-proposals/utils/markdownDiff.ts b/apps/frontend/src/domain/change-proposals/utils/markdownDiff.ts new file mode 100644 index 000000000..1fc0cc683 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/markdownDiff.ts @@ -0,0 +1,262 @@ +import { marked, Token, Tokens, TokensList } from 'marked'; +import { diffArrays, diffWords } from 'diff'; + +function toTokensList(tokens: Token[]): TokensList { + const list = tokens as TokensList; + list.links = {}; + return list; +} + +function renderToken(token: Token): string { + return marked.parser(toTokensList([token])); +} + +function renderInlineContent(text: string): string { + return marked.parseInline(text) as string; +} + +function renderListAllMarked(list: Tokens.List, tag: 'del' | 'ins'): string { + const listTag = list.ordered ? 'ol' : 'ul'; + const items = list.items + .map((item) => { + const content = renderInlineContent(item.text); + const checkbox = item.task + ? `<input type="checkbox" disabled ${item.checked ? 'checked' : ''}> ` + : ''; + return `<li><${tag}>${checkbox}${content}</${tag}></li>`; + }) + .join('\n'); + return `<${listTag}>\n${items}\n</${listTag}>\n`; +} + +function renderListWithItemDiff( + oldList: Tokens.List, + newList: Tokens.List, +): string { + const listTag = newList.ordered ? 'ol' : 'ul'; + const oldTexts = oldList.items.map((item) => item.text.trim()); + const newTexts = newList.items.map((item) => item.text.trim()); + const changes = diffArrays(oldTexts, newTexts); + + let oldIdx = 0; + let newIdx = 0; + const items: string[] = []; + + for (const change of changes) { + const count = change.count ?? change.value.length; + if (!change.added && !change.removed) { + for (let i = 0; i < count; i++) { + const item = newList.items[newIdx++]; + const content = renderInlineContent(item.text); + const checkbox = item.task + ? `<input type="checkbox" disabled ${item.checked ? 'checked' : ''}> ` + : ''; + items.push(`<li>${checkbox}${content}</li>`); + oldIdx++; + } + } else if (change.removed) { + for (let i = 0; i < count; i++) { + const item = oldList.items[oldIdx++]; + const content = renderInlineContent(item.text); + const checkbox = item.task + ? `<input type="checkbox" disabled ${item.checked ? 'checked' : ''}> ` + : ''; + items.push(`<li><del>${checkbox}${content}</del></li>`); + } + } else { + for (let i = 0; i < count; i++) { + const item = newList.items[newIdx++]; + const content = renderInlineContent(item.text); + const checkbox = item.task + ? `<input type="checkbox" disabled ${item.checked ? 'checked' : ''}> ` + : ''; + items.push(`<li><ins>${checkbox}${content}</ins></li>`); + } + } + } + + return `<${listTag}>\n${items.join('\n')}\n</${listTag}>\n`; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); +} + +function renderCodeBlockDiff( + oldToken: Tokens.Code, + newToken: Tokens.Code, +): string { + const langClass = newToken.lang ? ` class="language-${newToken.lang}"` : ''; + const changes = diffWords(oldToken.text, newToken.text); + const diffHtml = changes + .map((change) => { + const escaped = escapeHtml(change.value); + if (change.added) return `<span class="diff-ins">${escaped}</span>`; + if (change.removed) return `<span class="diff-del">${escaped}</span>`; + return escaped; + }) + .join(''); + return `<pre><code${langClass}>${diffHtml}\n</code></pre>\n`; +} + +function renderWordDiffInBlock( + oldToken: Token & { text: string }, + newToken: Token & { text: string }, + blockTag: string, +): string { + const changes = diffWords(oldToken.text, newToken.text); + const inlineHtml = changes + .map((change) => { + if (change.added) + return `<ins>${renderInlineContent(change.value)}</ins>`; + if (change.removed) + return `<del>${renderInlineContent(change.value)}</del>`; + return renderInlineContent(change.value); + }) + .join(''); + return `<${blockTag}>${inlineHtml}</${blockTag}>\n`; +} + +function renderDeletedToken(token: Token): string { + if (token.type === 'list') { + return renderListAllMarked(token as Tokens.List, 'del'); + } + if (token.type === 'paragraph') { + const content = renderInlineContent((token as Tokens.Paragraph).text); + return `<p><del>${content}</del></p>\n`; + } + if (token.type === 'heading') { + const heading = token as Tokens.Heading; + const content = renderInlineContent(heading.text); + return `<h${heading.depth}><del>${content}</del></h${heading.depth}>\n`; + } + if (token.type === 'code') { + const code = token as Tokens.Code; + const langClass = code.lang ? ` class="language-${code.lang}"` : ''; + const escaped = escapeHtml(code.text); + return `<pre><code${langClass}><span class="diff-del">${escaped}</span>\n</code></pre>\n`; + } + return `<del>${renderToken(token)}</del>`; +} + +function renderAddedToken(token: Token): string { + if (token.type === 'list') { + return renderListAllMarked(token as Tokens.List, 'ins'); + } + if (token.type === 'paragraph') { + const content = renderInlineContent((token as Tokens.Paragraph).text); + return `<p><ins>${content}</ins></p>\n`; + } + if (token.type === 'heading') { + const heading = token as Tokens.Heading; + const content = renderInlineContent(heading.text); + return `<h${heading.depth}><ins>${content}</ins></h${heading.depth}>\n`; + } + if (token.type === 'code') { + const code = token as Tokens.Code; + const langClass = code.lang ? ` class="language-${code.lang}"` : ''; + const escaped = escapeHtml(code.text); + return `<pre><code${langClass}><span class="diff-ins">${escaped}</span>\n</code></pre>\n`; + } + return `<ins>${renderToken(token)}</ins>`; +} + +function renderModifiedPair(oldToken: Token, newToken: Token): string { + if (oldToken.type === 'list' && newToken.type === 'list') { + return renderListWithItemDiff( + oldToken as Tokens.List, + newToken as Tokens.List, + ); + } + if (oldToken.type === 'paragraph' && newToken.type === 'paragraph') { + return renderWordDiffInBlock( + oldToken as Tokens.Paragraph, + newToken as Tokens.Paragraph, + 'p', + ); + } + if (oldToken.type === 'heading' && newToken.type === 'heading') { + const oldH = oldToken as Tokens.Heading; + const newH = newToken as Tokens.Heading; + return renderWordDiffInBlock(oldH, newH, `h${newH.depth}`); + } + if (oldToken.type === 'code' && newToken.type === 'code') { + return renderCodeBlockDiff( + oldToken as Tokens.Code, + newToken as Tokens.Code, + ); + } + return renderDeletedToken(oldToken) + renderAddedToken(newToken); +} + +export const markdownDiffCss = { + '& ins, & .diff-ins': { + backgroundColor: 'var(--Palette-Semantic-Green800)', + padding: '0 2px', + borderRadius: '2px', + textDecoration: 'none', + }, + '& del, & .diff-del': { + backgroundColor: 'var(--Palette-Semantic-Red800)', + padding: '0 2px', + borderRadius: '2px', + }, +}; + +export function buildDiffHtml(oldValue: string, newValue: string): string { + marked.setOptions({ breaks: true, gfm: true }); + + const oldTokens = marked.lexer(oldValue).filter((t) => t.type !== 'space'); + const newTokens = marked.lexer(newValue).filter((t) => t.type !== 'space'); + + const oldRaws = oldTokens.map((t) => t.raw); + const newRaws = newTokens.map((t) => t.raw); + const changes = diffArrays(oldRaws, newRaws); + + let oldIdx = 0; + let newIdx = 0; + const parts: string[] = []; + + for (let ci = 0; ci < changes.length; ci++) { + const change = changes[ci]; + const count = change.count ?? change.value.length; + + if (!change.added && !change.removed) { + for (let i = 0; i < count; i++) { + parts.push(renderToken(newTokens[newIdx++])); + oldIdx++; + } + } else if (change.removed) { + const next = changes[ci + 1]; + if (next && next.added) { + const nextCount = next.count ?? next.value.length; + if (count === 1 && nextCount === 1) { + parts.push( + renderModifiedPair(oldTokens[oldIdx++], newTokens[newIdx++]), + ); + } else { + for (let i = 0; i < count; i++) { + parts.push(renderDeletedToken(oldTokens[oldIdx++])); + } + for (let i = 0; i < nextCount; i++) { + parts.push(renderAddedToken(newTokens[newIdx++])); + } + } + ci++; + } else { + for (let i = 0; i < count; i++) { + parts.push(renderDeletedToken(oldTokens[oldIdx++])); + } + } + } else { + for (let i = 0; i < count; i++) { + parts.push(renderAddedToken(newTokens[newIdx++])); + } + } + } + + return parts.join(''); +} diff --git a/apps/frontend/src/domain/change-proposals/utils/renderDiffText.tsx b/apps/frontend/src/domain/change-proposals/utils/renderDiffText.tsx new file mode 100644 index 000000000..21a425c18 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/renderDiffText.tsx @@ -0,0 +1,42 @@ +import { PMText } from '@packmind/ui'; +import { diffWords } from 'diff'; + +export function renderDiffText(oldValue: string, newValue: string) { + const changes = diffWords(oldValue, newValue); + return changes.map((change, i) => { + if (change.added) { + return ( + <PMText + key={i} + as="span" + bg="green.subtle" + paddingX={0.5} + borderRadius="sm" + data-diff-change="" + > + {change.value} + </PMText> + ); + } + if (change.removed) { + return ( + <PMText + key={i} + as="span" + bg="red.subtle" + textDecoration="line-through" + paddingX={0.5} + borderRadius="sm" + data-diff-change="" + > + {change.value} + </PMText> + ); + } + return ( + <PMText key={i} as="span"> + {change.value} + </PMText> + ); + }); +} diff --git a/apps/frontend/src/domain/change-proposals/utils/serializeArtifactToMarkdown.ts b/apps/frontend/src/domain/change-proposals/utils/serializeArtifactToMarkdown.ts new file mode 100644 index 000000000..28bd4ef54 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/serializeArtifactToMarkdown.ts @@ -0,0 +1,94 @@ +import { AppliedStandard } from './applyStandardProposals'; + +function escapeSingleQuotes(value: string): string { + return value.replace(/'/g, "''"); +} + +type SerializableSkill = { + name: string; + description: string; + prompt: string; + license?: string; + compatibility?: string; + allowedTools?: string; + metadata?: Record<string, string>; +}; + +export function serializeSkillToMarkdown(applied: SerializableSkill): string { + const frontmatterFields: string[] = []; + + if (applied.name) { + frontmatterFields.push(`name: '${escapeSingleQuotes(applied.name)}'`); + } + + if (applied.description) { + frontmatterFields.push( + `description: '${escapeSingleQuotes(applied.description)}'`, + ); + } + + if (applied.license) { + frontmatterFields.push(`license: '${escapeSingleQuotes(applied.license)}'`); + } + + if (applied.compatibility) { + frontmatterFields.push( + `compatibility: '${escapeSingleQuotes(applied.compatibility)}'`, + ); + } + + if (applied.allowedTools) { + frontmatterFields.push( + `allowed-tools: '${escapeSingleQuotes(applied.allowedTools)}'`, + ); + } + + if (applied.metadata && Object.keys(applied.metadata).length > 0) { + const metadataYaml = Object.entries(applied.metadata) + .map(([key, value]) => ` ${key}: '${escapeSingleQuotes(String(value))}'`) + .join('\n'); + frontmatterFields.push(`metadata:\n${metadataYaml}`); + } + + return `---\n${frontmatterFields.join('\n')}\n---\n\n${applied.prompt}`; +} + +export function serializeStandardToMarkdown(applied: AppliedStandard): string { + const frontmatterFields: string[] = []; + + frontmatterFields.push(`name: '${escapeSingleQuotes(applied.name)}'`); + + if (applied.scope && applied.scope.trim() !== '') { + frontmatterFields.push(`alwaysApply: false`); + frontmatterFields.push( + `description: '${escapeSingleQuotes(applied.name)}'`, + ); + } else { + frontmatterFields.push(`alwaysApply: true`); + frontmatterFields.push( + `description: '${escapeSingleQuotes(applied.name)}'`, + ); + } + + const frontmatter = `---\n${frontmatterFields.join('\n')}\n---`; + + const lines: string[] = [`## Standard: ${applied.name}`]; + + if (applied.description) { + lines.push(''); + lines.push(applied.description); + } + + if (applied.rules.length > 0) { + lines.push(''); + const sortedRules = [...applied.rules] + .map((rule) => rule.content.trim()) + .filter((content) => content.length > 0) + .sort((a, b) => a.localeCompare(b)); + for (const rule of sortedRules) { + lines.push(`* ${rule}`); + } + } + + return `${frontmatter}\n\n${lines.join('\n')}`; +} diff --git a/apps/frontend/src/domain/deployments/api/gateways/DeploymentsGateway.spec.ts b/apps/frontend/src/domain/deployments/api/gateways/DeploymentsGateway.spec.ts new file mode 100644 index 000000000..d072f4120 --- /dev/null +++ b/apps/frontend/src/domain/deployments/api/gateways/DeploymentsGateway.spec.ts @@ -0,0 +1,140 @@ +import { ApiService } from '../../../../services/api/ApiService'; +import { DeploymentsGatewayApi } from './DeploymentsGateway'; +import type { + FindMarketplaceDistributionByIdResponse, + MarketplaceDistribution, + MarketplaceDistributionId, + MarketplaceId, + OrganizationId, + PackageId, + PublishPackageOnMarketplaceResponse, +} from '@packmind/types'; + +describe('DeploymentsGatewayApi - marketplace publish wrappers', () => { + const organizationId = 'org-1' as OrganizationId; + const marketplaceId = 'mkt-1' as MarketplaceId; + const packageId = 'pkg-1' as PackageId; + const marketplaceDistributionId = + 'md-1' as unknown as MarketplaceDistributionId; + + let postMock: jest.Mock; + let getMock: jest.Mock; + let apiServiceMock: ApiService; + let gateway: DeploymentsGatewayApi; + + beforeEach(() => { + postMock = jest.fn(); + getMock = jest.fn(); + apiServiceMock = { + baseApiUrl: 'http://localhost/api/v0', + post: postMock, + get: getMock, + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + getAxiosInstance: jest.fn(), + } as unknown as ApiService; + + // The default constructor wires the real api service; we replace it + // through the protected field once for testability — mirrors the + // structure of PackmindGateway without leaking the injection surface. + gateway = new DeploymentsGatewayApi(); + (gateway as unknown as { _api: ApiService })._api = apiServiceMock; + }); + + describe('publishPackageOnMarketplace', () => { + describe('when the api service resolves with a publish response', () => { + const response: PublishPackageOnMarketplaceResponse = { + marketplaceDistributionId, + status: 'in_progress', + marketplaceId, + packageId, + pluginSlug: 'my-plugin', + }; + let result: PublishPackageOnMarketplaceResponse; + + beforeEach(async () => { + postMock.mockResolvedValueOnce(response); + + result = await gateway.publishPackageOnMarketplace({ + organizationId, + marketplaceId, + packageId, + }); + }); + + it('POSTs the marketplace publish endpoint with the package id', () => { + expect(postMock).toHaveBeenCalledWith( + `/organizations/${organizationId}/marketplaces/${marketplaceId}/publish`, + { packageId }, + ); + }); + + it('returns the response from the api service', () => { + expect(result).toBe(response); + }); + }); + + it('propagates errors thrown by the api service so callers can map them', async () => { + const apiError = new Error('boom'); + postMock.mockRejectedValueOnce(apiError); + + await expect( + gateway.publishPackageOnMarketplace({ + organizationId, + marketplaceId, + packageId, + }), + ).rejects.toBe(apiError); + }); + }); + + describe('findMarketplaceDistributionById', () => { + describe('when the api service resolves with a wrapped distribution row', () => { + let response: FindMarketplaceDistributionByIdResponse; + let result: FindMarketplaceDistributionByIdResponse; + + beforeEach(async () => { + response = { + marketplaceDistribution: { + id: marketplaceDistributionId, + status: 'in_progress', + } as unknown as MarketplaceDistribution, + }; + getMock.mockResolvedValueOnce(response); + + result = await gateway.findMarketplaceDistributionById({ + organizationId, + marketplaceDistributionId, + }); + }); + + it('GETs the marketplace-distributions endpoint with the row id', () => { + expect(getMock).toHaveBeenCalledWith( + `/organizations/${organizationId}/marketplace-distributions/${marketplaceDistributionId}`, + ); + }); + + it('returns the wrapped response from the api service', () => { + expect(result).toBe(response); + }); + }); + + describe('when the api service resolves with a null row', () => { + let result: FindMarketplaceDistributionByIdResponse; + + beforeEach(async () => { + getMock.mockResolvedValueOnce({ marketplaceDistribution: null }); + + result = await gateway.findMarketplaceDistributionById({ + organizationId, + marketplaceDistributionId, + }); + }); + + it('returns the null wrapper from the api service', () => { + expect(result).toEqual({ marketplaceDistribution: null }); + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/deployments/api/gateways/DeploymentsGateway.ts b/apps/frontend/src/domain/deployments/api/gateways/DeploymentsGateway.ts index f468eb6dd..d74d09670 100644 --- a/apps/frontend/src/domain/deployments/api/gateways/DeploymentsGateway.ts +++ b/apps/frontend/src/domain/deployments/api/gateways/DeploymentsGateway.ts @@ -1,5 +1,5 @@ import { NewGateway, NewPackmindCommandBody } from '@packmind/types'; -import { SpaceId, RecipeId } from '@packmind/types'; +import { SpaceId } from '@packmind/types'; import { IUpdateTargetUseCase, IDeleteTargetUseCase } from '@packmind/types'; import { IAddTargetUseCase, @@ -8,6 +8,7 @@ import { } from '@packmind/types'; import { IGetPackageByIdUseCase, + IGetPackageSummaryUseCase, IPublishRecipes, IPublishStandards, IPublishPackages, @@ -47,9 +48,17 @@ import { GetDashboardNonLiveCommand, AddArtefactsToPackageCommand, } from '@packmind/types'; -import { OrganizationId } from '@packmind/types'; +import { + FindMarketplaceDistributionByIdCommand, + IFindMarketplaceDistributionByIdUseCase, + OrganizationId, + PublishPackageOnMarketplaceResponse, +} from '@packmind/types'; import { PackmindGateway } from '../../../../shared/PackmindGateway'; -import { IDeploymentsGateway } from './IDeploymentsGateway'; +import { + IDeploymentsGateway, + PublishPackageOnMarketplaceArgs, +} from './IDeploymentsGateway'; export class DeploymentsGatewayApi extends PackmindGateway @@ -179,6 +188,16 @@ export class DeploymentsGatewayApi ); }; + getPackageSummary: NewGateway<IGetPackageSummaryUseCase> = async ({ + organizationId, + slug, + }: { + organizationId: OrganizationId; + slug: string; + }) => { + return this._api.get(`/organizations/${organizationId}/packages/${slug}`); + }; + publishRecipes: NewGateway<IPublishRecipes> = async ({ organizationId, targetIds, @@ -329,4 +348,25 @@ export class DeploymentsGatewayApi `/organizations/${organizationId}/deployments/governance/drifted-packages`, ); }; + + publishPackageOnMarketplace = async ({ + organizationId, + marketplaceId, + packageId, + }: PublishPackageOnMarketplaceArgs): Promise<PublishPackageOnMarketplaceResponse> => { + return this._api.post<PublishPackageOnMarketplaceResponse>( + `${this._endpoint}/${organizationId}/marketplaces/${marketplaceId}/publish`, + { packageId }, + ); + }; + + findMarketplaceDistributionById: NewGateway<IFindMarketplaceDistributionByIdUseCase> = + async ({ + organizationId, + marketplaceDistributionId, + }: NewPackmindCommandBody<FindMarketplaceDistributionByIdCommand>) => { + return this._api.get( + `${this._endpoint}/${organizationId}/marketplace-distributions/${marketplaceDistributionId}`, + ); + }; } diff --git a/apps/frontend/src/domain/deployments/api/gateways/IDeploymentsGateway.ts b/apps/frontend/src/domain/deployments/api/gateways/IDeploymentsGateway.ts index 68b5413b3..9ad0d8ce1 100644 --- a/apps/frontend/src/domain/deployments/api/gateways/IDeploymentsGateway.ts +++ b/apps/frontend/src/domain/deployments/api/gateways/IDeploymentsGateway.ts @@ -1,5 +1,12 @@ -import { NewGateway } from '@packmind/types'; import { + MarketplaceId, + NewGateway, + OrganizationId, + PackageId, + PublishPackageOnMarketplaceResponse, +} from '@packmind/types'; +import { + IFindMarketplaceDistributionByIdUseCase, IGetPackageByIdUseCase, IListDeploymentsByPackage, IListDistributionsByRecipe, @@ -27,6 +34,17 @@ import { IGetDashboardNonLive, } from '@packmind/types'; +/** + * Arguments passed to `publishPackageOnMarketplace`. The marketplace and + * package live in different URL segments so we accept them as named fields + * rather than re-using the backend command shape. + */ +export type PublishPackageOnMarketplaceArgs = { + organizationId: OrganizationId; + marketplaceId: MarketplaceId; + packageId: PackageId; +}; + export interface IDeploymentsGateway { listDeploymentsByPackageId: NewGateway<IListDeploymentsByPackage>; listDistributionsByRecipeId: NewGateway<IListDistributionsByRecipe>; @@ -53,4 +71,20 @@ export interface IDeploymentsGateway { getDashboardNonLive: NewGateway<IGetDashboardNonLive>; listActiveDistributedPackagesBySpace: NewGateway<IListActiveDistributedPackagesBySpaceUseCase>; listDriftedPackagesByOrg: NewGateway<IListDriftedPackagesByOrgUseCase>; + /** + * Publishes a Packmind package as a managed plugin on a linked marketplace. + * Returns the freshly created `in_progress` row so the caller can poll for + * the final status. Errors surface as `PackmindError` (mapped to a + * `PublishFailureReason` by the mutation hook). + */ + publishPackageOnMarketplace( + args: PublishPackageOnMarketplaceArgs, + ): Promise<PublishPackageOnMarketplaceResponse>; + /** + * Fetches a single `MarketplaceDistribution` row by id. Used by status + * polling once the publish job has been enqueued. The endpoint always + * resolves with HTTP 200 — `marketplaceDistribution` is `null` when the + * row is missing or belongs to another organization. + */ + findMarketplaceDistributionById: NewGateway<IFindMarketplaceDistributionByIdUseCase>; } diff --git a/apps/frontend/src/domain/deployments/api/queries/DeploymentsQueries.ts b/apps/frontend/src/domain/deployments/api/queries/DeploymentsQueries.ts index 53f174983..edb740476 100644 --- a/apps/frontend/src/domain/deployments/api/queries/DeploymentsQueries.ts +++ b/apps/frontend/src/domain/deployments/api/queries/DeploymentsQueries.ts @@ -27,12 +27,13 @@ import { useCurrentSpace } from '../../../spaces/hooks/useCurrentSpace'; import { CHANGE_PROPOSALS_QUERY_SCOPE, GET_GROUPED_CHANGE_PROPOSALS_KEY, -} from '@packmind/proprietary/frontend/domain/change-proposals/api/queryKeys'; +} from '../../../change-proposals/api/queryKeys'; import { ORGANIZATION_QUERY_SCOPE } from '../../../organizations/api/queryKeys'; import { deploymentsGateways } from '../gateways'; import { DeploymentQueryKeys, GET_PACKAGE_BY_ID_KEY, + GET_PACKAGE_SUMMARY_KEY, GET_RENDER_MODE_CONFIGURATION_KEY, GET_TARGETS_BY_ORGANIZATION_KEY, GET_TARGETS_BY_REPOSITORY_KEY, @@ -283,6 +284,28 @@ export const useGetPackageByIdQuery = ( }); }; +export const getPackageSummaryOptions = ( + organizationId: OrganizationId | string, + slug: string, +) => ({ + queryKey: [...GET_PACKAGE_SUMMARY_KEY, organizationId, slug] as const, + queryFn: () => { + return deploymentsGateways.getPackageSummary({ + organizationId: organizationId as OrganizationId, + slug, + }); + }, + enabled: !!organizationId && !!slug, + staleTime: 1000 * 60 * 5, +}); + +export const useGetPackageSummaryQuery = ( + organizationId: OrganizationId | string | undefined, + slug: string | undefined, +) => { + return useQuery(getPackageSummaryOptions(organizationId ?? '', slug ?? '')); +}; + export const useGetDashboardKpiQuery = (spaceId: string) => { const { organization } = useAuthContext(); diff --git a/apps/frontend/src/domain/deployments/api/queries/marketplaceDistributionQueries.ts b/apps/frontend/src/domain/deployments/api/queries/marketplaceDistributionQueries.ts new file mode 100644 index 000000000..acae2e8d0 --- /dev/null +++ b/apps/frontend/src/domain/deployments/api/queries/marketplaceDistributionQueries.ts @@ -0,0 +1,61 @@ +import { queryOptions } from '@tanstack/react-query'; +import { MarketplaceDistributionId, OrganizationId } from '@packmind/types'; +import { deploymentsGateways } from '../gateways'; +import { ORGANIZATION_QUERY_SCOPE } from '../../../organizations/api/queryKeys'; +import { DEPLOYMENTS_QUERY_SCOPE } from '../queryKeys'; +import type { IDeploymentsGateway } from '../gateways/IDeploymentsGateway'; + +const MARKETPLACE_DISTRIBUTIONS_SCOPE = 'marketplace-distributions' as const; + +/** + * Factory for marketplace-distribution query keys. Lives under the per-org + * deployments scope so an org switch flushes the whole subtree in one + * invalidate call, mirroring `marketplaceQueryKeys` in the marketplaces + * domain. + */ +export const marketplaceDistributionQueryKeys = { + all: () => + [ + ORGANIZATION_QUERY_SCOPE, + DEPLOYMENTS_QUERY_SCOPE, + MARKETPLACE_DISTRIBUTIONS_SCOPE, + ] as const, + byId: ( + organizationId: OrganizationId | string, + marketplaceDistributionId: MarketplaceDistributionId | string, + ) => + [ + ...marketplaceDistributionQueryKeys.all(), + organizationId, + marketplaceDistributionId, + ] as const, +}; + +/** + * Query options factory for `findMarketplaceDistributionById`. Callers can + * pass a custom gateway in tests; production code uses the default singleton. + */ +export const marketplaceDistributionQueries = { + byId: ({ + organizationId, + marketplaceDistributionId, + gateway = deploymentsGateways, + }: { + organizationId: OrganizationId | string; + marketplaceDistributionId: MarketplaceDistributionId | string; + gateway?: Pick<IDeploymentsGateway, 'findMarketplaceDistributionById'>; + }) => + queryOptions({ + queryKey: marketplaceDistributionQueryKeys.byId( + organizationId, + marketplaceDistributionId, + ), + queryFn: () => + gateway.findMarketplaceDistributionById({ + organizationId: organizationId as OrganizationId, + marketplaceDistributionId: + marketplaceDistributionId as MarketplaceDistributionId, + }), + enabled: !!organizationId && !!marketplaceDistributionId, + }), +}; diff --git a/apps/frontend/src/domain/deployments/api/queries/useMarketplacePublishMutation.spec.ts b/apps/frontend/src/domain/deployments/api/queries/useMarketplacePublishMutation.spec.ts new file mode 100644 index 000000000..8c8d76281 --- /dev/null +++ b/apps/frontend/src/domain/deployments/api/queries/useMarketplacePublishMutation.spec.ts @@ -0,0 +1,169 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { + PackmindError, + type ServerErrorResponse, +} from '../../../../services/api/errors/PackmindError'; +import { + INVALID_TOKEN_MESSAGE, + mapPublishError, + useMarketplacePublishMutation, +} from './useMarketplacePublishMutation'; +import type { + MarketplaceDistributionId, + MarketplaceId, + OrganizationId, + PackageId, + PublishPackageOnMarketplaceResponse, +} from '@packmind/types'; + +const organizationId = 'org-1' as OrganizationId; +const marketplaceId = 'mkt-1' as MarketplaceId; +const packageId = 'pkg-1' as PackageId; + +const buildServerError = ( + status: number, + message: string, +): ServerErrorResponse => ({ + data: { message }, + status, + statusText: 'error', +}); + +const renderUseMutation = (gateway: { + publishPackageOnMarketplace: jest.Mock; +}) => { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + }, + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + return renderHook(() => useMarketplacePublishMutation({ gateway }), { + wrapper, + }); +}; + +describe('useMarketplacePublishMutation', () => { + describe('when the gateway resolves with an in_progress response', () => { + const response: PublishPackageOnMarketplaceResponse = { + marketplaceDistributionId: 'md-1' as unknown as MarketplaceDistributionId, + status: 'in_progress', + marketplaceId, + packageId, + pluginSlug: 'my-plugin', + }; + let publishPackageOnMarketplace: jest.Mock; + let result: ReturnType<typeof renderUseMutation>['result']; + + beforeEach(async () => { + publishPackageOnMarketplace = jest.fn().mockResolvedValue(response); + ({ result } = renderUseMutation({ publishPackageOnMarketplace })); + + act(() => { + result.current.mutate({ organizationId, marketplaceId, packageId }); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + }); + + it('forwards the mutation arguments to the gateway', () => { + expect(publishPackageOnMarketplace).toHaveBeenCalledWith({ + organizationId, + marketplaceId, + packageId, + }); + }); + + it('exposes the response as the mutation data', () => { + expect(result.current.data).toEqual(response); + }); + + it('does not expose any mutation error', () => { + expect(result.current.error).toBeNull(); + }); + }); + + describe('when the gateway rejects with a PackmindError', () => { + let error: PackmindError; + let result: ReturnType<typeof renderUseMutation>['result']; + + beforeEach(async () => { + error = new PackmindError( + buildServerError( + 400, + 'The package could not be published. Reason: Invalid or expired Git token.', + ), + ); + const publishPackageOnMarketplace = jest.fn().mockRejectedValue(error); + + ({ result } = renderUseMutation({ publishPackageOnMarketplace })); + + act(() => { + result.current.mutate({ organizationId, marketplaceId, packageId }); + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + }); + + it('exposes the PackmindError so callers can map it', () => { + expect(result.current.error).toBe(error); + }); + }); +}); + +describe('mapPublishError', () => { + it('maps 409 to name_conflict_unmanaged', () => { + const error = new PackmindError( + buildServerError(409, 'plugin name collision'), + ); + expect(mapPublishError(error)).toBe('name_conflict_unmanaged'); + }); + + it('maps 400 with the invalid-token sentence to invalid_token', () => { + const error = new PackmindError( + buildServerError(400, INVALID_TOKEN_MESSAGE), + ); + expect(mapPublishError(error)).toBe('invalid_token'); + }); + + describe('when mapping other 400 errors', () => { + it('maps a missing-descriptor 400 to descriptor_missing', () => { + const missing = new PackmindError( + buildServerError(400, 'marketplace.json not found'), + ); + expect(mapPublishError(missing)).toBe('descriptor_missing'); + }); + + it('maps a bad-format-descriptor 400 to descriptor_missing', () => { + const bad = new PackmindError( + buildServerError(400, 'descriptor has bad format'), + ); + expect(mapPublishError(bad)).toBe('descriptor_missing'); + }); + }); + + describe('when mapping unknown errors and non-PackmindError values', () => { + it('maps a plain Error to other', () => { + expect(mapPublishError(new Error('network down'))).toBe('other'); + }); + + it('maps undefined to other', () => { + expect(mapPublishError(undefined)).toBe('other'); + }); + + it('maps an unrecognised PackmindError status to other', () => { + expect( + mapPublishError(new PackmindError(buildServerError(500, 'boom'))), + ).toBe('other'); + }); + }); +}); diff --git a/apps/frontend/src/domain/deployments/api/queries/useMarketplacePublishMutation.ts b/apps/frontend/src/domain/deployments/api/queries/useMarketplacePublishMutation.ts new file mode 100644 index 000000000..ecf46ae2b --- /dev/null +++ b/apps/frontend/src/domain/deployments/api/queries/useMarketplacePublishMutation.ts @@ -0,0 +1,99 @@ +import { useMutation } from '@tanstack/react-query'; +import { + MarketplaceId, + OrganizationId, + PackageId, + PublishFailureReason, + PublishPackageOnMarketplaceResponse, +} from '@packmind/types'; +import { + isPackmindError, + PackmindError, +} from '../../../../services/api/errors/PackmindError'; +import { deploymentsGateways } from '../gateways'; +import type { IDeploymentsGateway } from '../gateways/IDeploymentsGateway'; + +/** + * Variables passed to the marketplace publish mutation. We accept the + * organization id explicitly rather than reading it from auth context inside + * the hook, so route loaders and components can both call this without + * surprises. + */ +export type MarketplacePublishMutationVariables = { + organizationId: OrganizationId; + marketplaceId: MarketplaceId; + packageId: PackageId; +}; + +/** + * The verbatim text the spec requires when the Git provider token has + * expired. Echoed exactly by the toast layer; never echoes the token + * itself. + */ +export const INVALID_TOKEN_MESSAGE = + 'The package could not be published. Reason: Invalid or expired Git token.'; + +/** + * Maps the server failure to a `PublishFailureReason` the UI can branch on. + * + * Backend → HTTP mapping: + * - `MarketplacePluginNameConflictError` → 409 + * - `GitProviderTokenInvalidError` → 400 + body contains + * "Invalid or expired Git token" + * - `MarketplaceDescriptorBadFormatError` → 400 + * - `MarketplaceDescriptorNotFoundError` → 400 + * - other 400 / 5xx → 'other' + */ +export const mapPublishError = (error: unknown): PublishFailureReason => { + if (!isPackmindError(error)) { + return 'other'; + } + + const { status, data } = error.serverError; + const message = data?.message ?? ''; + + if (status === 409) { + return 'name_conflict_unmanaged'; + } + + if (status === 400) { + if (message.includes('Invalid or expired Git token')) { + return 'invalid_token'; + } + // Both descriptor-not-found and descriptor-bad-format map to the same + // category from the user's perspective: the marketplace.json is broken. + return 'descriptor_missing'; + } + + return 'other'; +}; + +/** + * TanStack mutation hook that publishes a Packmind package as a managed + * plugin on a linked marketplace. + * + * The hook stays gateway-agnostic so tests can inject a stub. Production + * code uses the default `deploymentsGateways` singleton. + * + * Cache invalidation is intentionally limited: the publish endpoint is + * write-only and the polling helper (`marketplaceDistributionQueries.byId`) + * is consumed by the modal after success, so we don't blast the + * marketplaces list on every click. Callers that need to refresh the + * marketplaces list (e.g. to bump `pluginCount`) can do so once the + * background job emits `PluginPublishedEvent`. + */ +export const useMarketplacePublishMutation = ({ + gateway = deploymentsGateways, +}: { + gateway?: Pick<IDeploymentsGateway, 'publishPackageOnMarketplace'>; +} = {}) => + useMutation< + PublishPackageOnMarketplaceResponse, + PackmindError | Error, + MarketplacePublishMutationVariables + >({ + mutationFn: (variables) => + gateway.publishPackageOnMarketplace( + variables, + ) as Promise<PublishPackageOnMarketplaceResponse>, + }); diff --git a/apps/frontend/src/domain/deployments/api/queryKeys.ts b/apps/frontend/src/domain/deployments/api/queryKeys.ts index 51a65573c..dd1be932f 100644 --- a/apps/frontend/src/domain/deployments/api/queryKeys.ts +++ b/apps/frontend/src/domain/deployments/api/queryKeys.ts @@ -12,6 +12,7 @@ export enum DeploymentQueryKeys { LIST_ACTIVE_DISTRIBUTED_PACKAGES_BY_SPACE = 'list-active-distributed-packages-by-space', LIST_DRIFTED_PACKAGES_BY_ORG = 'list-drifted-packages-by-org', GET_PACKAGE_BY_ID = 'get-package-by-id', + GET_PACKAGE_SUMMARY = 'get-package-summary', UPDATE_PACKAGE = 'update-package', REMOVE_PACKAGE_FROM_TARGETS = 'remove-package-from-targets', GET_TARGETS_BY_GIT_REPO = 'get-targets-by-git-repo', @@ -101,6 +102,12 @@ export const GET_PACKAGE_BY_ID_KEY = [ DeploymentQueryKeys.GET_PACKAGE_BY_ID, ] as const; +export const GET_PACKAGE_SUMMARY_KEY = [ + ORGANIZATION_QUERY_SCOPE, + DEPLOYMENTS_QUERY_SCOPE, + DeploymentQueryKeys.GET_PACKAGE_SUMMARY, +] as const; + export const UPDATE_PACKAGE_MUTATION_KEY = [ ORGANIZATION_QUERY_SCOPE, DEPLOYMENTS_QUERY_SCOPE, diff --git a/apps/frontend/src/domain/deployments/components/MarketplacePublishCompletedSubscription.tsx b/apps/frontend/src/domain/deployments/components/MarketplacePublishCompletedSubscription.tsx new file mode 100644 index 000000000..c580a365a --- /dev/null +++ b/apps/frontend/src/domain/deployments/components/MarketplacePublishCompletedSubscription.tsx @@ -0,0 +1,185 @@ +import { useCallback } from 'react'; +import { pmToaster } from '@packmind/ui'; +import type { + MarketplaceId, + MarketplacePublishCompletedEvent, + OrganizationId, + PublishFailureReason, +} from '@packmind/types'; +import { useSSESubscription } from '../../sse'; +import { useAuthContext } from '../../accounts/hooks'; +import { marketplaceGateway } from '../../marketplaces/api/gateways'; +import { + marketplaceQueryKeys, + patchMarketplaceInCache, +} from '../../marketplaces/api/queries/MarketplaceQueries'; +import { queryClient } from '../../../shared/data/queryClient'; + +type MarketplacePublishCompletedPayload = + MarketplacePublishCompletedEvent['data']; + +/** + * Reconciles the marketplace right after a publish-job notification so the + * detail/list views surface the freshly opened sync PR and the new + * pending_merge distribution row without waiting for the next scheduled + * sweep or a manual "Sync now" click. Best-effort: any failure logs and is + * swallowed — the toast already informed the user. + */ +async function refreshMarketplaceAfterPublish( + organizationId: OrganizationId | string, + marketplaceId: MarketplaceId, +): Promise<void> { + try { + const result = await marketplaceGateway.syncMarketplaceNow( + organizationId as OrganizationId, + marketplaceId, + ); + patchMarketplaceInCache(queryClient, organizationId, marketplaceId, result); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.distributionList( + organizationId, + marketplaceId, + ), + }), + queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.distributionChangesForMarketplace( + organizationId, + marketplaceId, + ), + }), + result.state === 'drift' + ? queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.list(organizationId), + }) + : Promise.resolve(), + ]); + } catch (error) { + console.error('Failed to refresh marketplace after publish', { + error, + marketplaceId, + }); + } +} + +const FAILURE_TOAST_MESSAGES: Record<PublishFailureReason, string> = { + invalid_token: + 'The package could not be published. Reason: Invalid or expired Git token.', + name_conflict_unmanaged: + 'The package could not be published. Reason: another plugin with the same name already exists on this marketplace.', + descriptor_missing: + 'The package could not be published. Reason: the marketplace descriptor (marketplace.json) is missing or malformed.', + other: + 'The package could not be published. Please try again — if the problem persists, contact an organization admin.', +}; + +/** + * Mounted at the app root for authenticated users. Subscribes to the per-user + * MARKETPLACE_PUBLISH_COMPLETED SSE event so a toast surfaces the terminal + * outcome of the background publish job — with a "View pull request" action + * whenever the rolling PR URL is known. + */ +export function MarketplacePublishCompletedSubscription(): null { + const { isAuthenticated, organization } = useAuthContext(); + const organizationId = organization?.id; + + const handleEvent = useCallback( + (event: MessageEvent) => { + if (event.type && event.type !== 'MARKETPLACE_PUBLISH_COMPLETED') { + return; + } + + // The SSE service writes `data: ${JSON.stringify(event.data)}`, so the + // payload reaching the EventSource handler is already the inner event + // data — not the full envelope. + let payload: MarketplacePublishCompletedPayload; + try { + payload = JSON.parse(event.data) as MarketplacePublishCompletedPayload; + } catch (error) { + console.error('SSE: Failed to parse MARKETPLACE_PUBLISH_COMPLETED', { + error, + raw: event.data, + }); + return; + } + + const { + status, + prUrl, + packageName, + marketplaceName, + pluginSlug, + failureReason, + marketplaceId, + } = payload; + + // Refresh the cached marketplace state for any open detail / list view. + // The publish job has just updated the distribution row (status, + // contentHash, fingerprint, prUrl); kicking a reconcile here brings the + // marketplace's `pendingPrUrl` and `outdatedPluginSlugs` back in line. + if ( + organizationId && + marketplaceId && + (status === 'success' || status === 'no_changes') + ) { + void refreshMarketplaceAfterPublish( + organizationId, + marketplaceId as MarketplaceId, + ); + } + + const labelledPackage = packageName ? `“${packageName}”` : 'the package'; + const labelledMarketplace = marketplaceName + ? `“${marketplaceName}”` + : 'the marketplace'; + const labelledPlugin = pluginSlug ? `“${pluginSlug}”` : 'the plugin'; + + const prAction = prUrl + ? { + label: 'View pull request', + onClick: () => { + window.open(prUrl, '_blank', 'noopener,noreferrer'); + }, + } + : undefined; + + if (status === 'success') { + // "success" means the publish job completed: the plugin landed on the + // rolling sync PR. It is NOT live until that PR is merged on the + // marketplace repo — the copy must not overclaim. + pmToaster.success({ + title: 'Publish submitted for review', + description: `${labelledPackage} was submitted as ${labelledPlugin} to ${labelledMarketplace} — it goes live once the sync pull request is merged.`, + action: prAction, + }); + return; + } + + if (status === 'no_changes') { + pmToaster.info({ + title: 'Nothing new to publish', + description: `${labelledPackage} is already up to date on ${labelledMarketplace}.`, + action: prAction, + }); + return; + } + + pmToaster.error({ + title: 'Publish failed', + description: + FAILURE_TOAST_MESSAGES[failureReason ?? 'other'] ?? + FAILURE_TOAST_MESSAGES.other, + }); + }, + [organizationId], + ); + + useSSESubscription({ + eventType: 'MARKETPLACE_PUBLISH_COMPLETED', + params: [], + onEvent: handleEvent, + enabled: isAuthenticated, + }); + + return null; +} diff --git a/apps/frontend/src/domain/deployments/components/PackageArtifactsTable/PackageArtifactsTable.tsx b/apps/frontend/src/domain/deployments/components/PackageArtifactsTable/PackageArtifactsTable.tsx index 2cea75a6f..5f9b82a02 100644 --- a/apps/frontend/src/domain/deployments/components/PackageArtifactsTable/PackageArtifactsTable.tsx +++ b/apps/frontend/src/domain/deployments/components/PackageArtifactsTable/PackageArtifactsTable.tsx @@ -66,10 +66,22 @@ const DISTRIBUTION_STATUS_BADGE: Record< label: 'In progress', colorPalette: 'blue', }, + [DistributionStatus.pending_merge]: { + label: 'Pending PR review', + colorPalette: 'blue', + }, [DistributionStatus.no_changes]: { label: 'No changes', colorPalette: 'gray', }, + [DistributionStatus.to_be_removed]: { + label: 'To be removed', + colorPalette: 'gray', + }, + [DistributionStatus.removed]: { + label: 'Removed', + colorPalette: 'gray', + }, }; type Row = { diff --git a/apps/frontend/src/domain/deployments/components/PackageDeployments/DeployPackageButton.spec.tsx b/apps/frontend/src/domain/deployments/components/PackageDeployments/DeployPackageButton.spec.tsx new file mode 100644 index 000000000..75f75939e --- /dev/null +++ b/apps/frontend/src/domain/deployments/components/PackageDeployments/DeployPackageButton.spec.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { UIProvider } from '@packmind/ui'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { Package, PackageId, SpaceId } from '@packmind/types'; +import { DeployPackageButton } from './DeployPackageButton'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; + +jest.mock('../../../accounts/hooks/useAuthContext', () => ({ + useAuthContext: jest.fn(), +})); + +// The Distribute > "To code repositories" path embeds the existing +// RunDistribution composition which itself fans out to a bunch of TanStack +// queries (targets, providers, render-mode config…). We don't exercise that +// modal here — task 3.3 only cares about the menu surface and its feature- +// flag gate, so we stub it to a marker element. +jest.mock('../RunDistribution/RunDistribution', () => ({ + RunDistribution: Object.assign(() => <div data-testid="run-distribution" />, { + Body: () => null, + Cta: () => null, + }), +})); + +jest.mock('../RunMarketplacePublish/RunMarketplacePublish', () => ({ + RunMarketplacePublish: ({ open }: { open: boolean }) => + open ? <div data-testid="run-marketplace-publish" /> : null, +})); + +const mockedUseAuthContext = useAuthContext as jest.MockedFunction< + typeof useAuthContext +>; + +const fakePackage: Package = { + id: 'pkg-1' as PackageId, + spaceId: 'space-1' as SpaceId, + slug: 'pkg-1', + name: 'Pkg 1', + description: '', + recipes: [], + standards: [], + skills: [], + // Created/updated timestamps below are not consumed by the button — these + // are filler values so the type-checker is happy in test fixtures. +} as unknown as Package; + +const renderButton = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + <UIProvider> + <QueryClientProvider client={queryClient}> + <DeployPackageButton selectedPackages={[fakePackage]} /> + </QueryClientProvider> + </UIProvider>, + ); +}; + +describe('DeployPackageButton', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when the marketplace feature flag is off', () => { + beforeEach(() => { + mockedUseAuthContext.mockReturnValue({ + user: { id: 'u-1', email: 'someone@example.com' }, + } as unknown as ReturnType<typeof useAuthContext>); + }); + + it('renders the historical single-button entry without the marketplace channel', () => { + renderButton(); + + // The standalone trigger button is rendered and visible. + expect( + screen.getByRole('button', { name: 'Distribute' }), + ).toBeInTheDocument(); + + // No menu options are exposed when the flag is off. + expect( + screen.queryByRole('menuitem', { name: 'To code repositories' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('menuitem', { name: 'To marketplaces' }), + ).not.toBeInTheDocument(); + }); + }); + + describe('when the marketplace feature flag is on', () => { + beforeEach(() => { + mockedUseAuthContext.mockReturnValue({ + user: { id: 'u-1', email: 'someone@packmind.com' }, + } as unknown as ReturnType<typeof useAuthContext>); + }); + + it('exposes both menu entries on click', async () => { + renderButton(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Distribute/ })); + }); + + // Chakra Menu renders items only after open; using findByText avoids + // role-locator brittleness across PM/Chakra versions. + expect( + await screen.findByText('To code repositories'), + ).toBeInTheDocument(); + expect(await screen.findByText('To marketplaces')).toBeInTheDocument(); + }); + + it('opens the marketplace publish modal when the second entry is clicked', async () => { + renderButton(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Distribute/ })); + }); + + const marketplaceItem = await screen.findByText('To marketplaces'); + await act(async () => { + fireEvent.click(marketplaceItem); + }); + + expect( + await screen.findByTestId('run-marketplace-publish'), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/frontend/src/domain/deployments/components/PackageDeployments/DeployPackageButton.tsx b/apps/frontend/src/domain/deployments/components/PackageDeployments/DeployPackageButton.tsx index 36f095392..689b990f6 100644 --- a/apps/frontend/src/domain/deployments/components/PackageDeployments/DeployPackageButton.tsx +++ b/apps/frontend/src/domain/deployments/components/PackageDeployments/DeployPackageButton.tsx @@ -1,14 +1,21 @@ -import React from 'react'; +import React, { useState } from 'react'; import { + DEFAULT_FEATURE_DOMAIN_MAP, + MARKETPLACES_FEATURE_KEY, PMButton, - PMDialog, - PMPortal, PMCloseButton, + PMDialog, PMHeading, + PMMenu, + PMPortal, + isFeatureFlagEnabled, pmToaster, } from '@packmind/ui'; +import { LuChevronDown } from 'react-icons/lu'; import { RunDistribution } from '../RunDistribution/RunDistribution'; +import { RunMarketplacePublish } from '../RunMarketplacePublish/RunMarketplacePublish'; import { Package } from '@packmind/types'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; import { createPackagesDeploymentNotifications } from '../../utils/deploymentNotificationUtils'; export interface DeployPackageButtonProps { @@ -19,6 +26,22 @@ export interface DeployPackageButtonProps { selectedPackages: Package[]; } +const MENU_VALUE_CODE_REPOSITORIES = 'code-repositories' as const; +const MENU_VALUE_MARKETPLACES = 'marketplaces' as const; + +/** + * Distribute action surfaced on `PackagesPage` and `PackageDetailPage`. + * + * Originally a single button opening the "Distribute to targets" modal; now a + * `PMMenu` exposing two channels: + * - "To code repositories" — keeps the existing `RunDistribution` modal, + * no behavior change. + * - "To marketplaces" — opens the new `RunMarketplacePublish` modal. + * + * The "To marketplaces" entry is gated behind the existing marketplace + * feature flag (`MARKETPLACES_FEATURE_KEY`) so non-internal users keep + * seeing the historic single-channel UX. + */ export const DeployPackageButton: React.FC<DeployPackageButtonProps> = ({ label = 'Distribute', disabled = false, @@ -26,68 +49,142 @@ export const DeployPackageButton: React.FC<DeployPackageButtonProps> = ({ variant = 'primary', selectedPackages, }) => { + const { user } = useAuthContext(); + const canSeeMarketplaces = isFeatureFlagEnabled({ + featureKeys: [MARKETPLACES_FEATURE_KEY], + featureDomainMap: DEFAULT_FEATURE_DOMAIN_MAP, + userEmail: user?.email, + }); + + const [isCodeRepoOpen, setCodeRepoOpen] = useState(false); + const [isMarketplaceOpen, setMarketplaceOpen] = useState(false); + + // When the marketplace flag is off there is only one channel — keep the + // historical UX (single button opening the targets modal) so non-internal + // users see no visual regression. + if (!canSeeMarketplaces) { + return ( + <PMDialog.Root + open={isCodeRepoOpen} + onOpenChange={(details) => setCodeRepoOpen(details.open)} + size="md" + placement="center" + motionPreset="slide-in-bottom" + scrollBehavior={'outside'} + > + <PMDialog.Trigger asChild> + <PMButton size={size} variant={variant} disabled={disabled}> + {label} + </PMButton> + </PMDialog.Trigger> + <CodeRepositoryDialogContents + selectedPackages={selectedPackages} + onClose={() => setCodeRepoOpen(false)} + /> + </PMDialog.Root> + ); + } + return ( - <PMDialog.Root - size="md" - placement="center" - motionPreset="slide-in-bottom" - scrollBehavior={'outside'} - > - <PMDialog.Trigger asChild> - <PMButton size={size} variant={variant} disabled={disabled}> - {label} - </PMButton> - </PMDialog.Trigger> - <PMPortal> - <PMDialog.Backdrop /> - <PMDialog.Positioner> - <PMDialog.Content> - <PMDialog.Context> - {(store) => ( - <RunDistribution - selectedRecipes={[]} - selectedStandards={[]} - selectedPackages={selectedPackages} - onDistributionComplete={(deploymentResults) => { - store.setOpen(false); + <> + <PMMenu.Root> + <PMMenu.Trigger asChild> + <PMButton size={size} variant={variant} disabled={disabled}> + {label} + <LuChevronDown aria-hidden /> + </PMButton> + </PMMenu.Trigger> + <PMPortal> + <PMMenu.Positioner> + <PMMenu.Content> + <PMMenu.Item + value={MENU_VALUE_CODE_REPOSITORIES} + cursor={'pointer'} + onClick={() => setCodeRepoOpen(true)} + > + To code repositories + </PMMenu.Item> + <PMMenu.Item + value={MENU_VALUE_MARKETPLACES} + cursor={'pointer'} + onClick={() => setMarketplaceOpen(true)} + > + To marketplaces + </PMMenu.Item> + </PMMenu.Content> + </PMMenu.Positioner> + </PMPortal> + </PMMenu.Root> - const notifications = - createPackagesDeploymentNotifications(deploymentResults); + <PMDialog.Root + open={isCodeRepoOpen} + onOpenChange={(details) => setCodeRepoOpen(details.open)} + size="md" + placement="center" + motionPreset="slide-in-bottom" + scrollBehavior={'outside'} + > + <CodeRepositoryDialogContents + selectedPackages={selectedPackages} + onClose={() => setCodeRepoOpen(false)} + /> + </PMDialog.Root> - notifications.forEach((notification) => { - pmToaster.create({ - type: notification.type, - title: notification.title, - description: notification.description, - }); - }); - }} - > - <PMDialog.Header> - <PMDialog.Title asChild> - <PMHeading level="h2">Distribute to targets</PMHeading> - </PMDialog.Title> - <PMDialog.CloseTrigger asChild> - <PMCloseButton size="sm" /> - </PMDialog.CloseTrigger> - </PMDialog.Header> - <PMDialog.Body> - <RunDistribution.Body /> - </PMDialog.Body> - <PMDialog.Footer> - <PMDialog.Trigger asChild> - <PMButton variant="tertiary" size="sm"> - Cancel - </PMButton> - </PMDialog.Trigger> - <RunDistribution.Cta /> - </PMDialog.Footer> - </RunDistribution> - )} - </PMDialog.Context> - </PMDialog.Content> - </PMDialog.Positioner> - </PMPortal> - </PMDialog.Root> + <RunMarketplacePublish + open={isMarketplaceOpen} + onOpenChange={setMarketplaceOpen} + selectedPackages={selectedPackages} + /> + </> ); }; + +const CodeRepositoryDialogContents: React.FC<{ + selectedPackages: Package[]; + onClose: () => void; +}> = ({ selectedPackages, onClose }) => ( + <PMPortal> + <PMDialog.Backdrop /> + <PMDialog.Positioner> + <PMDialog.Content> + <RunDistribution + selectedRecipes={[]} + selectedStandards={[]} + selectedPackages={selectedPackages} + onDistributionComplete={(deploymentResults) => { + onClose(); + + const notifications = + createPackagesDeploymentNotifications(deploymentResults); + + notifications.forEach((notification) => { + pmToaster.create({ + type: notification.type, + title: notification.title, + description: notification.description, + }); + }); + }} + > + <PMDialog.Header> + <PMDialog.Title asChild> + <PMHeading level="h2">Distribute to targets</PMHeading> + </PMDialog.Title> + <PMDialog.CloseTrigger asChild> + <PMCloseButton size="sm" /> + </PMDialog.CloseTrigger> + </PMDialog.Header> + <PMDialog.Body> + <RunDistribution.Body /> + </PMDialog.Body> + <PMDialog.Footer> + <PMButton variant="tertiary" size="sm" onClick={onClose}> + Cancel + </PMButton> + <RunDistribution.Cta /> + </PMDialog.Footer> + </RunDistribution> + </PMDialog.Content> + </PMDialog.Positioner> + </PMPortal> +); diff --git a/apps/frontend/src/domain/deployments/components/PackageDetails/PackageDetails.tsx b/apps/frontend/src/domain/deployments/components/PackageDetails/PackageDetails.tsx index f85a31b20..b45784b60 100644 --- a/apps/frontend/src/domain/deployments/components/PackageDetails/PackageDetails.tsx +++ b/apps/frontend/src/domain/deployments/components/PackageDetails/PackageDetails.tsx @@ -1,10 +1,13 @@ import React, { useMemo } from 'react'; import { + DEFAULT_FEATURE_DOMAIN_MAP, + MARKETPLACE_PLUGIN_REMOVAL_FEATURE_KEY, PMPage, PMText, PMBox, PMVStack, PMAlert, + PMFeatureFlag, PMSpinner, PMHeading, PMHStack, @@ -39,6 +42,7 @@ import { PACKAGE_MESSAGES } from '../../constants/messages'; import { DeployPackageButton } from '../PackageDeployments/DeployPackageButton'; import { RemovePackageFromTargetsButton } from '../RemovePackageFromTargets'; import { PackageDistributionList } from '../PackageDistributionList'; +import { PackageMarketplaceDistributions } from '../PackageMarketplaceDistributions'; import { CopiableTextField } from '../../../../shared/components/inputs/CopiableTextField'; import { useState } from 'react'; @@ -54,7 +58,7 @@ export const PackageDetails = ({ spaceSlug, }: PackageDetailsProps) => { const navigate = useNavigate(); - const { organization } = useAuthContext(); + const { organization, user } = useAuthContext(); const { spaceId } = useCurrentSpace(); const [searchParams] = useSearchParams(); const tabParam = searchParams.get('tab'); @@ -460,7 +464,21 @@ export const PackageDetails = ({ triggerLabel: 'Distributions', content: ( <PMBox pt={4}> - <PackageDistributionList packageId={id} /> + <PMVStack align="stretch" gap={6}> + <PackageDistributionList packageId={id} /> + <PMFeatureFlag + featureKeys={[MARKETPLACE_PLUGIN_REMOVAL_FEATURE_KEY]} + featureDomainMap={DEFAULT_FEATURE_DOMAIN_MAP} + userEmail={user?.email} + > + {organization?.id && ( + <PackageMarketplaceDistributions + organizationId={organization.id} + packageId={id} + /> + )} + </PMFeatureFlag> + </PMVStack> </PMBox> ), }, diff --git a/apps/frontend/src/domain/deployments/components/PackageMarketplaceDistributions.spec.tsx b/apps/frontend/src/domain/deployments/components/PackageMarketplaceDistributions.spec.tsx new file mode 100644 index 000000000..59e684d89 --- /dev/null +++ b/apps/frontend/src/domain/deployments/components/PackageMarketplaceDistributions.spec.tsx @@ -0,0 +1,177 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { UIProvider } from '@packmind/ui'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { DistributionStatus } from '@packmind/types'; +import type { + MarketplaceDistributionId, + MarketplaceDistributionListItem, + MarketplaceId, + MarketplaceListItem, + OrganizationId, + PackageId, +} from '@packmind/types'; +import { PackageMarketplaceDistributions } from './PackageMarketplaceDistributions'; + +const orgId = 'org-1' as OrganizationId; +const packageId = 'pkg-1' as PackageId; + +const useMarketplacesMock = jest.fn(); +const useMarketplaceDistributionsMock = jest.fn(); +const useMarkPluginForRemovalByPackageMock = jest.fn(); + +jest.mock('../../marketplaces/api/queries', () => ({ + useMarketplaces: (...args: unknown[]) => useMarketplacesMock(...args), + useMarketplaceDistributions: (...args: unknown[]) => + useMarketplaceDistributionsMock(...args), + useMarkPluginForRemovalByPackage: (...args: unknown[]) => + useMarkPluginForRemovalByPackageMock(...args), +})); + +function makeMarketplace( + overrides: Partial<MarketplaceListItem> = {}, +): MarketplaceListItem { + return { + id: 'mkt-1' as MarketplaceId, + organizationId: orgId, + gitRepoId: 'repo-1' as MarketplaceListItem['gitRepoId'], + name: 'Acme Playbook', + vendor: 'anthropic', + addedBy: 'user-1' as MarketplaceListItem['addedBy'], + linkedAt: new Date('2026-04-01T00:00:00Z'), + state: 'healthy', + lastValidatedAt: null, + descriptor: { + vendor: 'anthropic', + name: 'Acme Playbook', + plugins: [], + raw: {}, + }, + pluginCount: 1, + createdAt: new Date('2026-04-01T00:00:00Z'), + updatedAt: new Date('2026-04-01T00:00:00Z'), + deletedAt: null, + addedByUserName: 'Jane', + ...overrides, + }; +} + +function makeDistribution( + overrides: Partial<MarketplaceDistributionListItem> = {}, +): MarketplaceDistributionListItem { + return { + id: 'dist-1' as MarketplaceDistributionId, + organizationId: orgId, + marketplaceId: 'mkt-1' as MarketplaceId, + packageId, + pluginSlug: 'my-plugin', + authorId: 'user-1' as MarketplaceDistributionListItem['authorId'], + status: DistributionStatus.success, + source: 'manual' as MarketplaceDistributionListItem['source'], + createdAt: new Date('2026-04-01T00:00:00Z'), + updatedAt: new Date('2026-04-01T00:00:00Z'), + deletedAt: null, + packageName: 'My Package', + authorName: 'Jane', + ...overrides, + }; +} + +function renderComponent() { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + <UIProvider> + <QueryClientProvider client={client}> + <PackageMarketplaceDistributions + organizationId={orgId} + packageId={packageId} + /> + </QueryClientProvider> + </UIProvider>, + ); +} + +describe('PackageMarketplaceDistributions', () => { + beforeEach(() => { + useMarkPluginForRemovalByPackageMock.mockReturnValue({ + mutate: jest.fn(), + isPending: false, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('shows an empty state when the org has no marketplaces', () => { + useMarketplacesMock.mockReturnValue({ data: [], isLoading: false }); + + renderComponent(); + + expect( + screen.getByText('Not published to any marketplace'), + ).toBeInTheDocument(); + }); + + it('renders a row for each marketplace where the package is published', () => { + useMarketplacesMock.mockReturnValue({ + data: [makeMarketplace()], + isLoading: false, + }); + useMarketplaceDistributionsMock.mockReturnValue({ + data: [makeDistribution()], + isLoading: false, + }); + + renderComponent(); + + expect( + screen.getByTestId('package-marketplace-row-mkt-1'), + ).toBeInTheDocument(); + expect(screen.getByText('Acme Playbook')).toBeInTheDocument(); + }); + + it('shows a pending-merge distribution with its badge and a Remove action', () => { + useMarketplacesMock.mockReturnValue({ + data: [makeMarketplace()], + isLoading: false, + }); + useMarketplaceDistributionsMock.mockReturnValue({ + data: [makeDistribution({ status: DistributionStatus.pending_merge })], + isLoading: false, + }); + + renderComponent(); + + expect( + screen.getByTestId('package-marketplace-row-mkt-1'), + ).toBeInTheDocument(); + expect( + screen.getByTestId( + `distribution-status-badge-${DistributionStatus.pending_merge}`, + ), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Remove/i })).toBeInTheDocument(); + }); + + it('skips marketplaces where the package has no active distribution', () => { + useMarketplacesMock.mockReturnValue({ + data: [makeMarketplace()], + isLoading: false, + }); + useMarketplaceDistributionsMock.mockReturnValue({ + data: [], + isLoading: false, + }); + + renderComponent(); + + expect(screen.queryByTestId('package-marketplace-row-mkt-1')).toBeNull(); + }); +}); diff --git a/apps/frontend/src/domain/deployments/components/PackageMarketplaceDistributions.tsx b/apps/frontend/src/domain/deployments/components/PackageMarketplaceDistributions.tsx new file mode 100644 index 000000000..be337d639 --- /dev/null +++ b/apps/frontend/src/domain/deployments/components/PackageMarketplaceDistributions.tsx @@ -0,0 +1,149 @@ +import { useMemo } from 'react'; +import { + PMBox, + PMEmptyState, + PMHeading, + PMHStack, + PMSpinner, + PMText, + PMVStack, +} from '@packmind/ui'; +import { + DistributionStatus, + type MarketplaceListItem, + type OrganizationId, + type PackageId, +} from '@packmind/types'; +import { + useMarketplaceDistributions, + useMarketplaces, + useMarkPluginForRemovalByPackage, +} from '../../marketplaces/api/queries'; +import { + DistributionStatusBadge, + RemovePluginButton, +} from '../../marketplaces/components'; + +export interface PackageMarketplaceDistributionsProps { + organizationId: OrganizationId | string; + packageId: PackageId; +} + +/** + * Lists the marketplaces this Packmind package is currently published to and + * offers a per-marketplace "Remove" affordance backed by + * `useMarkPluginForRemovalByPackage`. + * + * Renders nothing-ish when the package has no marketplace distributions so it + * can be embedded directly on the package details page without forcing layout + * decisions on the caller. + */ +export const PackageMarketplaceDistributions = ({ + organizationId, + packageId, +}: Readonly<PackageMarketplaceDistributionsProps>) => { + const { data: marketplaces, isLoading } = useMarketplaces(organizationId); + + if (isLoading) { + return ( + <PMEmptyState + icon={<PMSpinner />} + title="Loading marketplaces" + description="Checking which marketplaces this package is published to." + /> + ); + } + + const items = marketplaces ?? []; + + if (items.length === 0) { + return ( + <PMEmptyState + title="Not published to any marketplace" + description="This package has not been published to a marketplace yet." + /> + ); + } + + return ( + <PMVStack align="stretch" gap={3}> + <PMHeading size="md">Marketplace distributions</PMHeading> + {items.map((marketplace) => ( + <PackageMarketplaceRow + key={marketplace.id} + marketplace={marketplace} + organizationId={organizationId} + packageId={packageId} + /> + ))} + </PMVStack> + ); +}; + +interface PackageMarketplaceRowProps { + marketplace: MarketplaceListItem; + organizationId: OrganizationId | string; + packageId: PackageId; +} + +const PackageMarketplaceRow = ({ + marketplace, + organizationId, + packageId, +}: PackageMarketplaceRowProps) => { + const { data: distributions } = useMarketplaceDistributions( + organizationId, + marketplace.id, + ); + const markMutation = useMarkPluginForRemovalByPackage( + organizationId, + marketplace.id, + ); + + const relevantDistribution = useMemo( + () => + distributions?.find( + (d) => + d.packageId === packageId && + (d.status === DistributionStatus.success || + d.status === DistributionStatus.pending_merge || + d.status === DistributionStatus.to_be_removed), + ), + [distributions, packageId], + ); + + if (!relevantDistribution) { + return null; + } + + return ( + <PMBox + borderWidth="1px" + borderRadius="md" + p={3} + data-testid={`package-marketplace-row-${marketplace.id}`} + > + <PMHStack justify="space-between" align="center" gap={4}> + <PMVStack align="start" gap={0}> + <PMText variant="body-important">{marketplace.name}</PMText> + <PMText variant="small" color="faded"> + {relevantDistribution.pluginSlug} + </PMText> + </PMVStack> + <PMHStack gap={3} align="center"> + <DistributionStatusBadge status={relevantDistribution.status} /> + {(relevantDistribution.status === DistributionStatus.success || + relevantDistribution.status === + DistributionStatus.pending_merge) && ( + <RemovePluginButton + pluginSlug={relevantDistribution.pluginSlug} + marketplaceName={marketplace.name} + onMark={() => markMutation.mutate(packageId)} + isMarking={markMutation.isPending} + /> + )} + </PMHStack> + </PMHStack> + </PMBox> + ); +}; diff --git a/apps/frontend/src/domain/deployments/components/RunMarketplacePublish/MarketplaceListEmptyState.tsx b/apps/frontend/src/domain/deployments/components/RunMarketplacePublish/MarketplaceListEmptyState.tsx new file mode 100644 index 000000000..0ecfea1d3 --- /dev/null +++ b/apps/frontend/src/domain/deployments/components/RunMarketplacePublish/MarketplaceListEmptyState.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { PMEmptyState } from '@packmind/ui'; + +/** + * Empty state surfaced inside the marketplace publish modal when the + * organization has no marketplaces linked yet. The accompanying CTA to link a + * marketplace lives in the Settings > Marketplaces page, so this state is + * intentionally informational only — we don't want to bury a deep link inside + * a modal that's reached via the "Distribute" menu. + */ +export const MarketplaceListEmptyState: React.FC = () => ( + <PMEmptyState + title="No marketplaces linked yet" + description="Ask an organization admin to link a marketplace in Settings > Marketplaces, then come back to publish your package." + /> +); diff --git a/apps/frontend/src/domain/deployments/components/RunMarketplacePublish/RunMarketplacePublish.spec.tsx b/apps/frontend/src/domain/deployments/components/RunMarketplacePublish/RunMarketplacePublish.spec.tsx new file mode 100644 index 000000000..f1240008c --- /dev/null +++ b/apps/frontend/src/domain/deployments/components/RunMarketplacePublish/RunMarketplacePublish.spec.tsx @@ -0,0 +1,639 @@ +import React from 'react'; +import { + render, + screen, + fireEvent, + act, + waitFor, +} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UIProvider, pmToaster } from '@packmind/ui'; +import type { + MarketplaceId, + MarketplaceListItem, + OrganizationId, + Package, + PackageId, + SpaceId, +} from '@packmind/types'; +import { RunMarketplacePublish } from './RunMarketplacePublish'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; +import { useMarketplaces } from '../../../marketplaces/api/queries/MarketplaceQueries'; +import { useMarketplacePublishMutation } from '../../api/queries/useMarketplacePublishMutation'; +import { + PackmindError, + type ServerErrorResponse, +} from '../../../../services/api/errors/PackmindError'; + +jest.mock('../../../accounts/hooks/useAuthContext', () => ({ + useAuthContext: jest.fn(), +})); + +jest.mock('../../../marketplaces/api/queries/MarketplaceQueries', () => ({ + useMarketplaces: jest.fn(), +})); + +jest.mock('../../api/queries/useMarketplacePublishMutation', () => { + const actual = jest.requireActual( + '../../api/queries/useMarketplacePublishMutation', + ); + return { + ...actual, + useMarketplacePublishMutation: jest.fn(), + }; +}); + +const successToast = jest + .spyOn(pmToaster, 'success') + .mockImplementation(() => ''); +const errorToast = jest.spyOn(pmToaster, 'error').mockImplementation(() => ''); +const warningToast = jest + .spyOn(pmToaster, 'warning') + .mockImplementation(() => ''); + +const mockedUseAuthContext = useAuthContext as jest.MockedFunction< + typeof useAuthContext +>; +const mockedUseMarketplaces = useMarketplaces as jest.MockedFunction< + typeof useMarketplaces +>; +const mockedUseMarketplacePublishMutation = + useMarketplacePublishMutation as jest.MockedFunction< + typeof useMarketplacePublishMutation + >; + +const organizationId = 'org-1' as OrganizationId; +const fakeMarketplaceId = 'mkt-1' as MarketplaceId; + +const fakeMarketplace = ( + overrides: Partial<MarketplaceListItem> = {}, +): MarketplaceListItem => + ({ + id: fakeMarketplaceId, + organizationId, + gitRepoId: 'repo-1', + name: 'Acme Playbook', + vendor: 'anthropic', + addedBy: 'user-1', + linkedAt: new Date('2026-04-01T10:00:00.000Z'), + state: 'healthy', + lastValidatedAt: new Date('2026-04-01T10:00:00.000Z'), + descriptor: { + vendor: 'anthropic', + name: 'Acme Playbook', + plugins: [], + raw: {}, + }, + pluginCount: 3, + createdAt: new Date('2026-04-01T10:00:00.000Z'), + updatedAt: new Date('2026-04-01T10:00:00.000Z'), + deletedAt: null, + addedByUserName: 'Jane Admin', + ...overrides, + }) as unknown as MarketplaceListItem; + +const buildPackage = (id: string, name: string): Package => + ({ + id: id as PackageId, + spaceId: 'space-1' as SpaceId, + slug: id, + name, + description: '', + recipes: [], + standards: [], + skills: [], + }) as unknown as Package; + +const fakePackage = buildPackage('pkg-1', 'Security checks'); + +const renderModal = ( + overrides: { open?: boolean; selectedPackages?: Package[] } = {}, +) => { + const onOpenChange = jest.fn(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + render( + <UIProvider> + <QueryClientProvider client={queryClient}> + <RunMarketplacePublish + open={overrides.open ?? true} + onOpenChange={onOpenChange} + selectedPackages={overrides.selectedPackages ?? [fakePackage]} + /> + </QueryClientProvider> + </UIProvider>, + ); + return { onOpenChange }; +}; + +const buildServerError = ( + status: number, + message: string, +): ServerErrorResponse => ({ + data: { message }, + status, + statusText: 'error', +}); + +const setMutation = ( + overrides: Partial<{ + mutateAsync: jest.Mock; + isPending: boolean; + reset: jest.Mock; + }> = {}, +) => { + const mutateAsync = overrides.mutateAsync ?? jest.fn(); + const reset = overrides.reset ?? jest.fn(); + mockedUseMarketplacePublishMutation.mockReturnValue({ + mutateAsync, + mutate: jest.fn(), + isPending: overrides.isPending ?? false, + reset, + } as unknown as ReturnType<typeof useMarketplacePublishMutation>); + return { mutateAsync, reset }; +}; + +const successResponseFor = (packageId: string, pluginSlug: string) => ({ + marketplaceDistributionId: `md-${packageId}`, + status: 'in_progress' as const, + marketplaceId: fakeMarketplaceId, + packageId: packageId as PackageId, + pluginSlug, +}); + +describe('RunMarketplacePublish', () => { + beforeEach(() => { + mockedUseAuthContext.mockReturnValue({ + organization: { id: organizationId }, + user: { id: 'u-1', email: 'someone@packmind.com' }, + } as unknown as ReturnType<typeof useAuthContext>); + mockedUseMarketplaces.mockReturnValue({ + data: [fakeMarketplace()], + isLoading: false, + isError: false, + } as unknown as ReturnType<typeof useMarketplaces>); + setMutation(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when marketplaces are loaded', () => { + it('renders the modal title', async () => { + renderModal(); + + expect( + await screen.findByRole('heading', { + name: 'Publish to a marketplace', + }), + ).toBeInTheDocument(); + }); + + it('renders the modal subtitle', () => { + renderModal(); + + expect( + screen.getByText(/Pick a linked marketplace/i), + ).toBeInTheDocument(); + }); + + it('renders the singular submit button label', () => { + renderModal(); + + expect( + screen.getByRole('button', { name: 'Publish' }), + ).toBeInTheDocument(); + }); + + it('renders the marketplace name', () => { + renderModal(); + + expect(screen.getByText('Acme Playbook')).toBeInTheDocument(); + }); + }); + + describe('when closed', () => { + it('does not enter an infinite render loop (regression)', () => { + // Use the REAL mutation hook so `publishMutation` gets a fresh object + // identity on every render — the production condition. The shared stub + // (`setMutation`) returns a stable object, so it never reproduced the + // render → reset() → render loop that the `[open, publishMutation]` + // dependency caused. Depending on the stable `reset` callback fixes it. + const { useMarketplacePublishMutation: realPublishMutation } = + jest.requireActual('../../api/queries/useMarketplacePublishMutation'); + mockedUseMarketplacePublishMutation.mockImplementation(() => + realPublishMutation(), + ); + + expect(() => renderModal({ open: false })).not.toThrow(); + }); + }); + + describe('when no marketplaces are linked', () => { + beforeEach(() => { + mockedUseMarketplaces.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + } as unknown as ReturnType<typeof useMarketplaces>); + }); + + it('shows the empty state', async () => { + renderModal(); + + expect( + await screen.findByText(/No marketplaces linked yet/i), + ).toBeInTheDocument(); + }); + + it('disables the submit button', () => { + renderModal(); + + expect(screen.getByRole('button', { name: 'Publish' })).toBeDisabled(); + }); + }); + + describe('when a single package succeeds', () => { + const setup = () => { + const mutateAsync = jest + .fn() + .mockResolvedValue(successResponseFor('pkg-1', 'security-checks')); + setMutation({ mutateAsync }); + const { onOpenChange } = renderModal(); + return { mutateAsync, onOpenChange }; + }; + + it('submits the publish mutation once', async () => { + const { mutateAsync } = setup(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Publish' })); + }); + + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledWith({ + organizationId, + marketplaceId: fakeMarketplaceId, + packageId: fakePackage.id, + }); + }); + }); + + it('surfaces the single-package success toast', async () => { + setup(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Publish' })); + }); + + await waitFor(() => { + expect(successToast).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Publish started' }), + ); + }); + }); + + it('closes the modal', async () => { + const { onOpenChange } = setup(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Publish' })); + }); + + await waitFor(() => { + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + }); + }); + + describe('when a single-package publish fails with invalid token', () => { + it('surfaces the verbatim invalid-token toast', async () => { + const mutateAsync = jest + .fn() + .mockRejectedValue( + new PackmindError( + buildServerError( + 400, + 'The package could not be published. Reason: Invalid or expired Git token.', + ), + ), + ); + setMutation({ mutateAsync }); + + renderModal(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Publish' })); + }); + + await waitFor(() => { + expect(errorToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Publish failed', + description: + 'The package could not be published. Reason: Invalid or expired Git token.', + }), + ); + }); + }); + }); + + describe('when a single-package publish fails with a 409 name conflict', () => { + it('surfaces the name-collision toast', async () => { + const mutateAsync = jest + .fn() + .mockRejectedValue( + new PackmindError(buildServerError(409, 'plugin name conflict')), + ); + setMutation({ mutateAsync }); + + renderModal(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Publish' })); + }); + + await waitFor(() => { + expect(errorToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Publish failed', + description: expect.stringContaining( + 'another plugin with the same name already exists', + ), + }), + ); + }); + }); + }); + + describe('when a single-package publish fails with a generic 400', () => { + it('surfaces the descriptor-bad-format toast', async () => { + const mutateAsync = jest + .fn() + .mockRejectedValue( + new PackmindError(buildServerError(400, 'descriptor bad format')), + ); + setMutation({ mutateAsync }); + + renderModal(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Publish' })); + }); + + await waitFor(() => { + expect(errorToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Publish failed', + description: expect.stringContaining( + 'marketplace descriptor (marketplace.json) is missing or malformed', + ), + }), + ); + }); + }); + }); + + describe('when multiple packages all succeed', () => { + const pkgA = buildPackage('pkg-a', 'Alpha'); + const pkgB = buildPackage('pkg-b', 'Bravo'); + const pkgC = buildPackage('pkg-c', 'Charlie'); + + const setup = () => { + const mutateAsync = jest + .fn() + .mockResolvedValueOnce(successResponseFor('pkg-a', 'alpha')) + .mockResolvedValueOnce(successResponseFor('pkg-b', 'bravo')) + .mockResolvedValueOnce(successResponseFor('pkg-c', 'charlie')); + setMutation({ mutateAsync }); + const { onOpenChange } = renderModal({ + selectedPackages: [pkgA, pkgB, pkgC], + }); + return { mutateAsync, onOpenChange }; + }; + + it('renders the plural submit label', () => { + setMutation(); + renderModal({ selectedPackages: [pkgA, pkgB, pkgC] }); + + expect( + screen.getByRole('button', { name: 'Publish 3 packages' }), + ).toBeInTheDocument(); + }); + + it('fires one mutateAsync call per selected package', async () => { + const { mutateAsync } = setup(); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'Publish 3 packages' }), + ); + }); + + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledTimes(3); + }); + }); + + it('surfaces the multi-success toast', async () => { + setup(); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'Publish 3 packages' }), + ); + }); + + await waitFor(() => { + expect(successToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Publishing started', + description: expect.stringContaining('3 packages to Acme Playbook'), + }), + ); + }); + }); + + it('closes the modal', async () => { + const { onOpenChange } = setup(); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'Publish 3 packages' }), + ); + }); + + await waitFor(() => { + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + }); + }); + + describe('when multiple packages partially fail', () => { + const pkgA = buildPackage('pkg-a', 'Alpha'); + const pkgB = buildPackage('pkg-b', 'Bravo'); + const pkgC = buildPackage('pkg-c', 'Charlie'); + + const setup = () => { + const mutateAsync = jest + .fn() + .mockResolvedValueOnce(successResponseFor('pkg-a', 'alpha')) + .mockRejectedValueOnce( + new PackmindError(buildServerError(409, 'plugin name conflict')), + ) + .mockRejectedValueOnce( + new PackmindError(buildServerError(400, 'descriptor bad format')), + ); + setMutation({ mutateAsync }); + const { onOpenChange } = renderModal({ + selectedPackages: [pkgA, pkgB, pkgC], + }); + return { mutateAsync, onOpenChange }; + }; + + it('surfaces a warning toast listing the failing package names', async () => { + setup(); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'Publish 3 packages' }), + ); + }); + + await waitFor(() => { + expect(warningToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Some publishes failed', + description: expect.stringContaining('“Bravo”, “Charlie”'), + }), + ); + }); + }); + + it('still closes the modal', async () => { + const { onOpenChange } = setup(); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'Publish 3 packages' }), + ); + }); + + await waitFor(() => { + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + }); + }); + + describe('when every multi-package publish fails', () => { + const pkgA = buildPackage('pkg-a', 'Alpha'); + const pkgB = buildPackage('pkg-b', 'Bravo'); + + const setup = () => { + const mutateAsync = jest + .fn() + .mockRejectedValueOnce( + new PackmindError(buildServerError(409, 'plugin name conflict')), + ) + .mockRejectedValueOnce( + new PackmindError(buildServerError(409, 'plugin name conflict')), + ); + setMutation({ mutateAsync }); + const { onOpenChange } = renderModal({ + selectedPackages: [pkgA, pkgB], + }); + return { mutateAsync, onOpenChange }; + }; + + it('surfaces the all-failed error toast with the dominant reason', async () => { + setup(); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'Publish 2 packages' }), + ); + }); + + await waitFor(() => { + expect(errorToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Publish failed', + description: expect.stringContaining( + 'another plugin with the same name already exists', + ), + }), + ); + }); + }); + + it('keeps the modal open', async () => { + const { onOpenChange } = setup(); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'Publish 2 packages' }), + ); + }); + + await waitFor(() => { + expect(errorToast).toHaveBeenCalled(); + }); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); + }); + + describe('when a multi-package batch is in flight', () => { + const pkgA = buildPackage('pkg-a', 'Alpha'); + const pkgB = buildPackage('pkg-b', 'Bravo'); + + it('keeps the submit button disabled for the entire batch', async () => { + // Two pending promises we resolve manually so we can observe the + // disabled state mid-batch. + let resolveA: (value: ReturnType<typeof successResponseFor>) => void = + (() => undefined) as never; + let resolveB: (value: ReturnType<typeof successResponseFor>) => void = + (() => undefined) as never; + const promiseA = new Promise<ReturnType<typeof successResponseFor>>( + (resolve) => { + resolveA = resolve; + }, + ); + const promiseB = new Promise<ReturnType<typeof successResponseFor>>( + (resolve) => { + resolveB = resolve; + }, + ); + const mutateAsync = jest + .fn() + .mockReturnValueOnce(promiseA) + .mockReturnValueOnce(promiseB); + setMutation({ mutateAsync }); + + renderModal({ selectedPackages: [pkgA, pkgB] }); + + const button = screen.getByRole('button', { + name: 'Publish 2 packages', + }); + + await act(async () => { + fireEvent.click(button); + }); + + // Both calls are in flight; the submit button must stay disabled even + // though the mutation hook's own `isPending` flag would oscillate. + // Chakra's loading-state button strips the spinner-wrapped text from + // the accessible name, so query the visible text and walk up to the + // owning button instead of relying on the role name. + const loadingLabel = screen.getByText('Publishing 2 packages…'); + expect(loadingLabel.closest('button')).toBeDisabled(); + + await act(async () => { + resolveA(successResponseFor('pkg-a', 'alpha')); + resolveB(successResponseFor('pkg-b', 'bravo')); + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/deployments/components/RunMarketplacePublish/RunMarketplacePublish.tsx b/apps/frontend/src/domain/deployments/components/RunMarketplacePublish/RunMarketplacePublish.tsx new file mode 100644 index 000000000..ee4b8b792 --- /dev/null +++ b/apps/frontend/src/domain/deployments/components/RunMarketplacePublish/RunMarketplacePublish.tsx @@ -0,0 +1,385 @@ +import React, { useEffect, useState } from 'react'; +import { + PMAlert, + PMBadge, + PMButton, + PMCloseButton, + PMDialog, + PMField, + PMHStack, + PMHeading, + PMPortal, + PMRadioGroup, + PMSpinner, + PMText, + PMVStack, + pmToaster, +} from '@packmind/ui'; +import { + MarketplaceId, + MarketplaceListItem, + Package, + PublishFailureReason, +} from '@packmind/types'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; +import { useMarketplaces } from '../../../marketplaces/api/queries/MarketplaceQueries'; +import { isPackmindError } from '../../../../services/api/errors/PackmindError'; +import { + INVALID_TOKEN_MESSAGE, + mapPublishError, + useMarketplacePublishMutation, +} from '../../api/queries/useMarketplacePublishMutation'; +import { MarketplaceListEmptyState } from './MarketplaceListEmptyState'; + +export interface RunMarketplacePublishProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedPackages: Package[]; +} + +const MODAL_TITLE = 'Publish to a marketplace'; +const MODAL_SUBTITLE = + 'Pick a linked marketplace. Packmind will open or amend a “Packmind sync” pull request on the marketplace repository so your package becomes a managed plugin.'; +const SUBMIT_LABEL = 'Publish'; +const SUBMIT_PENDING_LABEL = 'Publishing…'; + +/** + * Toast wording per the functional spec (3C). Kept verbatim — especially the + * invalid-token sentence which the security review pinned to that exact + * phrasing so the token itself never leaks via a generic message. + */ +const FAILURE_TOAST_MESSAGES: Record<PublishFailureReason, string> = { + invalid_token: INVALID_TOKEN_MESSAGE, + name_conflict_unmanaged: + 'The package could not be published. Reason: another plugin with the same name already exists on this marketplace.', + descriptor_missing: + 'The package could not be published. Reason: the marketplace descriptor (marketplace.json) is missing or malformed.', + other: + 'The package could not be published. Please try again — if the problem persists, contact an organization admin.', +}; + +/** + * Modal exposing the "Publish to a marketplace" channel of the Distribute + * menu. + * + * Multi-package publishes are supported: when N packages are selected the + * modal fans out N parallel publish calls to the same marketplace. The + * backend serializes the per-package rolling-PR commits via the + * single-worker BullMQ queue, so the frontend just has to aggregate the + * outcomes for the toasts. + * + * Fan-out across multiple marketplaces is still out of scope: the modal + * publishes to exactly one marketplace per click. + */ +export const RunMarketplacePublish: React.FC<RunMarketplacePublishProps> = ({ + open, + onOpenChange, + selectedPackages, +}) => { + const { organization } = useAuthContext(); + const { + data: marketplaces = [], + isLoading: marketplacesLoading, + isError: marketplacesHasError, + } = useMarketplaces(organization?.id ?? ''); + const publishMutation = useMarketplacePublishMutation(); + // `publishMutation` is a fresh object on every render, but its `reset` + // callback is stable. Depend on the function, not the wrapper object — + // otherwise the effect below re-runs every render and its `reset()` call + // re-renders us into an infinite loop ("Maximum update depth exceeded"). + const { reset: resetPublishMutation } = publishMutation; + + const [selectedMarketplaceId, setSelectedMarketplaceId] = useState< + MarketplaceId | undefined + >(); + // Local in-flight flag. We can't lean on `publishMutation.isPending` for a + // batch because each `mutateAsync` resets the hook's state, so the flag + // would oscillate during a multi-publish fan-out. This stays true across + // the entire `Promise.allSettled` window. + const [isSubmitting, setIsSubmitting] = useState(false); + + // Reset selection whenever the modal opens / closes so a previously + // selected marketplace doesn't linger across consecutive publishes. + useEffect(() => { + if (!open) { + setSelectedMarketplaceId(undefined); + resetPublishMutation(); + } + }, [open, resetPublishMutation]); + + // Auto-pick the only marketplace if there is exactly one, mirroring + // RunDistribution's "auto-select single target" affordance. + useEffect(() => { + if (marketplaces.length === 1) { + setSelectedMarketplaceId(marketplaces[0].id); + } + }, [marketplaces]); + + const selectedMarketplace = marketplaces.find( + (mkt) => mkt.id === selectedMarketplaceId, + ); + const packageCount = selectedPackages.length; + + const handleSubmit = async () => { + if ( + !organization?.id || + !selectedMarketplaceId || + selectedPackages.length === 0 + ) { + return; + } + + const marketplaceName = selectedMarketplace?.name ?? 'the marketplace'; + setIsSubmitting(true); + try { + const results = await Promise.allSettled( + selectedPackages.map((pkg) => + publishMutation.mutateAsync({ + organizationId: organization.id, + marketplaceId: selectedMarketplaceId, + packageId: pkg.id, + }), + ), + ); + + const fulfilled = results + .map((res, idx) => ({ res, pkg: selectedPackages[idx] })) + .filter( + ( + entry, + ): entry is { + res: PromiseFulfilledResult< + Awaited<ReturnType<typeof publishMutation.mutateAsync>> + >; + pkg: Package; + } => entry.res.status === 'fulfilled', + ); + const rejected = results + .map((res, idx) => ({ res, pkg: selectedPackages[idx] })) + .filter( + ( + entry, + ): entry is { + res: PromiseRejectedResult; + pkg: Package; + } => entry.res.status === 'rejected', + ); + + if (packageCount === 1) { + // Preserve the original single-package UX verbatim. + const onlyPkg = selectedPackages[0]; + if (fulfilled.length === 1) { + const response = fulfilled[0].res.value; + pmToaster.success({ + title: 'Publish started', + description: `Packmind is publishing “${onlyPkg.name}” as “${response.pluginSlug}”. You’ll see the pull request on the marketplace repository shortly.`, + }); + onOpenChange(false); + } else { + const error = rejected[0].res.reason; + const reason = mapPublishError(error); + const fallbackDescription = isPackmindError(error) + ? error.serverError.data.message + : 'Please try again — if the problem persists, contact an organization admin.'; + pmToaster.error({ + title: 'Publish failed', + description: FAILURE_TOAST_MESSAGES[reason] ?? fallbackDescription, + }); + } + return; + } + + // Multi-package aggregation. + if (rejected.length === 0) { + pmToaster.success({ + title: 'Publishing started', + description: `Packmind is publishing ${packageCount} packages to ${marketplaceName}. You’ll see the pull request on the marketplace repository shortly.`, + }); + onOpenChange(false); + return; + } + + if (fulfilled.length === 0) { + const dominantReason = pickDominantReason( + rejected.map((entry) => mapPublishError(entry.res.reason)), + ); + pmToaster.error({ + title: 'Publish failed', + description: `No packages could be published. Most common reason: ${FAILURE_TOAST_MESSAGES[dominantReason]}`, + }); + return; + } + + // Mixed outcome: some publishes started, some failed. + const failingNames = rejected + .map((entry) => `“${entry.pkg.name}”`) + .join(', '); + pmToaster.warning({ + title: 'Some publishes failed', + description: `${fulfilled.length} of ${packageCount} packages are being published to ${marketplaceName}. ${rejected.length} failed: ${failingNames}.`, + }); + onOpenChange(false); + } finally { + setIsSubmitting(false); + } + }; + + const canSubmit = + selectedPackages.length > 0 && + !!selectedMarketplaceId && + !marketplacesLoading && + !isSubmitting; + + const submitLabel = (() => { + if (isSubmitting) { + return packageCount > 1 + ? `Publishing ${packageCount} packages…` + : SUBMIT_PENDING_LABEL; + } + return packageCount > 1 ? `Publish ${packageCount} packages` : SUBMIT_LABEL; + })(); + + return ( + <PMDialog.Root + open={open} + onOpenChange={(details) => onOpenChange(details.open)} + size="md" + placement="center" + motionPreset="slide-in-bottom" + scrollBehavior={'outside'} + > + <PMPortal> + <PMDialog.Backdrop /> + <PMDialog.Positioner> + <PMDialog.Content> + <PMDialog.Header> + <PMDialog.Title asChild> + <PMHeading level="h2">{MODAL_TITLE}</PMHeading> + </PMDialog.Title> + <PMDialog.CloseTrigger asChild> + <PMCloseButton size="sm" /> + </PMDialog.CloseTrigger> + </PMDialog.Header> + <PMDialog.Body> + <PMVStack align="stretch" gap={4}> + <PMText variant="small" color="secondary"> + {MODAL_SUBTITLE} + </PMText> + + {marketplacesLoading && ( + <PMHStack gap={2}> + <PMSpinner size="sm" /> + <PMText variant="small">Loading marketplaces…</PMText> + </PMHStack> + )} + + {marketplacesHasError && ( + <PMAlert.Root status="error"> + <PMAlert.Indicator /> + <PMAlert.Title> + Failed to load marketplaces. Please try again later. + </PMAlert.Title> + </PMAlert.Root> + )} + + {!marketplacesLoading && + !marketplacesHasError && + marketplaces.length === 0 && <MarketplaceListEmptyState />} + + {!marketplacesLoading && + !marketplacesHasError && + marketplaces.length > 0 && ( + <PMField.Root> + <PMField.Label>Marketplace</PMField.Label> + <PMRadioGroup.Root + value={selectedMarketplaceId ?? undefined} + onValueChange={(details) => + setSelectedMarketplaceId( + details.value as MarketplaceId, + ) + } + > + <PMVStack gap={2} align="stretch"> + {marketplaces.map((mkt) => ( + <MarketplaceRadioOption + key={mkt.id} + marketplace={mkt} + /> + ))} + </PMVStack> + </PMRadioGroup.Root> + </PMField.Root> + )} + </PMVStack> + </PMDialog.Body> + <PMDialog.Footer> + <PMButton + variant="tertiary" + size="sm" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + Cancel + </PMButton> + <PMButton + variant="primary" + size="sm" + onClick={handleSubmit} + disabled={!canSubmit} + loading={isSubmitting} + > + {submitLabel} + </PMButton> + </PMDialog.Footer> + </PMDialog.Content> + </PMDialog.Positioner> + </PMPortal> + </PMDialog.Root> + ); +}; + +const MarketplaceRadioOption: React.FC<{ + marketplace: MarketplaceListItem; +}> = ({ marketplace }) => ( + <PMRadioGroup.Item value={marketplace.id}> + <PMRadioGroup.ItemHiddenInput /> + <PMRadioGroup.ItemControl> + <PMRadioGroup.ItemIndicator /> + </PMRadioGroup.ItemControl> + <PMRadioGroup.ItemText> + <PMHStack gap={2} align="center"> + <PMText>{marketplace.name}</PMText> + <PMBadge size="sm" variant="subtle"> + {marketplace.pluginCount} plugin + {marketplace.pluginCount === 1 ? '' : 's'} + </PMBadge> + </PMHStack> + </PMRadioGroup.ItemText> + </PMRadioGroup.Item> +); + +/** + * Picks the most frequent failure reason across rejected publishes, with + * `'other'` as the tie-breaking fallback. Used for the all-failed multi + * publish toast so the user sees the dominant cause first. + */ +const pickDominantReason = ( + reasons: PublishFailureReason[], +): PublishFailureReason => { + if (reasons.length === 0) { + return 'other'; + } + const counts = new Map<PublishFailureReason, number>(); + for (const reason of reasons) { + counts.set(reason, (counts.get(reason) ?? 0) + 1); + } + let dominant: PublishFailureReason = reasons[0]; + let dominantCount = 0; + for (const [reason, count] of counts) { + if (count > dominantCount) { + dominant = reason; + dominantCount = count; + } + } + return dominant; +}; diff --git a/apps/frontend/src/domain/deployments/components/governance/GovernanceDriftRow.tsx b/apps/frontend/src/domain/deployments/components/governance/GovernanceDriftRow.tsx index 4921b80c4..3e24c13b0 100644 --- a/apps/frontend/src/domain/deployments/components/governance/GovernanceDriftRow.tsx +++ b/apps/frontend/src/domain/deployments/components/governance/GovernanceDriftRow.tsx @@ -1,7 +1,7 @@ import { useNavigate } from 'react-router'; import { PMBox, PMHStack, PMIcon, PMText } from '@packmind/ui'; import type { DriftedPackageInfo } from '@packmind/types'; -import { LuChevronRight } from 'react-icons/lu'; +import { LuChevronRight, LuPackage } from 'react-icons/lu'; import { formatRelativeDate } from '../redesign/selectors/installDriftEntries'; import { routes } from '../../../../shared/utils/routes'; @@ -50,16 +50,20 @@ export function GovernanceDriftRow({ }} > <PMHStack gap={4} align="center" justify="space-between" height="full"> - <PMText - fontSize="md" - fontWeight="medium" - color="primary" - truncate - minW={0} - flex={1} - > - {entry.packageName} - </PMText> + <PMHStack gap={2.5} align="center" minW={0} flex={1}> + <PMIcon color="text.faded" fontSize="md" flexShrink={0} aria-hidden> + <LuPackage /> + </PMIcon> + <PMText + fontSize="md" + fontWeight="medium" + color="primary" + truncate + minW={0} + > + {entry.packageName} + </PMText> + </PMHStack> <PMHStack gap={5} align="center" flexShrink={0}> <PMText fontSize="sm" diff --git a/apps/frontend/src/domain/deployments/components/redesign/DeploymentsOverviewRedesign.tsx b/apps/frontend/src/domain/deployments/components/redesign/DeploymentsOverviewRedesign.tsx index 927cce802..4ea43f547 100644 --- a/apps/frontend/src/domain/deployments/components/redesign/DeploymentsOverviewRedesign.tsx +++ b/apps/frontend/src/domain/deployments/components/redesign/DeploymentsOverviewRedesign.tsx @@ -24,6 +24,7 @@ import { useCurrentSpace } from '../../../spaces/hooks/useCurrentSpace'; import { useGetGitProvidersQuery } from '../../../git/api/queries/GitProviderQueries'; import { useListActiveDistributedPackagesBySpaceQuery } from '../../api/queries/DeploymentsQueries'; import { routes } from '../../../../shared/utils/routes'; +import { getEnvVar } from '../../../../shared/utils/getEnvVar'; import { buildPackageDriftOverview, packageBehindInstallCount, @@ -86,7 +87,8 @@ export function DeploymentsOverviewRedesignContent() { const { organization } = useAuthContext(); const { spaceId, spaceSlug, isReady } = useCurrentSpace(); const [searchParams, setSearchParams] = useSearchParams(); - const isStubMode = import.meta.env.DEV && searchParams.get('stub') === '1'; + const isStubMode = + getEnvVar('MODE') === 'development' && searchParams.get('stub') === '1'; const { data, isLoading, isError } = useListActiveDistributedPackagesBySpaceQuery( isStubMode ? undefined : spaceId, diff --git a/apps/frontend/src/domain/deployments/components/redesign/components/PackageDetailPane.tsx b/apps/frontend/src/domain/deployments/components/redesign/components/PackageDetailPane.tsx index 2006b4e4d..707e65e08 100644 --- a/apps/frontend/src/domain/deployments/components/redesign/components/PackageDetailPane.tsx +++ b/apps/frontend/src/domain/deployments/components/redesign/components/PackageDetailPane.tsx @@ -72,6 +72,9 @@ const DISTRIBUTION_VERB: Record<DistributionStatus, string> = { [DistributionStatus.failure]: 'Failed', [DistributionStatus.in_progress]: 'Started', [DistributionStatus.no_changes]: 'Checked', + [DistributionStatus.pending_merge]: 'Pending merge', + [DistributionStatus.to_be_removed]: 'Marked for removal', + [DistributionStatus.removed]: 'Removed', }; function formatAbsoluteDate(iso: string): string { diff --git a/apps/frontend/src/domain/deployments/components/redesign/components/RepositoryDetailPane.tsx b/apps/frontend/src/domain/deployments/components/redesign/components/RepositoryDetailPane.tsx index 4d92ba5b6..2759807c0 100644 --- a/apps/frontend/src/domain/deployments/components/redesign/components/RepositoryDetailPane.tsx +++ b/apps/frontend/src/domain/deployments/components/redesign/components/RepositoryDetailPane.tsx @@ -81,6 +81,9 @@ const DISTRIBUTION_VERB: Record<DistributionStatus, string> = { [DistributionStatus.failure]: 'Failed', [DistributionStatus.in_progress]: 'Started', [DistributionStatus.no_changes]: 'Checked', + [DistributionStatus.pending_merge]: 'Awaiting merge', + [DistributionStatus.to_be_removed]: 'Marked for removal', + [DistributionStatus.removed]: 'Removed', }; type RepositoryDetailPaneProps = { diff --git a/apps/frontend/src/domain/detection/api/gateways/DetectionGatewayApi.ts b/apps/frontend/src/domain/detection/api/gateways/DetectionGatewayApi.ts new file mode 100644 index 000000000..caa0150fc --- /dev/null +++ b/apps/frontend/src/domain/detection/api/gateways/DetectionGatewayApi.ts @@ -0,0 +1,206 @@ +import { + ActiveDetectionProgram, + DetectionProgram, + DetectionProgramMetadata, + LanguageDetectionPrograms, + RuleDetectionAssessment, + DetectionHeuristics, +} from '@packmind/types'; +import { + RuleLanguageDetectionStatus, + RuleDetectionStatusSummary, +} from '@packmind/types'; +import { PackmindGateway } from '../../../../shared/PackmindGateway'; +import { IDetectionGateway } from './IDetectionGateway'; + +export class DetectionGatewayApi + extends PackmindGateway + implements IDetectionGateway +{ + constructor() { + super('/standards'); + } + + async saveDetectionProgram( + standardId: string, + ruleId: string, + code: string, + ): Promise<void> { + return this._api.post<void>( + `${this._endpoint}/${standardId}/rules/${ruleId}/detection-program`, + { + code, + mode: 'singleAst', + language: 'typescript', + }, + ); + } + + async updateDetectionProgram( + standardId: string, + ruleId: string, + detectionProgramId: string, + code: string, + ): Promise<void> { + return this._api.put<void>( + `${this._endpoint}/${standardId}/rules/${ruleId}/detection-program/${detectionProgramId}`, + { + code, + }, + ); + } + + async getActiveDetectionPrograms( + standardId: string, + ruleId: string, + ): Promise<LanguageDetectionPrograms[]> { + return this._api.get<LanguageDetectionPrograms[]>( + `${this._endpoint}/${standardId}/rules/${ruleId}/detection-program`, + ); + } + + async getAllDetectionPrograms( + standardId: string, + ruleId: string, + ): Promise<DetectionProgram[]> { + return this._api.get<DetectionProgram[]>( + `${this._endpoint}/${standardId}/rules/${ruleId}/detection-programs/all`, + ); + } + + async generateProgram( + standardId: string, + ruleId: string, + language?: string, + ): Promise<void> { + const payload = language ? { language } : undefined; + return this._api.post<void>( + `${this._endpoint}/${standardId}/rules/${ruleId}/detection-program/generate`, + payload, + ); + } + + async getDetectionProgramMetadata( + standardId: string, + ruleId: string, + detectionProgramId: string, + ): Promise<DetectionProgramMetadata | null> { + return this._api.get<DetectionProgramMetadata | null>( + `${this._endpoint}/${standardId}/rules/${ruleId}/detection-programs/${detectionProgramId}/metadata`, + ); + } + + async activateDetectionProgram( + standardId: string, + ruleId: string, + activeDetectionProgramId: string, + detectionProgramId: string, + ): Promise<ActiveDetectionProgram> { + return this._api.post<ActiveDetectionProgram>( + `${this._endpoint}/${standardId}/rules/${ruleId}/detection-program/${activeDetectionProgramId}/activate`, + { + detectionProgramId, + }, + ); + } + + async getRuleDetectionAssessment( + standardId: string, + ruleId: string, + language: string, + ): Promise<RuleDetectionAssessment | null> { + return this._api.get<RuleDetectionAssessment | null>( + `${this._endpoint}/${standardId}/rules/${ruleId}/detection-assessment?language=${language}`, + ); + } + + async getRuleLanguageDetectionStatus( + standardId: string, + ruleId: string, + language: string, + ): Promise<{ status: RuleLanguageDetectionStatus }> { + return this._api.get<{ status: RuleLanguageDetectionStatus }>( + `${this._endpoint}/${standardId}/rules/${ruleId}/detection-status?language=${language}`, + ); + } + + async getStandardRulesDetectionStatus( + standardId: string, + ): Promise<RuleDetectionStatusSummary[]> { + return this._api.get<RuleDetectionStatusSummary[]>( + `${this._endpoint}/${standardId}/detection-status`, + ); + } + + async testProgramExecution( + standardId: string, + ruleId: string, + detectionProgramId: string, + sandboxCode: string, + ): Promise< + { line: number; character: number; rule: string; standard: string }[] + > { + return this._api.post< + { line: number; character: number; rule: string; standard: string }[] + >( + `${this._endpoint}/${standardId}/rules/${ruleId}/detection-program/${detectionProgramId}/test`, + { + sandboxCode, + }, + ); + } + + async getDetectionHeuristics( + standardId: string, + ruleId: string, + language: string, + ): Promise<DetectionHeuristics | null> { + return this._api.get<DetectionHeuristics | null>( + `${this._endpoint}/${standardId}/rules/${ruleId}/detection-heuristics?language=${language}`, + ); + } + + async updateDetectionHeuristics( + standardId: string, + ruleId: string, + detectionHeuristicsId: string, + heuristics: string[], + clarificationQuestion?: { + question: string; + answer: string; + }, + ): Promise<DetectionHeuristics> { + return this._api.put<DetectionHeuristics>( + `${this._endpoint}/${standardId}/rules/${ruleId}/detection-heuristics/${detectionHeuristicsId}`, + { + heuristics, + ...(clarificationQuestion && { clarificationQuestion }), + }, + ); + } + + async startRuleDetectionAssessment( + standardId: string, + ruleId: string, + language: string, + ): Promise<RuleDetectionAssessment> { + return this._api.post<RuleDetectionAssessment>( + `${this._endpoint}/${standardId}/rules/${ruleId}/detection-assessment`, + { + language, + }, + ); + } + + async updateActiveDetectionProgramSeverity( + standardId: string, + ruleId: string, + activeDetectionProgramId: string, + severity: string, + ): Promise<ActiveDetectionProgram> { + return this._api.patch<ActiveDetectionProgram>( + `${this._endpoint}/${standardId}/rules/${ruleId}/detection-program/${activeDetectionProgramId}/severity`, + { severity }, + ); + } +} diff --git a/apps/frontend/src/domain/detection/api/gateways/IDetectionGateway.ts b/apps/frontend/src/domain/detection/api/gateways/IDetectionGateway.ts new file mode 100644 index 000000000..14dadff38 --- /dev/null +++ b/apps/frontend/src/domain/detection/api/gateways/IDetectionGateway.ts @@ -0,0 +1,111 @@ +import { + ActiveDetectionProgram, + DetectionProgram, + DetectionProgramMetadata, + LanguageDetectionPrograms, + RuleDetectionAssessment, + DetectionHeuristics, +} from '@packmind/types'; +import { + RuleLanguageDetectionStatus, + RuleDetectionStatusSummary, +} from '@packmind/types'; + +export interface IDetectionGateway { + saveDetectionProgram( + standardId: string, + ruleId: string, + code: string, + ): Promise<void>; + + updateDetectionProgram( + standardId: string, + ruleId: string, + detectionProgramId: string, + code: string, + ): Promise<void>; + + getActiveDetectionPrograms( + standardId: string, + ruleId: string, + ): Promise<LanguageDetectionPrograms[]>; + + getAllDetectionPrograms( + standardId: string, + ruleId: string, + ): Promise<DetectionProgram[]>; + + generateProgram( + standardId: string, + ruleId: string, + language?: string, + ): Promise<void>; + + getDetectionProgramMetadata( + standardId: string, + ruleId: string, + detectionProgramId: string, + ): Promise<DetectionProgramMetadata | null>; + + activateDetectionProgram( + standardId: string, + ruleId: string, + activeDetectionProgramId: string, + detectionProgramId: string, + ): Promise<ActiveDetectionProgram>; + + getRuleDetectionAssessment( + standardId: string, + ruleId: string, + language: string, + ): Promise<RuleDetectionAssessment | null>; + + getRuleLanguageDetectionStatus( + standardId: string, + ruleId: string, + language: string, + ): Promise<{ status: RuleLanguageDetectionStatus }>; + + getStandardRulesDetectionStatus( + standardId: string, + ): Promise<RuleDetectionStatusSummary[]>; + + testProgramExecution( + standardId: string, + ruleId: string, + detectionProgramId: string, + sandboxCode: string, + ): Promise< + { line: number; character: number; rule: string; standard: string }[] + >; + + getDetectionHeuristics( + standardId: string, + ruleId: string, + language: string, + ): Promise<DetectionHeuristics | null>; + + updateDetectionHeuristics( + standardId: string, + ruleId: string, + detectionHeuristicsId: string, + heuristics: string[], + clarificationQuestion?: { + question: string; + answer: string; + }, + ): Promise<DetectionHeuristics>; + + startRuleDetectionAssessment( + standardId: string, + ruleId: string, + language: string, + ): Promise<RuleDetectionAssessment>; + + updateActiveDetectionProgramSeverity( + standardId: string, + ruleId: string, + activeDetectionProgramId: string, + severity: string, + ): Promise<ActiveDetectionProgram>; +} diff --git a/apps/frontend/src/domain/detection/api/gateways/index.ts b/apps/frontend/src/domain/detection/api/gateways/index.ts new file mode 100644 index 000000000..e2849c504 --- /dev/null +++ b/apps/frontend/src/domain/detection/api/gateways/index.ts @@ -0,0 +1,6 @@ +import { DetectionGatewayApi } from './DetectionGatewayApi'; + +export const detectionGateway = new DetectionGatewayApi(); + +export type { IDetectionGateway } from './IDetectionGateway'; +export { DetectionGatewayApi } from './DetectionGatewayApi'; diff --git a/apps/frontend/src/domain/detection/api/queries/DetectionProgramQueries.ts b/apps/frontend/src/domain/detection/api/queries/DetectionProgramQueries.ts new file mode 100644 index 000000000..6a2ea669e --- /dev/null +++ b/apps/frontend/src/domain/detection/api/queries/DetectionProgramQueries.ts @@ -0,0 +1,445 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { detectionGateway } from '../gateways'; +import { + GET_ACTIVE_DETECTION_PROGRAMS_KEY, + GET_ALL_DETECTION_PROGRAMS_KEY, + GET_DETECTION_PROGRAM_METADATA_KEY, + GET_RULE_DETECTION_ASSESSMENT_KEY, + GET_RULE_LANGUAGE_DETECTION_STATUS_KEY, + GET_STANDARD_RULES_DETECTION_STATUS_KEY, + GET_DETECTION_HEURISTICS_KEY, +} from '../queryKeys'; + +export const UPDATE_DETECTION_PROGRAM_MUTATION_KEY = 'updateDetectionProgram'; +export const GENERATE_PROGRAM_MUTATION_KEY = 'generateProgram'; +export const ACTIVATE_DETECTION_PROGRAM_MUTATION_KEY = + 'activateDetectionProgram'; +export const TEST_PROGRAM_EXECUTION_MUTATION_KEY = 'testProgramExecution'; +export const UPDATE_DETECTION_HEURISTICS_MUTATION_KEY = + 'updateDetectionHeuristics'; +export const START_RULE_DETECTION_ASSESSMENT_MUTATION_KEY = + 'startRuleDetectionAssessment'; +export const UPDATE_ACTIVE_DETECTION_PROGRAM_SEVERITY_MUTATION_KEY = + 'updateActiveDetectionProgramSeverity'; + +export const useSaveDetectionProgramMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + standardId, + ruleId, + code, + }: { + standardId: string; + ruleId: string; + code: string; + }) => { + return detectionGateway.saveDetectionProgram(standardId, ruleId, code); + }, + onSuccess: async (_, variables) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [ + ...GET_ACTIVE_DETECTION_PROGRAMS_KEY, + variables.standardId, + variables.ruleId, + ], + }), + queryClient.invalidateQueries({ + queryKey: [ + ...GET_ALL_DETECTION_PROGRAMS_KEY, + variables.standardId, + variables.ruleId, + ], + }), + ]); + }, + }); +}; + +export const useGetActiveDetectionProgramsQuery = ( + standardId: string, + ruleId: string, +) => { + return useQuery({ + queryKey: [...GET_ACTIVE_DETECTION_PROGRAMS_KEY, standardId, ruleId], + queryFn: () => + detectionGateway.getActiveDetectionPrograms(standardId, ruleId), + enabled: !!standardId && !!ruleId, + }); +}; + +export const useUpdateDetectionProgramMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: [UPDATE_DETECTION_PROGRAM_MUTATION_KEY], + mutationFn: async ({ + standardId, + ruleId, + detectionProgramId, + code, + }: { + standardId: string; + ruleId: string; + detectionProgramId: string; + code: string; + }) => { + return detectionGateway.updateDetectionProgram( + standardId, + ruleId, + detectionProgramId, + code, + ); + }, + onSuccess: async (_, variables) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [ + ...GET_ACTIVE_DETECTION_PROGRAMS_KEY, + variables.standardId, + variables.ruleId, + ], + }), + queryClient.invalidateQueries({ + queryKey: [ + ...GET_ALL_DETECTION_PROGRAMS_KEY, + variables.standardId, + variables.ruleId, + ], + }), + ]); + }, + }); +}; + +export const useActivateDetectionDraftMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: [ACTIVATE_DETECTION_PROGRAM_MUTATION_KEY], + mutationFn: async ({ + standardId, + ruleId, + activeDetectionProgramId, + detectionProgramId, + }: { + standardId: string; + ruleId: string; + activeDetectionProgramId: string; + detectionProgramId: string; + }) => { + return detectionGateway.activateDetectionProgram( + standardId, + ruleId, + activeDetectionProgramId, + detectionProgramId, + ); + }, + onSuccess: async (_, variables) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [ + ...GET_ACTIVE_DETECTION_PROGRAMS_KEY, + variables.standardId, + variables.ruleId, + ], + }), + queryClient.invalidateQueries({ + queryKey: [ + ...GET_ALL_DETECTION_PROGRAMS_KEY, + variables.standardId, + variables.ruleId, + ], + }), + ]); + }, + }); +}; + +export const useGetAllDetectionProgramsQuery = ( + standardId: string, + ruleId: string, +) => { + return useQuery({ + queryKey: [...GET_ALL_DETECTION_PROGRAMS_KEY, standardId, ruleId], + queryFn: () => detectionGateway.getAllDetectionPrograms(standardId, ruleId), + enabled: !!standardId && !!ruleId, + }); +}; + +export const useGenerateProgramMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: [GENERATE_PROGRAM_MUTATION_KEY], + mutationFn: async ({ + standardId, + ruleId, + language, + }: { + standardId: string; + ruleId: string; + language?: string; + }) => { + return detectionGateway.generateProgram(standardId, ruleId, language); + }, + onSuccess: async (_, variables) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [ + ...GET_ACTIVE_DETECTION_PROGRAMS_KEY, + variables.standardId, + variables.ruleId, + ], + }), + queryClient.invalidateQueries({ + queryKey: [ + ...GET_ALL_DETECTION_PROGRAMS_KEY, + variables.standardId, + variables.ruleId, + ], + }), + ]); + }, + onError: async (error, variables, context) => { + console.error('Error generating program'); + console.log('error: ', error); + console.log('variables: ', variables); + console.log('context: ', context); + }, + }); +}; + +export const useGetDetectionProgramMetadataQuery = ( + standardId: string, + ruleId: string, + detectionProgramId: string | null, + options?: { refetchInterval?: number | false }, +) => { + return useQuery({ + queryKey: [ + ...GET_DETECTION_PROGRAM_METADATA_KEY, + standardId, + ruleId, + detectionProgramId, + ], + queryFn: () => + detectionGateway.getDetectionProgramMetadata( + standardId, + ruleId, + detectionProgramId!, + ), + enabled: !!standardId && !!ruleId && !!detectionProgramId, + ...options, + }); +}; + +export const useGetRuleDetectionAssessmentQuery = ( + standardId: string, + ruleId: string, + language: string, +) => { + return useQuery({ + queryKey: [ + ...GET_RULE_DETECTION_ASSESSMENT_KEY, + standardId, + ruleId, + language, + ], + queryFn: () => + detectionGateway.getRuleDetectionAssessment(standardId, ruleId, language), + enabled: !!standardId && !!ruleId && !!language, + staleTime: 0, + gcTime: 0, + refetchOnMount: 'always', + refetchOnWindowFocus: 'always', + refetchOnReconnect: 'always', + }); +}; + +export const useGetRuleLanguageDetectionStatusQuery = ( + standardId: string, + ruleId: string, + language: string, +) => { + return useQuery({ + queryKey: [ + ...GET_RULE_LANGUAGE_DETECTION_STATUS_KEY, + standardId, + ruleId, + language, + ], + queryFn: () => + detectionGateway.getRuleLanguageDetectionStatus( + standardId, + ruleId, + language, + ), + enabled: !!standardId && !!ruleId && !!language, + }); +}; + +export const useGetStandardRulesDetectionStatusQuery = (standardId: string) => { + return useQuery({ + queryKey: [...GET_STANDARD_RULES_DETECTION_STATUS_KEY, standardId], + queryFn: () => detectionGateway.getStandardRulesDetectionStatus(standardId), + enabled: !!standardId, + }); +}; + +export const useTestProgramExecutionMutation = () => { + return useMutation({ + mutationKey: [TEST_PROGRAM_EXECUTION_MUTATION_KEY], + mutationFn: async ({ + standardId, + ruleId, + detectionProgramId, + sandboxCode, + }: { + standardId: string; + ruleId: string; + detectionProgramId: string; + sandboxCode: string; + }) => { + return detectionGateway.testProgramExecution( + standardId, + ruleId, + detectionProgramId, + sandboxCode, + ); + }, + }); +}; + +export const getDetectionHeuristicsQueryOptions = ( + standardId: string, + ruleId: string, + language: string, +) => ({ + queryKey: [...GET_DETECTION_HEURISTICS_KEY, standardId, ruleId, language], + queryFn: () => + detectionGateway.getDetectionHeuristics(standardId, ruleId, language), + enabled: !!standardId && !!ruleId && !!language, +}); + +export const useGetDetectionHeuristicsQuery = ( + standardId: string, + ruleId: string, + language: string, +) => { + return useQuery( + getDetectionHeuristicsQueryOptions(standardId, ruleId, language), + ); +}; + +export const useUpdateDetectionHeuristicsMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: [UPDATE_DETECTION_HEURISTICS_MUTATION_KEY], + mutationFn: async ({ + standardId, + ruleId, + detectionHeuristicsId, + heuristics, + clarificationQuestion, + }: { + standardId: string; + ruleId: string; + detectionHeuristicsId: string; + heuristics: string[]; + clarificationQuestion?: { + question: string; + answer: string; + }; + }) => { + return detectionGateway.updateDetectionHeuristics( + standardId, + ruleId, + detectionHeuristicsId, + heuristics, + clarificationQuestion, + ); + }, + onSuccess: async (_, variables) => { + await queryClient.invalidateQueries({ + queryKey: [ + ...GET_DETECTION_HEURISTICS_KEY, + variables.standardId, + variables.ruleId, + ], + }); + }, + }); +}; + +export const useStartRuleDetectionAssessmentMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: [START_RULE_DETECTION_ASSESSMENT_MUTATION_KEY], + mutationFn: async ({ + standardId, + ruleId, + language, + }: { + standardId: string; + ruleId: string; + language: string; + }) => { + return detectionGateway.startRuleDetectionAssessment( + standardId, + ruleId, + language, + ); + }, + onSuccess: async (_, variables) => { + await queryClient.invalidateQueries({ + queryKey: [ + ...GET_RULE_DETECTION_ASSESSMENT_KEY, + variables.standardId, + variables.ruleId, + variables.language, + ], + }); + }, + }); +}; + +export const useUpdateActiveDetectionProgramSeverityMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: [UPDATE_ACTIVE_DETECTION_PROGRAM_SEVERITY_MUTATION_KEY], + mutationFn: async ({ + standardId, + ruleId, + activeDetectionProgramId, + severity, + }: { + standardId: string; + ruleId: string; + activeDetectionProgramId: string; + severity: string; + }) => { + return detectionGateway.updateActiveDetectionProgramSeverity( + standardId, + ruleId, + activeDetectionProgramId, + severity, + ); + }, + onSuccess: async (_, variables) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [ + ...GET_ACTIVE_DETECTION_PROGRAMS_KEY, + variables.standardId, + variables.ruleId, + ], + }), + queryClient.invalidateQueries({ + queryKey: [ + ...GET_STANDARD_RULES_DETECTION_STATUS_KEY, + variables.standardId, + ], + }), + ]); + }, + }); +}; diff --git a/apps/frontend/src/domain/detection/api/queries/index.ts b/apps/frontend/src/domain/detection/api/queries/index.ts new file mode 100644 index 000000000..1dcba42b7 --- /dev/null +++ b/apps/frontend/src/domain/detection/api/queries/index.ts @@ -0,0 +1 @@ +export * from './DetectionProgramQueries'; diff --git a/apps/frontend/src/domain/detection/api/queryKeys.ts b/apps/frontend/src/domain/detection/api/queryKeys.ts new file mode 100644 index 000000000..16154cb10 --- /dev/null +++ b/apps/frontend/src/domain/detection/api/queryKeys.ts @@ -0,0 +1,56 @@ +import { ORGANIZATION_QUERY_SCOPE } from '../../organizations/api/queryKeys'; + +export const DETECTION_QUERY_SCOPE = 'detection'; + +export enum DetectionQueryKeys { + GET_ACTIVE_DETECTION_PROGRAMS = 'get-active-detection-programs', + GET_ALL_DETECTION_PROGRAMS = 'get-all-detection-programs', + GET_DETECTION_PROGRAM_METADATA = 'get-detection-program-metadata', + GET_RULE_DETECTION_ASSESSMENT = 'get-rule-detection-assessment', + GET_RULE_LANGUAGE_DETECTION_STATUS = 'get-rule-language-detection-status', + GET_STANDARD_RULES_DETECTION_STATUS = 'get-standard-rules-detection-status', + GET_DETECTION_HEURISTICS = 'get-detection-heuristics', +} + +// Base query key arrays for reuse +export const GET_ACTIVE_DETECTION_PROGRAMS_KEY = [ + ORGANIZATION_QUERY_SCOPE, + DETECTION_QUERY_SCOPE, + DetectionQueryKeys.GET_ACTIVE_DETECTION_PROGRAMS, +] as const; + +export const GET_ALL_DETECTION_PROGRAMS_KEY = [ + ORGANIZATION_QUERY_SCOPE, + DETECTION_QUERY_SCOPE, + DetectionQueryKeys.GET_ALL_DETECTION_PROGRAMS, +] as const; + +export const GET_DETECTION_PROGRAM_METADATA_KEY = [ + ORGANIZATION_QUERY_SCOPE, + DETECTION_QUERY_SCOPE, + DetectionQueryKeys.GET_DETECTION_PROGRAM_METADATA, +] as const; + +export const GET_RULE_DETECTION_ASSESSMENT_KEY = [ + ORGANIZATION_QUERY_SCOPE, + DETECTION_QUERY_SCOPE, + DetectionQueryKeys.GET_RULE_DETECTION_ASSESSMENT, +] as const; + +export const GET_RULE_LANGUAGE_DETECTION_STATUS_KEY = [ + ORGANIZATION_QUERY_SCOPE, + DETECTION_QUERY_SCOPE, + DetectionQueryKeys.GET_RULE_LANGUAGE_DETECTION_STATUS, +] as const; + +export const GET_STANDARD_RULES_DETECTION_STATUS_KEY = [ + ORGANIZATION_QUERY_SCOPE, + DETECTION_QUERY_SCOPE, + DetectionQueryKeys.GET_STANDARD_RULES_DETECTION_STATUS, +] as const; + +export const GET_DETECTION_HEURISTICS_KEY = [ + ORGANIZATION_QUERY_SCOPE, + DETECTION_QUERY_SCOPE, + DetectionQueryKeys.GET_DETECTION_HEURISTICS, +] as const; diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/ActiveConfigurationSection.test.tsx b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/ActiveConfigurationSection.test.tsx new file mode 100644 index 000000000..5ebd8694f --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/ActiveConfigurationSection.test.tsx @@ -0,0 +1,485 @@ +import { ActiveConfigurationSection } from './ActiveConfigurationSection'; +import { + ActiveConfigurationSectionData, + ActiveConfigurationSectionProps, + ActiveConfigurationState, +} from './types'; +import { render, RenderResult } from '@testing-library/react'; +import { + QueryClient, + QueryClientProvider, + UseQueryResult, +} from '@tanstack/react-query'; +import * as DetectionProgramQueries from '../../api/queries/DetectionProgramQueries'; +import { + createDetectionProgramId, + createRuleDetectionAssessmentId, + createRuleId, + createStandardId, + DetectionModeEnum, + DetectionStatus, + ProgrammingLanguage, + RuleDetectionAssessment, + RuleDetectionAssessmentStatus, +} from '@packmind/types'; +import React from 'react'; +import { UIProvider } from '@packmind/ui'; +import userEvent from '@testing-library/user-event'; + +// Mock removed - not needed + +describe('ActivateConfigurationCard', () => { + let configuration: ActiveConfigurationSectionData; + let props: ActiveConfigurationSectionProps; + let screen: RenderResult; + let ruleDetectionAssessmentQuerySpy: jest.SpyInstance; + + beforeEach(() => { + configuration = { + id: 'some-random-id', + language: 'typescript', + detectionProgram: undefined, + draftProgram: undefined, + state: ActiveConfigurationState.OK, + }; + + props = { + configuration, + onTestProgram: jest.fn(), + ruleId: createRuleId('rule-id'), + standardId: createStandardId('standard-id'), + onOpenAssessmentDrawer: jest.fn(), + }; + + ruleDetectionAssessmentQuerySpy = jest + .spyOn(DetectionProgramQueries, 'useGetRuleDetectionAssessmentQuery') + .mockReturnValue({ data: undefined } as Partial< + UseQueryResult<RuleDetectionAssessment | null> + > as UseQueryResult<RuleDetectionAssessment | null>); + + jest + .spyOn(DetectionProgramQueries, 'useGetDetectionHeuristicsQuery') + .mockReturnValue({ + data: undefined, + } as Partial<UseQueryResult<unknown>> as UseQueryResult<unknown>); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const queryClient = new QueryClient(); + + function renderWithContext() { + return render( + <UIProvider> + <QueryClientProvider client={queryClient}> + <ActiveConfigurationSection {...props} /> + </QueryClientProvider> + </UIProvider>, + ); + } + + describe('when there is no configuration for the rule', () => { + beforeEach(() => { + configuration.state = ActiveConfigurationState.NO_CONFIG; + }); + + describe('when assessment is not available', () => { + beforeEach(() => { + screen = renderWithContext(); + }); + + it('shows a "Loading assessment data" message', () => { + expect( + screen.getByText('Loading assessment data.'), + ).toBeInTheDocument(); + }); + }); + + describe('when assessment is available', () => { + let ruleDetectionAssessment: RuleDetectionAssessment; + + beforeEach(() => { + ruleDetectionAssessment = { + id: createRuleDetectionAssessmentId('assessment-id'), + details: '', + detectionMode: DetectionModeEnum.SINGLE_AST, + language: ProgrammingLanguage.TYPESCRIPT, + ruleId: createRuleId(props.ruleId), + status: RuleDetectionAssessmentStatus.NOT_STARTED, + clarificationAnswers: [], + clarificationQuestion: '', + }; + + ruleDetectionAssessmentQuerySpy.mockReturnValue({ + data: ruleDetectionAssessment, + } as Partial< + UseQueryResult<RuleDetectionAssessment | null> + > as UseQueryResult<RuleDetectionAssessment | null>); + }); + + describe('when assessment is in progress', () => { + beforeEach(() => { + ruleDetectionAssessment.status = + RuleDetectionAssessmentStatus.IN_PROGRESS; + ruleDetectionAssessment.details = + 'Checking if the rule can be detected by linter'; + + screen = renderWithContext(); + }); + + it('shows an "Checking if the rule can be detected by linter" message', () => { + expect( + screen.getByText( + 'Checking if the rule can be detected by linter...', + ), + ).toBeInTheDocument(); + }); + + it('does not show a configure button', () => { + expect(screen.queryByText('Configure')).not.toBeInTheDocument(); + }); + }); + + describe('when assessment has failed', () => { + beforeEach(() => { + ruleDetectionAssessment.status = RuleDetectionAssessmentStatus.FAILED; + ruleDetectionAssessment.details = + 'Some reasons why this can not be automated'; + + screen = renderWithContext(); + }); + + it('shows a "This rule can not be automated" message', () => { + expect( + screen.getByText('This rule can not be automated.'), + ).toBeInTheDocument(); + }); + + it('shows a tooltip why the rule can not be automated', async () => { + const user = userEvent.setup(); + const icon = screen.container.querySelector('svg'); + await user.hover(icon!); + + expect( + await screen.findByText(ruleDetectionAssessment.details), + ).toBeInTheDocument(); + }); + }); + + describe('when assessment is done', () => { + let onGenerateProgramSpy: jest.Mock; + + beforeEach(() => { + ruleDetectionAssessment.status = + RuleDetectionAssessmentStatus.SUCCESS; + onGenerateProgramSpy = jest.fn(); + props.onGenerateProgram = onGenerateProgramSpy; + + screen = renderWithContext(); + }); + + it('shows a "No configuration" message', () => { + expect(screen.getByText('No configuration.')).toBeInTheDocument(); + }); + + it('shows a button to start configuration', async () => { + const user = userEvent.setup(); + const button = screen.getByText('Configure'); + + await user.click(button); + + expect(onGenerateProgramSpy).toHaveBeenCalledWith( + configuration.language, + ); + }); + }); + }); + }); + + describe('when a configuration is available', () => { + beforeEach(() => { + configuration.detectionProgram = { + code: '', + createdAt: undefined, + id: createDetectionProgramId('123'), + language: ProgrammingLanguage.TYPESCRIPT, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: createRuleId('456'), + status: DetectionStatus.READY, + sourceCodeState: 'AST', + version: 5, + }; + }); + + describe('when configuration is in progress', () => { + beforeEach(() => { + configuration.state = ActiveConfigurationState.IN_PROGRESS; + + screen = renderWithContext(); + }); + + it('shows a "Configuration in progress" message', () => { + expect( + screen.getByText('Configuration in progress.'), + ).toBeInTheDocument(); + }); + }); + + describe('when configuration needs review', () => { + let onTestProgramSpy: jest.Mock; + let onGenerateProgramSpy: jest.Mock; + let onActivateDraft: jest.Mock; + + beforeEach(() => { + configuration.state = ActiveConfigurationState.TO_REVIEW; + onTestProgramSpy = jest.fn(); + props.onTestProgram = onTestProgramSpy; + onGenerateProgramSpy = jest.fn(); + props.onGenerateProgram = onGenerateProgramSpy; + onActivateDraft = jest.fn(); + props.onActivateDraft = onActivateDraft; + + screen = renderWithContext(); + }); + + it('shows the ToReview section', () => { + expect( + screen.getAllByText(/Program is outdated/i)[0], + ).toBeInTheDocument(); + }); + + describe('when there are no draft', () => { + beforeEach(() => { + configuration.detectionProgram = null; + + screen = renderWithContext(); + }); + + it('shows a "New draft" button', async () => { + const user = userEvent.setup(); + await user.click(screen.getAllByText('New draft')[0]); + + expect(onGenerateProgramSpy).toHaveBeenCalledWith( + configuration.language, + ); + }); + + it('shows that a draft requires review', () => { + expect( + screen.getByText(/Draft requires review/i), + ).toBeInTheDocument(); + }); + }); + + describe('when only a draft exists', () => { + beforeEach(() => { + configuration.detectionProgram = null; + configuration.draftProgram = { + code: '', + createdAt: undefined, + id: createDetectionProgramId('only-draft-id'), + language: ProgrammingLanguage.TYPESCRIPT, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: createRuleId('some-rule-id'), + sourceCodeState: 'AST', + status: DetectionStatus.READY, + version: 2, + }; + + screen = renderWithContext(); + }); + + it('shows the draft summary information', () => { + expect( + screen.getByText(/Draft v2 requires review/i), + ).toBeInTheDocument(); + }); + + it('lets the user activate the draft', async () => { + const user = userEvent.setup(); + await user.click(screen.getByText('Activate draft')); + + expect(onActivateDraft).toHaveBeenCalled(); + }); + }); + + describe('when there is a successful draft', () => { + beforeEach(() => { + configuration.draftProgram = { + code: '', + createdAt: undefined, + id: createDetectionProgramId('some-draft-id'), + language: ProgrammingLanguage.TYPESCRIPT, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: createRuleId('some-rule-id'), + sourceCodeState: 'AST', + status: DetectionStatus.READY, + version: 0, + }; + + screen = renderWithContext(); + }); + + it('shows "Program is outdated" message', () => { + expect( + screen.getAllByText(/Program is outdated/i)[0], + ).toBeInTheDocument(); + }); + + it('shows "Test active version" section', () => { + expect( + screen.getAllByText(/Test active version/i)[0], + ).toBeInTheDocument(); + }); + + it('shows "Deal with false-positives" section', () => { + expect( + screen.getAllByText(/Deal with false-positives/i)[0], + ).toBeInTheDocument(); + }); + + describe('when clicking "Generate new program" button', () => { + it('triggers onGenerateProgram', async () => { + const user = userEvent.setup(); + await user.click(screen.getAllByText('Generate new program')[0]); + + expect(onGenerateProgramSpy).toHaveBeenCalledWith( + configuration.language, + ); + }); + }); + }); + + describe('when there is a draft to-review', () => { + beforeEach(() => { + configuration.draftProgram = { + code: '', + createdAt: undefined, + id: createDetectionProgramId('some-draft-id'), + language: ProgrammingLanguage.TYPESCRIPT, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: createRuleId('some-rule-id'), + sourceCodeState: 'AST', + status: DetectionStatus.TO_REVIEW, + version: 0, + }; + + screen = renderWithContext(); + }); + + it('shows "Program is outdated" message', () => { + expect( + screen.getAllByText(/Program is outdated/i)[0], + ).toBeInTheDocument(); + }); + + it('shows "Test active version" section', () => { + expect( + screen.getAllByText(/Test active version/i)[0], + ).toBeInTheDocument(); + }); + + it('shows "Deal with false-positives" section', () => { + expect( + screen.getAllByText(/Deal with false-positives/i)[0], + ).toBeInTheDocument(); + }); + + describe('when clicking "Generate new program" button', () => { + it('triggers onGenerateProgram', async () => { + const user = userEvent.setup(); + await user.click(screen.getAllByText('Generate new program')[0]); + + expect(onGenerateProgramSpy).toHaveBeenCalledWith( + configuration.language, + ); + }); + }); + }); + + describe('when there is a draft neither ok nor to review', () => { + beforeEach(() => { + configuration.draftProgram = { + code: '', + createdAt: undefined, + id: createDetectionProgramId('some-draft-id'), + language: ProgrammingLanguage.TYPESCRIPT, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: createRuleId('some-rule-id'), + sourceCodeState: 'AST', + status: DetectionStatus.ERROR, + version: 0, + }; + + screen = renderWithContext(); + }); + + it('shows "Program is outdated" message', () => { + expect( + screen.getAllByText(/Program is outdated/i)[0], + ).toBeInTheDocument(); + }); + + it('shows "Test active version" section', () => { + expect( + screen.getAllByText(/Test active version/i)[0], + ).toBeInTheDocument(); + }); + + it('shows "Deal with false-positives" section', () => { + expect( + screen.getAllByText(/Deal with false-positives/i)[0], + ).toBeInTheDocument(); + }); + + describe('when clicking "Generate new program" button', () => { + it('triggers onGenerateProgram', async () => { + const user = userEvent.setup(); + await user.click(screen.getAllByText('Generate new program')[0]); + + expect(onGenerateProgramSpy).toHaveBeenCalledWith( + configuration.language, + ); + }); + }); + }); + }); + + describe('when configuration is ok', () => { + let onTestProgramSpy: jest.Mock; + + beforeEach(() => { + configuration.state = ActiveConfigurationState.OK; + + onTestProgramSpy = jest.fn(); + props.onTestProgram = onTestProgramSpy; + + screen = renderWithContext(); + }); + + it('shows "Rule is detectable" section', () => { + expect(screen.getByText(/Rule is detectable/i)).toBeInTheDocument(); + }); + + it('shows "Test active version" section', () => { + expect(screen.getByText(/Test active version/i)).toBeInTheDocument(); + }); + + it('shows DetectabilitySection', () => { + expect(screen.getByText(/Rule is detectable/i)).toBeInTheDocument(); + }); + + it('shows a "Test" button that triggers onTestProgram', async () => { + const user = userEvent.setup(); + + // Look for the "Test" button directly in the section + const testButton = screen.getByRole('button', { name: /test/i }); + await user.click(testButton); + + expect(onTestProgramSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/ActiveConfigurationSection.tsx b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/ActiveConfigurationSection.tsx new file mode 100644 index 000000000..4f6432422 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/ActiveConfigurationSection.tsx @@ -0,0 +1,239 @@ +import { + PMBox, + PMButton, + PMHStack, + PMIcon, + PMText, + PMTooltip, + PMVStack, +} from '@packmind/ui'; +import React from 'react'; +import { RxQuestionMarkCircled } from 'react-icons/rx'; +import { ActiveConfigurationSectionProps } from './types'; +import { + useActiveConfigurationSectionViewModel, + NoConfigurationAssessmentView, +} from './useActiveConfigurationSectionViewModel'; +import { + ActiveConfigurationSectionKey, + ActiveConfigurationViewState, +} from './viewState'; +import { SectionDescriptor } from './useActiveConfigurationSectionViewModel'; +import { DetectabilitySection } from './sections/DetectabilitySection'; +import { TestActiveVersionSection } from './sections/TestActiveVersionSection'; +import { FalsePositivesSection } from './sections/FalsePositivesSection'; +import { ToReviewSection } from './sections/ToReviewSection'; +import { DraftReviewSummarySection } from './sections/DraftReviewSummarySection'; + +export const ActiveConfigurationSection: React.FC< + ActiveConfigurationSectionProps +> = (props) => { + const { configuration } = props; + + const isAssessmentFeatureEnabled = true; + + if (!configuration) { + return null; + } + + return ( + <ActiveConfigurationSectionInner + {...props} + configuration={configuration} + isAssessmentFeatureEnabled={isAssessmentFeatureEnabled} + /> + ); +}; + +type ActiveConfigurationSectionInnerProps = ActiveConfigurationSectionProps & { + isAssessmentFeatureEnabled: boolean; +}; + +const ActiveConfigurationSectionInner: React.FC< + ActiveConfigurationSectionInnerProps +> = ({ + configuration, + onGenerateProgram, + isGenerating = false, + standardId, + standardName, + ruleId, + onTestProgram, + onActivateDraft, + isActivatingDraft = false, + onOpenAssessmentDrawer, + isAssessmentFeatureEnabled, + onNavigateToExamples, + onReviewDraft, +}) => { + const { descriptor, sections, assessmentView } = + useActiveConfigurationSectionViewModel({ + configuration, + standardId, + ruleId, + standardName, + onGenerateProgram, + isGenerating, + onTestProgram, + onActivateDraft, + isActivatingDraft, + onOpenAssessmentDrawer, + isAssessmentFeatureEnabled, + onNavigateToExamples, + onReviewDraft, + }); + + if (descriptor.viewState === ActiveConfigurationViewState.NO_CONFIGURATION) { + return ( + <NoConfigurationCard + assessment={assessmentView} + isGenerating={isGenerating} + onGenerateProgram={ + onGenerateProgram + ? () => onGenerateProgram(configuration.language) + : undefined + } + /> + ); + } + + if (descriptor.viewState === ActiveConfigurationViewState.IN_PROGRESS) { + return ( + <PMVStack alignItems="stretch" gap={4} width="full"> + <PMText color="faded" fontSize="sm"> + Configuration in progress. + </PMText> + </PMVStack> + ); + } + + if (descriptor.viewState === ActiveConfigurationViewState.ERROR) { + return null; + } + + return <StateSectionsList sections={sections} />; +}; + +type NoConfigurationCardProps = { + assessment: NoConfigurationAssessmentView | null; + isGenerating: boolean; + onGenerateProgram?: () => void; +}; + +const NoConfigurationCard: React.FC<NoConfigurationCardProps> = ({ + assessment, + isGenerating, + onGenerateProgram, +}) => { + if (!assessment) { + return null; + } + + if (assessment.status === 'loading') { + return ( + <PMText color="faded" fontSize="sm"> + Loading assessment data. + </PMText> + ); + } + + if (assessment.status === 'inProgress') { + return ( + <PMText color="faded" fontSize="sm"> + Checking if the rule can be detected by linter... + </PMText> + ); + } + + if (assessment.status === 'failed') { + const tooltipLabel = ( + <PMVStack alignItems="flex-start" gap={2} width="full"> + <PMText color="tertiary"> + We have assessed that the rule is not detectable : + </PMText> + {assessment.details && ( + <PMBox padding={2} width="full"> + <PMText whiteSpace="pre-wrap">{assessment.details}</PMText> + </PMBox> + )} + </PMVStack> + ); + + return ( + <PMHStack justifyContent="space-between" alignItems="center" width="full"> + <PMHStack> + <PMText color="faded" fontSize="sm"> + This rule can not be automated. + </PMText> + <PMTooltip label={tooltipLabel} placement="top"> + <PMIcon + as={RxQuestionMarkCircled} + color="text.tertiary" + boxSize={4} + cursor="help" + /> + </PMTooltip> + </PMHStack> + {assessment.canRefine && ( + <PMButton size="sm" variant="outline" onClick={assessment.onRefine}> + Refine + </PMButton> + )} + </PMHStack> + ); + } + + return ( + <PMHStack justifyContent="space-between" alignItems="center" width="full"> + <PMText color="faded" fontSize="sm"> + No configuration. + </PMText> + <PMButton + size="sm" + variant="outline" + onClick={onGenerateProgram} + loading={isGenerating} + disabled={!onGenerateProgram || isGenerating} + > + Configure + </PMButton> + </PMHStack> + ); +}; + +const StateSectionsList: React.FC<{ sections: SectionDescriptor[] }> = ({ + sections, +}) => { + if (!sections.length) { + return null; + } + + return ( + <PMVStack alignItems="stretch" gap={4} width="full"> + {sections.map((section) => { + switch (section.key) { + case ActiveConfigurationSectionKey.DETECTABILITY: + return ( + <DetectabilitySection key={section.key} {...section.props} /> + ); + case ActiveConfigurationSectionKey.TEST_ACTIVE_VERSION: + return ( + <TestActiveVersionSection key={section.key} {...section.props} /> + ); + case ActiveConfigurationSectionKey.FALSE_POSITIVES: + return ( + <FalsePositivesSection key={section.key} {...section.props} /> + ); + case ActiveConfigurationSectionKey.TO_REVIEW_CARD: + return <ToReviewSection key={section.key} {...section.props} />; + case ActiveConfigurationSectionKey.DRAFT_REVIEW_SUMMARY: + return ( + <DraftReviewSummarySection key={section.key} {...section.props} /> + ); + default: + return null; + } + })} + </PMVStack> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/index.ts b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/index.ts new file mode 100644 index 000000000..01d7815f0 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/index.ts @@ -0,0 +1,10 @@ +export { ActiveConfigurationSection } from './ActiveConfigurationSection'; +export { ActiveConfigurationSection as ActiveConfigurationCard } from './ActiveConfigurationSection'; + +export type { + ActiveConfigurationSectionProps, + ActiveConfigurationSectionData, + ActiveConfigurationCardProps, + ActiveConfigurationCardData, +} from './types'; +export { ActiveConfigurationState } from './types'; diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/DetectabilitySection.test.tsx b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/DetectabilitySection.test.tsx new file mode 100644 index 000000000..d35c092a2 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/DetectabilitySection.test.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { render, RenderResult } from '@testing-library/react'; +import { UIProvider } from '@packmind/ui'; +import { DetectabilitySection } from './DetectabilitySection'; + +describe('DetectabilitySection', () => { + let screen: RenderResult; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function renderWithContext(standardName?: string) { + return render( + <UIProvider> + <DetectabilitySection standardName={standardName} /> + </UIProvider>, + ); + } + + describe('when rendered', () => { + beforeEach(() => { + screen = renderWithContext(); + }); + + it('shows "Rule is detectable" message', () => { + expect(screen.getByText(/Rule is detectable/i)).toBeInTheDocument(); + }); + + it('shows description about Packmind linter detecting violations', () => { + expect( + screen.getByText(/Packmind linter will now detect violations/i), + ).toBeInTheDocument(); + }); + + it('shows "Linter usage" link', () => { + expect(screen.getByText('Linter usage')).toBeInTheDocument(); + }); + + describe('when clicking "Linter usage" link', () => { + it('links to documentation URL', () => { + const link = screen.getByRole('link', { name: 'Linter usage' }); + + expect(link).toHaveAttribute( + 'href', + 'https://docs.packmind.com/linter/linter', + ); + }); + + it('opens in a new tab', () => { + const link = screen.getByRole('link', { name: 'Linter usage' }); + + expect(link).toHaveAttribute('target', '_blank'); + }); + + it('prevents security vulnerabilities with proper rel attribute', () => { + const link = screen.getByRole('link', { name: 'Linter usage' }); + + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + }); + }); + + describe('when standard name is provided', () => { + beforeEach(() => { + screen = renderWithContext('My Custom Standard'); + }); + + it('displays the standard name in the description', () => { + expect( + screen.getByText( + /Packmind linter will now detect violations of this rules in code where standard 'My Custom Standard' is deployed\./i, + ), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/DetectabilitySection.tsx b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/DetectabilitySection.tsx new file mode 100644 index 000000000..4e5755739 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/DetectabilitySection.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { PMBox, PMButton, PMText, PMVStack } from '@packmind/ui'; + +interface DetectabilitySectionProps { + standardName?: string; +} + +const LINTER_DOC_URL = 'https://docs.packmind.com/linter/linter'; + +export const DetectabilitySection: React.FC<DetectabilitySectionProps> = ({ + standardName, +}) => { + return ( + <PMBox + border="1px solid" + borderColor="border.secondary" + borderRadius="md" + p={4} + backgroundColor="background.secondary" + width="full" + > + <PMVStack alignItems="flex-start" gap={2}> + <PMText fontSize="sm" fontWeight="bold"> + Rule is detectable + </PMText> + <PMText fontSize="sm" color="faded"> + Packmind linter will now detect violations of this rules in code where + standard '{standardName}' is deployed. + </PMText> + <PMButton size="sm" variant="outline" asChild> + <a href={LINTER_DOC_URL} target="_blank" rel="noopener noreferrer"> + Linter usage + </a> + </PMButton> + </PMVStack> + </PMBox> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/DraftReviewSummarySection.tsx b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/DraftReviewSummarySection.tsx new file mode 100644 index 000000000..6e9df50e3 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/DraftReviewSummarySection.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { + IPMButtonProps, + PMButton, + PMHStack, + PMText, + PMVStack, +} from '@packmind/ui'; + +type DraftReviewSummarySectionProps = { + draftVersion?: number | string | null; + mainButtonProps: IPMButtonProps | null; +}; + +export const DraftReviewSummarySection: React.FC< + DraftReviewSummarySectionProps +> = ({ draftVersion, mainButtonProps }) => { + return ( + <PMHStack justifyContent="space-between" alignItems="center" width="full"> + <PMVStack alignItems="flex-start" gap={2}> + {draftVersion ? ( + <PMText fontSize="sm" color="faded"> + Draft v{draftVersion} requires review + </PMText> + ) : ( + <PMText fontSize="sm" color="faded"> + Draft requires review + </PMText> + )} + </PMVStack> + {mainButtonProps && ( + <PMButton size="sm" variant="outline" {...mainButtonProps} /> + )} + </PMHStack> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/FalsePositivesSection.test.tsx b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/FalsePositivesSection.test.tsx new file mode 100644 index 000000000..8762a5bad --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/FalsePositivesSection.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { render, RenderResult } from '@testing-library/react'; +import { UIProvider } from '@packmind/ui'; +import userEvent from '@testing-library/user-event'; +import { FalsePositivesSection } from './FalsePositivesSection'; + +describe('FalsePositivesSection', () => { + let screen: RenderResult; + let onCodeExamplesClick: jest.Mock; + + beforeEach(() => { + onCodeExamplesClick = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function renderWithContext() { + return render( + <UIProvider> + <FalsePositivesSection onCodeExamplesClick={onCodeExamplesClick} /> + </UIProvider>, + ); + } + + describe('when rendered', () => { + beforeEach(() => { + screen = renderWithContext(); + }); + + it('shows "Deal with false-positives" heading', () => { + expect( + screen.getByText(/Deal with false-positives/i), + ).toBeInTheDocument(); + }); + + it('shows description about program being generated by AI', () => { + expect( + screen.getByText(/Program is generated by AI/i), + ).toBeInTheDocument(); + }); + + it('shows "Code examples" button', () => { + expect(screen.getByText('Code examples')).toBeInTheDocument(); + }); + + describe('when "Code examples" button is clicked', () => { + it('calls onCodeExamplesClick', async () => { + const user = userEvent.setup(); + const button = screen.getByText('Code examples'); + + await user.click(button); + + expect(onCodeExamplesClick).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/FalsePositivesSection.tsx b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/FalsePositivesSection.tsx new file mode 100644 index 000000000..d976f6595 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/FalsePositivesSection.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { PMButton, PMText, PMVStack } from '@packmind/ui'; + +interface FalsePositivesSectionProps { + onCodeExamplesClick: () => void; +} + +export const FalsePositivesSection: React.FC<FalsePositivesSectionProps> = ({ + onCodeExamplesClick, +}) => { + return ( + <PMVStack alignItems="flex-start" gap={2}> + <PMText fontSize="sm" fontWeight="medium"> + Deal with false-positives + </PMText> + <PMText fontSize="sm" color="faded"> + Program is generated by AI and might miss (or wrongly detect) + violations. Add Code examples illustrating these cases to generate a new + program. + </PMText> + <PMButton size="2xs" variant="tertiary" onClick={onCodeExamplesClick}> + Code examples + </PMButton> + </PMVStack> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/TestActiveVersionSection.test.tsx b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/TestActiveVersionSection.test.tsx new file mode 100644 index 000000000..97c5bb6cf --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/TestActiveVersionSection.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render, RenderResult } from '@testing-library/react'; +import { UIProvider } from '@packmind/ui'; +import userEvent from '@testing-library/user-event'; +import { TestActiveVersionSection } from './TestActiveVersionSection'; + +describe('TestActiveVersionSection', () => { + let screen: RenderResult; + let onTestClick: jest.Mock; + + beforeEach(() => { + onTestClick = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function renderWithContext() { + return render( + <UIProvider> + <TestActiveVersionSection onTestClick={onTestClick} /> + </UIProvider>, + ); + } + + describe('when rendered', () => { + beforeEach(() => { + screen = renderWithContext(); + }); + + it('shows "Test active version" heading', () => { + expect(screen.getByText(/Test active version/i)).toBeInTheDocument(); + }); + + it('shows description about testing current linter configuration', () => { + expect( + screen.getByText(/Test current linter configuration/i), + ).toBeInTheDocument(); + }); + + it('shows "Test" button', () => { + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + + describe('when "Test" button is clicked', () => { + it('calls onTestClick', async () => { + const user = userEvent.setup(); + const button = screen.getByText('Test'); + + await user.click(button); + + expect(onTestClick).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/TestActiveVersionSection.tsx b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/TestActiveVersionSection.tsx new file mode 100644 index 000000000..a681db0b4 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/TestActiveVersionSection.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { PMButton, PMText, PMVStack } from '@packmind/ui'; + +interface TestActiveVersionSectionProps { + onTestClick: () => void; +} + +export const TestActiveVersionSection: React.FC< + TestActiveVersionSectionProps +> = ({ onTestClick }) => { + return ( + <PMVStack alignItems="flex-start" gap={2}> + <PMText fontSize="sm" fontWeight="medium"> + Test active version + </PMText> + <PMText fontSize="sm" color="faded"> + Test current linter configuration for this rule on your code. + </PMText> + <PMButton size="2xs" variant="tertiary" onClick={onTestClick}> + Test + </PMButton> + </PMVStack> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/ToReviewSection.test.tsx b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/ToReviewSection.test.tsx new file mode 100644 index 000000000..01b62f895 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/ToReviewSection.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { render, RenderResult } from '@testing-library/react'; +import { UIProvider } from '@packmind/ui'; +import userEvent from '@testing-library/user-event'; +import { ToReviewSection } from './ToReviewSection'; + +describe('ToReviewSection', () => { + let screen: RenderResult; + let onGenerateProgramClick: jest.Mock; + + beforeEach(() => { + onGenerateProgramClick = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function renderWithContext(isGenerating = false) { + return render( + <UIProvider> + <ToReviewSection + onGenerateProgramClick={onGenerateProgramClick} + isGenerating={isGenerating} + /> + </UIProvider>, + ); + } + + describe('when rendered', () => { + beforeEach(() => { + screen = renderWithContext(); + }); + + it('shows "Program is outdated" heading', () => { + expect(screen.getByText(/Program is outdated/i)).toBeInTheDocument(); + }); + + it('shows message about active version not matching rule specifications', () => { + expect( + screen.getByText(/Active version of the program does not match/i), + ).toBeInTheDocument(); + }); + + it('shows "Code examples have changed" bullet point', () => { + expect( + screen.getByText(/Code examples have changed/i), + ).toBeInTheDocument(); + }); + + it('shows "Detectability clues have changed" bullet point', () => { + expect( + screen.getByText(/Detectability clues have changed/i), + ).toBeInTheDocument(); + }); + + it('shows "Generate new program" button', () => { + expect(screen.getByText('Generate new program')).toBeInTheDocument(); + }); + + describe('when "Generate new program" button is clicked', () => { + it('calls onGenerateProgramClick', async () => { + const user = userEvent.setup(); + const button = screen.getByText('Generate new program'); + + await user.click(button); + + expect(onGenerateProgramClick).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when isGenerating is true', () => { + beforeEach(() => { + screen = renderWithContext(true); + }); + + it('disables the "Generate new program" button', () => { + const button = screen.getByText('Generate new program').closest('button'); + expect(button).toBeDisabled(); + }); + }); +}); diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/ToReviewSection.tsx b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/ToReviewSection.tsx new file mode 100644 index 000000000..1f1e6d7d3 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/sections/ToReviewSection.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { + PMBox, + PMButton, + PMList, + PMText, + PMTooltip, + PMVStack, +} from '@packmind/ui'; + +interface ToReviewSectionProps { + onGenerateProgramClick: () => void; + onReviewDraft?: () => void; + isGenerating?: boolean; + hasDraftToReview?: boolean; + isDraftInProgress?: boolean; +} + +export const ToReviewSection: React.FC<ToReviewSectionProps> = ({ + onGenerateProgramClick, + onReviewDraft, + isGenerating = false, + hasDraftToReview = false, + isDraftInProgress = false, +}) => { + const handleButtonClick = () => { + if (hasDraftToReview && onReviewDraft) { + onReviewDraft(); + } else { + onGenerateProgramClick(); + } + }; + + return ( + <PMBox + border="1px solid" + borderColor="border.secondary" + borderRadius="md" + p={4} + backgroundColor="background.secondary" + width="full" + > + <PMVStack alignItems="flex-start" gap={2}> + <PMText fontSize="sm" fontWeight="medium"> + Program is outdated + </PMText> + <PMText fontSize="sm" color="faded"> + Active version of the program does not match rule specifications + anymore. + </PMText> + <PMList.Root as="ul" listStyle="disc" pl={4}> + <PMList.Item> + <PMText fontSize="sm" color="faded"> + Code examples have changed + </PMText> + </PMList.Item> + <PMList.Item> + <PMText fontSize="sm" color="faded"> + Detectability clues have changed + </PMText> + </PMList.Item> + </PMList.Root> + <PMTooltip + label="Running generation will create a draft to ensure your current linter configuration does not break" + placement="top" + > + <PMButton + size="sm" + variant="outline" + onClick={handleButtonClick} + loading={isGenerating || isDraftInProgress} + disabled={isGenerating || isDraftInProgress} + > + {hasDraftToReview ? 'Review draft' : 'Generate new program'} + </PMButton> + </PMTooltip> + </PMVStack> + </PMBox> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/types.ts b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/types.ts new file mode 100644 index 000000000..a6fcfab30 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/types.ts @@ -0,0 +1,43 @@ +import { DetectionProgram, DetectionSeverity } from '@packmind/types'; +import { DraftCardData } from '../DetectionDraftCard/DetectionDraftCard'; + +export enum ActiveConfigurationState { + OK = 'ok', + TO_REVIEW = 'toReview', + NO_CONFIG = 'noConfig', + IN_PROGRESS = 'inProgress', + ERROR = 'error', +} + +export type ActiveConfigurationSectionData = { + id: string; + language: string; + detectionProgram: DetectionProgram | null | undefined; + draftProgram: DetectionProgram | null | undefined; + state: ActiveConfigurationState; + isExampleOnly?: boolean; + isToReview?: boolean; + severity?: DetectionSeverity; +}; + +// Keep old name as alias for backward compatibility during migration +export type ActiveConfigurationCardData = ActiveConfigurationSectionData; + +export type ActiveConfigurationSectionProps = { + configuration: ActiveConfigurationSectionData; + onGenerateProgram?: (language: string) => void; + isGenerating?: boolean; + standardId: string; + standardName?: string; + ruleId: string; + onTestProgram: (program: ActiveConfigurationSectionData) => void; + onActivateDraft?: (draft: DraftCardData) => void; + activatingDraftId?: string | null; + isActivatingDraft?: boolean; + onOpenAssessmentDrawer: (language: string) => void; + onNavigateToExamples?: () => void; + onReviewDraft?: () => void; +}; + +// Keep old name as alias for backward compatibility during migration +export type ActiveConfigurationCardProps = ActiveConfigurationSectionProps; diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/useActiveConfigurationSectionViewModel.ts b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/useActiveConfigurationSectionViewModel.ts new file mode 100644 index 000000000..4790bb70b --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/useActiveConfigurationSectionViewModel.ts @@ -0,0 +1,266 @@ +import { useMemo } from 'react'; +import { IPMButtonProps } from '@packmind/ui'; +import { + RuleDetectionAssessmentStatus, + DetectionStatus, +} from '@packmind/types'; +import { + useGetDetectionHeuristicsQuery, + useGetRuleDetectionAssessmentQuery, +} from '../../api/queries/DetectionProgramQueries'; +import { getToReviewMainAction } from './utils'; +import { + ActiveConfigurationSectionData, + ActiveConfigurationSectionProps, +} from './types'; +import { + ActiveConfigurationSectionKey, + ActiveConfigurationViewDescriptor, + ActiveConfigurationViewState, + resolveActiveConfigurationView, +} from './viewState'; + +type HookParams = Pick< + ActiveConfigurationSectionProps, + | 'configuration' + | 'standardId' + | 'ruleId' + | 'standardName' + | 'onGenerateProgram' + | 'isGenerating' + | 'onTestProgram' + | 'onActivateDraft' + | 'isActivatingDraft' + | 'onOpenAssessmentDrawer' + | 'onNavigateToExamples' + | 'onReviewDraft' +> & { + isAssessmentFeatureEnabled: boolean; +}; + +export type NoConfigurationAssessmentView = + | { status: 'loading' } + | { status: 'inProgress' } + | { + status: 'failed'; + details?: string; + canRefine: boolean; + onRefine: () => void; + } + | { status: 'ready' }; + +type DetectabilityDescriptor = { + key: ActiveConfigurationSectionKey.DETECTABILITY; + props: { + standardName?: string; + }; +}; + +type TestDescriptor = { + key: ActiveConfigurationSectionKey.TEST_ACTIVE_VERSION; + props: { + onTestClick: () => void; + }; +}; + +type FalsePositivesDescriptor = { + key: ActiveConfigurationSectionKey.FALSE_POSITIVES; + props: { + onCodeExamplesClick: () => void; + }; +}; + +type ToReviewDescriptor = { + key: ActiveConfigurationSectionKey.TO_REVIEW_CARD; + props: { + onGenerateProgramClick: () => void; + onReviewDraft?: () => void; + isGenerating: boolean; + hasDraftToReview: boolean; + isDraftInProgress: boolean; + }; +}; + +type DraftSummaryDescriptor = { + key: ActiveConfigurationSectionKey.DRAFT_REVIEW_SUMMARY; + props: { + draftVersion?: number | string | null; + mainButtonProps: IPMButtonProps | null; + }; +}; + +export type SectionDescriptor = + | DetectabilityDescriptor + | TestDescriptor + | FalsePositivesDescriptor + | ToReviewDescriptor + | DraftSummaryDescriptor; + +export type ActiveConfigurationSectionViewModel = { + descriptor: ActiveConfigurationViewDescriptor; + sections: SectionDescriptor[]; + assessmentView: NoConfigurationAssessmentView | null; +}; + +export const useActiveConfigurationSectionViewModel = ( + params: HookParams, +): ActiveConfigurationSectionViewModel => { + const { + configuration, + standardId, + ruleId, + standardName, + onGenerateProgram, + isGenerating = false, + onTestProgram, + onActivateDraft, + isActivatingDraft = false, + onOpenAssessmentDrawer, + isAssessmentFeatureEnabled, + onNavigateToExamples, + onReviewDraft, + } = params; + + const descriptor = useMemo( + () => resolveActiveConfigurationView(configuration), + [configuration], + ); + + const assessmentQuery = useGetRuleDetectionAssessmentQuery( + standardId, + ruleId, + configuration.language, + ); + + const heuristicsQuery = useGetDetectionHeuristicsQuery( + standardId, + ruleId, + configuration.language, + ); + + const assessmentView = useMemo<NoConfigurationAssessmentView | null>(() => { + if ( + descriptor.viewState !== ActiveConfigurationViewState.NO_CONFIGURATION + ) { + return null; + } + + const assessment = assessmentQuery.data; + if (!assessment) { + return { status: 'loading' }; + } + + if (assessment.status === RuleDetectionAssessmentStatus.IN_PROGRESS) { + return { status: 'inProgress' }; + } + + if (assessment.status === RuleDetectionAssessmentStatus.FAILED) { + const canRefine = + isAssessmentFeatureEnabled && Boolean(heuristicsQuery.data); + + return { + status: 'failed', + details: assessment.details ?? undefined, + canRefine, + onRefine: () => onOpenAssessmentDrawer(configuration.language), + }; + } + + return { status: 'ready' }; + }, [ + assessmentQuery.data, + configuration.language, + heuristicsQuery.data, + isAssessmentFeatureEnabled, + onOpenAssessmentDrawer, + descriptor.viewState, + ]); + + const sections = useMemo<SectionDescriptor[]>(() => { + return descriptor.sectionKeys.reduce<SectionDescriptor[]>((acc, key) => { + switch (key) { + case ActiveConfigurationSectionKey.DETECTABILITY: + acc.push({ + key, + props: { + standardName, + }, + }); + break; + case ActiveConfigurationSectionKey.TEST_ACTIVE_VERSION: + acc.push({ + key, + props: { + onTestClick: () => onTestProgram(configuration), + }, + }); + break; + case ActiveConfigurationSectionKey.FALSE_POSITIVES: + acc.push({ + key, + props: { + onCodeExamplesClick: onNavigateToExamples ?? (() => undefined), + }, + }); + break; + case ActiveConfigurationSectionKey.TO_REVIEW_CARD: + acc.push({ + key, + props: { + onGenerateProgramClick: () => + onGenerateProgram + ? onGenerateProgram(configuration.language) + : undefined, + onReviewDraft, + isGenerating, + hasDraftToReview: + configuration?.draftProgram?.status !== undefined && + [DetectionStatus.READY, DetectionStatus.TO_REVIEW].includes( + configuration.draftProgram.status, + ), + isDraftInProgress: + configuration?.draftProgram?.status === + DetectionStatus.IN_PROGRESS, + }, + }); + break; + case ActiveConfigurationSectionKey.DRAFT_REVIEW_SUMMARY: + acc.push({ + key, + props: { + draftVersion: configuration.draftProgram?.version ?? null, + mainButtonProps: getToReviewMainAction({ + configuration, + onGenerateProgram, + onActivateDraft, + isGenerating, + isActivatingDraft, + }), + }, + }); + break; + default: + break; + } + + return acc; + }, []); + }, [ + configuration, + descriptor.sectionKeys, + isActivatingDraft, + isGenerating, + onActivateDraft, + onGenerateProgram, + onNavigateToExamples, + onReviewDraft, + onTestProgram, + standardName, + ]); + + return { + descriptor, + sections, + assessmentView, + }; +}; diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/utils.ts b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/utils.ts new file mode 100644 index 000000000..4516f3ca4 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/utils.ts @@ -0,0 +1,69 @@ +import { IPMButtonProps } from '@packmind/ui'; +import { DetectionStatus } from '@packmind/types'; +import { ActiveConfigurationSectionProps } from './types'; +import { DraftCardData } from '../DetectionDraftCard/DetectionDraftCard'; + +export function getToReviewMainAction({ + configuration, + onGenerateProgram, + onActivateDraft, + isGenerating, + isActivatingDraft, +}: Pick< + ActiveConfigurationSectionProps, + | 'configuration' + | 'onGenerateProgram' + | 'onActivateDraft' + | 'isGenerating' + | 'isActivatingDraft' +>): IPMButtonProps | null { + if (!onGenerateProgram || !onActivateDraft) { + return null; + } + + const draftProgram = configuration.draftProgram ?? null; + if (!draftProgram) { + return { + onClick: () => onGenerateProgram(configuration.language), + children: 'New draft', + loading: isGenerating, + disabled: isGenerating, + }; + } + + const draftCard: DraftCardData = { + id: `${configuration.id}-draft-${draftProgram.id}`, + language: draftProgram.language ?? configuration.language, + activeDetectionProgramId: configuration.id, + hasActiveProgram: !!configuration.detectionProgram, + draftProgram, + status: draftProgram.status, + mode: draftProgram.mode, + version: draftProgram.version, + }; + + if (draftProgram?.status === DetectionStatus.READY) { + return { + onClick: () => onActivateDraft(draftCard), + children: 'Activate draft', + loading: isActivatingDraft, + disabled: isActivatingDraft, + }; + } + + if (draftProgram.status === DetectionStatus.TO_REVIEW) { + return { + onClick: () => onGenerateProgram(configuration.language), + children: 'Update draft', + loading: isGenerating, + disabled: isGenerating, + }; + } + + return { + onClick: () => onGenerateProgram(configuration.language), + children: 'Retry draft', + loading: isGenerating, + disabled: isGenerating, + }; +} diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/viewState.test.ts b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/viewState.test.ts new file mode 100644 index 000000000..3aa0dcba3 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/viewState.test.ts @@ -0,0 +1,114 @@ +import { + ActiveConfigurationSectionData, + ActiveConfigurationState, +} from './types'; +import { + ActiveConfigurationSectionKey, + ActiveConfigurationViewState, + resolveActiveConfigurationView, +} from './viewState'; + +describe('resolveActiveConfigurationView', () => { + const baseConfig: ActiveConfigurationSectionData = { + id: 'config', + language: 'ts', + detectionProgram: null, + draftProgram: null, + state: ActiveConfigurationState.OK, + }; + + it('returns no configuration descriptor', () => { + const descriptor = resolveActiveConfigurationView({ + ...baseConfig, + state: ActiveConfigurationState.NO_CONFIG, + }); + + expect(descriptor).toEqual({ + viewState: ActiveConfigurationViewState.NO_CONFIGURATION, + sectionKeys: [], + hasDetectionProgram: false, + hasDraftProgram: false, + }); + }); + + it('returns in progress descriptor', () => { + const descriptor = resolveActiveConfigurationView({ + ...baseConfig, + state: ActiveConfigurationState.IN_PROGRESS, + }); + + expect(descriptor.viewState).toBe(ActiveConfigurationViewState.IN_PROGRESS); + }); + + it('maps ok state to ready sections', () => { + const descriptor = resolveActiveConfigurationView({ + ...baseConfig, + state: ActiveConfigurationState.OK, + }); + + expect(descriptor).toEqual({ + viewState: ActiveConfigurationViewState.READY, + sectionKeys: [ + ActiveConfigurationSectionKey.DETECTABILITY, + ActiveConfigurationSectionKey.TEST_ACTIVE_VERSION, + ActiveConfigurationSectionKey.FALSE_POSITIVES, + ], + hasDetectionProgram: false, + hasDraftProgram: false, + }); + }); + + it('maps to review with detection program', () => { + const descriptor = resolveActiveConfigurationView({ + ...baseConfig, + detectionProgram: { id: 'dp' } as never, + state: ActiveConfigurationState.TO_REVIEW, + }); + + expect(descriptor).toEqual({ + viewState: ActiveConfigurationViewState.TO_REVIEW_WITH_ACTIVE, + sectionKeys: [ + ActiveConfigurationSectionKey.TO_REVIEW_CARD, + ActiveConfigurationSectionKey.TEST_ACTIVE_VERSION, + ActiveConfigurationSectionKey.FALSE_POSITIVES, + ], + hasDetectionProgram: true, + hasDraftProgram: false, + }); + }); + + it('maps to review with draft only', () => { + const descriptor = resolveActiveConfigurationView({ + ...baseConfig, + draftProgram: { id: 'draft' } as never, + state: ActiveConfigurationState.TO_REVIEW, + }); + + expect(descriptor).toEqual({ + viewState: ActiveConfigurationViewState.TO_REVIEW_DRAFT_ONLY, + sectionKeys: [ActiveConfigurationSectionKey.DRAFT_REVIEW_SUMMARY], + hasDetectionProgram: false, + hasDraftProgram: true, + }); + }); + + describe('when state is error', () => { + it('returns error view state', () => { + const descriptor = resolveActiveConfigurationView({ + ...baseConfig, + state: ActiveConfigurationState.ERROR, + }); + + expect(descriptor.viewState).toBe(ActiveConfigurationViewState.ERROR); + }); + + it('returns empty section keys', () => { + const descriptor = resolveActiveConfigurationView({ + ...baseConfig, + state: ActiveConfigurationState.ERROR, + }); + + expect(descriptor.sectionKeys).toEqual([]); + }); + }); +}); diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/viewState.ts b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/viewState.ts new file mode 100644 index 000000000..42dcfb11e --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationSection/viewState.ts @@ -0,0 +1,86 @@ +import { + ActiveConfigurationSectionData, + ActiveConfigurationState, +} from './types'; + +export enum ActiveConfigurationViewState { + NO_CONFIGURATION = 'noConfiguration', + IN_PROGRESS = 'inProgress', + READY = 'ready', + TO_REVIEW_WITH_ACTIVE = 'toReviewWithActive', + TO_REVIEW_DRAFT_ONLY = 'toReviewDraftOnly', + ERROR = 'error', +} + +export enum ActiveConfigurationSectionKey { + DETECTABILITY = 'detectability', + TEST_ACTIVE_VERSION = 'testActiveVersion', + FALSE_POSITIVES = 'falsePositives', + TO_REVIEW_CARD = 'toReviewCard', + DRAFT_REVIEW_SUMMARY = 'draftReviewSummary', +} + +export type ActiveConfigurationViewDescriptor = { + viewState: ActiveConfigurationViewState; + sectionKeys: ActiveConfigurationSectionKey[]; + hasDetectionProgram: boolean; + hasDraftProgram: boolean; +}; + +const SECTIONS_BY_VIEW_STATE: Record< + ActiveConfigurationViewState, + ActiveConfigurationSectionKey[] +> = { + [ActiveConfigurationViewState.NO_CONFIGURATION]: [], + [ActiveConfigurationViewState.IN_PROGRESS]: [], + [ActiveConfigurationViewState.READY]: [ + ActiveConfigurationSectionKey.DETECTABILITY, + ActiveConfigurationSectionKey.TEST_ACTIVE_VERSION, + ActiveConfigurationSectionKey.FALSE_POSITIVES, + ], + [ActiveConfigurationViewState.TO_REVIEW_WITH_ACTIVE]: [ + ActiveConfigurationSectionKey.TO_REVIEW_CARD, + ActiveConfigurationSectionKey.TEST_ACTIVE_VERSION, + ActiveConfigurationSectionKey.FALSE_POSITIVES, + ], + [ActiveConfigurationViewState.TO_REVIEW_DRAFT_ONLY]: [ + ActiveConfigurationSectionKey.DRAFT_REVIEW_SUMMARY, + ], + [ActiveConfigurationViewState.ERROR]: [], +}; + +export function resolveActiveConfigurationView( + configuration: ActiveConfigurationSectionData, +): ActiveConfigurationViewDescriptor { + const hasDetectionProgram = Boolean(configuration.detectionProgram); + const hasDraftProgram = Boolean(configuration.draftProgram); + + let viewState: ActiveConfigurationViewState; + switch (configuration.state) { + case ActiveConfigurationState.NO_CONFIG: + viewState = ActiveConfigurationViewState.NO_CONFIGURATION; + break; + case ActiveConfigurationState.IN_PROGRESS: + viewState = ActiveConfigurationViewState.IN_PROGRESS; + break; + case ActiveConfigurationState.OK: + viewState = ActiveConfigurationViewState.READY; + break; + case ActiveConfigurationState.TO_REVIEW: + viewState = hasDetectionProgram + ? ActiveConfigurationViewState.TO_REVIEW_WITH_ACTIVE + : ActiveConfigurationViewState.TO_REVIEW_DRAFT_ONLY; + break; + case ActiveConfigurationState.ERROR: + default: + viewState = ActiveConfigurationViewState.ERROR; + break; + } + + return { + viewState, + sectionKeys: SECTIONS_BY_VIEW_STATE[viewState], + hasDetectionProgram, + hasDraftProgram, + }; +} diff --git a/apps/frontend/src/domain/detection/components/ActiveConfigurationsList.tsx b/apps/frontend/src/domain/detection/components/ActiveConfigurationsList.tsx new file mode 100644 index 000000000..9f483aa72 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ActiveConfigurationsList.tsx @@ -0,0 +1,166 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + PMAlert, + PMEmptyState, + PMFlex, + PMPageSection, + PMSpinner, +} from '@packmind/ui'; +import { + ActiveConfigurationSection as ActiveConfigurationCard, + ActiveConfigurationSectionData as ActiveConfigurationCardData, + ActiveConfigurationState, +} from './ActiveConfigurationSection/'; +import { DraftCardData } from './DetectionDraftCard/DetectionDraftCard'; +import { DetectionAssessmentDrawer } from './DetectionAssessmentDrawer'; + +const LoadingState = ({ + title, + description, +}: { + title: string; + description: string; +}) => ( + <PMEmptyState icon={<PMSpinner />} title={title} description={description} /> +); + +const ErrorState = ({ + title, + description, +}: { + title: string; + description: string; +}) => ( + <PMAlert.Root status="error"> + <PMAlert.Indicator /> + <PMAlert.Title>{title}</PMAlert.Title> + <PMAlert.Description>{description}</PMAlert.Description> + </PMAlert.Root> +); + +interface ActiveConfigurationSectionProps { + configurations: ActiveConfigurationCardData[]; + isLoading: boolean; + isError: boolean; + onGenerateProgram: (language?: string) => void; + isGeneratingProgram: boolean; + standardId: string; + standardName?: string; + ruleId: string; + onTestProgram: (program: ActiveConfigurationCardData) => void; + onActivateDraft: (draft: DraftCardData) => void; + activatingDraftId: string | null; + isActivatingDraft: boolean; + onNavigateToExamples?: () => void; + onReviewDraft?: () => void; +} + +export const ActiveConfigurationSection: React.FC< + ActiveConfigurationSectionProps +> = ({ + configurations, + isLoading, + isError, + onGenerateProgram, + isGeneratingProgram, + standardId, + standardName, + ruleId, + onTestProgram, + onActivateDraft, + activatingDraftId, + isActivatingDraft, + onNavigateToExamples, + onReviewDraft, +}) => { + const [openDrawerLanguage, setOpenDrawerLanguage] = useState<string | null>( + null, + ); + + const handleOpenAssessmentDrawer = useCallback((language: string) => { + setOpenDrawerLanguage(language); + }, []); + + const handleCloseDrawer = useCallback(() => { + setOpenDrawerLanguage(null); + }, []); + + useEffect(() => { + if (openDrawerLanguage) { + const languageExists = configurations.some( + (c) => c.language === openDrawerLanguage, + ); + + if (!languageExists) { + setOpenDrawerLanguage(null); + } + } + }, [configurations, openDrawerLanguage]); + + if (isLoading) { + return ( + <PMPageSection variant="outline"> + <LoadingState + title="Loading detection programs" + description="Please wait while we fetch the latest configurations." + /> + </PMPageSection> + ); + } + + if (isError) { + return ( + <PMPageSection variant="outline"> + <ErrorState + title="Unable to load detection programs" + description="Try again later or regenerate the program from the CLI." + /> + </PMPageSection> + ); + } + + if (configurations.length === 0) { + return ( + <PMPageSection variant="outline"> + <PMEmptyState + title="No configurations yet" + description="Generate a detection program to start configuring this rule." + /> + </PMPageSection> + ); + } + + return ( + <> + <PMFlex gap={4} wrap="wrap"> + {configurations + .filter((config) => config != null) + .map((config) => ( + <ActiveConfigurationCard + key={config.id} + configuration={config} + onGenerateProgram={onGenerateProgram} + isGenerating={isGeneratingProgram} + standardId={standardId} + standardName={standardName} + ruleId={ruleId} + onTestProgram={onTestProgram} + onActivateDraft={onActivateDraft} + activatingDraftId={activatingDraftId} + isActivatingDraft={isActivatingDraft} + onOpenAssessmentDrawer={handleOpenAssessmentDrawer} + onNavigateToExamples={onNavigateToExamples} + onReviewDraft={onReviewDraft} + /> + ))} + </PMFlex> + <DetectionAssessmentDrawer + isOpen={openDrawerLanguage !== null} + onClose={handleCloseDrawer} + standardId={standardId} + ruleId={ruleId} + language={openDrawerLanguage} + /> + </> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/DetectionAccordions/AccordionProgramActionButtons.spec.tsx b/apps/frontend/src/domain/detection/components/DetectionAccordions/AccordionProgramActionButtons.spec.tsx new file mode 100644 index 000000000..ffd4ad2a8 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionAccordions/AccordionProgramActionButtons.spec.tsx @@ -0,0 +1,322 @@ +import React from 'react'; +import { render, RenderResult, fireEvent, act } from '@testing-library/react'; +import { UIProvider } from '@packmind/ui'; +import { + DetectionStatus, + ProgrammingLanguage, + createRuleId, + createStandardId, + RuleDetectionAssessmentStatus, +} from '@packmind/types'; +import { ActiveConfigurationSectionData } from '../ActiveConfigurationSection'; +import { DraftCardData } from '../DetectionDraftCard/DetectionDraftCard'; +import { AccordionProgramActionButtons } from './AccordionProgramActionButtons'; +import * as DetectionProgramQueries from '../../api/queries/DetectionProgramQueries'; + +jest.mock('../../api/queries/DetectionProgramQueries'); + +describe('AccordionProgramActionButtons', () => { + let screen: RenderResult; + let onTestProgram: jest.Mock; + let onGenerateProgram: jest.Mock; + let onActivateDraft: jest.Mock; + let onRetryDraft: jest.Mock; + let onViewModeChange: jest.Mock; + let onShowLogs: jest.Mock; + let onShowProgram: jest.Mock; + + const standardId = createStandardId('standard-123'); + const ruleId = createRuleId('rule-456'); + + const createActiveConfig = ( + status: DetectionStatus = DetectionStatus.READY, + ): ActiveConfigurationSectionData => ({ + id: 'config-123', + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgram: { + id: 'program-123', + ruleId: createRuleId('rule-456'), + language: ProgrammingLanguage.TYPESCRIPT, + status, + code: '', + sourceCodeState: 'NONE' as const, + version: 1, + mode: 'singleAst', + }, + draftProgram: null, + state: 'ok' as any, // eslint-disable-line @typescript-eslint/no-explicit-any + }); + + const createDraftData = ( + status: DetectionStatus = DetectionStatus.READY, + ): DraftCardData => ({ + id: 'draft-123', + language: ProgrammingLanguage.TYPESCRIPT, + activeDetectionProgramId: 'active-456', + draftProgram: { + id: 'draft-program-123', + ruleId: createRuleId('rule-456'), + language: ProgrammingLanguage.TYPESCRIPT, + status, + code: '', + sourceCodeState: 'NONE' as const, + version: 2, + mode: 'singleAst', + } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + status, + mode: 'AI_GENERATION', + version: 2, + }); + + beforeEach(() => { + onTestProgram = jest.fn(); + onGenerateProgram = jest.fn(); + onActivateDraft = jest.fn(); + onRetryDraft = jest.fn(); + onViewModeChange = jest.fn(); + onShowLogs = jest.fn(); + onShowProgram = jest.fn(); + + // Mock the assessment query with SUCCESS status by default + jest + .spyOn(DetectionProgramQueries, 'useGetRuleDetectionAssessmentQuery') + .mockReturnValue({ + data: { + status: RuleDetectionAssessmentStatus.SUCCESS, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + interface RenderProps { + activeConfigurations?: ActiveConfigurationSectionData[]; + activeDraft?: DraftCardData; + isGeneratingProgram?: boolean; + selectedLanguage?: string; + isActivating?: boolean; + viewMode?: 'active' | 'draft'; + } + + function renderComponent({ + activeConfigurations = [], + activeDraft, + isGeneratingProgram = false, + selectedLanguage = ProgrammingLanguage.TYPESCRIPT, + isActivating = false, + viewMode = 'active', + }: RenderProps = {}) { + return render( + <UIProvider> + <AccordionProgramActionButtons + activeConfigurations={activeConfigurations} + activeDraft={activeDraft} + onTestProgram={onTestProgram} + onGenerateProgram={onGenerateProgram} + onActivateDraft={onActivateDraft} + onRetryDraft={onRetryDraft} + isGeneratingProgram={isGeneratingProgram} + selectedLanguage={selectedLanguage} + viewMode={viewMode} + onViewModeChange={onViewModeChange} + standardId={standardId} + ruleId={ruleId} + onShowLogs={onShowLogs} + onShowProgram={onShowProgram} + isActivating={isActivating} + /> + </UIProvider>, + ); + } + + describe('view mode synchronization', () => { + describe('when only active program exists', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [createActiveConfig()], + viewMode: 'active', + }); + }); + + it('calls onViewModeChange with active mode to sync state', () => { + expect(onViewModeChange).toHaveBeenCalledWith('active'); + }); + }); + + describe('when only draft exists', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [ + { ...createActiveConfig(), detectionProgram: null }, + ], + activeDraft: createDraftData(), + viewMode: 'active', + }); + }); + + it('calls onViewModeChange with draft mode to sync state', () => { + expect(onViewModeChange).toHaveBeenCalledWith('draft'); + }); + }); + + describe('when both active and draft exist', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [createActiveConfig()], + activeDraft: createDraftData(), + viewMode: 'active', + }); + }); + + it('does not auto-switch mode', () => { + expect(onViewModeChange).not.toHaveBeenCalled(); + }); + }); + }); + + describe('switch button visibility', () => { + describe('when both active program and draft exist', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [createActiveConfig()], + activeDraft: createDraftData(), + }); + }); + + it('renders the See draft switch button', () => { + expect(screen.getByText('Draft: OK')).toBeInTheDocument(); + }); + }); + + describe('when only active program exists', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [createActiveConfig()], + }); + }); + + it('does not render the switch button', () => { + expect(screen.queryByText('Draft: OK')).not.toBeInTheDocument(); + }); + }); + + describe('when only draft exists', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [ + { ...createActiveConfig(), detectionProgram: null }, + ], + activeDraft: createDraftData(), + }); + }); + + it('does not render the switch button', () => { + expect(screen.queryByText('See draft')).not.toBeInTheDocument(); + }); + }); + }); + + describe('active dropdown', () => { + describe('when active config has READY status', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [createActiveConfig(DetectionStatus.READY)], + }); + }); + + it('renders the Active dropdown button', () => { + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + }); + + describe('when active config has TO_REVIEW status', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [createActiveConfig(DetectionStatus.TO_REVIEW)], + }); + }); + + it('renders the To review dropdown button', () => { + expect(screen.getByText('To review')).toBeInTheDocument(); + }); + }); + + describe('when active config has ERROR status', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [createActiveConfig(DetectionStatus.ERROR)], + }); + }); + + it('does not render Test program action without READY config', () => { + // Active dropdown won't render if there are no actions + // Generate new draft would be the only action if not generating + expect(screen.queryByText('Test program')).not.toBeInTheDocument(); + }); + }); + + describe('when not generating program', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [createActiveConfig()], + isGeneratingProgram: false, + }); + }); + + it('renders the Active dropdown button', () => { + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + }); + + describe('when generating program with active config', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [createActiveConfig()], + isGeneratingProgram: true, + }); + }); + + it('renders menu with disabled Generate action', () => { + // Menu still renders with disabled "Generate new draft" action when there's an active program + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + }); + }); + + describe('when no active program and no draft', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [ + { ...createActiveConfig(), detectionProgram: null }, + ], + }); + }); + + it('renders Active dropdown with Generate new draft action', () => { + // Even without READY active config, the dropdown shows for "Generate new draft" + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('does not render the switch button', () => { + expect(screen.queryByText('See draft')).not.toBeInTheDocument(); + }); + }); + + describe('when no active program, no draft, and generating', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [ + { ...createActiveConfig(), detectionProgram: null }, + ], + isGeneratingProgram: true, + }); + }); + + it('does not render the Active dropdown', () => { + expect(screen.queryByText('Active')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/frontend/src/domain/detection/components/DetectionAccordions/AccordionProgramActionButtons.tsx b/apps/frontend/src/domain/detection/components/DetectionAccordions/AccordionProgramActionButtons.tsx new file mode 100644 index 000000000..8daca888b --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionAccordions/AccordionProgramActionButtons.tsx @@ -0,0 +1,176 @@ +import React, { useEffect } from 'react'; +import { + PMButton, + PMButtonGroup, + PMIcon, + PMMenu, + PMPortal, +} from '@packmind/ui'; +import { ActiveConfigurationSectionData } from '../ActiveConfigurationSection'; +import { DraftCardData } from '../DetectionDraftCard/DetectionDraftCard'; +import { DetectionDraftMenu, getMenuLabel } from './DetectionDraftMenu'; +import { ActiveProgramMenu } from './ActiveProgramMenu'; +import { LuChevronDown } from 'react-icons/lu'; +import { determineDraftStatus } from '../DetectionDraftCard/determineDraftStatus'; +import { useGetRuleDetectionAssessmentQuery } from '../../api/queries'; + +export type ViewMode = 'active' | 'draft'; + +interface AccordionProgramActionButtonsProps { + activeConfigurations: ActiveConfigurationSectionData[]; + activeDraft?: DraftCardData; + onTestProgram: ( + draft: DraftCardData | ActiveConfigurationSectionData, + ) => void; + onGenerateProgram: (language?: string) => void; + onActivateDraft: (draft: DraftCardData) => void; + onRetryDraft: (draft: DraftCardData) => void; + isGeneratingProgram: boolean; + selectedLanguage: string; + viewMode: ViewMode; + onViewModeChange: (mode: ViewMode) => void; + // Props for DetectionDraftMenu + standardId: string; + ruleId: string; + onShowLogs: () => void; + onShowProgram: () => void; + onShowDetails: (config: ActiveConfigurationSectionData) => void; + isActivating?: boolean; +} + +export const AccordionProgramActionButtons: React.FC< + AccordionProgramActionButtonsProps +> = ({ + activeConfigurations, + activeDraft, + onTestProgram, + onGenerateProgram, + onActivateDraft, + onRetryDraft, + isGeneratingProgram, + selectedLanguage, + viewMode, + onViewModeChange, + standardId, + ruleId, + onShowLogs, + onShowProgram, + onShowDetails, + isActivating, +}) => { + // Compute derived state + const hasActiveProgram = activeConfigurations.some( + (config) => config.detectionProgram, + ); + const hasDraft = !!activeDraft; + const showToggle = hasActiveProgram && hasDraft; + + const { data: assessment } = useGetRuleDetectionAssessmentQuery( + standardId, + ruleId, + activeDraft?.language ?? '', + ); + + // Update view mode if conditions change + useEffect(() => { + if (hasDraft && !hasActiveProgram) { + onViewModeChange('draft'); + } else if (!hasDraft && hasActiveProgram) { + onViewModeChange('active'); + } + }, [hasDraft, hasActiveProgram, onViewModeChange]); + + const handleToggle = () => { + onViewModeChange(viewMode === 'active' ? 'draft' : 'active'); + }; + + const buttons: React.ReactNode[] = []; + if (viewMode === 'active') { + buttons.push( + <ActiveProgramMenu + key="active-menu" + activeConfigurations={activeConfigurations} + onTestProgram={onTestProgram} + onShowDetails={onShowDetails} + onGenerateProgram={onGenerateProgram} + isGeneratingProgram={isGeneratingProgram} + selectedLanguage={selectedLanguage} + />, + ); + if (showToggle) { + buttons.push( + <PMMenu.Root key="draft-toggle-menu"> + <PMMenu.Trigger asChild> + <PMButton + size="2xs" + variant={'tertiary'} + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + }} + > + {getMenuLabel( + determineDraftStatus(assessment?.status, activeDraft.status), + )} + <PMIcon size="xs"> + <LuChevronDown /> + </PMIcon> + </PMButton> + </PMMenu.Trigger> + <PMPortal> + <PMMenu.Positioner> + <PMMenu.Content> + <PMMenu.Item + value={'editDraft'} + onClick={(e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + handleToggle(); + }} + > + Edit draft + </PMMenu.Item> + </PMMenu.Content> + </PMMenu.Positioner> + </PMPortal> + </PMMenu.Root>, + ); + } + } else { + if (showToggle) { + buttons.push( + <PMButton + key="back-button" + size="2xs" + variant={'tertiary'} + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + handleToggle(); + }} + > + Back + </PMButton>, + ); + } + + if (activeDraft) { + buttons.push( + <DetectionDraftMenu + key="draft-menu" + draft={activeDraft} + onMakeActive={onActivateDraft} + isActivating={isActivating} + onTestDraft={onTestProgram} + onRetryDraft={onRetryDraft} + isGenerating={isGeneratingProgram} + standardId={standardId} + ruleId={ruleId} + onShowLogs={onShowLogs} + onShowProgram={onShowProgram} + />, + ); + } + } + + // Render based on current view mode + return <PMButtonGroup>{buttons}</PMButtonGroup>; +}; diff --git a/apps/frontend/src/domain/detection/components/DetectionAccordions/ActiveProgramMenu.test.tsx b/apps/frontend/src/domain/detection/components/DetectionAccordions/ActiveProgramMenu.test.tsx new file mode 100644 index 000000000..149f4748a --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionAccordions/ActiveProgramMenu.test.tsx @@ -0,0 +1,254 @@ +import React from 'react'; +import { + render, + RenderResult, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { + DetectionStatus, + ProgrammingLanguage, + createRuleId, +} from '@packmind/types'; +import { ActiveProgramMenu } from './ActiveProgramMenu'; +import { ActiveConfigurationSectionData } from '../ActiveConfigurationSection'; +import { UIProvider } from '@packmind/ui'; + +describe('ActiveProgramMenu', () => { + let screen: RenderResult; + let onTestProgram: jest.Mock; + let onGenerateProgram: jest.Mock; + let onShowDetails: jest.Mock; + + const createActiveConfig = ( + status: DetectionStatus = DetectionStatus.READY, + ): ActiveConfigurationSectionData => ({ + id: 'config-123', + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgram: { + id: 'program-123', + ruleId: createRuleId('rule-456'), + language: ProgrammingLanguage.TYPESCRIPT, + status, + code: '', + sourceCodeState: 'NONE' as const, + version: 1, + mode: 'singleAst', + }, + draftProgram: null, + state: 'ok' as any, // eslint-disable-line @typescript-eslint/no-explicit-any + }); + + beforeEach(() => { + onTestProgram = jest.fn(); + onGenerateProgram = jest.fn(); + onShowDetails = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + interface RenderProps { + activeConfigurations?: ActiveConfigurationSectionData[]; + isGeneratingProgram?: boolean; + selectedLanguage?: string; + } + + function renderComponent({ + activeConfigurations = [], + isGeneratingProgram = false, + selectedLanguage = ProgrammingLanguage.TYPESCRIPT, + }: RenderProps = {}) { + return render( + <UIProvider> + <ActiveProgramMenu + activeConfigurations={activeConfigurations} + onTestProgram={onTestProgram} + onShowDetails={onShowDetails} + onGenerateProgram={onGenerateProgram} + isGeneratingProgram={isGeneratingProgram} + selectedLanguage={selectedLanguage} + /> + </UIProvider>, + ); + } + + describe('menu label', () => { + describe('when config status is READY', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [createActiveConfig(DetectionStatus.READY)], + }); + }); + + it('displays "Active" label', () => { + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + }); + + describe('when config status is TO_REVIEW', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [createActiveConfig(DetectionStatus.TO_REVIEW)], + }); + }); + + it('displays "To review" label', () => { + expect(screen.getByText('To review')).toBeInTheDocument(); + }); + }); + + describe('when no active program exists but not generating', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [ + { ...createActiveConfig(), detectionProgram: null }, + ], + isGeneratingProgram: false, + }); + }); + + it('displays "Active" label for Generate action', () => { + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + }); + }); + + describe('menu visibility', () => { + describe('when generating and no detection program', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [ + { ...createActiveConfig(), detectionProgram: null }, + ], + isGeneratingProgram: true, + }); + }); + + it('does not render menu', () => { + expect(screen.queryByText('Active')).not.toBeInTheDocument(); + }); + }); + }); + + describe('menu actions', () => { + describe('when config has READY status', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [createActiveConfig(DetectionStatus.READY)], + isGeneratingProgram: false, + }); + }); + + describe('when menu is opened', () => { + beforeEach(async () => { + const trigger = screen.getByText('Active'); + fireEvent.click(trigger); + await waitFor(() => { + screen.getByText('Test program'); + }); + }); + + it('displays "Test program" action', () => { + expect(screen.getByText('Test program')).toBeInTheDocument(); + }); + + it('displays "Generation details" action', () => { + expect(screen.getByText('Generation details')).toBeInTheDocument(); + }); + + it('displays "Generate new draft" action', () => { + expect(screen.getByText('Generate new draft')).toBeInTheDocument(); + }); + + describe('when "Test program" is clicked', () => { + beforeEach(() => { + const testItem = screen.getByText('Test program'); + fireEvent.click(testItem); + }); + + it('calls onTestProgram callback', () => { + expect(onTestProgram).toHaveBeenCalled(); + }); + }); + + describe('when "Generate new draft" is clicked', () => { + beforeEach(() => { + const generateItem = screen.getByText('Generate new draft'); + fireEvent.click(generateItem); + }); + + it('calls onGenerateProgram callback with the language', () => { + expect(onGenerateProgram).toHaveBeenCalledWith( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + }); + }); + }); + + describe('when config has TO_REVIEW status', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [createActiveConfig(DetectionStatus.TO_REVIEW)], + isGeneratingProgram: false, + }); + }); + + describe('when menu is opened', () => { + beforeEach(async () => { + const trigger = screen.getByText('To review'); + fireEvent.click(trigger); + await waitFor(() => { + screen.getByText('Generation details'); + }); + }); + + it('does not display "Test program" action', () => { + expect(screen.queryByText('Test program')).not.toBeInTheDocument(); + }); + + it('displays "Generation details" action', () => { + expect(screen.getByText('Generation details')).toBeInTheDocument(); + }); + + it('displays "Generate new draft" action', () => { + expect(screen.getByText('Generate new draft')).toBeInTheDocument(); + }); + }); + }); + + describe('when generating program', () => { + beforeEach(() => { + screen = renderComponent({ + activeConfigurations: [createActiveConfig(DetectionStatus.READY)], + isGeneratingProgram: true, + }); + }); + + describe('when menu is opened', () => { + beforeEach(async () => { + const trigger = screen.getByText('Active'); + fireEvent.click(trigger); + await waitFor(() => { + screen.getByText('Test program'); + }); + }); + + it('displays "Test program" action', () => { + expect(screen.getByText('Test program')).toBeInTheDocument(); + }); + + it('displays "Generate new draft" action', () => { + expect(screen.getByText('Generate new draft')).toBeInTheDocument(); + }); + + it('disables "Generate new draft" action', () => { + const generateItem = screen.getByText('Generate new draft'); + expect(generateItem.closest('[data-disabled]')).toBeInTheDocument(); + }); + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/detection/components/DetectionAccordions/ActiveProgramMenu.tsx b/apps/frontend/src/domain/detection/components/DetectionAccordions/ActiveProgramMenu.tsx new file mode 100644 index 000000000..a75706937 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionAccordions/ActiveProgramMenu.tsx @@ -0,0 +1,217 @@ +import React, { useMemo } from 'react'; +import { + PMMenu, + PMPortal, + PMIcon, + PMButton, + PMText, + PMVStack, + PMSeparator, +} from '@packmind/ui'; +import { LuChevronDown, LuPlay, LuFileText, LuSparkles } from 'react-icons/lu'; +import { DetectionStatus } from '@packmind/types'; +import { ActiveConfigurationSectionData } from '../ActiveConfigurationSection'; +import { formatDate } from '../../../../shared/utils/dateUtils'; + +export enum ActiveProgramStatus { + ACTIVE = 'active', + TO_REVIEW = 'to_review', +} + +interface MenuAction { + label: string; + onClick: () => void; + disabled?: boolean; + icon?: React.ReactNode; +} + +interface ActiveProgramMenuProps { + activeConfigurations: ActiveConfigurationSectionData[]; + onTestProgram: (config: ActiveConfigurationSectionData) => void; + onShowDetails: (config: ActiveConfigurationSectionData) => void; + onGenerateProgram: (language?: string) => void; + isGeneratingProgram: boolean; + selectedLanguage: string; +} + +const determineActiveStatus = ( + activeConfigurations: ActiveConfigurationSectionData[], +): ActiveProgramStatus => { + const hasReadyConfig = activeConfigurations.some( + (config) => config.detectionProgram?.status === DetectionStatus.READY, + ); + + if (hasReadyConfig) { + return ActiveProgramStatus.ACTIVE; + } + + const hasToReviewConfig = activeConfigurations.some( + (config) => config.detectionProgram?.status === DetectionStatus.TO_REVIEW, + ); + + if (hasToReviewConfig) { + return ActiveProgramStatus.TO_REVIEW; + } + + return ActiveProgramStatus.ACTIVE; +}; + +const getMenuLabel = (status: ActiveProgramStatus): string => { + switch (status) { + case ActiveProgramStatus.ACTIVE: + return 'Active'; + case ActiveProgramStatus.TO_REVIEW: + return 'To review'; + } +}; + +const getButtonVariant = ( + status: ActiveProgramStatus, +): 'success' | 'warning' => { + if (status === ActiveProgramStatus.ACTIVE) { + return 'success'; + } + return 'warning'; +}; + +export const ActiveProgramMenu: React.FC<ActiveProgramMenuProps> = ({ + activeConfigurations, + onTestProgram, + onShowDetails, + onGenerateProgram, + isGeneratingProgram, + selectedLanguage, +}) => { + const status = determineActiveStatus(activeConfigurations); + + // Find config with READY status for full actions + const readyConfig = activeConfigurations.find( + (config) => config.detectionProgram?.status === DetectionStatus.READY, + ); + + // Find config with TO_REVIEW status for details action + const toReviewConfig = activeConfigurations.find( + (config) => config.detectionProgram?.status === DetectionStatus.TO_REVIEW, + ); + + // Use ready config if available, otherwise to review config + const activeConfig = readyConfig ?? toReviewConfig; + + // Check if any draft is pending (IN_PROGRESS) or ready (READY) + const hasPendingOrReadyDraft = activeConfigurations.some( + (config) => + config.draftProgram?.status === DetectionStatus.IN_PROGRESS || + config.draftProgram?.status === DetectionStatus.READY, + ); + + const actions = useMemo<MenuAction[]>(() => { + const menuActions: MenuAction[] = []; + + if (readyConfig) { + // Full actions for READY status + menuActions.push({ + label: 'Test program', + onClick: () => onTestProgram(readyConfig), + icon: <LuPlay />, + }); + menuActions.push({ + label: 'Generation details', + onClick: () => onShowDetails(readyConfig), + icon: <LuFileText />, + }); + } else if (toReviewConfig) { + // Limited actions for TO_REVIEW status - just show details + menuActions.push({ + label: 'Generation details', + onClick: () => onShowDetails(toReviewConfig), + icon: <LuFileText />, + }); + } + + if (onGenerateProgram) { + menuActions.push({ + label: 'Generate new draft', + onClick: () => onGenerateProgram(selectedLanguage), + icon: <LuSparkles />, + disabled: isGeneratingProgram || hasPendingOrReadyDraft, + }); + } + + return menuActions; + }, [ + readyConfig, + toReviewConfig, + onTestProgram, + onShowDetails, + onGenerateProgram, + isGeneratingProgram, + hasPendingOrReadyDraft, + selectedLanguage, + ]); + + // Don't render if no actions or if we're only generating without an active program + const hasActiveProgram = readyConfig || toReviewConfig; + if (actions.length === 0 || (!hasActiveProgram && isGeneratingProgram)) { + return null; + } + + return ( + <PMMenu.Root> + <PMMenu.Trigger asChild> + <PMButton + variant={getButtonVariant(status)} + size="2xs" + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + }} + > + {getMenuLabel(status)} + <PMIcon size="xs"> + <LuChevronDown /> + </PMIcon> + </PMButton> + </PMMenu.Trigger> + <PMPortal> + <PMMenu.Positioner> + <PMMenu.Content> + <PMVStack alignItems="left" px="1" pb="2"> + <PMText variant={'body-important'}>Information</PMText> + <PMText fontSize={'sm'}> + Version: {activeConfig?.detectionProgram?.version} + </PMText> + {activeConfig?.detectionProgram?.createdAt && ( + <PMText fontSize={'sm'}> + Generated on{' '} + {formatDate(activeConfig.detectionProgram.createdAt)} + </PMText> + )} + </PMVStack> + <PMSeparator borderColor="border.tertiary" pb="2" /> + {actions.map((action, index) => ( + <PMMenu.Item + key={index} + value={action.label} + cursor={action.disabled ? 'not-allowed' : 'pointer'} + disabled={action.disabled} + onClick={(e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!action.disabled) { + action.onClick(); + } + }} + > + {action.icon && ( + <PMIcon size="sm" mr={2}> + {action.icon} + </PMIcon> + )} + {action.label} + </PMMenu.Item> + ))} + </PMMenu.Content> + </PMMenu.Positioner> + </PMPortal> + </PMMenu.Root> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/DetectionAccordions/DetectabilityAccordion.tsx b/apps/frontend/src/domain/detection/components/DetectionAccordions/DetectabilityAccordion.tsx new file mode 100644 index 000000000..c2a606b16 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionAccordions/DetectabilityAccordion.tsx @@ -0,0 +1,103 @@ +import { DetectionAccordion } from './DetectionAccordion'; +import { DetectabilitySection } from './DetectabilitySection'; +import React, { useMemo } from 'react'; +import { useGetRuleDetectionAssessmentQuery } from '../../api/queries'; +import { RuleDetectionAssessmentStatus, RuleExample } from '@packmind/types'; +import { PMBadge } from '@packmind/ui'; + +export enum DetectionAccordionStatus { + SUCCESS = 'SUCCESS', + FAILED = 'FAILED', + IN_PROGRESS = 'IN_PROGRESS', +} + +const STATUS_CONFIG: Record< + DetectionAccordionStatus, + { text: string; colorPalette: string } +> = { + [DetectionAccordionStatus.SUCCESS]: { + text: 'Success', + colorPalette: 'green', + }, + [DetectionAccordionStatus.FAILED]: { + text: 'Failed', + colorPalette: 'red', + }, + [DetectionAccordionStatus.IN_PROGRESS]: { + text: 'In progress', + colorPalette: 'blue', + }, +}; + +function convertToAccordionStatus( + status: RuleDetectionAssessmentStatus, +): DetectionAccordionStatus { + switch (status) { + case RuleDetectionAssessmentStatus.SUCCESS: + return DetectionAccordionStatus.SUCCESS; + case RuleDetectionAssessmentStatus.FAILED: + return DetectionAccordionStatus.FAILED; + case RuleDetectionAssessmentStatus.IN_PROGRESS: + return DetectionAccordionStatus.IN_PROGRESS; + default: + return DetectionAccordionStatus.IN_PROGRESS; + } +} + +type DetectabilityAccordionProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + standardId: string; + ruleId: string; + language: string; + ruleExamples?: RuleExample[]; +}; + +const DetectabilityBadge: React.FunctionComponent<{ + status?: DetectionAccordionStatus; +}> = ({ status }) => { + if (!status) return null; + + const badgeConfig = STATUS_CONFIG[status]; + return ( + <PMBadge + colorPalette={badgeConfig.colorPalette} + backgroundColor="colorPalette.500" + variant="solid" + > + {badgeConfig.text} + </PMBadge> + ); +}; + +export const DetectabilityAccordion: React.FunctionComponent< + DetectabilityAccordionProps +> = ({ isOpen, onOpenChange, standardId, ruleId, language, ruleExamples }) => { + const { data: assessment } = useGetRuleDetectionAssessmentQuery( + standardId, + ruleId, + language, + ); + + const detectabilityStatus = useMemo(() => { + return assessment?.status + ? convertToAccordionStatus(assessment.status) + : undefined; + }, [assessment?.status]); + + return ( + <DetectionAccordion + title="Detectability" + open={isOpen} + onOpenChange={onOpenChange} + actions={<DetectabilityBadge status={detectabilityStatus} />} + > + <DetectabilitySection + standardId={standardId} + ruleId={ruleId} + language={language} + ruleExamples={ruleExamples} + /> + </DetectionAccordion> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/DetectionAccordions/DetectabilitySection.tsx b/apps/frontend/src/domain/detection/components/DetectionAccordions/DetectabilitySection.tsx new file mode 100644 index 000000000..d3bc99946 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionAccordions/DetectabilitySection.tsx @@ -0,0 +1,569 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + PMBox, + PMButton, + PMGrid, + PMHeading, + PMHStack, + PMIcon, + PMInput, + PMList, + PMRadioGroup, + PMSpinner, + PMText, + PMVStack, +} from '@packmind/ui'; +import { + DetectionStatus, + RuleDetectionAssessmentStatus, + RuleExample, +} from '@packmind/types'; +import { LuSendHorizontal, LuPlay } from 'react-icons/lu'; +import { + useGetActiveDetectionProgramsQuery, + useGetDetectionHeuristicsQuery, + useGetRuleDetectionAssessmentQuery, + useUpdateDetectionHeuristicsMutation, + useStartRuleDetectionAssessmentMutation, +} from '../../api/queries/DetectionProgramQueries'; +import { HeuristicsEditor } from '../HeuristicsEditor'; +import { formatDuration } from '../../../../shared/utils/dateUtils'; + +interface IDetectabilitySectionProps { + standardId: string; + ruleId: string; + language: string; + ruleExamples?: RuleExample[]; +} + +interface ILoadingOverlayProps { + message: string; +} + +const OTHER_ANSWER_VALUE = '__OTHER__'; + +const LoadingOverlay: React.FC<ILoadingOverlayProps> = ({ message }) => ( + <PMBox + position="absolute" + top={0} + left={0} + right={0} + bottom={0} + background="background.primary" + display="flex" + alignItems="center" + justifyContent="center" + borderRadius="md" + zIndex={1} + > + <PMVStack gap={3}> + <PMSpinner size="lg" /> + <PMText color="faded">{message}</PMText> + </PMVStack> + </PMBox> +); + +export const DetectabilitySection: React.FC<IDetectabilitySectionProps> = ({ + standardId, + ruleId, + language, + ruleExamples = [], +}) => { + const [heuristicsText, setHeuristicsText] = useState(''); + const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null); + const [otherAnswerText, setOtherAnswerText] = useState(''); + const [previousAssessmentData, setPreviousAssessmentData] = useState<{ + details?: string; + clarificationQuestion: string | null; + clarificationAnswers: string[] | null; + updatedAt?: Date; + }>({ + clarificationQuestion: null, + clarificationAnswers: null, + }); + + const { data: assessment } = useGetRuleDetectionAssessmentQuery( + standardId, + ruleId, + language, + ); + + const startAssessment = useStartRuleDetectionAssessmentMutation(); + + // Check if at least one negative example exists for the current language + const hasNegativeExampleForLanguage = useMemo(() => { + return ruleExamples.some( + (example) => + example.lang === language && + example.negative && + example.negative.trim() !== '', + ); + }, [ruleExamples, language]); + + // Show trigger button when no assessment exists or status is NOT_STARTED + const shouldShowTriggerButton = + (!assessment || + assessment.status === RuleDetectionAssessmentStatus.NOT_STARTED) && + hasNegativeExampleForLanguage; + + const handleTriggerAssessment = useCallback(() => { + startAssessment.mutate({ + standardId, + ruleId, + language, + }); + }, [startAssessment, standardId, ruleId, language]); + + const { data: detectionHeuristics, isLoading: isLoadingHeuristics } = + useGetDetectionHeuristicsQuery(standardId, ruleId, language); + + const { data: activePrograms } = useGetActiveDetectionProgramsQuery( + standardId, + ruleId, + ); + + const updateHeuristics = useUpdateDetectionHeuristicsMutation(); + + const programForLanguage = useMemo(() => { + return activePrograms?.find( + (program) => + program.detectionProgram?.language === language || + program.language === language, + ); + }, [activePrograms, language]); + + const isEditable = useMemo(() => { + if (!assessment) return false; + + // Check if assessment is in error + const isAssessmentInError = + assessment.status === RuleDetectionAssessmentStatus.FAILED; + + // Check if program generation is in error, failed, or needs review + const isProgramGenerationInError = + programForLanguage?.detectionProgram?.status === DetectionStatus.ERROR || + programForLanguage?.detectionProgram?.status === + DetectionStatus.FAILURE || + programForLanguage?.detectionProgram?.status === + DetectionStatus.TO_REVIEW; + + return isAssessmentInError || isProgramGenerationInError; + }, [assessment, programForLanguage]); + + const isProgramGenerating = useMemo(() => { + return ( + programForLanguage?.detectionProgram?.status === + DetectionStatus.IN_PROGRESS || + programForLanguage?.draftDetectionProgram?.status === + DetectionStatus.IN_PROGRESS + ); + }, [programForLanguage]); + + useEffect(() => { + if (detectionHeuristics && Array.isArray(detectionHeuristics.heuristics)) { + // Join array with newlines for display + setHeuristicsText(detectionHeuristics.heuristics.join('\n')); + } + }, [detectionHeuristics]); + + useEffect(() => { + if ( + assessment && + assessment.status === RuleDetectionAssessmentStatus.SUCCESS + ) { + setPreviousAssessmentData({ + details: assessment.details, + clarificationQuestion: assessment.clarificationQuestion, + clarificationAnswers: assessment.clarificationAnswers, + updatedAt: assessment.updatedAt, + }); + } + }, [assessment]); + + const handleAnswerChange = useCallback((value: string | null) => { + setSelectedAnswer(value); + if (value !== OTHER_ANSWER_VALUE) { + setOtherAnswerText(''); + } + }, []); + + const handleOtherAnswerTextChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + setOtherAnswerText(e.target.value); + }, + [], + ); + + const isSendDisabled = useCallback(() => { + if (updateHeuristics.isPending) { + return true; + } + + const hasAnswerSelected = selectedAnswer !== null; + + if (!hasAnswerSelected) { + return true; + } + + if (selectedAnswer === OTHER_ANSWER_VALUE && !otherAnswerText.trim()) { + return true; + } + + return false; + }, [updateHeuristics.isPending, selectedAnswer, otherAnswerText]); + + const handleHeuristicsSubmit = useCallback( + (text: string) => { + if (!detectionHeuristics?.id || !assessment) { + return; + } + + // Save current assessment data before mutation + setPreviousAssessmentData({ + details: assessment.details, + clarificationQuestion: assessment.clarificationQuestion, + clarificationAnswers: assessment.clarificationAnswers, + updatedAt: assessment.updatedAt, + }); + + const heuristicsArray = text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + updateHeuristics.mutate({ + standardId, + ruleId, + detectionHeuristicsId: detectionHeuristics.id, + heuristics: heuristicsArray, + }); + }, + [detectionHeuristics, standardId, ruleId, updateHeuristics, assessment], + ); + + const handleSendAnswer = useCallback(() => { + if (!detectionHeuristics?.id || !assessment) { + return; + } + + // Save current assessment data before mutation + setPreviousAssessmentData({ + details: assessment.details, + clarificationQuestion: assessment.clarificationQuestion, + clarificationAnswers: assessment.clarificationAnswers, + updatedAt: assessment.updatedAt, + }); + + const heuristicsArray = heuristicsText + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + let clarificationQuestion: + | { question: string; answer: string } + | undefined = undefined; + + if (selectedAnswer !== null && assessment?.clarificationQuestion) { + const answer = + selectedAnswer === OTHER_ANSWER_VALUE + ? otherAnswerText.trim() + : selectedAnswer; + + if (answer) { + clarificationQuestion = { + question: assessment.clarificationQuestion, + answer, + }; + } + } + + updateHeuristics.mutate( + { + standardId, + ruleId, + detectionHeuristicsId: detectionHeuristics.id, + heuristics: heuristicsArray, + clarificationQuestion, + }, + { + onSuccess: () => { + setSelectedAnswer(null); + setOtherAnswerText(''); + }, + }, + ); + }, [ + detectionHeuristics, + heuristicsText, + standardId, + ruleId, + updateHeuristics, + selectedAnswer, + otherAnswerText, + assessment, + ]); + + if (!language) { + return null; + } + + // Show trigger button when assessment doesn't exist or is NOT_STARTED + if (shouldShowTriggerButton) { + return ( + <PMVStack width="full" gap={4} p={4} alignItems="center"> + <PMText color="tertiary" textAlign="center"> + No detectability assessment has been run for this rule yet. + </PMText> + <PMButton + variant="primary" + onClick={handleTriggerAssessment} + loading={startAssessment.isPending} + disabled={startAssessment.isPending} + > + <PMIcon size="sm" mr={2}> + <LuPlay /> + </PMIcon> + Trigger manually + </PMButton> + </PMVStack> + ); + } + + if (!assessment) { + return null; + } + + const isAssessmentPending = + assessment.status === RuleDetectionAssessmentStatus.IN_PROGRESS; + + const displayDetails = isAssessmentPending + ? previousAssessmentData.details + : assessment.details; + + const displayClarificationQuestion = isAssessmentPending + ? previousAssessmentData.clarificationQuestion + : assessment.clarificationQuestion; + + const displayClarificationAnswers = isAssessmentPending + ? previousAssessmentData.clarificationAnswers + : assessment.clarificationAnswers; + + const displayUpdatedAt = isAssessmentPending + ? previousAssessmentData.updatedAt + : assessment.updatedAt; + + // Show clues block unless assessment is IN_PROGRESS with no previous data and no heuristics + // (i.e., initial loading state with no content to display) + // When assessment is SUCCESS, always show the clues block so users can see/edit heuristics + const shouldShowCluesBlock = !( + isAssessmentPending && + !previousAssessmentData.clarificationQuestion && + !detectionHeuristics?.heuristics?.length + ); + + // Show program generation loading overlay when a program is generating and no heuristics exist yet + const shouldShowProgramGenerationLoading = + isProgramGenerating && !detectionHeuristics?.heuristics?.length; + + return ( + <PMVStack width="full" gap={0} p={4}> + {(displayDetails || + (isAssessmentPending && !previousAssessmentData.details)) && ( + <PMVStack width="full" gap={2} align="flex-start"> + {displayDetails && ( + <PMText color="tertiary">Why this rule cannot be detected:</PMText> + )} + <PMVStack + borderRadius="md" + p={3} + rounded="md" + textAlign="left" + width="full" + position="relative" + minHeight={ + isAssessmentPending && !previousAssessmentData.details + ? '150px' + : undefined + } + > + {displayDetails && ( + <> + <PMText whiteSpace="pre-wrap" width="full"> + {displayDetails} + </PMText> + {displayUpdatedAt && ( + <PMText + fontSize="sm" + color="tertiary" + textAlign="right" + mt={2} + width="full" + > + Last updated: {formatDuration(new Date(displayUpdatedAt))} + </PMText> + )} + </> + )} + {isAssessmentPending && ( + <LoadingOverlay message="Packmind is checking the detectability of the rule" /> + )} + </PMVStack> + </PMVStack> + )} + + {shouldShowCluesBlock && ( + <PMVStack + width="full" + gap={2} + align="flex-start" + position="relative" + minHeight={shouldShowProgramGenerationLoading ? '150px' : undefined} + > + <PMText color="tertiary">How to detect it?</PMText> + {shouldShowProgramGenerationLoading && ( + <LoadingOverlay message="Rule is detectable. A detection program is generating." /> + )} + {/** Use grid to guarantee equal heights between columns */} + <PMGrid + gap={4} + alignItems="stretch" + width="full" + gridTemplateColumns={ + displayClarificationQuestion && displayClarificationAnswers + ? '1fr 1fr' + : '1fr' + } + > + {displayClarificationQuestion && displayClarificationAnswers && ( + <PMVStack + borderRadius="md" + borderWidth={1} + borderColor="border.tertiary" + p={3} + width="full" + height="full" + gap={3} + justifyContent="space-between" + position="relative" + > + <PMVStack gap={3} width="full"> + <PMHeading width="full" level="h5"> + {displayClarificationQuestion} + </PMHeading> + <PMRadioGroup.Root + value={selectedAnswer ?? undefined} + onValueChange={(details) => + handleAnswerChange(details.value) + } + variant="outline" + colorPalette="blue" + > + <PMVStack gap={2} align="flex-start"> + {displayClarificationAnswers.map((answer) => ( + <PMRadioGroup.Item key={answer} value={answer}> + <PMRadioGroup.ItemHiddenInput /> + <PMRadioGroup.ItemControl> + <PMRadioGroup.ItemIndicator borderColor="border.tertiary" /> + </PMRadioGroup.ItemControl> + <PMRadioGroup.ItemText> + {answer} + </PMRadioGroup.ItemText> + </PMRadioGroup.Item> + ))} + <PMRadioGroup.Item value={OTHER_ANSWER_VALUE}> + <PMRadioGroup.ItemHiddenInput /> + <PMRadioGroup.ItemControl> + <PMRadioGroup.ItemIndicator borderColor="border.tertiary" /> + </PMRadioGroup.ItemControl> + <PMRadioGroup.ItemText>Other</PMRadioGroup.ItemText> + </PMRadioGroup.Item> + </PMVStack> + </PMRadioGroup.Root> + {selectedAnswer === OTHER_ANSWER_VALUE && ( + <PMBox width="full"> + <PMInput + placeholder="Please specify..." + value={otherAnswerText} + onChange={handleOtherAnswerTextChange} + disabled={updateHeuristics.isPending} + width="full" + size="sm" + /> + </PMBox> + )} + </PMVStack> + <PMHStack gap={2} justify="flex-end" width="full"> + {detectionHeuristics && ( + <PMButton + onClick={handleSendAnswer} + disabled={isSendDisabled()} + loading={updateHeuristics.isPending} + size="xs" + > + Send{' '} + <PMIcon size="xs"> + <LuSendHorizontal /> + </PMIcon> + </PMButton> + )} + </PMHStack> + {isAssessmentPending && ( + <LoadingOverlay message="Packmind is checking the detectability of the rule" /> + )} + </PMVStack> + )} + + <PMBox width="full" height="full"> + {isLoadingHeuristics ? ( + <PMText color="faded">Loading clues...</PMText> + ) : ( + <HeuristicsEditor + value={heuristicsText ?? ''} + onSubmit={handleHeuristicsSubmit} + isLoading={updateHeuristics.isPending} + isEditable={isEditable} + maxLength={3000} + /> + )} + </PMBox> + </PMGrid> + </PMVStack> + )} + + {assessment.status === RuleDetectionAssessmentStatus.SUCCESS && + !shouldShowProgramGenerationLoading && ( + <PMVStack width="full" align="flex-start" mt={2}> + <PMHeading size="sm" color="tertiary"> + Flagged a false positive? + </PMHeading> + <PMList.Root as="ul" listStyle="disc" pl={4}> + <PMList.Item> + <PMText color="tertiary"> + Add a new "don't" example that reflects the + code flagged as a false positive + </PMText> + </PMList.Item> + <PMList.Item> + <PMText color="tertiary"> + The status of the detection program should be updated to + "To review" + </PMText> + </PMList.Item> + <PMList.Item> + <PMText color="tertiary"> + You can then update the detection clues to indicate why this + kind of code example should not be flagged + </PMText> + </PMList.Item> + <PMList.Item> + <PMText color="tertiary"> + Click on "Retry" to re-generate a new detection + program + </PMText> + </PMList.Item> + </PMList.Root> + </PMVStack> + )} + </PMVStack> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/DetectionAccordions/DetectionAccordion.tsx b/apps/frontend/src/domain/detection/components/DetectionAccordions/DetectionAccordion.tsx new file mode 100644 index 000000000..464aea846 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionAccordions/DetectionAccordion.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { PMAccordion, PMHStack, PMText } from '@packmind/ui'; + +interface DetectionAccordionProps { + title: string; + actions?: React.ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; + disabled?: boolean; + children: React.ReactNode; +} + +export const DetectionAccordion: React.FC<DetectionAccordionProps> = ({ + title, + actions, + open, + onOpenChange, + disabled = false, + children, +}) => { + const accordionValue = 'content'; + const value = open && !disabled ? [accordionValue] : []; + + const handleValueChange = (details: { value: string[] }) => { + onOpenChange(details.value.includes(accordionValue)); + }; + + return ( + <PMAccordion.Root + value={value} + onValueChange={handleValueChange} + collapsible + backgroundColor={disabled ? 'background.tertiary' : 'background.primary'} + px="4" + paddingTop="2" + paddingBottom={open ? '4' : '2'} + rounded="md" + variant="plain" + disabled={disabled} + > + <PMAccordion.Item value={accordionValue} disabled={disabled}> + <PMAccordion.ItemTrigger + cursor={disabled ? 'not-allowed' : 'pointer'} + disabled={disabled} + > + <PMHStack gap={3} align="center" width="full"> + <PMAccordion.ItemIndicator /> + <PMText>{title}</PMText> + <PMHStack gap={2} marginLeft="auto"> + {actions} + </PMHStack> + </PMHStack> + </PMAccordion.ItemTrigger> + {!disabled && ( + <PMAccordion.ItemContent>{children}</PMAccordion.ItemContent> + )} + </PMAccordion.Item> + </PMAccordion.Root> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/DetectionAccordions/DetectionDraftMenu.test.tsx b/apps/frontend/src/domain/detection/components/DetectionAccordions/DetectionDraftMenu.test.tsx new file mode 100644 index 000000000..89be9d5c5 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionAccordions/DetectionDraftMenu.test.tsx @@ -0,0 +1,493 @@ +import React from 'react'; +import { + render, + RenderResult, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { + DetectionStatus, + RuleDetectionAssessmentStatus, + RuleDetectionAssessment, + DetectionModeEnum, + ProgrammingLanguage, + createRuleDetectionAssessmentId, + createRuleId, + createStandardId, +} from '@packmind/types'; +import { DetectionDraftMenu } from './DetectionDraftMenu'; +import { DraftCardData } from '../DetectionDraftCard/DetectionDraftCard'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UIProvider } from '@packmind/ui'; +import * as DetectionProgramQueries from '../../api/queries/DetectionProgramQueries'; + +jest.mock('../../api/queries/DetectionProgramQueries'); + +describe('DetectionDraftMenu', () => { + let screen: RenderResult; + let baseDraft: DraftCardData; + + let onMakeActive: jest.Mock; + let onRetryDraft: jest.Mock; + let onTestDraft: jest.Mock; + let onShowProgram: jest.Mock; + let onShowLogs: jest.Mock; + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + beforeEach(() => { + baseDraft = { + id: 'draft-123', + language: 'typescript', + activeDetectionProgramId: 'active-456', + draftProgram: { + id: 'program-123', + ruleId: createRuleId('rule-456'), + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.IN_PROGRESS, + code: '', + sourceCodeState: 'NONE' as const, + version: 1, + mode: 'singleAst', + } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + status: DetectionStatus.IN_PROGRESS, + mode: 'AI_GENERATION', + version: 1, + }; + + onMakeActive = jest.fn(); + onRetryDraft = jest.fn(); + onTestDraft = jest.fn(); + onShowProgram = jest.fn(); + onShowLogs = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + function renderWithContext(assessment?: RuleDetectionAssessment) { + jest + .spyOn(DetectionProgramQueries, 'useGetRuleDetectionAssessmentQuery') + .mockReturnValue({ + data: assessment, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + return render( + <UIProvider> + <QueryClientProvider client={queryClient}> + <DetectionDraftMenu + draft={baseDraft} + onMakeActive={onMakeActive} + onTestDraft={onTestDraft} + onRetryDraft={onRetryDraft} + onShowLogs={onShowLogs} + onShowProgram={onShowProgram} + standardId={createStandardId('standard-123')} + ruleId={createRuleId('rule-456')} + /> + </QueryClientProvider> + </UIProvider>, + ); + } + + function createAssessment( + status: RuleDetectionAssessmentStatus, + ): RuleDetectionAssessment { + return { + id: createRuleDetectionAssessmentId('assessment-123'), + ruleId: createRuleId('rule-456'), + language: ProgrammingLanguage.TYPESCRIPT, + detectionMode: DetectionModeEnum.SINGLE_AST, + status, + details: 'Assessment details', + clarificationQuestion: null, + clarificationAnswers: null, + updatedAt: new Date(), + }; + } + + describe('menu label', () => { + describe('when state is ASSESSING', () => { + beforeEach(() => { + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.IN_PROGRESS), + ); + }); + + it('displays "Draft: Pending"', () => { + expect(screen.getByText('Draft: Pending')).toBeInTheDocument(); + }); + }); + + describe('when state is ASSESSMENT_FAILED', () => { + beforeEach(() => { + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.FAILED), + ); + }); + + it('displays "Draft: Error"', () => { + expect(screen.getByText('Draft: Error')).toBeInTheDocument(); + }); + }); + + describe('when state is TO_REVIEW', () => { + beforeEach(() => { + baseDraft.status = DetectionStatus.TO_REVIEW; + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ); + }); + + it('displays "Draft: To Review"', () => { + expect(screen.getByText('Draft: To Review')).toBeInTheDocument(); + }); + }); + + describe('when state is GENERATING', () => { + beforeEach(() => { + baseDraft.status = DetectionStatus.IN_PROGRESS; + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ); + }); + + it('displays "Draft: Pending"', () => { + expect(screen.getByText('Draft: Pending')).toBeInTheDocument(); + }); + }); + + describe('when state is GENERATION_FAILED', () => { + beforeEach(() => { + baseDraft.status = DetectionStatus.FAILURE; + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ); + }); + + it('displays "Draft: Error"', () => { + expect(screen.getByText('Draft: Error')).toBeInTheDocument(); + }); + }); + + describe('when state is GENERATION_SUCCESSFUL', () => { + beforeEach(() => { + baseDraft.status = DetectionStatus.READY; + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ); + }); + + it('displays "Draft: OK"', () => { + expect(screen.getByText('Draft: OK')).toBeInTheDocument(); + }); + }); + }); + + describe('menu actions', () => { + describe('when state is GENERATING', () => { + beforeEach(() => { + baseDraft.status = DetectionStatus.IN_PROGRESS; + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ); + }); + + it('shows "Show log" action in menu', async () => { + const trigger = screen.getByText('Draft: Pending'); + fireEvent.click(trigger); + + await waitFor(() => { + const menuItems = screen.getAllByText('Show log'); + expect(menuItems.length).toBeGreaterThan(0); + }); + }); + + describe('when "Show log" is clicked', () => { + it('calls onShowLogs', async () => { + const trigger = screen.getByText('Draft: Pending'); + fireEvent.click(trigger); + + await waitFor(() => { + const showLogItem = screen.getAllByText('Show log')[0]; + fireEvent.click(showLogItem); + }); + + expect(onShowLogs).toHaveBeenCalled(); + }); + }); + }); + + describe('when state is GENERATION_FAILED', () => { + beforeEach(() => { + baseDraft.status = DetectionStatus.FAILURE; + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ); + }); + + it('shows "Retry" action in menu', async () => { + const trigger = screen.getByText('Draft: Error'); + fireEvent.click(trigger); + + await waitFor(() => { + expect(screen.getByText('Retry')).toBeInTheDocument(); + }); + }); + + it('shows "Show log" action in menu', async () => { + const trigger = screen.getByText('Draft: Error'); + fireEvent.click(trigger); + + await waitFor(() => { + const showLogItems = screen.getAllByText('Show log'); + expect(showLogItems.length).toBeGreaterThan(0); + }); + }); + + describe('when "Retry" is clicked', () => { + it('calls onRetryDraft with the draft', async () => { + const trigger = screen.getByText('Draft: Error'); + fireEvent.click(trigger); + + await waitFor(() => { + const retryItem = screen.getByText('Retry'); + fireEvent.click(retryItem); + }); + + expect(onRetryDraft).toHaveBeenCalledWith(baseDraft); + }); + }); + }); + + describe('when state is GENERATION_SUCCESSFUL', () => { + beforeEach(() => { + baseDraft.status = DetectionStatus.READY; + }); + + describe('when there is an active program', () => { + beforeEach(() => { + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ); + }); + + it('shows "Show log" action in menu', async () => { + const trigger = screen.getByText('Draft: OK'); + fireEvent.click(trigger); + + await waitFor(() => { + expect(screen.getByText('Show log')).toBeInTheDocument(); + }); + }); + + it('shows "Show program" action in menu', async () => { + const trigger = screen.getByText('Draft: OK'); + fireEvent.click(trigger); + + await waitFor(() => { + expect(screen.getByText('Show program')).toBeInTheDocument(); + }); + }); + + it('shows "Test draft program" action in menu', async () => { + const trigger = screen.getByText('Draft: OK'); + fireEvent.click(trigger); + + await waitFor(() => { + expect(screen.getByText('Test draft program')).toBeInTheDocument(); + }); + }); + + it('shows "Set as active" action in menu', async () => { + const trigger = screen.getByText('Draft: OK'); + fireEvent.click(trigger); + + await waitFor(() => { + expect(screen.getByText('Set as active')).toBeInTheDocument(); + }); + }); + + describe('when "Show program" is clicked', () => { + it('calls onShowProgram', async () => { + const trigger = screen.getByText('Draft: OK'); + fireEvent.click(trigger); + + await waitFor(() => { + const showProgramItem = screen.getByText('Show program'); + fireEvent.click(showProgramItem); + }); + + expect(onShowProgram).toHaveBeenCalled(); + }); + }); + + describe('when "Test draft program" is clicked', () => { + it('calls onTestDraft with the draft', async () => { + const trigger = screen.getByText('Draft: OK'); + fireEvent.click(trigger); + + await waitFor(() => { + const testDraftItem = screen.getByText('Test draft program'); + fireEvent.click(testDraftItem); + }); + + expect(onTestDraft).toHaveBeenCalledWith(baseDraft); + }); + }); + + describe('when "Set as active" is clicked', () => { + it('shows confirmation dialog title', async () => { + const trigger = screen.getByText('Draft: OK'); + fireEvent.click(trigger); + + await waitFor(() => { + const setActiveItem = screen.getByText('Set as active'); + fireEvent.click(setActiveItem); + }); + + await waitFor(() => { + expect( + screen.getByText('Activate Detection Program'), + ).toBeInTheDocument(); + }); + }); + + it('shows confirmation dialog message', async () => { + const trigger = screen.getByText('Draft: OK'); + fireEvent.click(trigger); + + await waitFor(() => { + const setActiveItem = screen.getByText('Set as active'); + fireEvent.click(setActiveItem); + }); + + await waitFor(() => { + expect( + screen.getByText( + 'Are you sure you want to activate this typescript detection program (v1)? This will replace the current active program.', + ), + ).toBeInTheDocument(); + }); + }); + + describe('when confirmation dialog is confirmed', () => { + async function openDialogAndConfirm() { + const trigger = screen.getByText('Draft: OK'); + fireEvent.click(trigger); + + await waitFor(() => { + const setActiveItem = screen.getByText('Set as active'); + fireEvent.click(setActiveItem); + }); + + await waitFor(() => { + screen.getByText('Activate Detection Program'); + }); + + const activateButton = screen.getByRole('button', { + name: 'Activate', + }); + fireEvent.click(activateButton); + } + + it('calls onMakeActive with the draft', async () => { + await openDialogAndConfirm(); + + await waitFor(() => { + expect(onMakeActive).toHaveBeenCalledWith(baseDraft); + }); + }); + }); + + describe('when confirmation dialog is cancelled', () => { + async function openDialogAndCancel() { + const trigger = screen.getByText('Draft: OK'); + fireEvent.click(trigger); + + await waitFor(() => { + const setActiveItem = screen.getByText('Set as active'); + fireEvent.click(setActiveItem); + }); + + await waitFor(() => { + screen.getByText('Activate Detection Program'); + }); + + const cancelButton = screen.getByRole('button', { + name: 'Cancel', + }); + fireEvent.click(cancelButton); + + await waitFor(() => { + const dialog = screen.queryByText('Activate Detection Program'); + return dialog === null; + }); + } + + it('closes the dialog', async () => { + await openDialogAndCancel(); + + expect( + screen.queryByText('Activate Detection Program'), + ).not.toBeInTheDocument(); + }); + + it('does not call onMakeActive', async () => { + await openDialogAndCancel(); + + expect(onMakeActive).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('when there is no active program', () => { + beforeEach(() => { + baseDraft.activeDetectionProgramId = ''; + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ); + }); + + describe('when "Set as active" is clicked', () => { + async function clickSetAsActive() { + const trigger = screen.getByText('Draft: OK'); + fireEvent.click(trigger); + + await waitFor(() => { + const setActiveItem = screen.getByText('Set as active'); + fireEvent.click(setActiveItem); + }); + + await waitFor(() => { + return onMakeActive.mock.calls.length > 0; + }); + } + + it('calls onMakeActive with the draft', async () => { + await clickSetAsActive(); + + expect(onMakeActive).toHaveBeenCalledWith(baseDraft); + }); + + it('does not show confirmation dialog', async () => { + await clickSetAsActive(); + + expect( + screen.queryByText('Activate Detection Program'), + ).not.toBeInTheDocument(); + }); + }); + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/detection/components/DetectionAccordions/DetectionDraftMenu.tsx b/apps/frontend/src/domain/detection/components/DetectionAccordions/DetectionDraftMenu.tsx new file mode 100644 index 000000000..67cd8de10 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionAccordions/DetectionDraftMenu.tsx @@ -0,0 +1,192 @@ +import React, { useState } from 'react'; +import { useGetRuleDetectionAssessmentQuery } from '../../api/queries/DetectionProgramQueries'; +import { + determineDraftStatus, + DraftStatus, +} from '../DetectionDraftCard/determineDraftStatus'; +import { + DraftInfo, + getTimelineConfig, + LoadingStates, + TimelineHandlers, +} from '../DetectionDraftCard/getTimelineConfig'; +import { DraftCardProps } from '../DetectionDraftCard/DetectionDraftCard'; +import { + PMMenu, + PMPortal, + PMIcon, + PMButton, + PMAlertDialog, +} from '@packmind/ui'; +import { LuChevronDown } from 'react-icons/lu'; + +export const getMenuLabel = (state: DraftStatus): string => { + switch (state) { + case DraftStatus.GENERATION_SUCCESSFUL: + return 'Draft: OK'; + case DraftStatus.ASSESSMENT_FAILED: + case DraftStatus.GENERATION_FAILED: + return 'Draft: Error'; + case DraftStatus.TO_REVIEW: + return 'Draft: To Review'; + default: + return 'Draft: Pending'; + } +}; + +const getButtonVariant = ( + state: DraftStatus, +): 'success' | 'danger' | 'warning' | 'tertiary' => { + switch (state) { + case DraftStatus.GENERATION_SUCCESSFUL: + return 'success'; + case DraftStatus.ASSESSMENT_FAILED: + case DraftStatus.GENERATION_FAILED: + return 'danger'; + case DraftStatus.TO_REVIEW: + return 'warning'; + default: + return 'tertiary'; + } +}; + +export const DetectionDraftMenu: React.FC<DraftCardProps> = ({ + draft, + onMakeActive, + isActivating, + onTestDraft, + onRetryDraft, + isGenerating, + standardId, + ruleId, + onShowLogs, + onShowProgram, +}) => { + const [confirmationDialog, setConfirmationDialog] = useState<{ + isOpen: boolean; + title: string; + message: string; + confirmText: string; + onConfirm: () => void; + } | null>(null); + + const { data: assessment } = useGetRuleDetectionAssessmentQuery( + standardId, + ruleId, + draft.language, + ); + const state = determineDraftStatus(assessment?.status, draft.status); + + const handlers: TimelineHandlers = { + onShowLogs, + onShowProgram, + onTestDraft: () => onTestDraft(draft), + onMakeActive: () => onMakeActive(draft), + onRetryDraft: () => onRetryDraft?.(draft), + }; + + const loadingStates: LoadingStates = { + isActivating: isActivating ?? false, + isGenerating: isGenerating ?? false, + }; + + const draftInfo: DraftInfo = { + language: draft.language, + version: draft.version, + hasActiveProgram: + draft.hasActiveProgram ?? !!draft.activeDetectionProgramId, + }; + + const timelineConfig = getTimelineConfig( + state, + handlers, + loadingStates, + draftInfo, + ); + + // Collect all buttons from all steps + const allButtons = [ + ...(timelineConfig.step1.buttons ?? []), + ...(timelineConfig.step2.buttons ?? []), + ...(timelineConfig.step3.buttons ?? []), + ]; + + const handleButtonClick = (button: (typeof allButtons)[number]) => { + if (button.confirmation) { + setConfirmationDialog({ + isOpen: true, + title: button.confirmation.title, + message: button.confirmation.message, + confirmText: button.confirmation.confirmText ?? 'Confirm', + onConfirm: () => { + button.onClick(); + setConfirmationDialog(null); + }, + }); + } else { + button.onClick(); + } + }; + + return ( + <> + <PMMenu.Root> + <PMMenu.Trigger asChild> + <PMButton + size="2xs" + variant={getButtonVariant(state)} + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + }} + > + {getMenuLabel(state)} + <PMIcon size="xs"> + <LuChevronDown /> + </PMIcon> + </PMButton> + </PMMenu.Trigger> + <PMPortal> + <PMMenu.Positioner> + <PMMenu.Content> + {allButtons.map((button, index) => ( + <PMMenu.Item + key={index} + value={button.label} + cursor="pointer" + disabled={button.disabled} + onClick={(e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + handleButtonClick(button); + }} + > + {button.icon && ( + <PMIcon size="sm" mr={2}> + {button.icon} + </PMIcon> + )} + {button.label} + </PMMenu.Item> + ))} + </PMMenu.Content> + </PMMenu.Positioner> + </PMPortal> + </PMMenu.Root> + {confirmationDialog && ( + <PMAlertDialog + title={confirmationDialog.title} + message={confirmationDialog.message} + confirmText={confirmationDialog.confirmText} + confirmColorScheme="blue" + onConfirm={confirmationDialog.onConfirm} + open={confirmationDialog.isOpen} + onOpenChange={({ open }) => { + if (!open) { + setConfirmationDialog(null); + } + }} + /> + )} + </> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/DetectionAccordions/ProgramGenerationAccordion.tsx b/apps/frontend/src/domain/detection/components/DetectionAccordions/ProgramGenerationAccordion.tsx new file mode 100644 index 000000000..070e8c19e --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionAccordions/ProgramGenerationAccordion.tsx @@ -0,0 +1,222 @@ +import React, { useCallback, useState } from 'react'; +import { ActiveConfigurationSection } from '../ActiveConfigurationsList'; +import { ActiveConfigurationSectionData as ActiveConfigurationCardData } from '../ActiveConfigurationSection/'; +import { + DetectionDraftCard, + DraftCardData, +} from '../DetectionDraftCard/DetectionDraftCard'; +import { DetectionAccordion } from './DetectionAccordion'; +import { ExecutionLogsDrawer } from '../ExecutionLogsDrawer'; +import { ProgramContentDrawer } from '../ProgramContentDrawer'; +import { ProgramDetailsDrawer } from '../ProgramDetailsDrawer'; +import { + AccordionProgramActionButtons, + ViewMode, +} from './AccordionProgramActionButtons'; +import { SeverityDropdownBadge } from '../SeverityDropdownBadge'; +import { useUpdateActiveDetectionProgramSeverityMutation } from '../../api/queries/DetectionProgramQueries'; + +interface ProgramGenerationSectionProps { + isOpen: boolean; + standardId: string; + standardName?: string; + ruleId: string; + activeConfigurations: ActiveConfigurationCardData[]; + draftPrograms: DraftCardData[]; + isLoadingActivePrograms: boolean; + isActiveProgramsError: boolean; + onGenerateProgram: (language?: string) => void; + isGeneratingProgram: boolean; + onTestProgram: (draft: DraftCardData | ActiveConfigurationCardData) => void; + onActivateDraft: (draft: DraftCardData) => void; + activatingDraftId: string | null; + isActivatingDraft: boolean; + onRetryDraft: (draft: DraftCardData) => void; + selectedLanguage: string; + onOpenChange: (open: boolean) => void; + disabled: boolean; + onNavigateToExamples?: () => void; +} + +export const ProgramGenerationAccordion: React.FC< + ProgramGenerationSectionProps +> = ({ + isOpen, + standardId, + standardName, + ruleId, + activeConfigurations, + draftPrograms, + isLoadingActivePrograms, + isActiveProgramsError, + onGenerateProgram, + isGeneratingProgram, + onTestProgram, + onActivateDraft, + activatingDraftId, + isActivatingDraft, + onRetryDraft, + selectedLanguage, + onOpenChange, + disabled, + onNavigateToExamples, +}) => { + const updateSeverity = useUpdateActiveDetectionProgramSeverityMutation(); + const [isLogsDrawerOpen, setIsLogsDrawerOpen] = useState(false); + const [isProgramDrawerOpen, setIsProgramDrawerOpen] = useState(false); + const [isDetailsDrawerOpen, setIsDetailsDrawerOpen] = useState(false); + const [selectedActiveConfig, setSelectedActiveConfig] = + useState<ActiveConfigurationCardData | null>(null); + const [viewMode, setViewMode] = useState<ViewMode>('active'); + const activeDraft = draftPrograms[0]; + + // Determine if we have both active program and draft + const hasActiveProgram = activeConfigurations.some( + (config) => config.detectionProgram, + ); + const hasDraft = !!activeDraft; + + // Compute what content to show based on view mode from composer + // Logic based on the truth table: + // Draft Active DefaultView ShowSwitch + // 0 0 active 0 (show active section, even if empty) + // 0 1 active 0 (show active only) + // 1 0 draft 0 (show draft only) + // 1 1 active 1 (show toggle, default active, switch between views) + const shouldShowDraft = + hasDraft && !hasActiveProgram ? true : viewMode === 'draft'; + const shouldShowActive = !shouldShowDraft; + + const handleViewModeChange = useCallback((mode: ViewMode) => { + setViewMode(mode); + }, []); + + const handleReviewDraft = useCallback(() => { + setViewMode('draft'); + }, []); + + const handleShowDetails = useCallback( + (config: ActiveConfigurationCardData) => { + setSelectedActiveConfig(config); + setIsDetailsDrawerOpen(true); + }, + [], + ); + + return ( + <DetectionAccordion + title="Detection program" + actions={ + <> + {!disabled && + activeConfigurations.length > 0 && + activeConfigurations[0].severity && + activeConfigurations[0].detectionProgram && ( + <SeverityDropdownBadge + severity={activeConfigurations[0].severity} + onSeverityChange={(newSeverity) => { + updateSeverity.mutate({ + standardId, + ruleId, + activeDetectionProgramId: activeConfigurations[0].id, + severity: newSeverity, + }); + }} + isDisabled={updateSeverity.isPending} + /> + )} + {!disabled && ( + <AccordionProgramActionButtons + activeConfigurations={activeConfigurations} + activeDraft={activeDraft} + onTestProgram={onTestProgram} + onGenerateProgram={onGenerateProgram} + onActivateDraft={onActivateDraft} + onRetryDraft={onRetryDraft} + isGeneratingProgram={isGeneratingProgram} + selectedLanguage={selectedLanguage} + viewMode={viewMode} + onViewModeChange={handleViewModeChange} + standardId={standardId} + ruleId={ruleId} + onShowLogs={() => setIsLogsDrawerOpen(true)} + onShowProgram={() => setIsProgramDrawerOpen(true)} + onShowDetails={handleShowDetails} + isActivating={ + activatingDraftId === activeDraft?.id && isActivatingDraft + } + /> + )} + </> + } + open={isOpen} + onOpenChange={onOpenChange} + disabled={disabled} + > + {shouldShowActive && ( + <ActiveConfigurationSection + configurations={activeConfigurations} + isLoading={isLoadingActivePrograms} + isError={isActiveProgramsError} + onGenerateProgram={onGenerateProgram} + isGeneratingProgram={isGeneratingProgram} + standardId={standardId} + standardName={standardName} + ruleId={ruleId} + onTestProgram={onTestProgram} + onActivateDraft={onActivateDraft} + activatingDraftId={activatingDraftId} + isActivatingDraft={isActivatingDraft} + onNavigateToExamples={onNavigateToExamples} + onReviewDraft={handleReviewDraft} + /> + )} + + {shouldShowDraft && activeDraft && !isLoadingActivePrograms && ( + <> + <DetectionDraftCard + key={activeDraft.id} + draft={activeDraft} + onMakeActive={onActivateDraft} + isActivating={ + activatingDraftId === activeDraft.id && isActivatingDraft + } + onTestDraft={onTestProgram} + onRetryDraft={onRetryDraft} + isGenerating={isGeneratingProgram} + standardId={standardId} + ruleId={ruleId} + onShowLogs={() => setIsLogsDrawerOpen(true)} + onShowProgram={() => setIsProgramDrawerOpen(true)} + /> + <ExecutionLogsDrawer + isOpen={isLogsDrawerOpen} + onClose={() => setIsLogsDrawerOpen(false)} + standardId={standardId} + ruleId={ruleId} + detectionProgramId={activeDraft.draftProgram.id} + /> + <ProgramContentDrawer + isOpen={isProgramDrawerOpen} + onClose={() => setIsProgramDrawerOpen(false)} + standardId={standardId} + ruleId={ruleId} + detectionProgramId={activeDraft.draftProgram.id} + programCode={activeDraft.draftProgram.code} + /> + </> + )} + + {selectedActiveConfig?.detectionProgram && ( + <ProgramDetailsDrawer + isOpen={isDetailsDrawerOpen} + onClose={() => setIsDetailsDrawerOpen(false)} + standardId={standardId} + ruleId={ruleId} + detectionProgramId={selectedActiveConfig.detectionProgram.id} + programCode={selectedActiveConfig.detectionProgram.code} + /> + )} + </DetectionAccordion> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/DetectionAccordions/StatusDropdownBadge.tsx b/apps/frontend/src/domain/detection/components/DetectionAccordions/StatusDropdownBadge.tsx new file mode 100644 index 000000000..d2cf040ec --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionAccordions/StatusDropdownBadge.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { + PMBox, + PMMenu, + PMPortal, + PMIcon, + PMVStack, + PMText, +} from '@packmind/ui'; +import { LuChevronDown } from 'react-icons/lu'; + +export interface StatusMenuAction { + label: string; + onClick: () => void; +} + +export interface StatusTooltipData { + version?: number; + createdAt?: Date; +} + +export enum BadgeStatus { + SUCCESS = 'SUCCESS', + FAILED = 'FAILED', + IN_PROGRESS = 'IN_PROGRESS', +} + +interface StatusDropdownBadgeProps { + status: BadgeStatus; + tooltipData?: StatusTooltipData; + menuActions: StatusMenuAction[]; +} + +export interface BadgeConfig { + text: string; + colorPalette: 'green' | 'red' | 'blue'; +} + +const STATUS_CONFIG: Record<BadgeStatus, BadgeConfig> = { + [BadgeStatus.SUCCESS]: { + text: 'Active', + colorPalette: 'green', + }, + [BadgeStatus.FAILED]: { + text: 'Failed', + colorPalette: 'red', + }, + [BadgeStatus.IN_PROGRESS]: { + text: 'In progress', + colorPalette: 'blue', + }, +}; + +export const getBadgeConfig = (status: BadgeStatus): BadgeConfig => { + return STATUS_CONFIG[status]; +}; + +const formatDate = (date: Date): string => { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +export const StatusDropdownBadge: React.FC<StatusDropdownBadgeProps> = ({ + status, + tooltipData, + menuActions, +}) => { + const config = getBadgeConfig(status); + + return ( + <PMMenu.Root> + <PMMenu.Trigger asChild> + <PMBox + as="span" + backgroundColor={`${config.colorPalette}.solid`} + color="white" + px={2} + py={0.5} + borderRadius="full" + fontSize="xs" + fontWeight="small" + cursor="pointer" + display="inline-flex" + alignItems="center" + gap={0.5} + _hover={{ + opacity: 0.9, + }} + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + }} + > + {config.text} + <PMIcon size="xs"> + <LuChevronDown /> + </PMIcon> + </PMBox> + </PMMenu.Trigger> + <PMPortal> + <PMMenu.Positioner> + <PMMenu.Content> + {tooltipData && (tooltipData.version || tooltipData.createdAt) && ( + <> + <PMBox px={3} py={2}> + <PMVStack gap={2} alignItems="flex-start"> + <PMText fontWeight="semibold" fontSize="sm"> + Information + </PMText> + {tooltipData.version && ( + <PMVStack gap={1} alignItems="flex-start" width="full"> + <PMText fontSize="xs" color="secondary"> + Version + </PMText> + <PMText fontSize="sm">{tooltipData.version}</PMText> + </PMVStack> + )} + {tooltipData.createdAt && ( + <PMVStack gap={1} alignItems="flex-start" width="full"> + <PMText fontSize="xs" color="secondary"> + Generation details + </PMText> + <PMText fontSize="sm"> + {formatDate(tooltipData.createdAt)} + </PMText> + </PMVStack> + )} + </PMVStack> + </PMBox> + <PMMenu.Separator /> + </> + )} + {menuActions.map((action, index) => ( + <PMMenu.Item + key={index} + value={action.label} + cursor="pointer" + onClick={(e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + action.onClick(); + }} + > + {action.label} + </PMMenu.Item> + ))} + </PMMenu.Content> + </PMMenu.Positioner> + </PMPortal> + </PMMenu.Root> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/DetectionAccordions/index.ts b/apps/frontend/src/domain/detection/components/DetectionAccordions/index.ts new file mode 100644 index 000000000..01a87e22f --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionAccordions/index.ts @@ -0,0 +1,14 @@ +export { DetectionAccordion } from './DetectionAccordion'; +export { + StatusDropdownBadge, + BadgeStatus, + getBadgeConfig, +} from './StatusDropdownBadge'; +export type { + StatusMenuAction, + StatusTooltipData, + BadgeConfig, +} from './StatusDropdownBadge'; +export { ProgramGenerationAccordion } from './ProgramGenerationAccordion'; +export { DetectabilityAccordion } from './DetectabilityAccordion'; +export { DetectabilitySection } from './DetectabilitySection'; diff --git a/apps/frontend/src/domain/detection/components/DetectionAssessmentDrawer.test.tsx b/apps/frontend/src/domain/detection/components/DetectionAssessmentDrawer.test.tsx new file mode 100644 index 000000000..12756de0d --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionAssessmentDrawer.test.tsx @@ -0,0 +1,855 @@ +import { DetectionAssessmentDrawer } from './DetectionAssessmentDrawer'; +import { render, RenderResult } from '@testing-library/react'; +import { + QueryClient, + QueryClientProvider, + UseMutationResult, + UseQueryResult, +} from '@tanstack/react-query'; +import * as DetectionProgramQueries from '../api/queries/DetectionProgramQueries'; +import { + createActiveDetectionProgramId, + createDetectionHeuristicsId, + createDetectionProgramId, + createRuleDetectionAssessmentId, + createRuleId, + createStandardId, + DetectionHeuristics, + DetectionModeEnum, + DetectionStatus, + LanguageDetectionPrograms, + ProgrammingLanguage, + RuleDetectionAssessment, + RuleDetectionAssessmentStatus, +} from '@packmind/types'; +import React from 'react'; +import { UIProvider, pmToaster } from '@packmind/ui'; +import userEvent from '@testing-library/user-event'; + +jest.mock('../api/queries/DetectionProgramQueries'); +jest.mock('@packmind/ui', () => ({ + ...jest.requireActual('@packmind/ui'), + pmToaster: { + create: jest.fn(), + }, +})); + +type UpdateDetectionHeuristicsMutationResult = UseMutationResult< + DetectionHeuristics, + Error, + { + standardId: string; + ruleId: string; + detectionHeuristicsId: string; + heuristics: string[]; + }, + unknown +>; + +describe('DetectionAssessmentDrawer', () => { + let assessment: RuleDetectionAssessment; + let props: { + isOpen: boolean; + onClose: () => void; + standardId: string; + ruleId: string; + language: string | null; + }; + let screen: RenderResult; + let detectionHeuristics: DetectionHeuristics; + let activePrograms: LanguageDetectionPrograms[]; + + beforeEach(() => { + jest.clearAllMocks(); + assessment = { + id: createRuleDetectionAssessmentId('assessment-id'), + details: 'Some assessment details', + detectionMode: DetectionModeEnum.SINGLE_AST, + language: ProgrammingLanguage.TYPESCRIPT, + ruleId: createRuleId('rule-id'), + status: RuleDetectionAssessmentStatus.NOT_STARTED, + clarificationQuestion: '', + clarificationAnswers: [], + updatedAt: new Date('2025-01-01T12:00:00Z'), + }; + + detectionHeuristics = { + id: createDetectionHeuristicsId('heuristics-id'), + ruleId: createRuleId('rule-id'), + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['Initial heuristics text'], + }; + + activePrograms = []; + + props = { + isOpen: true, + onClose: jest.fn(), + standardId: createStandardId('standard-id'), + ruleId: createRuleId('rule-id'), + language: ProgrammingLanguage.TYPESCRIPT, + }; + }); + + afterEach(() => { + jest.restoreAllMocks(); + if (screen) { + screen.unmount(); + } + }); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + function renderWithContext() { + return render( + <UIProvider> + <QueryClientProvider client={queryClient}> + <DetectionAssessmentDrawer {...props} /> + </QueryClientProvider> + </UIProvider>, + ); + } + + describe('when drawer is closed', () => { + beforeEach(() => { + props.isOpen = false; + jest + .spyOn(DetectionProgramQueries, 'useGetRuleDetectionAssessmentQuery') + .mockReturnValue({ data: assessment } as Partial< + UseQueryResult<RuleDetectionAssessment | null> + > as UseQueryResult<RuleDetectionAssessment | null>); + + jest + .spyOn(DetectionProgramQueries, 'useGetDetectionHeuristicsQuery') + .mockReturnValue({ data: detectionHeuristics } as Partial< + UseQueryResult<DetectionHeuristics | null> + > as UseQueryResult<DetectionHeuristics | null>); + + jest + .spyOn(DetectionProgramQueries, 'useGetActiveDetectionProgramsQuery') + .mockReturnValue({ data: activePrograms } as Partial< + UseQueryResult<LanguageDetectionPrograms[]> + > as UseQueryResult<LanguageDetectionPrograms[]>); + + jest + .spyOn(DetectionProgramQueries, 'useUpdateDetectionHeuristicsMutation') + .mockReturnValue({ + mutate: jest.fn(), + isPending: false, + } as Partial<UpdateDetectionHeuristicsMutationResult> as UpdateDetectionHeuristicsMutationResult); + + screen = renderWithContext(); + }); + + it('does not render the drawer content', () => { + expect( + screen.queryByText('Rule detection assessment'), + ).not.toBeInTheDocument(); + }); + }); + + describe('when drawer is open', () => { + beforeEach(() => { + jest + .spyOn(DetectionProgramQueries, 'useGetRuleDetectionAssessmentQuery') + .mockReturnValue({ data: assessment } as Partial< + UseQueryResult<RuleDetectionAssessment | null> + > as UseQueryResult<RuleDetectionAssessment | null>); + + jest + .spyOn(DetectionProgramQueries, 'useGetDetectionHeuristicsQuery') + .mockReturnValue({ data: detectionHeuristics } as Partial< + UseQueryResult<DetectionHeuristics | null> + > as UseQueryResult<DetectionHeuristics | null>); + + jest + .spyOn(DetectionProgramQueries, 'useGetActiveDetectionProgramsQuery') + .mockReturnValue({ data: activePrograms } as Partial< + UseQueryResult<LanguageDetectionPrograms[]> + > as UseQueryResult<LanguageDetectionPrograms[]>); + + jest + .spyOn(DetectionProgramQueries, 'useUpdateDetectionHeuristicsMutation') + .mockReturnValue({ + mutate: jest.fn(), + isPending: false, + } as Partial<UpdateDetectionHeuristicsMutationResult> as UpdateDetectionHeuristicsMutationResult); + }); + + it('renders the drawer title', () => { + screen = renderWithContext(); + expect(screen.getByText('Rule detection assessment')).toBeInTheDocument(); + }); + + describe('when assessment has details', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + assessment.details = 'Some reasons why this rule cannot be detected'; + screen = renderWithContext(); + }); + + it('displays the assessment details header', () => { + expect( + screen.getByText('Why this rule cannot be detected:'), + ).toBeInTheDocument(); + }); + + it('displays the assessment details content', () => { + expect(screen.getByText(assessment.details)).toBeInTheDocument(); + }); + }); + + describe('when assessment has no details', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + assessment.details = ''; + screen = renderWithContext(); + }); + + it('does not display the assessment details section', () => { + expect( + screen.queryByText('Why this rule cannot be detected:'), + ).not.toBeInTheDocument(); + }); + }); + + describe('when heuristics are loading', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + jest + .spyOn(DetectionProgramQueries, 'useGetDetectionHeuristicsQuery') + .mockReturnValue({ + data: undefined, + isLoading: true, + } as Partial< + UseQueryResult<DetectionHeuristics | null> + > as UseQueryResult<DetectionHeuristics | null>); + + screen = renderWithContext(); + }); + + it('shows loading message', () => { + expect(screen.getByText('Loading heuristics...')).toBeInTheDocument(); + }); + }); + + describe('when heuristics are not available', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + jest + .spyOn(DetectionProgramQueries, 'useGetDetectionHeuristicsQuery') + .mockReturnValue({ data: null } as Partial< + UseQueryResult<DetectionHeuristics | null> + > as UseQueryResult<DetectionHeuristics | null>); + + screen = renderWithContext(); + }); + + it('renders the textarea', () => { + const textarea = screen.getByPlaceholderText( + 'Enter detection heuristics...', + ); + expect(textarea).toBeInTheDocument(); + }); + + it('displays empty value in textarea', () => { + const textarea = screen.getByPlaceholderText( + 'Enter detection heuristics...', + ); + expect(textarea).toHaveValue(''); + }); + }); + + describe('when heuristics are available', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + screen = renderWithContext(); + }); + + it('renders the textarea', () => { + const textarea = screen.getByPlaceholderText( + 'Enter detection heuristics...', + ); + expect(textarea).toBeInTheDocument(); + }); + + it('displays the heuristics value in textarea', () => { + const textarea = screen.getByPlaceholderText( + 'Enter detection heuristics...', + ); + expect(textarea).toHaveValue(detectionHeuristics.heuristics.join('\n')); + }); + + describe('when textarea should be editable', () => { + describe('when assessment is in error', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + assessment.status = RuleDetectionAssessmentStatus.FAILED; + screen = renderWithContext(); + }); + + it('enables the textarea', () => { + const textarea = screen.getByPlaceholderText( + 'Enter detection heuristics...', + ); + expect(textarea).not.toBeDisabled(); + }); + + it('shows the Save Changes button', () => { + expect(screen.getByText('Save Changes')).toBeInTheDocument(); + }); + + it('allows editing the heuristics text', async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText( + 'Enter detection heuristics...', + ); + + await user.clear(textarea); + await user.type(textarea, 'Updated heuristics'); + + expect(textarea).toHaveValue('Updated heuristics'); + }); + + describe('when text has not been changed', () => { + it('disables Save button', () => { + const saveButton = screen.getByText('Save Changes'); + + expect(saveButton).toBeDisabled(); + }); + }); + + describe('when text has been changed', () => { + beforeEach(async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText( + 'Enter detection heuristics...', + ); + + await user.clear(textarea); + await user.type(textarea, 'New heuristics'); + }); + + it('enables Save button', () => { + const saveButton = screen.getByText('Save Changes'); + + expect(saveButton).not.toBeDisabled(); + }); + }); + }); + + describe('when program generation is in error', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + activePrograms = [ + { + id: createActiveDetectionProgramId('program-id'), + detectionProgramVersion: createDetectionProgramId( + 'detection-program-id', + ), + ruleId: createRuleId('rule-id'), + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgramDraftVersion: null, + detectionProgram: { + id: createDetectionProgramId('detection-program-id'), + ruleId: createRuleId('rule-id'), + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.ERROR, + mode: DetectionModeEnum.SINGLE_AST, + sourceCodeState: 'AST', + code: '', + version: 1, + }, + draftDetectionProgram: null, + }, + ]; + + jest + .spyOn( + DetectionProgramQueries, + 'useGetActiveDetectionProgramsQuery', + ) + .mockReturnValue({ data: activePrograms } as Partial< + UseQueryResult<LanguageDetectionPrograms[]> + > as UseQueryResult<LanguageDetectionPrograms[]>); + + screen = renderWithContext(); + }); + + it('enables the textarea', () => { + const textarea = screen.getByPlaceholderText( + 'Enter detection heuristics...', + ); + expect(textarea).not.toBeDisabled(); + }); + + it('shows the Save Changes button', () => { + expect(screen.getByText('Save Changes')).toBeInTheDocument(); + }); + }); + + describe('when program generation has failed', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + activePrograms = [ + { + id: createActiveDetectionProgramId('program-id'), + detectionProgramVersion: createDetectionProgramId( + 'detection-program-id', + ), + ruleId: createRuleId('rule-id'), + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgramDraftVersion: null, + detectionProgram: { + id: createDetectionProgramId('detection-program-id'), + ruleId: createRuleId('rule-id'), + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.FAILURE, + mode: DetectionModeEnum.SINGLE_AST, + sourceCodeState: 'AST', + code: '', + version: 1, + }, + draftDetectionProgram: null, + }, + ]; + + jest + .spyOn( + DetectionProgramQueries, + 'useGetActiveDetectionProgramsQuery', + ) + .mockReturnValue({ data: activePrograms } as Partial< + UseQueryResult<LanguageDetectionPrograms[]> + > as UseQueryResult<LanguageDetectionPrograms[]>); + + screen = renderWithContext(); + }); + + it('enables the textarea', () => { + const textarea = screen.getByPlaceholderText( + 'Enter detection heuristics...', + ); + expect(textarea).not.toBeDisabled(); + }); + + it('shows the Save Changes button', () => { + expect(screen.getByText('Save Changes')).toBeInTheDocument(); + }); + }); + }); + + describe('when textarea should be disabled', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + assessment.status = RuleDetectionAssessmentStatus.SUCCESS; + activePrograms = [ + { + id: createActiveDetectionProgramId('program-id'), + detectionProgramVersion: createDetectionProgramId( + 'detection-program-id', + ), + ruleId: createRuleId('rule-id'), + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgramDraftVersion: null, + detectionProgram: { + id: createDetectionProgramId('detection-program-id'), + ruleId: createRuleId('rule-id'), + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.READY, + mode: DetectionModeEnum.SINGLE_AST, + sourceCodeState: 'AST', + code: '', + version: 1, + }, + draftDetectionProgram: null, + }, + ]; + + jest + .spyOn( + DetectionProgramQueries, + 'useGetActiveDetectionProgramsQuery', + ) + .mockReturnValue({ data: activePrograms } as Partial< + UseQueryResult<LanguageDetectionPrograms[]> + > as UseQueryResult<LanguageDetectionPrograms[]>); + + screen = renderWithContext(); + }); + + it('disables the textarea', () => { + const textarea = screen.getByPlaceholderText( + 'Enter detection heuristics...', + ); + expect(textarea).toBeDisabled(); + }); + + it('does not show the Save Changes button', () => { + expect(screen.queryByText('Save Changes')).not.toBeInTheDocument(); + }); + }); + + describe('when saving changes', () => { + let mutateSpy: ReturnType<typeof jest.fn>; + let onCloseSpy: ReturnType<typeof jest.fn>; + + beforeEach(() => { + if (screen) { + screen.unmount(); + } + assessment.status = RuleDetectionAssessmentStatus.FAILED; + mutateSpy = jest.fn(); + onCloseSpy = jest.fn(); + props.onClose = onCloseSpy; + + jest + .spyOn( + DetectionProgramQueries, + 'useUpdateDetectionHeuristicsMutation', + ) + .mockReturnValue({ + mutate: mutateSpy, + isPending: false, + } as Partial<UpdateDetectionHeuristicsMutationResult> as UpdateDetectionHeuristicsMutationResult); + + screen = renderWithContext(); + }); + + it('calls the mutation with correct parameters', async () => { + const user = userEvent.setup(); + const textarea = screen.getByPlaceholderText( + 'Enter detection heuristics...', + ); + const saveButton = screen.getByText('Save Changes'); + + await user.clear(textarea); + await user.type(textarea, 'Updated heuristics'); + await user.click(saveButton); + + expect(mutateSpy).toHaveBeenCalledWith( + { + standardId: props.standardId, + ruleId: props.ruleId, + detectionHeuristicsId: detectionHeuristics.id, + heuristics: ['Updated heuristics'], + }, + expect.any(Object), + ); + }); + }); + + describe('when mutation is pending', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + assessment.status = RuleDetectionAssessmentStatus.FAILED; + + jest + .spyOn( + DetectionProgramQueries, + 'useUpdateDetectionHeuristicsMutation', + ) + .mockReturnValue({ + mutate: jest.fn(), + isPending: true, + } as Partial<UpdateDetectionHeuristicsMutationResult> as UpdateDetectionHeuristicsMutationResult); + + screen = renderWithContext(); + }); + + it('disables the textarea', () => { + const textareas = screen.getAllByPlaceholderText( + 'Enter detection heuristics...', + ); + const textarea = textareas[textareas.length - 1]; + expect(textarea).toBeDisabled(); + }); + }); + }); + + describe('status badge display', () => { + describe('when status is FAILED', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + assessment.status = RuleDetectionAssessmentStatus.FAILED; + screen = renderWithContext(); + }); + + it('displays status badge with formatted FAILED status', () => { + expect(screen.getByText('Status: Failed')).toBeInTheDocument(); + }); + }); + + describe('when status is SUCCESS', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + assessment.status = RuleDetectionAssessmentStatus.SUCCESS; + screen = renderWithContext(); + }); + + it('displays status badge with formatted SUCCESS status', () => { + expect(screen.getByText('Status: Success')).toBeInTheDocument(); + }); + }); + + describe('when status is IN_PROGRESS', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + assessment.status = RuleDetectionAssessmentStatus.IN_PROGRESS; + screen = renderWithContext(); + }); + + it('displays status badge with formatted IN_PROGRESS status', () => { + expect(screen.getByText('Status: In Progress')).toBeInTheDocument(); + }); + }); + + describe('when status is NOT_STARTED', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + assessment.status = RuleDetectionAssessmentStatus.NOT_STARTED; + screen = renderWithContext(); + }); + + it('displays status badge with formatted NOT_STARTED status', () => { + expect(screen.getByText('Status: Not Started')).toBeInTheDocument(); + }); + }); + }); + + describe('duration display', () => { + describe('when updatedAt is provided', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + // Mock current time to be 2 hours after updatedAt + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01T14:00:00Z')); + assessment.updatedAt = new Date('2025-01-01T12:00:00Z'); + screen = renderWithContext(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('displays time since last update', () => { + expect(screen.getByText('2 hours ago')).toBeInTheDocument(); + }); + }); + + describe('when updatedAt is not provided', () => { + beforeEach(() => { + if (screen) { + screen.unmount(); + } + delete (assessment as { updatedAt?: Date }).updatedAt; + screen = renderWithContext(); + }); + + it('does not display duration', () => { + expect(screen.queryByText(/ago/)).not.toBeInTheDocument(); + }); + }); + }); + + describe('auto-close prevention', () => { + let onCloseSpy: ReturnType<typeof jest.fn>; + + beforeEach(() => { + onCloseSpy = jest.fn(); + props.onClose = onCloseSpy; + }); + + describe('when status changes to SUCCESS', () => { + describe('when drawer attempts to close', () => { + it('prevents auto-close', () => { + if (screen) { + screen.unmount(); + } + assessment.status = RuleDetectionAssessmentStatus.IN_PROGRESS; + screen = renderWithContext(); + + // Simulate status change to SUCCESS + assessment.status = RuleDetectionAssessmentStatus.SUCCESS; + screen.rerender( + <UIProvider> + <QueryClientProvider client={queryClient}> + <DetectionAssessmentDrawer {...props} /> + </QueryClientProvider> + </UIProvider>, + ); + + expect(onCloseSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when status is already SUCCESS', () => { + describe('when drawer close is triggered', () => { + it('allows closing', async () => { + if (screen) { + screen.unmount(); + } + assessment.status = RuleDetectionAssessmentStatus.SUCCESS; + screen = renderWithContext(); + + const closeButton = screen.getByRole('button', { name: /close/i }); + const user = userEvent.setup(); + await user.click(closeButton); + + expect(onCloseSpy).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('success toaster notification', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when status transitions to SUCCESS and drawer is open', () => { + it('displays success toaster with correct message', () => { + if (screen) { + screen.unmount(); + } + assessment.status = RuleDetectionAssessmentStatus.IN_PROGRESS; + + // Set up mock to return IN_PROGRESS first + const assessmentQuerySpy = jest + .spyOn( + DetectionProgramQueries, + 'useGetRuleDetectionAssessmentQuery', + ) + .mockReturnValueOnce({ data: { ...assessment } } as Partial< + UseQueryResult<RuleDetectionAssessment | null> + > as UseQueryResult<RuleDetectionAssessment | null>); + + screen = renderWithContext(); + + // Simulate status change to SUCCESS by updating the mock + assessment.status = RuleDetectionAssessmentStatus.SUCCESS; + assessmentQuerySpy.mockReturnValue({ + data: { ...assessment }, + } as Partial< + UseQueryResult<RuleDetectionAssessment | null> + > as UseQueryResult<RuleDetectionAssessment | null>); + + screen.rerender( + <UIProvider> + <QueryClientProvider client={queryClient}> + <DetectionAssessmentDrawer {...props} /> + </QueryClientProvider> + </UIProvider>, + ); + + expect(pmToaster.create).toHaveBeenCalledWith({ + type: 'success', + title: 'Assessment successful!', + description: 'Program generation has started.', + }); + }); + }); + + describe('when status transitions to SUCCESS and drawer is closed', () => { + it('does not display toaster', () => { + if (screen) { + screen.unmount(); + } + props.isOpen = false; + assessment.status = RuleDetectionAssessmentStatus.IN_PROGRESS; + screen = renderWithContext(); + + jest.clearAllMocks(); + + // Simulate status change to SUCCESS while drawer is closed + assessment.status = RuleDetectionAssessmentStatus.SUCCESS; + screen.rerender( + <UIProvider> + <QueryClientProvider client={queryClient}> + <DetectionAssessmentDrawer {...props} /> + </QueryClientProvider> + </UIProvider>, + ); + + expect(pmToaster.create).not.toHaveBeenCalled(); + }); + }); + + describe('when status is already SUCCESS', () => { + it('does not display toaster on initial render', () => { + if (screen) { + screen.unmount(); + } + assessment.status = RuleDetectionAssessmentStatus.SUCCESS; + screen = renderWithContext(); + + expect(pmToaster.create).not.toHaveBeenCalled(); + }); + }); + + describe('when status changes from SUCCESS to another status', () => { + it('does not display toaster', () => { + if (screen) { + screen.unmount(); + } + assessment.status = RuleDetectionAssessmentStatus.SUCCESS; + screen = renderWithContext(); + + jest.clearAllMocks(); + + // Simulate status change from SUCCESS to FAILED + assessment.status = RuleDetectionAssessmentStatus.FAILED; + screen.rerender( + <UIProvider> + <QueryClientProvider client={queryClient}> + <DetectionAssessmentDrawer {...props} /> + </QueryClientProvider> + </UIProvider>, + ); + + expect(pmToaster.create).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/detection/components/DetectionAssessmentDrawer.tsx b/apps/frontend/src/domain/detection/components/DetectionAssessmentDrawer.tsx new file mode 100644 index 000000000..f6f15d0cb --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionAssessmentDrawer.tsx @@ -0,0 +1,438 @@ +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { + PMBadge, + PMBox, + PMButton, + PMCloseButton, + PMDrawer, + PMHStack, + PMInput, + PMPortal, + PMRadioGroup, + PMText, + PMTextArea, + PMVStack, + pmToaster, +} from '@packmind/ui'; +import { + RuleDetectionAssessmentStatus, + DetectionStatus, +} from '@packmind/types'; +import { + useGetDetectionHeuristicsQuery, + useUpdateDetectionHeuristicsMutation, + useGetActiveDetectionProgramsQuery, + useGetRuleDetectionAssessmentQuery, +} from '../api/queries/DetectionProgramQueries'; + +interface DetectionAssessmentDrawerProps { + isOpen: boolean; + onClose: () => void; + standardId: string; + ruleId: string; + language: string | null; +} + +const OTHER_ANSWER_VALUE = '__OTHER__'; + +const getStatusColor = (status: RuleDetectionAssessmentStatus): string => { + switch (status) { + case RuleDetectionAssessmentStatus.FAILED: + return 'red'; + case RuleDetectionAssessmentStatus.SUCCESS: + return 'green'; + case RuleDetectionAssessmentStatus.IN_PROGRESS: + return 'gray'; + default: + return 'gray'; + } +}; + +const formatAssessmentStatus = ( + status: RuleDetectionAssessmentStatus, +): string => { + switch (status) { + case RuleDetectionAssessmentStatus.FAILED: + return 'Failed'; + case RuleDetectionAssessmentStatus.SUCCESS: + return 'Success'; + case RuleDetectionAssessmentStatus.IN_PROGRESS: + return 'In Progress'; + case RuleDetectionAssessmentStatus.NOT_STARTED: + return 'Not Started'; + default: + return status; + } +}; + +const formatDuration = (updatedAt: Date): string => { + const now = new Date(); + const diffInMs = now.getTime() - updatedAt.getTime(); + const diffInMinutes = Math.floor(diffInMs / 60000); + const diffInHours = Math.floor(diffInMinutes / 60); + const diffInDays = Math.floor(diffInHours / 24); + + if (diffInDays > 0) { + return `${diffInDays} day${diffInDays > 1 ? 's' : ''} ago`; + } else if (diffInHours > 0) { + return `${diffInHours} hour${diffInHours > 1 ? 's' : ''} ago`; + } else if (diffInMinutes > 0) { + return `${diffInMinutes} minute${diffInMinutes > 1 ? 's' : ''} ago`; + } else { + return 'Just now'; + } +}; + +export const DetectionAssessmentDrawer: React.FC< + DetectionAssessmentDrawerProps +> = ({ isOpen, onClose, standardId, ruleId, language }) => { + const [heuristicsText, setHeuristicsText] = useState(''); + const [hasChanges, setHasChanges] = useState(false); + const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null); + const [otherAnswerText, setOtherAnswerText] = useState(''); + const [previousStatus, setPreviousStatus] = + useState<RuleDetectionAssessmentStatus | null>(null); + + const { data: assessment } = useGetRuleDetectionAssessmentQuery( + standardId, + ruleId, + language ?? '', + ); + + const { data: detectionHeuristics, isLoading: isLoadingHeuristics } = + useGetDetectionHeuristicsQuery(standardId, ruleId, language ?? ''); + + const { data: activePrograms } = useGetActiveDetectionProgramsQuery( + standardId, + ruleId, + ); + + const updateHeuristics = useUpdateDetectionHeuristicsMutation(); + + const isEditable = useMemo(() => { + if (!assessment) return false; + + // Check if assessment is in error + const isAssessmentInError = + assessment.status === RuleDetectionAssessmentStatus.FAILED; + + // Check if program generation is in error + const programForLanguage = activePrograms?.find( + (program) => + program.detectionProgram?.language === language || + program.language === language, + ); + const isProgramGenerationInError = + programForLanguage?.detectionProgram?.status === DetectionStatus.ERROR || + programForLanguage?.detectionProgram?.status === DetectionStatus.FAILURE; + + return isAssessmentInError || isProgramGenerationInError; + }, [assessment, activePrograms, language]); + + const textareaRows = useMemo(() => { + const heuristicsLines = heuristicsText.split('\n').length; + const MIN_ROWS = 5; + const MAX_ROWS = 8; + + if (heuristicsLines <= MIN_ROWS) { + return MIN_ROWS; + } + + if (heuristicsLines > MIN_ROWS && heuristicsLines < MAX_ROWS) { + return heuristicsLines; + } + + return MAX_ROWS; + }, [heuristicsText]); + + useEffect(() => { + if (detectionHeuristics && Array.isArray(detectionHeuristics.heuristics)) { + // Join array with newlines for display in textarea + setHeuristicsText(detectionHeuristics.heuristics.join('\n')); + setHasChanges(false); + } + }, [detectionHeuristics]); + + useEffect(() => { + if (!assessment) return; + + const justChangedToSuccess = + previousStatus !== null && + previousStatus !== RuleDetectionAssessmentStatus.SUCCESS && + assessment.status === RuleDetectionAssessmentStatus.SUCCESS; + + if (justChangedToSuccess && isOpen) { + pmToaster.create({ + type: 'success', + title: 'Assessment successful!', + description: 'Program generation has started.', + }); + } + + setPreviousStatus(assessment.status); + }, [assessment, isOpen, previousStatus]); + + const handleOpenChange = useCallback( + ({ open }: { open: boolean }) => { + if (!open) { + onClose(); + } + }, + [onClose], + ); + + const handleHeuristicsChange = useCallback( + (e: React.ChangeEvent<HTMLTextAreaElement>) => { + const newValue = e.target.value; + setHeuristicsText(newValue); + const originalText = detectionHeuristics?.heuristics.join('\n') ?? ''; + setHasChanges(newValue !== originalText); + }, + [detectionHeuristics], + ); + + const handleAnswerChange = useCallback((value: string | null) => { + setSelectedAnswer(value); + if (value !== OTHER_ANSWER_VALUE) { + setOtherAnswerText(''); + } + }, []); + + const handleOtherAnswerTextChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + setOtherAnswerText(e.target.value); + }, + [], + ); + + const isSaveDisabled = useCallback(() => { + if (updateHeuristics.isPending) { + return true; + } + + const hasHeuristicsChanges = hasChanges; + const hasAnswerSelected = selectedAnswer !== null; + + if (!hasHeuristicsChanges && !hasAnswerSelected) { + return true; + } + + if (selectedAnswer === OTHER_ANSWER_VALUE && !otherAnswerText.trim()) { + return true; + } + + return false; + }, [updateHeuristics.isPending, hasChanges, selectedAnswer, otherAnswerText]); + + const handleSave = useCallback(() => { + if (!detectionHeuristics?.id) { + return; + } + + const heuristicsArray = heuristicsText + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + let clarificationQuestion: + | { question: string; answer: string } + | undefined = undefined; + + if (selectedAnswer !== null && assessment?.clarificationQuestion) { + const answer = + selectedAnswer === OTHER_ANSWER_VALUE + ? otherAnswerText.trim() + : selectedAnswer; + + if (answer) { + clarificationQuestion = { + question: assessment.clarificationQuestion, + answer, + }; + } + } + + updateHeuristics.mutate( + { + standardId, + ruleId, + detectionHeuristicsId: detectionHeuristics.id, + heuristics: heuristicsArray, + clarificationQuestion, + }, + { + onSuccess: () => { + setHasChanges(false); + setSelectedAnswer(null); + setOtherAnswerText(''); + }, + }, + ); + }, [ + detectionHeuristics, + heuristicsText, + standardId, + ruleId, + updateHeuristics, + selectedAnswer, + otherAnswerText, + assessment?.clarificationQuestion, + ]); + + if (!assessment) { + return null; + } + + return ( + <PMDrawer.Root open={isOpen} onOpenChange={handleOpenChange} size="xl"> + <PMPortal> + <PMDrawer.Backdrop /> + <PMDrawer.Positioner> + <PMDrawer.Content> + <PMDrawer.Header> + <PMVStack align="flex-start" gap={2} width="full"> + <PMHStack gap={3} align="center"> + <PMDrawer.Title>Rule detection assessment</PMDrawer.Title> + <PMBadge colorPalette={getStatusColor(assessment.status)}> + Status: {formatAssessmentStatus(assessment.status)} + </PMBadge> + {assessment.updatedAt && ( + <PMText fontSize="sm" color="tertiary"> + {formatDuration(new Date(assessment.updatedAt))} + </PMText> + )} + </PMHStack> + </PMVStack> + </PMDrawer.Header> + <PMDrawer.Body> + <PMVStack width="full"> + {assessment.details && ( + <PMVStack width="full" gap={2} align="flex-start"> + <PMText color="tertiary"> + Why this rule cannot be detected: + </PMText> + <PMBox + borderRadius="md" + borderWidth={1} + borderColor="border.tertiary" + p={3} + rounded="md" + mb={3} + width="full" + > + <PMText whiteSpace="pre-wrap"> + {assessment.details} + </PMText> + </PMBox> + </PMVStack> + )} + + <PMVStack gap={2} align="flex-start" width="full"> + <PMText>Detection Heuristics:</PMText> + {isLoadingHeuristics ? ( + <PMText color="faded">Loading heuristics...</PMText> + ) : ( + <> + <PMTextArea + value={heuristicsText ?? ''} + onChange={handleHeuristicsChange} + placeholder="Enter detection heuristics..." + rows={textareaRows} + disabled={!isEditable || updateHeuristics.isPending} + maxLength={3000} + /> + + {assessment.clarificationQuestion && + assessment.clarificationAnswers && + isEditable && ( + <PMVStack + gap={3} + align="flex-start" + width="full" + mt={4} + > + <PMText fontWeight="medium"> + Clarification Question: + </PMText> + <PMBox + borderRadius="md" + borderWidth={1} + borderColor="border.tertiary" + p={3} + width="full" + > + <PMText mb={3}> + {assessment.clarificationQuestion} + </PMText> + <PMRadioGroup.Root + value={selectedAnswer ?? undefined} + onValueChange={(details) => + handleAnswerChange(details.value) + } + > + <PMVStack gap={2} align="flex-start"> + {assessment.clarificationAnswers.map( + (answer) => ( + <PMRadioGroup.Item + key={answer} + value={answer} + > + <PMRadioGroup.ItemHiddenInput /> + <PMRadioGroup.ItemControl> + <PMRadioGroup.ItemIndicator /> + </PMRadioGroup.ItemControl> + <PMRadioGroup.ItemText> + {answer} + </PMRadioGroup.ItemText> + </PMRadioGroup.Item> + ), + )} + <PMRadioGroup.Item value={OTHER_ANSWER_VALUE}> + <PMRadioGroup.ItemHiddenInput /> + <PMRadioGroup.ItemControl> + <PMRadioGroup.ItemIndicator /> + </PMRadioGroup.ItemControl> + <PMRadioGroup.ItemText> + Other + </PMRadioGroup.ItemText> + </PMRadioGroup.Item> + </PMVStack> + </PMRadioGroup.Root> + {selectedAnswer === OTHER_ANSWER_VALUE && ( + <PMBox mt={2} ml={6}> + <PMInput + placeholder="Please specify..." + value={otherAnswerText} + onChange={handleOtherAnswerTextChange} + disabled={updateHeuristics.isPending} + /> + </PMBox> + )} + </PMBox> + </PMVStack> + )} + </> + )} + <PMHStack gap={2} justify="flex-end" width="full"> + {detectionHeuristics && isEditable && ( + <PMButton + onClick={handleSave} + disabled={isSaveDisabled()} + loading={updateHeuristics.isPending} + > + Save Changes + </PMButton> + )} + </PMHStack> + </PMVStack> + </PMVStack> + </PMDrawer.Body> + <PMDrawer.CloseTrigger asChild> + <PMCloseButton size="sm" /> + </PMDrawer.CloseTrigger> + </PMDrawer.Content> + </PMDrawer.Positioner> + </PMPortal> + </PMDrawer.Root> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/DetectionCardUtils.ts b/apps/frontend/src/domain/detection/components/DetectionCardUtils.ts new file mode 100644 index 000000000..72ae4e0e1 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionCardUtils.ts @@ -0,0 +1,41 @@ +import { + ProgrammingLanguage, + ProgrammingLanguageDetails, +} from '@packmind/types'; + +export const formatLabelFromEnum = (value?: string | null) => { + if (!value) { + return '—'; + } + + const normalized = value + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/-/g, '_') + .toLowerCase(); + + return normalized + .split('_') + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(' '); +}; + +/** + * Returns the display name for a programming language from ProgrammingLanguageDetails. + * Falls back to formatted enum value if language is not found, or '—' if null/undefined. + */ +export const getLanguageDisplayName = (language?: string | null): string => { + if (!language) { + return '—'; + } + + const languageDetails = + ProgrammingLanguageDetails[language as ProgrammingLanguage]; + + if (languageDetails) { + return languageDetails.displayName; + } + + // Fallback to formatted enum if not found in ProgrammingLanguageDetails + return formatLabelFromEnum(language); +}; diff --git a/apps/frontend/src/domain/detection/components/DetectionDraftCard/DetectionDraftCard.stories.tsx b/apps/frontend/src/domain/detection/components/DetectionDraftCard/DetectionDraftCard.stories.tsx new file mode 100644 index 000000000..091fe406e --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionDraftCard/DetectionDraftCard.stories.tsx @@ -0,0 +1,218 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { DetectionDraftCard } from './DetectionDraftCard'; +import { + DetectionStatus, + RuleDetectionAssessmentStatus, + RuleDetectionAssessment, + DetectionModeEnum, + ProgrammingLanguage, + createRuleDetectionAssessmentId, + createRuleId, +} from '@packmind/types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +// Helper to create a decorator with mocked assessment data +const createQueryMock = (assessmentData?: RuleDetectionAssessment | null) => { + return ( + Story: React.ComponentType, + context: { + args: { + standardId: string; + ruleId: string; + draft: { language: string }; + }; + }, + ) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); + + // Pre-populate the query cache with mock assessment data if provided + if (assessmentData && context.args.draft.language) { + const queryKey = [ + 'organization', + 'detection', + 'get-rule-detection-assessment', + context.args.standardId, + context.args.ruleId, + context.args.draft.language, + ]; + queryClient.setQueryData(queryKey, assessmentData); + } + + return ( + <QueryClientProvider client={queryClient}> + <Story /> + </QueryClientProvider> + ); + }; +}; + +function createMockAssessment( + status: RuleDetectionAssessmentStatus, +): RuleDetectionAssessment { + return { + id: createRuleDetectionAssessmentId('assessment-123'), + ruleId: createRuleId('rule-456'), + language: ProgrammingLanguage.TYPESCRIPT, + detectionMode: DetectionModeEnum.SINGLE_AST, + status, + details: 'Assessment details', + clarificationQuestion: null, + clarificationAnswers: null, + updatedAt: new Date(), + }; +} + +const meta = { + title: 'Domain/Detection/DetectionDraftCard', + component: DetectionDraftCard, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], + argTypes: { + draft: { control: 'object' }, + isActivating: { control: 'boolean' }, + isGenerating: { control: 'boolean' }, + }, + args: { + // eslint-disable-next-line @typescript-eslint/no-empty-function + onMakeActive: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onTestDraft: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onRetryDraft: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onShowLogs: () => {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onShowProgram: () => {}, + standardId: 'standard-123', + ruleId: 'rule-456', + }, + decorators: [], +} satisfies Meta<typeof DetectionDraftCard>; + +export default meta; +type Story = StoryObj<typeof meta>; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const baseDraftProgram: any = { + id: 'draft-program-123', + ruleId: 'rule-456', + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.READY, + code: '', + sourceCodeState: 'NONE' as const, + version: 1, + mode: 'singleAst', +}; + +const baseDraft = { + id: 'draft-123', + language: 'typescript', + activeDetectionProgramId: 'active-456', + draftProgram: baseDraftProgram, + mode: 'AI_GENERATION', + version: 1, +}; + +// State: assessing (assessment is NOT_STARTED or IN_PROGRESS) +export const Assessing: Story = { + args: { + draft: { + ...baseDraft, + status: DetectionStatus.IN_PROGRESS, + }, + }, + decorators: [ + createQueryMock( + createMockAssessment(RuleDetectionAssessmentStatus.IN_PROGRESS), + ), + ], +}; + +// State: assessment_failed (assessment status is FAILED) +export const AssessmentFailed: Story = { + args: { + draft: { + ...baseDraft, + status: DetectionStatus.IN_PROGRESS, + }, + }, + decorators: [ + createQueryMock(createMockAssessment(RuleDetectionAssessmentStatus.FAILED)), + ], +}; + +// State: assessment_successful (assessment SUCCESS but generation not started) +export const AssessmentSuccessful: Story = { + args: { + draft: { + ...baseDraft, + status: DetectionStatus.TO_REVIEW, + }, + }, + decorators: [ + createQueryMock( + createMockAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ), + ], +}; + +// State: generating (assessment SUCCESS and draft status is IN_PROGRESS) +export const Generating: Story = { + args: { + draft: { + ...baseDraft, + status: DetectionStatus.IN_PROGRESS, + }, + isGenerating: true, + }, + decorators: [ + createQueryMock( + createMockAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ), + ], +}; + +// State: generation_failed (assessment SUCCESS and draft status is FAILURE or ERROR) +export const GenerationFailed: Story = { + args: { + draft: { + ...baseDraft, + status: DetectionStatus.FAILURE, + }, + }, + decorators: [ + createQueryMock( + createMockAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ), + ], +}; + +// State: generation_successful (assessment SUCCESS and draft status is READY) +export const GenerationSuccessful: Story = { + args: { + draft: { + ...baseDraft, + status: DetectionStatus.READY, + draftProgram: { + ...baseDraftProgram, + sourceCodeState: 'AST' as const, + code: 'export function detectPattern() { return true; }', + }, + }, + }, + decorators: [ + createQueryMock( + createMockAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ), + ], +}; diff --git a/apps/frontend/src/domain/detection/components/DetectionDraftCard/DetectionDraftCard.test.tsx b/apps/frontend/src/domain/detection/components/DetectionDraftCard/DetectionDraftCard.test.tsx new file mode 100644 index 000000000..337522e30 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionDraftCard/DetectionDraftCard.test.tsx @@ -0,0 +1,491 @@ +import React from 'react'; +import { + render, + RenderResult, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { + DetectionStatus, + RuleDetectionAssessmentStatus, + RuleDetectionAssessment, + DetectionModeEnum, + ProgrammingLanguage, + createRuleDetectionAssessmentId, + createRuleId, + createStandardId, +} from '@packmind/types'; +import { DetectionDraftCard, DraftCardData } from './DetectionDraftCard'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UIProvider } from '@packmind/ui'; +import * as DetectionProgramQueries from '../../api/queries/DetectionProgramQueries'; +jest.mock('../../api/queries/DetectionProgramQueries'); + +describe('ProgramGenerationTimeline', () => { + let screen: RenderResult; + let baseDraft: DraftCardData; + + let onMakeActive: jest.Mock; + let onRetryDraft: jest.Mock; + let onTestDraft: jest.Mock; + let onShowProgram: jest.Mock; + let onShowLogs: jest.Mock; + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + beforeEach(() => { + baseDraft = { + id: 'draft-123', + language: 'typescript', + activeDetectionProgramId: 'active-456', + draftProgram: { + id: 'program-123', + ruleId: createRuleId('rule-456'), + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.IN_PROGRESS, + code: '', + sourceCodeState: 'NONE' as const, + version: 1, + mode: 'singleAst', + } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + status: DetectionStatus.IN_PROGRESS, + mode: 'AI_GENERATION', + version: 1, + }; + + onMakeActive = jest.fn(); + onRetryDraft = jest.fn(); + onTestDraft = jest.fn(); + onShowProgram = jest.fn(); + onShowLogs = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + function renderWithContext(assessment?: RuleDetectionAssessment) { + jest + .spyOn(DetectionProgramQueries, 'useGetRuleDetectionAssessmentQuery') + .mockReturnValue({ + data: assessment, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + // Mock the metadata query used by ExecutionLogsDrawer + jest + .spyOn(DetectionProgramQueries, 'useGetDetectionProgramMetadataQuery') + .mockReturnValue({ + data: undefined, + isLoading: false, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + return render( + <UIProvider> + <QueryClientProvider client={queryClient}> + <DetectionDraftCard + draft={baseDraft} + onMakeActive={onMakeActive} + onTestDraft={onTestDraft} + onRetryDraft={onRetryDraft} + onShowLogs={onShowLogs} + onShowProgram={onShowProgram} + standardId={createStandardId('standard-123')} + ruleId={createRuleId('rule-456')} + /> + </QueryClientProvider> + </UIProvider>, + ); + } + + function createAssessment( + status: RuleDetectionAssessmentStatus, + ): RuleDetectionAssessment { + return { + id: createRuleDetectionAssessmentId('assessment-123'), + ruleId: createRuleId('rule-456'), + language: ProgrammingLanguage.TYPESCRIPT, + detectionMode: DetectionModeEnum.SINGLE_AST, + status, + details: 'Assessment details', + clarificationQuestion: null, + clarificationAnswers: null, + updatedAt: new Date(), + }; + } + + describe('when assessment is running', () => { + beforeEach(() => { + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.IN_PROGRESS), + ); + }); + + it('shows "Checking the detectability of the rule"', () => { + expect( + screen.getByText('Checking the detectability of the rule'), + ).toBeInTheDocument(); + }); + + it('disables the "Generating program" section', () => { + const generatingText = screen.getByText('Generating program'); + // The text should be present but with faded color + expect(generatingText).toBeInTheDocument(); + }); + + it('disables the "Ready to use" section', () => { + const readyText = screen.getByText('Ready to use'); + // The text should be present but with faded color + expect(readyText).toBeInTheDocument(); + }); + }); + + describe('when assessment is done', () => { + describe('when assessment failed', () => { + beforeEach(() => { + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.FAILED), + ); + }); + + it('shows "The rule can not be detected"', () => { + expect( + screen.getByText('The rule can not be detected'), + ).toBeInTheDocument(); + }); + + it('disables the "Generating program" section', () => { + expect(screen.getByText('Generating program')).toBeInTheDocument(); + }); + + it('disables the "Ready to use" section', () => { + expect(screen.getByText('Ready to use')).toBeInTheDocument(); + }); + }); + + describe('when assessment succeeded', () => { + describe('when program is not generated yet', () => { + beforeEach(() => { + // Use a status that doesn't match any handled status to trigger ASSESSMENT_SUCCESSFUL + baseDraft.status = '' as DetectionStatus; + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ); + }); + + it('shows "The rule can be detected"', () => { + expect( + screen.getByText('The rule can be detected'), + ).toBeInTheDocument(); + }); + + it('shows "Generating program" as active', () => { + expect(screen.getByText('Generating program')).toBeInTheDocument(); + }); + + it('shows the generating program description', () => { + expect( + screen.getByText( + /Packmind AI generates a program that comply with rule specifications/, + ), + ).toBeInTheDocument(); + }); + + it('disables the "Ready to use" section', () => { + expect(screen.getByText('Ready to use')).toBeInTheDocument(); + }); + }); + + describe('when program needs review (TO_REVIEW)', () => { + beforeEach(() => { + baseDraft.status = DetectionStatus.TO_REVIEW; + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ); + }); + + it('shows "The rule can be detected"', () => { + expect( + screen.getByText('The rule can be detected'), + ).toBeInTheDocument(); + }); + + it('shows "Program needs update" as active', () => { + expect(screen.getByText('Program needs update')).toBeInTheDocument(); + }); + + it('shows the program needs update description', () => { + expect( + screen.getByText( + /The rule specifications or examples have been modified since this program was generated/, + ), + ).toBeInTheDocument(); + }); + + it('shows "Regenerate" button', () => { + expect(screen.getByText('Regenerate')).toBeInTheDocument(); + }); + + it('shows "Show program" button', () => { + expect(screen.getByText('Show program')).toBeInTheDocument(); + }); + + it('disables the "Ready to use" section', () => { + expect(screen.getByText('Ready to use')).toBeInTheDocument(); + }); + }); + + describe('when program is being generated', () => { + beforeEach(() => { + baseDraft.status = DetectionStatus.IN_PROGRESS; + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ); + }); + + it('shows "The rule can be detected"', () => { + expect( + screen.getByText('The rule can be detected'), + ).toBeInTheDocument(); + }); + + it('shows "Generating program" as active', () => { + expect(screen.getByText('Generating program')).toBeInTheDocument(); + }); + + it('disables the "Ready to use" section', () => { + expect(screen.getByText('Ready to use')).toBeInTheDocument(); + }); + + describe('when "Show log" button is clicked', () => { + it('opens logs drawer', async () => { + const showLogButton = screen.getByText('Show log'); + fireEvent.click(showLogButton); + await waitFor(() => { + expect(onShowLogs).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('when program generation is done', () => { + describe('when program generation failed', () => { + beforeEach(() => { + baseDraft.status = DetectionStatus.FAILURE; + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ); + }); + + it('shows "The rule can be detected"', () => { + expect( + screen.getByText('The rule can be detected'), + ).toBeInTheDocument(); + }); + + it('shows "Unable to generate a program"', () => { + expect( + screen.getByText('Unable to generate a program'), + ).toBeInTheDocument(); + }); + + it('disables the "Ready to use" section', () => { + expect(screen.getByText('Ready to use')).toBeInTheDocument(); + }); + + describe('when "Show log" button is clicked', () => { + it('opens logs drawer', async () => { + const showLogButton = screen.getByText('Show log'); + fireEvent.click(showLogButton); + await waitFor(() => { + expect(onShowLogs).toHaveBeenCalled(); + }); + }); + }); + + describe('when "Retry" button is clicked', () => { + it('calls onRetryDraft', () => { + const retryButton = screen.getByText('Retry'); + fireEvent.click(retryButton); + expect(onRetryDraft).toHaveBeenCalled(); + }); + }); + }); + + describe('when program generation succeeded', () => { + beforeEach(() => { + baseDraft.status = DetectionStatus.READY; + }); + + describe('when there is an active program', () => { + beforeEach(() => { + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ); + }); + + it('shows "The rule can be detected"', () => { + expect( + screen.getByText('The rule can be detected'), + ).toBeInTheDocument(); + }); + + it('shows "Program has been generated"', () => { + expect( + screen.getByText('Program has been generated'), + ).toBeInTheDocument(); + }); + + it('shows "Ready to use" as active', () => { + expect(screen.getByText('Ready to use')).toBeInTheDocument(); + }); + + describe('when "Show log" button is clicked', () => { + it('opens logs drawer', async () => { + const showLogButton = screen.getByText('Show log'); + fireEvent.click(showLogButton); + await waitFor(() => { + expect(onShowLogs).toHaveBeenCalled(); + }); + }); + }); + + describe('when "Show program" button is clicked', () => { + it('opens program drawer', async () => { + const showProgramButton = screen.getByText('Show program'); + fireEvent.click(showProgramButton); + await waitFor(() => { + expect(onShowProgram).toHaveBeenCalled(); + }); + }); + }); + + describe('when "Test draft program" button is clicked', () => { + it('calls onTestDraft', async () => { + const testDraftButton = screen.getByText('Test draft program'); + fireEvent.click(testDraftButton); + + await waitFor(() => { + expect(onTestDraft).toHaveBeenCalled(); + }); + }); + }); + + describe('when "Set as active" button is clicked', () => { + it('shows confirmation dialog title', async () => { + const setActiveButton = screen.getByText('Set as active'); + fireEvent.click(setActiveButton); + await waitFor(() => { + expect( + screen.getByText('Activate Detection Program'), + ).toBeInTheDocument(); + }); + }); + + it('shows confirmation dialog message', async () => { + const setActiveButton = screen.getByText('Set as active'); + fireEvent.click(setActiveButton); + await waitFor(() => { + expect( + screen.getByText( + 'Are you sure you want to activate this typescript detection program (v1)? This will replace the current active program.', + ), + ).toBeInTheDocument(); + }); + }); + + describe('when confirmation dialog is confirmed', () => { + it('calls onMakeActive', async () => { + const setActiveButton = screen.getByText('Set as active'); + fireEvent.click(setActiveButton); + + await screen.findByText('Activate Detection Program'); + + const activateButton = screen.getByRole('button', { + name: 'Activate', + }); + fireEvent.click(activateButton); + + await waitFor(() => { + expect(onMakeActive).toHaveBeenCalled(); + }); + }); + }); + + describe('when confirmation dialog is cancelled', () => { + it('closes the dialog', async () => { + const setActiveButton = screen.getByText('Set as active'); + fireEvent.click(setActiveButton); + + await screen.findByText('Activate Detection Program'); + + const cancelButton = screen.getByRole('button', { + name: 'Cancel', + }); + fireEvent.click(cancelButton); + + await waitFor(() => { + expect( + screen.queryByText('Activate Detection Program'), + ).not.toBeInTheDocument(); + }); + }); + + it('does not call onMakeActive', async () => { + const setActiveButton = screen.getByText('Set as active'); + fireEvent.click(setActiveButton); + + await screen.findByText('Activate Detection Program'); + + const cancelButton = screen.getByRole('button', { + name: 'Cancel', + }); + fireEvent.click(cancelButton); + + await screen.findByRole('button', { name: 'Set as active' }); + expect(onMakeActive).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('when there is no active program', () => { + beforeEach(() => { + baseDraft.activeDetectionProgramId = ''; + screen = renderWithContext( + createAssessment(RuleDetectionAssessmentStatus.SUCCESS), + ); + }); + + describe('when "Set as active" button is clicked', () => { + it('calls onMakeActive directly', async () => { + const setActiveButton = screen.getByText('Set as active'); + fireEvent.click(setActiveButton); + + await waitFor(() => { + expect(onMakeActive).toHaveBeenCalled(); + }); + }); + + it('does not show confirmation dialog', () => { + const setActiveButton = screen.getByText('Set as active'); + fireEvent.click(setActiveButton); + + expect( + screen.queryByText('Activate Detection Program'), + ).not.toBeInTheDocument(); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/detection/components/DetectionDraftCard/DetectionDraftCard.tsx b/apps/frontend/src/domain/detection/components/DetectionDraftCard/DetectionDraftCard.tsx new file mode 100644 index 000000000..154ebb832 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionDraftCard/DetectionDraftCard.tsx @@ -0,0 +1,505 @@ +import React from 'react'; +import { + PMAlertDialog, + PMBox, + PMButton, + PMHStack, + PMIcon, + PMSpinner, + PMText, + type PMTextColors, + PMTimeline, + PMTimelineConnector, + PMTimelineContent, + PMTimelineDescription, + PMTimelineIndicator, + PMTimelineItem, + PMTimelineSeparator, + PMTimelineTitle, + type IPMButtonProps, +} from '@packmind/ui'; +import { DetectionProgram, DetectionStatus } from '@packmind/types'; +import { useGetRuleDetectionAssessmentQuery } from '../../api/queries/DetectionProgramQueries'; +import { LuCheck, LuCircleAlert } from 'react-icons/lu'; +import { determineDraftStatus, DraftStatus } from './determineDraftStatus'; + +export type DraftCardData = { + id: string; + language: string; + activeDetectionProgramId: string; + hasActiveProgram?: boolean; + draftProgram: DetectionProgram; + status: DetectionStatus | string; + mode?: string; + version?: number; +}; + +export interface DraftCardProps { + draft: DraftCardData; + onMakeActive: (draft: DraftCardData) => void; + isActivating?: boolean; + onTestDraft: (draft: DraftCardData) => void; + onRetryDraft?: (draft: DraftCardData) => void; + onShowLogs: () => void; + onShowProgram: () => void; + isGenerating?: boolean; + standardId: string; + ruleId: string; +} + +export const DetectionDraftCard: React.FC<DraftCardProps> = ({ + draft, + onMakeActive, + isActivating, + onTestDraft, + onRetryDraft, + isGenerating, + standardId, + ruleId, + onShowLogs, + onShowProgram, +}) => { + const { data: assessment } = useGetRuleDetectionAssessmentQuery( + standardId, + ruleId, + draft.language, + ); + const state = determineDraftStatus(assessment?.status, draft.status); + + const handlers: TimelineHandlers = { + onShowLogs: () => onShowLogs(), + onShowProgram: () => onShowProgram(), + onTestDraft: () => onTestDraft(draft), + onMakeActive: () => onMakeActive(draft), + onRetryDraft: () => onRetryDraft?.(draft), + }; + + const loadingStates: LoadingStates = { + isActivating: isActivating ?? false, + isGenerating: isGenerating ?? false, + }; + + const draftInfo: DraftInfo = { + language: draft.language, + version: draft.version, + hasActiveProgram: + draft.hasActiveProgram ?? !!draft.activeDetectionProgramId, + }; + + const timelineConfig = getTimelineConfig( + state, + handlers, + loadingStates, + draftInfo, + ); + + return ( + <PMBox width="full" m={4}> + <PMTimeline variant="subtleOnPrimary"> + <TimelineStep config={timelineConfig.step1} /> + <TimelineStep config={timelineConfig.step2} /> + <TimelineStep config={timelineConfig.step3} /> + </PMTimeline> + </PMBox> + ); +}; + +function getStepIcon(status: TimelineStepStatus) { + switch (status) { + case TimelineStepStatus.failure: + return ( + <PMIcon bgColor={'colorPalette.primary'} color="text.error" size="xs"> + <LuCircleAlert /> + </PMIcon> + ); + case TimelineStepStatus.success: + return ( + <PMIcon bgColor={'colorPalette.primary'} color="text.success" size="xs"> + <LuCheck /> + </PMIcon> + ); + case TimelineStepStatus.warning: + return ( + <PMIcon bgColor={'colorPalette.primary'} color="text.warning" size="xs"> + <LuCircleAlert /> + </PMIcon> + ); + case TimelineStepStatus.pending: + return <PMSpinner size="xs" />; + default: + return; + } +} + +type TimelineButtonConfirmation = { + title: string; + message: string; + confirmText?: string; +}; + +type TimelineButton = { + label: string; + onClick: () => void; + disabled?: boolean; + variant?: IPMButtonProps['variant']; + size?: IPMButtonProps['size']; + confirmation?: TimelineButtonConfirmation; +}; + +enum TimelineStepStatus { + pending, + success, + failure, + warning, + unreachable, +} + +type TimelineStepConfig = { + title: string; + description?: string | React.ReactNode; + isLast: boolean; + buttons?: TimelineButton[]; + status: TimelineStepStatus; +}; + +type TimelineConfig = { + step1: TimelineStepConfig; + step2: TimelineStepConfig; + step3: TimelineStepConfig; +}; + +type TimelineHandlers = { + onShowLogs: () => void; + onShowProgram: () => void; + onTestDraft: () => void; + onMakeActive: () => void; + onRetryDraft: () => void; +}; + +function getStepTextColor(status: TimelineStepStatus): PMTextColors { + if (status === TimelineStepStatus.unreachable) return 'faded'; + if (status === TimelineStepStatus.warning) return 'warning'; + + return 'primary'; +} + +interface TimelineStepProps { + config: TimelineStepConfig; +} + +const TimelineStep: React.FC<TimelineStepProps> = ({ config }) => { + return ( + <PMTimelineItem> + <PMTimelineConnector> + {!config.isLast && <PMTimelineSeparator />} + <PMTimelineIndicator icon={getStepIcon(config.status)} /> + </PMTimelineConnector> + <PMTimelineContent> + <PMTimelineTitle> + <PMText color={getStepTextColor(config.status)}> + {config.title} + </PMText> + </PMTimelineTitle> + {config.description && ( + <PMTimelineDescription>{config.description}</PMTimelineDescription> + )} + {config.buttons && config.buttons.length > 0 && ( + <PMBox mt={2}> + <PMHStack gap={2}> + {config.buttons.map((button) => + button.confirmation ? ( + <PMAlertDialog + key={button.label} + trigger={ + <PMButton + size={button?.size ?? 'sm'} + variant={button?.variant ?? 'outline'} + disabled={button.disabled} + > + {button.label} + </PMButton> + } + title={button.confirmation.title} + message={button.confirmation.message} + confirmText={button.confirmation.confirmText ?? 'Confirm'} + confirmColorScheme="blue" + onConfirm={button.onClick} + /> + ) : ( + <PMButton + key={button.label} + size={button?.size ?? 'sm'} + variant={button?.variant ?? 'outline'} + onClick={button.onClick} + disabled={button.disabled} + > + {button.label} + </PMButton> + ), + )} + </PMHStack> + </PMBox> + )} + </PMTimelineContent> + </PMTimelineItem> + ); +}; + +type LoadingStates = { + isActivating: boolean; + isGenerating: boolean; +}; + +type DraftInfo = { + language: string; + version?: number; + hasActiveProgram: boolean; +}; + +function getTimelineConfig( + state: DraftStatus, + handlers: TimelineHandlers, + loadingStates: LoadingStates, + draftInfo?: DraftInfo, +): TimelineConfig { + switch (state) { + case DraftStatus.ASSESSING: + return { + step1: { + title: 'Checking the detectability of the rule', + isLast: false, + status: TimelineStepStatus.pending, + }, + step2: { + title: 'Generating program', + isLast: false, + status: TimelineStepStatus.unreachable, + }, + step3: { + title: 'Ready to use', + isLast: true, + status: TimelineStepStatus.unreachable, + }, + }; + + case DraftStatus.ASSESSMENT_FAILED: + return { + step1: { + title: 'The rule can not be detected', + isLast: false, + status: TimelineStepStatus.failure, + }, + step2: { + title: 'Generating program', + isLast: false, + status: TimelineStepStatus.unreachable, + }, + step3: { + title: 'Ready to use', + isLast: true, + status: TimelineStepStatus.unreachable, + }, + }; + + case DraftStatus.ASSESSMENT_SUCCESSFUL: + return { + step1: { + title: 'The rule can be detected', + isLast: false, + status: TimelineStepStatus.success, + }, + step2: { + title: 'Generating program', + description: ( + <> + <PMText as="p" variant="small"> + Packmind AI generates a program that comply with rule + specifications. Program is ran on code examples to ensure its + validity. + </PMText> + <PMText as="p" color="faded" variant="small"> + Note: generation can take more than a minute to finish. + </PMText> + </> + ), + isLast: false, + status: TimelineStepStatus.pending, + }, + step3: { + title: 'Ready to use', + isLast: true, + status: TimelineStepStatus.unreachable, + }, + }; + + case DraftStatus.GENERATING: + return { + step1: { + title: 'The rule can be detected', + isLast: false, + status: TimelineStepStatus.success, + }, + step2: { + title: 'Generating program', + description: ( + <> + <PMText as="p" variant="small"> + Packmind AI generates a program that comply with rule + specifications. Program is ran on code examples to ensure its + validity. + </PMText> + <PMText as="p" color="faded" variant="small"> + Note: generation can take more than a minute to finish. + </PMText> + </> + ), + isLast: false, + status: TimelineStepStatus.pending, + buttons: [ + { + label: 'Show log', + onClick: handlers.onShowLogs, + variant: 'tertiary', + size: '2xs', + }, + ], + }, + step3: { + title: 'Ready to use', + isLast: true, + status: TimelineStepStatus.unreachable, + }, + }; + + case DraftStatus.GENERATION_FAILED: + return { + step1: { + title: 'The rule can be detected', + isLast: false, + status: TimelineStepStatus.success, + }, + step2: { + title: 'Unable to generate a program', + isLast: false, + status: TimelineStepStatus.failure, + buttons: [ + { + label: 'Retry', + onClick: handlers.onRetryDraft, + disabled: loadingStates.isGenerating, + }, + { + label: 'Show log', + onClick: handlers.onShowLogs, + variant: 'tertiary', + size: '2xs', + }, + ], + }, + step3: { + title: 'Ready to use', + isLast: true, + status: TimelineStepStatus.unreachable, + }, + }; + + case DraftStatus.GENERATION_SUCCESSFUL: + return { + step1: { + title: 'The rule can be detected', + isLast: false, + status: TimelineStepStatus.success, + }, + step2: { + title: 'Program has been generated', + isLast: false, + status: TimelineStepStatus.success, + buttons: [ + { + label: 'Show log', + onClick: handlers.onShowLogs, + variant: 'tertiary', + size: '2xs', + }, + { + label: 'Show program', + onClick: handlers.onShowProgram, + variant: 'tertiary', + size: '2xs', + }, + ], + }, + step3: { + title: 'Ready to use', + isLast: true, + status: TimelineStepStatus.success, + buttons: [ + { + label: 'Test draft program', + onClick: handlers.onTestDraft, + }, + { + label: 'Set as active', + onClick: handlers.onMakeActive, + disabled: loadingStates.isActivating, + variant: 'primary', + confirmation: draftInfo?.hasActiveProgram + ? { + title: 'Activate Detection Program', + message: `Are you sure you want to activate this ${draftInfo.language} detection program${draftInfo.version ? ` (v${draftInfo.version})` : ''}? This will replace the current active program.`, + confirmText: 'Activate', + } + : undefined, + }, + ], + }, + }; + + case DraftStatus.TO_REVIEW: + return { + step1: { + title: 'The rule can be detected', + isLast: false, + status: TimelineStepStatus.success, + }, + step2: { + title: 'Program needs update', + description: ( + <> + <PMText as="p" variant="small"> + The rule specifications or examples have been modified since + this program was generated. The program needs to be regenerated + to comply with the updated specifications. + </PMText> + </> + ), + isLast: false, + status: TimelineStepStatus.warning, + buttons: [ + { + label: 'Regenerate', + onClick: handlers.onRetryDraft, + disabled: loadingStates.isGenerating, + size: '2xs', + }, + { + label: 'Show program', + onClick: handlers.onShowProgram, + variant: 'tertiary', + size: '2xs', + }, + ], + }, + step3: { + title: 'Ready to use', + isLast: true, + status: TimelineStepStatus.unreachable, + }, + }; + + default: { + const _exhaustiveCheck: never = state; + throw new Error(`Unhandled DraftStatus: ${_exhaustiveCheck}`); + } + } +} diff --git a/apps/frontend/src/domain/detection/components/DetectionDraftCard/determineDraftStatus.test.ts b/apps/frontend/src/domain/detection/components/DetectionDraftCard/determineDraftStatus.test.ts new file mode 100644 index 000000000..1cd94262e --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionDraftCard/determineDraftStatus.test.ts @@ -0,0 +1,111 @@ +import { + DetectionStatus, + RuleDetectionAssessmentStatus, +} from '@packmind/types'; +import { determineDraftStatus, DraftStatus } from './determineDraftStatus'; + +describe('determineDraftStatus', () => { + describe('when assessment is not started or in progress', () => { + describe('when assessment status is undefined', () => { + it('returns ASSESSING', () => { + const state = determineDraftStatus( + undefined, + DetectionStatus.IN_PROGRESS, + ); + expect(state).toEqual(DraftStatus.ASSESSING); + }); + }); + + describe('when assessment status is NOT_STARTED', () => { + it('returns ASSESSING', () => { + const state = determineDraftStatus( + RuleDetectionAssessmentStatus.NOT_STARTED, + DetectionStatus.IN_PROGRESS, + ); + expect(state).toEqual(DraftStatus.ASSESSING); + }); + }); + + describe('when assessment status is IN_PROGRESS', () => { + it('returns ASSESSING', () => { + const state = determineDraftStatus( + RuleDetectionAssessmentStatus.IN_PROGRESS, + DetectionStatus.IN_PROGRESS, + ); + expect(state).toEqual(DraftStatus.ASSESSING); + }); + }); + }); + + describe('when assessment is done', () => { + describe('when assessment failed', () => { + it('returns ASSESSMENT_FAILED', () => { + const state = determineDraftStatus( + RuleDetectionAssessmentStatus.FAILED, + DetectionStatus.IN_PROGRESS, + ); + expect(state).toEqual(DraftStatus.ASSESSMENT_FAILED); + }); + }); + + describe('when assessment succeeded', () => { + describe('when draft status is TO_REVIEW', () => { + it('returns TO_REVIEW', () => { + const state = determineDraftStatus( + RuleDetectionAssessmentStatus.SUCCESS, + DetectionStatus.TO_REVIEW, + ); + expect(state).toEqual(DraftStatus.TO_REVIEW); + }); + }); + + describe('when program generation is in progress', () => { + describe('when draft status is IN_PROGRESS', () => { + it('returns GENERATING', () => { + const state = determineDraftStatus( + RuleDetectionAssessmentStatus.SUCCESS, + DetectionStatus.IN_PROGRESS, + ); + expect(state).toEqual(DraftStatus.GENERATING); + }); + }); + }); + + describe('when program generation is done', () => { + describe('when program generation failed', () => { + describe('when draft status is FAILURE', () => { + it('returns GENERATION_FAILED', () => { + const state = determineDraftStatus( + RuleDetectionAssessmentStatus.SUCCESS, + DetectionStatus.FAILURE, + ); + expect(state).toEqual(DraftStatus.GENERATION_FAILED); + }); + }); + + describe('when draft status is ERROR', () => { + it('returns GENERATION_FAILED', () => { + const state = determineDraftStatus( + RuleDetectionAssessmentStatus.SUCCESS, + DetectionStatus.ERROR, + ); + expect(state).toEqual(DraftStatus.GENERATION_FAILED); + }); + }); + }); + + describe('when program generation succeeded', () => { + describe('when draft status is READY', () => { + it('returns GENERATION_SUCCESSFUL', () => { + const state = determineDraftStatus( + RuleDetectionAssessmentStatus.SUCCESS, + DetectionStatus.READY, + ); + expect(state).toEqual(DraftStatus.GENERATION_SUCCESSFUL); + }); + }); + }); + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/detection/components/DetectionDraftCard/determineDraftStatus.ts b/apps/frontend/src/domain/detection/components/DetectionDraftCard/determineDraftStatus.ts new file mode 100644 index 000000000..d51596da6 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionDraftCard/determineDraftStatus.ts @@ -0,0 +1,62 @@ +import { + DetectionStatus, + RuleDetectionAssessmentStatus, +} from '@packmind/types'; + +export enum DraftStatus { + ASSESSING = 'assessing', + ASSESSMENT_FAILED = 'assessment_failed', + ASSESSMENT_SUCCESSFUL = 'assessment_successful', + GENERATING = 'generating', + GENERATION_FAILED = 'generation_failed', + GENERATION_SUCCESSFUL = 'generation_successful', + TO_REVIEW = 'to_review', +} + +export function determineDraftStatus( + assessmentStatus: RuleDetectionAssessmentStatus | undefined, + draftStatus: DetectionStatus | string, +): DraftStatus { + // If no assessment or assessment is running + if ( + !assessmentStatus || + assessmentStatus === RuleDetectionAssessmentStatus.NOT_STARTED || + assessmentStatus === RuleDetectionAssessmentStatus.IN_PROGRESS + ) { + return DraftStatus.ASSESSING; + } + + // If assessment failed + if (assessmentStatus === RuleDetectionAssessmentStatus.FAILED) { + return DraftStatus.ASSESSMENT_FAILED; + } + + // If assessment succeeded + if (assessmentStatus === RuleDetectionAssessmentStatus.SUCCESS) { + // Check draft status + if (draftStatus === DetectionStatus.IN_PROGRESS) { + return DraftStatus.GENERATING; + } + + if ( + draftStatus === DetectionStatus.FAILURE || + draftStatus === DetectionStatus.ERROR + ) { + return DraftStatus.GENERATION_FAILED; + } + + if (draftStatus === DetectionStatus.READY) { + return DraftStatus.GENERATION_SUCCESSFUL; + } + + if (draftStatus === DetectionStatus.TO_REVIEW) { + return DraftStatus.TO_REVIEW; + } + + // Assessment successful but generation hasn't started or is in an intermediate state + return DraftStatus.ASSESSMENT_SUCCESSFUL; + } + + // Default fallback + return DraftStatus.ASSESSING; +} diff --git a/apps/frontend/src/domain/detection/components/DetectionDraftCard/getTimelineConfig.tsx b/apps/frontend/src/domain/detection/components/DetectionDraftCard/getTimelineConfig.tsx new file mode 100644 index 000000000..85b1e0395 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionDraftCard/getTimelineConfig.tsx @@ -0,0 +1,318 @@ +import { DraftStatus } from './determineDraftStatus'; +import { IPMButtonProps, PMText } from '@packmind/ui'; +import React from 'react'; +import { + LuFileText, + LuCode, + LuPlay, + LuCircleCheckBig, + LuRefreshCw, +} from 'react-icons/lu'; + +type TimelineButtonConfirmation = { + title: string; + message: string; + confirmText?: string; +}; + +export type TimelineButton = { + label: string; + onClick: () => void; + disabled?: boolean; + icon?: React.ReactNode; + variant?: IPMButtonProps['variant']; + size?: IPMButtonProps['size']; + confirmation?: TimelineButtonConfirmation; +}; + +export enum TimelineStepStatus { + pending, + success, + failure, + warning, + unreachable, +} + +export type TimelineStepConfig = { + title: string; + description?: string | React.ReactNode; + isLast: boolean; + buttons?: TimelineButton[]; + status: TimelineStepStatus; +}; + +type TimelineConfig = { + step1: TimelineStepConfig; + step2: TimelineStepConfig; + step3: TimelineStepConfig; +}; +export type TimelineHandlers = { + onShowLogs: () => void; + onShowProgram: () => void; + onTestDraft: () => void; + onMakeActive: () => void; + onRetryDraft: () => void; +}; +export type LoadingStates = { + isActivating: boolean; + isGenerating: boolean; +}; + +export type DraftInfo = { + language: string; + version?: number; + hasActiveProgram: boolean; +}; + +export function getTimelineConfig( + state: DraftStatus, + handlers: TimelineHandlers, + loadingStates: LoadingStates, + draftInfo?: DraftInfo, +): TimelineConfig { + switch (state) { + case DraftStatus.ASSESSING: + return { + step1: { + title: 'Checking the detectability of the rule', + isLast: false, + status: TimelineStepStatus.pending, + }, + step2: { + title: 'Generating program', + isLast: false, + status: TimelineStepStatus.unreachable, + }, + step3: { + title: 'Ready to use', + isLast: true, + status: TimelineStepStatus.unreachable, + }, + }; + + case DraftStatus.ASSESSMENT_FAILED: + return { + step1: { + title: 'The rule can not be detected', + isLast: false, + status: TimelineStepStatus.failure, + }, + step2: { + title: 'Generating program', + isLast: false, + status: TimelineStepStatus.unreachable, + }, + step3: { + title: 'Ready to use', + isLast: true, + status: TimelineStepStatus.unreachable, + }, + }; + + case DraftStatus.ASSESSMENT_SUCCESSFUL: + return { + step1: { + title: 'The rule can be detected', + isLast: false, + status: TimelineStepStatus.success, + }, + step2: { + title: 'Generating program', + description: ( + <> + <PMText as="p" variant="small"> + Packmind AI generates a program that comply with rule + specifications. Program is ran on code examples to ensure its + validity. + </PMText> + <PMText as="p" color="faded" variant="small"> + Note: generation can take more than a minute to finish. + </PMText> + </> + ), + isLast: false, + status: TimelineStepStatus.pending, + }, + step3: { + title: 'Ready to use', + isLast: true, + status: TimelineStepStatus.unreachable, + }, + }; + + case DraftStatus.GENERATING: + return { + step1: { + title: 'The rule can be detected', + isLast: false, + status: TimelineStepStatus.success, + }, + step2: { + title: 'Generating program', + description: ( + <> + <PMText as="p" variant="small"> + Packmind AI generates a program that comply with rule + specifications. Program is ran on code examples to ensure its + validity. + </PMText> + <PMText as="p" color="faded" variant="small"> + Note: generation can take more than a minute to finish. + </PMText> + </> + ), + isLast: false, + status: TimelineStepStatus.pending, + buttons: [ + { + label: 'Show log', + onClick: handlers.onShowLogs, + variant: 'tertiary', + size: '2xs', + icon: <LuFileText />, + }, + ], + }, + step3: { + title: 'Ready to use', + isLast: true, + status: TimelineStepStatus.unreachable, + }, + }; + + case DraftStatus.GENERATION_FAILED: + return { + step1: { + title: 'The rule can be detected', + isLast: false, + status: TimelineStepStatus.success, + }, + step2: { + title: 'Unable to generate a program', + isLast: false, + status: TimelineStepStatus.failure, + buttons: [ + { + label: 'Retry', + onClick: handlers.onRetryDraft, + disabled: loadingStates.isGenerating, + size: '2xs', + icon: <LuRefreshCw />, + }, + { + label: 'Show log', + onClick: handlers.onShowLogs, + variant: 'tertiary', + size: '2xs', + icon: <LuFileText />, + }, + ], + }, + step3: { + title: 'Ready to use', + isLast: true, + status: TimelineStepStatus.unreachable, + }, + }; + + case DraftStatus.GENERATION_SUCCESSFUL: + return { + step1: { + title: 'The rule can be detected', + isLast: false, + status: TimelineStepStatus.success, + }, + step2: { + title: 'Program has been generated', + isLast: false, + status: TimelineStepStatus.success, + buttons: [ + { + label: 'Show log', + onClick: handlers.onShowLogs, + variant: 'tertiary', + size: '2xs', + icon: <LuFileText />, + }, + { + label: 'Show program', + onClick: handlers.onShowProgram, + variant: 'tertiary', + size: '2xs', + icon: <LuCode />, + }, + ], + }, + step3: { + title: 'Ready to use', + isLast: true, + status: TimelineStepStatus.success, + buttons: [ + { + label: 'Test draft program', + onClick: handlers.onTestDraft, + icon: <LuPlay />, + }, + { + label: 'Set as active', + onClick: handlers.onMakeActive, + disabled: loadingStates.isActivating, + icon: <LuCircleCheckBig />, + variant: 'primary', + confirmation: draftInfo?.hasActiveProgram + ? { + title: 'Activate Detection Program', + message: `Are you sure you want to activate this ${draftInfo.language} detection program${draftInfo.version ? ` (v${draftInfo.version})` : ''}? This will replace the current active program.`, + confirmText: 'Activate', + } + : undefined, + }, + ], + }, + }; + + case DraftStatus.TO_REVIEW: + return { + step1: { + title: 'The rule can be detected', + isLast: false, + status: TimelineStepStatus.success, + }, + step2: { + title: 'Program needs update', + description: ( + <> + <PMText as="p" variant="small"> + The rule specifications or examples have been modified since + this program was generated. The program needs to be regenerated + to comply with the updated specifications. + </PMText> + </> + ), + isLast: false, + status: TimelineStepStatus.warning, + buttons: [ + { + label: 'Regenerate', + onClick: handlers.onRetryDraft, + disabled: loadingStates.isGenerating, + size: '2xs', + icon: <LuRefreshCw />, + }, + { + label: 'Show program', + onClick: handlers.onShowProgram, + variant: 'tertiary', + size: '2xs', + icon: <LuCode />, + }, + ], + }, + step3: { + title: 'Ready to use', + isLast: true, + status: TimelineStepStatus.unreachable, + }, + }; + } +} diff --git a/apps/frontend/src/domain/detection/components/DetectionProgramConfiguration.spec.tsx b/apps/frontend/src/domain/detection/components/DetectionProgramConfiguration.spec.tsx new file mode 100644 index 000000000..31471c976 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionProgramConfiguration.spec.tsx @@ -0,0 +1,390 @@ +import React from 'react'; +import { render, RenderResult } from '@testing-library/react'; +import { + QueryClient, + QueryClientProvider, + UseQueryResult, + UseMutationResult, +} from '@tanstack/react-query'; +import { UIProvider } from '@packmind/ui'; +import { + createDetectionHeuristicsId, + createRuleDetectionAssessmentId, + createRuleId, + createStandardId, + DetectionHeuristics, + DetectionStatus, + LanguageDetectionPrograms, + ProgrammingLanguage, + RuleDetectionAssessment, + RuleDetectionAssessmentStatus, + DetectionModeEnum, +} from '@packmind/types'; +import { DetectionProgramConfiguration } from './DetectionProgramConfiguration'; +import { ActiveConfigurationSectionData as ActiveConfigurationCardData } from './ActiveConfigurationSection/'; +import { DraftCardData } from './DetectionDraftCard/DetectionDraftCard'; +import * as DetectionProgramQueries from '../api/queries/DetectionProgramQueries'; + +jest.mock('../api/queries/DetectionProgramQueries'); + +jest.mock('../../accounts/hooks/useAuthContext', () => ({ + useAuthContext: () => ({ + user: { email: 'test@packmind.com' }, + organization: { + id: 'org-1', + name: 'Org', + slug: 'org-slug', + role: 'ADMIN', + }, + }), +})); + +type UpdateDetectionHeuristicsMutationResult = UseMutationResult< + DetectionHeuristics, + Error, + { + standardId: string; + ruleId: string; + detectionHeuristicsId: string; + heuristics: string[]; + clarificationQuestion?: { question: string; answer: string }; + }, + unknown +>; + +describe('DetectionProgramConfiguration', () => { + let props: { + standardId: string; + ruleId: string; + detectionLanguages: string[]; + selectedLanguage: string; + activeConfigurations: ActiveConfigurationCardData[]; + draftPrograms: DraftCardData[]; + isLoadingActivePrograms: boolean; + isActiveProgramsError: boolean; + onGenerateProgram: jest.Mock; + isGeneratingProgram: boolean; + onTestProgram: jest.Mock; + onActivateDraft: jest.Mock; + activatingDraftId: string | null; + isActivatingDraft: boolean; + onRetryDraft: jest.Mock; + onNavigateToExamples?: jest.Mock; + }; + let screen: RenderResult; + let assessment: RuleDetectionAssessment; + let detectionHeuristics: DetectionHeuristics; + let activePrograms: LanguageDetectionPrograms[]; + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + beforeEach(() => { + assessment = { + id: createRuleDetectionAssessmentId('assessment-id'), + details: 'Assessment details', + detectionMode: DetectionModeEnum.SINGLE_AST, + language: ProgrammingLanguage.TYPESCRIPT, + ruleId: createRuleId('rule-id'), + status: RuleDetectionAssessmentStatus.SUCCESS, + clarificationQuestion: '', + clarificationAnswers: [], + updatedAt: new Date('2025-01-01T12:00:00Z'), + }; + + detectionHeuristics = { + id: createDetectionHeuristicsId('heuristics-id'), + ruleId: createRuleId('rule-id'), + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['Initial heuristics text'], + }; + + activePrograms = []; + + props = { + standardId: createStandardId('standard-id'), + ruleId: createRuleId('rule-id'), + detectionLanguages: [ProgrammingLanguage.TYPESCRIPT], + selectedLanguage: ProgrammingLanguage.TYPESCRIPT, + activeConfigurations: [], + draftPrograms: [], + isLoadingActivePrograms: false, + isActiveProgramsError: false, + onGenerateProgram: jest.fn(), + isGeneratingProgram: false, + onTestProgram: jest.fn(), + onActivateDraft: jest.fn(), + activatingDraftId: null, + isActivatingDraft: false, + onRetryDraft: jest.fn(), + }; + + jest.clearAllMocks(); + + // Setup default mocks for all queries + jest + .spyOn(DetectionProgramQueries, 'useGetDetectionHeuristicsQuery') + .mockReturnValue({ data: detectionHeuristics } as Partial< + UseQueryResult<DetectionHeuristics | null> + > as UseQueryResult<DetectionHeuristics | null>); + + jest + .spyOn(DetectionProgramQueries, 'useGetActiveDetectionProgramsQuery') + .mockReturnValue({ data: activePrograms } as Partial< + UseQueryResult<LanguageDetectionPrograms[]> + > as UseQueryResult<LanguageDetectionPrograms[]>); + + jest + .spyOn(DetectionProgramQueries, 'useUpdateDetectionHeuristicsMutation') + .mockReturnValue({ + mutate: jest.fn(), + isPending: false, + } as Partial<UpdateDetectionHeuristicsMutationResult> as UpdateDetectionHeuristicsMutationResult); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + function renderWithContext() { + return render( + <UIProvider> + <QueryClientProvider client={queryClient}> + <DetectionProgramConfiguration {...props} /> + </QueryClientProvider> + </UIProvider>, + ); + } + + describe('when no detection languages are available', () => { + beforeEach(() => { + props.detectionLanguages = []; + props.selectedLanguage = ''; + jest + .spyOn(DetectionProgramQueries, 'useGetRuleDetectionAssessmentQuery') + .mockReturnValue({ data: assessment } as Partial< + UseQueryResult<RuleDetectionAssessment | null> + > as UseQueryResult<RuleDetectionAssessment | null>); + + screen = renderWithContext(); + }); + + it('renders empty state message for no examples', () => { + expect( + screen.getByText('No code examples for this language'), + ).toBeInTheDocument(); + }); + + it('renders Add button', () => { + expect(screen.getByRole('button', { name: /add/i })).toBeInTheDocument(); + }); + }); + + describe('when detection languages are available', () => { + beforeEach(() => { + jest + .spyOn(DetectionProgramQueries, 'useGetRuleDetectionAssessmentQuery') + .mockReturnValue({ data: assessment } as Partial< + UseQueryResult<RuleDetectionAssessment | null> + > as UseQueryResult<RuleDetectionAssessment | null>); + }); + + describe('when selected language has no examples', () => { + beforeEach(() => { + props.detectionLanguages = [ProgrammingLanguage.JAVASCRIPT]; + props.selectedLanguage = ProgrammingLanguage.PYTHON; + screen = renderWithContext(); + }); + + it('hides Detectability accordion', () => { + expect(screen.queryByText('Detectability')).not.toBeInTheDocument(); + }); + + it('hides Program accordion', () => { + expect(screen.queryByText('Program')).not.toBeInTheDocument(); + }); + + it('renders empty state message', () => { + expect( + screen.getByText('No code examples for this language'), + ).toBeInTheDocument(); + }); + + it('renders Add button', () => { + const addButton = screen.getByRole('button', { name: /add/i }); + expect(addButton).toBeInTheDocument(); + }); + + describe('when Add button is clicked', () => { + it('calls onNavigateToExamples', () => { + const onNavigateToExamples = jest.fn(); + props.onNavigateToExamples = onNavigateToExamples; + + screen.unmount(); + screen = renderWithContext(); + + const addButton = screen.getByRole('button', { name: /add/i }); + addButton.click(); + + expect(onNavigateToExamples).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when assessment status is FAILED', () => { + beforeEach(() => { + assessment.status = RuleDetectionAssessmentStatus.FAILED; + screen = renderWithContext(); + }); + + it('renders detectability accordion', () => { + expect(screen.getByText('Detectability')).toBeInTheDocument(); + }); + + it('displays Failed status', () => { + expect(screen.getByText('Failed')).toBeInTheDocument(); + }); + + it('disables the program accordion', () => { + // When disabled, the accordion content is not rendered + expect(screen.queryByText('Generate Program')).not.toBeInTheDocument(); + }); + }); + + describe('when assessment status is SUCCESS', () => { + beforeEach(() => { + assessment.status = RuleDetectionAssessmentStatus.SUCCESS; + screen = renderWithContext(); + }); + + it('renders detectability accordion', () => { + expect(screen.getByText('Detectability')).toBeInTheDocument(); + }); + + it('displays Active status badge', () => { + const activeElements = screen.getAllByText('Active'); + expect(activeElements.length).toBeGreaterThanOrEqual(1); + }); + + it('enables the program accordion', () => { + // When enabled, the accordion content is rendered + expect(screen.getByText('No configurations yet')).toBeInTheDocument(); + }); + }); + + describe('when assessment status is IN_PROGRESS', () => { + beforeEach(() => { + assessment.status = RuleDetectionAssessmentStatus.IN_PROGRESS; + screen = renderWithContext(); + }); + + it('renders detectability accordion', () => { + expect(screen.getByText('Detectability')).toBeInTheDocument(); + }); + + it('displays In progress status', () => { + expect(screen.getByText('In progress')).toBeInTheDocument(); + }); + + it('disables the program accordion', () => { + // When disabled, the accordion content is not rendered + expect(screen.queryByText('Generate Program')).not.toBeInTheDocument(); + }); + }); + + describe('program accordion status', () => { + describe('when generating program', () => { + beforeEach(() => { + props.isGeneratingProgram = true; + screen = renderWithContext(); + }); + + it('hides Generate new draft action', () => { + expect( + screen.queryByText('Generate new draft'), + ).not.toBeInTheDocument(); + }); + }); + + describe('when active configurations have READY status', () => { + beforeEach(() => { + props.activeConfigurations = [ + { + detectionProgram: { + status: DetectionStatus.READY, + }, + } as ActiveConfigurationCardData, + ]; + screen = renderWithContext(); + }); + + it('displays success status for program accordion', () => { + const successBadges = screen.getAllByText('Active'); + expect(successBadges.length).toBeGreaterThan(0); + }); + }); + + describe('when active configurations have ERROR status', () => { + beforeEach(() => { + props.activeConfigurations = [ + { + detectionProgram: { + status: DetectionStatus.ERROR, + }, + } as ActiveConfigurationCardData, + ]; + screen = renderWithContext(); + }); + + it('renders program accordion with Active button visible', () => { + // The new design shows Active dropdown button, not a status badge + const activeButtons = screen.getAllByText('Active'); + expect(activeButtons.length).toBeGreaterThan(0); + }); + }); + + describe('when active configurations have FAILURE status', () => { + beforeEach(() => { + props.activeConfigurations = [ + { + detectionProgram: { + status: DetectionStatus.FAILURE, + }, + } as ActiveConfigurationCardData, + ]; + screen = renderWithContext(); + }); + + it('renders program accordion with Active button visible', () => { + // The new design shows Active dropdown button, not a status badge + const activeButtons = screen.getAllByText('Active'); + expect(activeButtons.length).toBeGreaterThan(0); + }); + }); + }); + + describe('program accordion disabled state', () => { + describe('when assessment status is FAILED', () => { + beforeEach(() => { + assessment.status = RuleDetectionAssessmentStatus.FAILED; + screen = renderWithContext(); + }); + + it('disables the program accordion', () => { + // When disabled, the accordion content is not rendered + expect( + screen.queryByText('Generate Program'), + ).not.toBeInTheDocument(); + }); + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/detection/components/DetectionProgramConfiguration.tsx b/apps/frontend/src/domain/detection/components/DetectionProgramConfiguration.tsx new file mode 100644 index 000000000..fbddbc420 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/DetectionProgramConfiguration.tsx @@ -0,0 +1,147 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { PMVStack, PMEmptyState, PMButton } from '@packmind/ui'; +import { RuleDetectionAssessmentStatus, RuleExample } from '@packmind/types'; +import { useGetRuleDetectionAssessmentQuery } from '../api/queries/DetectionProgramQueries'; +import { ActiveConfigurationSectionData as ActiveConfigurationCardData } from './ActiveConfigurationSection/'; +import { DraftCardData } from './DetectionDraftCard/DetectionDraftCard'; +import { + ProgramGenerationAccordion, + DetectabilityAccordion, +} from './DetectionAccordions'; + +interface DetectionProgramConfigurationProps { + standardId: string; + standardName?: string; + ruleId: string; + detectionLanguages: string[]; + selectedLanguage: string; + activeConfigurations: ActiveConfigurationCardData[]; + draftPrograms: DraftCardData[]; + isLoadingActivePrograms: boolean; + isActiveProgramsError: boolean; + onGenerateProgram: (language?: string) => void; + isGeneratingProgram: boolean; + onTestProgram: (draft: DraftCardData | ActiveConfigurationCardData) => void; + onActivateDraft: (draft: DraftCardData) => void; + activatingDraftId: string | null; + isActivatingDraft: boolean; + onRetryDraft: (draft: DraftCardData) => void; + onNavigateToExamples?: () => void; + ruleExamples?: RuleExample[]; +} + +export const DetectionProgramConfiguration: React.FC< + DetectionProgramConfigurationProps +> = ({ + standardId, + standardName, + ruleId, + detectionLanguages, + selectedLanguage, + activeConfigurations, + draftPrograms, + isLoadingActivePrograms, + isActiveProgramsError, + onGenerateProgram, + isGeneratingProgram, + onTestProgram, + onActivateDraft, + activatingDraftId, + isActivatingDraft, + onRetryDraft, + onNavigateToExamples, + ruleExamples = [], +}) => { + const language = selectedLanguage; + + const { data: assessment } = useGetRuleDetectionAssessmentQuery( + standardId, + ruleId, + language, + ); + + // Controlled accordion state that updates when assessment loads + const [isDetectabilityOpen, setIsDetectabilityOpen] = useState(false); + const [isProgramOpen, setIsProgramOpen] = useState(false); + + useEffect(() => { + const shouldOpenDetectability = + !assessment || + assessment.status === RuleDetectionAssessmentStatus.NOT_STARTED || + assessment.status === RuleDetectionAssessmentStatus.FAILED || + assessment.status === RuleDetectionAssessmentStatus.IN_PROGRESS; + const shouldOpenProgram = + assessment?.status === RuleDetectionAssessmentStatus.SUCCESS; + + setIsDetectabilityOpen(shouldOpenDetectability); + setIsProgramOpen(shouldOpenProgram); + }, [assessment]); + + const isProgramDisabled = useMemo( + () => assessment?.status !== RuleDetectionAssessmentStatus.SUCCESS, + [assessment?.status], + ); + + const hasNoExamples = useMemo(() => { + // Check if the selected language has examples by checking if it's in detectionLanguages + return !detectionLanguages.includes(selectedLanguage); + }, [detectionLanguages, selectedLanguage]); + + if (hasNoExamples) { + return ( + <PMEmptyState + backgroundColor={'background.primary'} + borderRadius={'md'} + width={'2xl'} + mx={'auto'} + mt={32} + title={'No code examples for this language'} + > + Create code examples to document the rule usage and enable violation + detection with Packmind linter + <PMButton variant="primary" onClick={onNavigateToExamples}> + Add + </PMButton> + </PMEmptyState> + ); + } + + if (!language) { + return null; + } + + return ( + <PMVStack alignItems="stretch" gap={4} width="full"> + <DetectabilityAccordion + isOpen={isDetectabilityOpen} + onOpenChange={setIsDetectabilityOpen} + standardId={standardId} + ruleId={ruleId} + language={language} + ruleExamples={ruleExamples} + /> + + <ProgramGenerationAccordion + isOpen={isProgramOpen} + standardId={standardId} + standardName={standardName} + ruleId={ruleId} + activeConfigurations={activeConfigurations} + draftPrograms={draftPrograms} + isLoadingActivePrograms={isLoadingActivePrograms} + isActiveProgramsError={isActiveProgramsError} + onGenerateProgram={onGenerateProgram} + isGeneratingProgram={isGeneratingProgram} + onTestProgram={onTestProgram} + onActivateDraft={onActivateDraft} + activatingDraftId={activatingDraftId} + isActivatingDraft={isActivatingDraft} + onRetryDraft={onRetryDraft} + selectedLanguage={selectedLanguage} + onOpenChange={setIsProgramOpen} + disabled={isProgramDisabled} + onNavigateToExamples={onNavigateToExamples} + /> + </PMVStack> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/ExecutionLogsDrawer.tsx b/apps/frontend/src/domain/detection/components/ExecutionLogsDrawer.tsx new file mode 100644 index 000000000..71bf7dd13 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ExecutionLogsDrawer.tsx @@ -0,0 +1,160 @@ +import React, { useMemo } from 'react'; +import { + PMBox, + PMCloseButton, + PMDrawer, + PMPortal, + PMText, + PMVStack, + PMSpinner, +} from '@packmind/ui'; +import { + DETECTION_LOG_MESSAGES, + DetectionLogMessageType, + ExecutionLogMetadata, +} from '@packmind/types'; +import { useGetDetectionProgramMetadataQuery } from '../api/queries/DetectionProgramQueries'; + +interface ExecutionLogsDrawerProps { + isOpen: boolean; + onClose: () => void; + standardId: string; + ruleId: string; + detectionProgramId: string; +} + +const LOG_MESSAGE_TRANSLATIONS: Record< + DetectionLogMessageType, + (metadata?: ExecutionLogMetadata) => string +> = { + [DETECTION_LOG_MESSAGES.AI_AGENT_STRATEGY_ASSESSMENT]: () => + 'Assess the best strategy to detect the practice...', + [DETECTION_LOG_MESSAGES.AI_AGENT_RESULT_NOT_GOOD_WILL_RESTART]: () => + 'The generated solution is not accurate, the process will stop...', + [DETECTION_LOG_MESSAGES.AI_AGENT_RESULT_SUCCESS]: () => + 'The generated solution provides satisfying results!', + [DETECTION_LOG_MESSAGES.AI_AGENT_CRASH_RESTART]: () => + 'The process has stopped...', + [DETECTION_LOG_MESSAGES.AI_AGENT_PROGRAM_GENERATION_STARTED]: () => + 'Generation of program has started...', + [DETECTION_LOG_MESSAGES.AI_AGENT_NEW_PROGRAM_UNDER_TEST]: () => + 'A new version of the program is now tested...', + [DETECTION_LOG_MESSAGES.AI_AGENT_PROGRAM_DEBUGGING]: () => + 'Now enter into a debugging phase with AI Agent to generate a valid program...', + [DETECTION_LOG_MESSAGES.AI_AGENT_PROGRAM_SUCCESSFUL]: () => + 'A program has been successfully tested against an existing practice code example', + [DETECTION_LOG_MESSAGES.AI_AGENT_PROGRAM_TEST_AGAINST_EXAMPLES]: () => + 'Evaluate program against existing code examples...', + [DETECTION_LOG_MESSAGES.AI_AGENT_PROGRAM_CHECK_POSITIVE_EXAMPLES]: () => + 'Now checking against existing positive examples that no violations are found...', + [DETECTION_LOG_MESSAGES.AI_AGENT_RUN_TEST]: (metadata) => { + const testName = metadata?.testName || 'Unknown test'; + return `Running test: ${testName}`; + }, + [DETECTION_LOG_MESSAGES.AI_AGENT_RUN_TEST_NO_NAME]: (metadata) => { + const testContent = metadata?.testContent; + return testContent + ? `Running test with content:\n${testContent}` + : 'Running test...'; + }, + [DETECTION_LOG_MESSAGES.AI_AGENT_TIMEOUT]: () => 'Operation timed out', +}; + +const translateLogMessage = ( + message: string, + metadata?: ExecutionLogMetadata, +): string => { + const translator = + LOG_MESSAGE_TRANSLATIONS[message as DetectionLogMessageType]; + if (translator) { + return translator(metadata); + } + return message; +}; + +const formatLogTimestamp = (timestamp: number): string => { + const date = new Date(Number(timestamp)); + return date.toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +}; + +export const ExecutionLogsDrawer: React.FC<ExecutionLogsDrawerProps> = ({ + isOpen, + onClose, + standardId, + ruleId, + detectionProgramId, +}) => { + const { data: metadata, isLoading } = useGetDetectionProgramMetadataQuery( + standardId, + ruleId, + detectionProgramId, + { + refetchInterval: isOpen ? 5000 : false, + }, + ); + + const sortedLogs = useMemo(() => { + if (!metadata?.logs) { + return []; + } + + return [...metadata.logs].sort((a, b) => a.timestamp - b.timestamp); + }, [metadata?.logs]); + + const handleOpenChange = ({ open }: { open: boolean }) => { + if (!open) { + onClose(); + } + }; + + return ( + <PMDrawer.Root open={isOpen} onOpenChange={handleOpenChange} size="xl"> + <PMPortal> + <PMDrawer.Backdrop /> + <PMDrawer.Positioner> + <PMDrawer.Content> + <PMDrawer.Header> + <PMDrawer.Title>Execution Logs</PMDrawer.Title> + </PMDrawer.Header> + <PMDrawer.Body> + <PMBox + p={4} + width="full" + overflowY="auto" + rounded="md" + backgroundColor="background.secondary" + > + {isLoading ? ( + <PMSpinner /> + ) : sortedLogs.length === 0 ? ( + <PMBox p={4}> + <PMText color="faded">No execution logs available.</PMText> + </PMBox> + ) : ( + <PMVStack gap={2} width="full" align="flex-start"> + {sortedLogs.map((log, index) => ( + <PMText key={index} fontSize="sm" whiteSpace="pre-wrap"> + [{formatLogTimestamp(log.timestamp)}] -{' '} + {translateLogMessage(log.message, log.metadata)} + </PMText> + ))} + </PMVStack> + )} + </PMBox> + </PMDrawer.Body> + <PMDrawer.CloseTrigger asChild> + <PMCloseButton size="sm" /> + </PMDrawer.CloseTrigger> + </PMDrawer.Content> + </PMDrawer.Positioner> + </PMPortal> + </PMDrawer.Root> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/HeuristicsEditor.tsx b/apps/frontend/src/domain/detection/components/HeuristicsEditor.tsx new file mode 100644 index 000000000..455e94e35 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/HeuristicsEditor.tsx @@ -0,0 +1,134 @@ +import React, { useState, useCallback } from 'react'; +import { + PMBox, + PMButton, + PMHStack, + PMIcon, + PMText, + PMTextArea, + PMVStack, +} from '@packmind/ui'; +import { LuPencil, LuX, LuCheck } from 'react-icons/lu'; + +interface HeuristicsEditorProps { + value: string; + onSubmit: (text: string) => Promise<void> | void; + isLoading?: boolean; + isEditable: boolean; + maxLength?: number; + rows?: number; +} + +export const HeuristicsEditor: React.FC<HeuristicsEditorProps> = ({ + value, + onSubmit, + isLoading = false, + isEditable, + maxLength = 3000, +}) => { + const [isEditMode, setIsEditMode] = useState(false); + const [editValue, setEditValue] = useState(''); + + const handleEditClick = useCallback(() => { + setEditValue(value); + setIsEditMode(true); + }, [value]); + + const handleCancelClick = useCallback(() => { + setEditValue(''); + setIsEditMode(false); + }, []); + + const handleSubmitClick = useCallback(async () => { + await onSubmit(editValue); + setIsEditMode(false); + setEditValue(''); + }, [editValue, onSubmit]); + + const handleTextChange = useCallback( + (e: React.ChangeEvent<HTMLTextAreaElement>) => { + setEditValue(e.target.value); + }, + [], + ); + + return ( + <PMBox + borderRadius="md" + borderWidth={1} + borderColor="border.tertiary" + p={3} + width="full" + height="full" + > + <PMVStack width="full" height="full" gap={3} align="flex-start"> + {!isEditMode ? ( + <> + {isEditable && ( + <PMHStack width="full" justify="space-between" align="center"> + <PMText fontWeight="medium">Clues</PMText> + <PMButton + size="xs" + variant="ghost" + onClick={handleEditClick} + aria-label="Edit heuristics" + > + <PMIcon size="xs"> + <LuPencil /> + </PMIcon> + </PMButton> + </PMHStack> + )} + <PMText whiteSpace="pre-wrap" width="full" color="secondary"> + {value || 'No clues defined yet...'} + </PMText> + </> + ) : ( + <> + <PMHStack + width="full" + justify="space-between" + align="center" + gap={2} + > + <PMText fontWeight="medium">Clues</PMText> + <PMHStack gap={2}> + <PMButton + size="xs" + variant="ghost" + onClick={handleCancelClick} + disabled={isLoading} + aria-label="Cancel editing" + > + <PMIcon size="xs"> + <LuX /> + </PMIcon> + </PMButton> + <PMButton + size="xs" + onClick={handleSubmitClick} + loading={isLoading} + disabled={isLoading} + aria-label="Submit heuristics" + > + <PMIcon size="xs"> + <LuCheck /> + </PMIcon> + </PMButton> + </PMHStack> + </PMHStack> + <PMTextArea + value={editValue} + onChange={handleTextChange} + placeholder="Enter detection heuristics..." + disabled={isLoading} + maxLength={maxLength} + width="full" + height="full" + /> + </> + )} + </PMVStack> + </PMBox> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/ProgramContentDrawer.tsx b/apps/frontend/src/domain/detection/components/ProgramContentDrawer.tsx new file mode 100644 index 000000000..feebb4ff0 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ProgramContentDrawer.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { + PMBox, + PMCloseButton, + PMDrawer, + PMPortal, + PMText, + PMCodeMirror, + PMMarkdownViewer, +} from '@packmind/ui'; +import { useGetDetectionProgramMetadataQuery } from '../api/queries/DetectionProgramQueries'; + +interface ProgramContentDrawerProps { + isOpen: boolean; + onClose: () => void; + standardId: string; + ruleId: string; + detectionProgramId: string; + programCode: string; +} + +export const ProgramContentDrawer: React.FC<ProgramContentDrawerProps> = ({ + isOpen, + onClose, + standardId, + ruleId, + detectionProgramId, + programCode, +}) => { + const handleOpenChange = ({ open }: { open: boolean }) => { + if (!open) { + onClose(); + } + }; + + const { data: draftMetadata } = useGetDetectionProgramMetadataQuery( + standardId, + ruleId, + detectionProgramId, + ); + + return ( + <PMDrawer.Root open={isOpen} onOpenChange={handleOpenChange} size="xl"> + <PMPortal> + <PMDrawer.Backdrop /> + <PMDrawer.Positioner> + <PMDrawer.Content> + <PMDrawer.Header> + <PMDrawer.Title>Program Content</PMDrawer.Title> + </PMDrawer.Header> + <PMDrawer.Body height="full" display="flex" flexDirection="column"> + <PMBox + width="full" + flex="1" + display="flex" + flexDirection="column" + > + {draftMetadata?.programDescription && ( + <PMMarkdownViewer + content={draftMetadata?.programDescription} + /> + )} + + {!programCode || programCode.trim() === '' ? ( + <PMBox p={4}> + <PMText color="faded">No program content available.</PMText> + </PMBox> + ) : ( + <PMCodeMirror + value={programCode} + editable={false} + language="javascript" + height="100%" + basicSetup={{ + lineNumbers: true, + foldGutter: false, + dropCursor: false, + allowMultipleSelections: false, + }} + /> + )} + </PMBox> + </PMDrawer.Body> + <PMDrawer.CloseTrigger asChild> + <PMCloseButton size="sm" /> + </PMDrawer.CloseTrigger> + </PMDrawer.Content> + </PMDrawer.Positioner> + </PMPortal> + </PMDrawer.Root> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/ProgramDetailsDrawer.tsx b/apps/frontend/src/domain/detection/components/ProgramDetailsDrawer.tsx new file mode 100644 index 000000000..f278ab5bf --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ProgramDetailsDrawer.tsx @@ -0,0 +1,238 @@ +import React, { useMemo } from 'react'; +import { + PMBox, + PMCloseButton, + PMDrawer, + PMPortal, + PMText, + PMVStack, + PMSpinner, + PMCodeMirror, + PMMarkdownViewer, + PMAccordion, +} from '@packmind/ui'; +import { + DETECTION_LOG_MESSAGES, + DetectionLogMessageType, + ExecutionLogMetadata, +} from '@packmind/types'; +import { useGetDetectionProgramMetadataQuery } from '../api/queries/DetectionProgramQueries'; + +interface ProgramDetailsDrawerProps { + isOpen: boolean; + onClose: () => void; + standardId: string; + ruleId: string; + detectionProgramId: string; + programCode: string; +} + +const LOG_MESSAGE_TRANSLATIONS: Record< + DetectionLogMessageType, + (metadata?: ExecutionLogMetadata) => string +> = { + [DETECTION_LOG_MESSAGES.AI_AGENT_STRATEGY_ASSESSMENT]: () => + 'Assess the best strategy to detect the practice...', + [DETECTION_LOG_MESSAGES.AI_AGENT_RESULT_NOT_GOOD_WILL_RESTART]: () => + 'The generated solution is not accurate, the process will stop...', + [DETECTION_LOG_MESSAGES.AI_AGENT_RESULT_SUCCESS]: () => + 'The generated solution provides satisfying results!', + [DETECTION_LOG_MESSAGES.AI_AGENT_CRASH_RESTART]: () => + 'The process has stopped...', + [DETECTION_LOG_MESSAGES.AI_AGENT_PROGRAM_GENERATION_STARTED]: () => + 'Generation of program has started...', + [DETECTION_LOG_MESSAGES.AI_AGENT_NEW_PROGRAM_UNDER_TEST]: () => + 'A new version of the program is now tested...', + [DETECTION_LOG_MESSAGES.AI_AGENT_PROGRAM_DEBUGGING]: () => + 'Now enter into a debugging phase with AI Agent to generate a valid program...', + [DETECTION_LOG_MESSAGES.AI_AGENT_PROGRAM_SUCCESSFUL]: () => + 'A program has been successfully tested against an existing practice code example', + [DETECTION_LOG_MESSAGES.AI_AGENT_PROGRAM_TEST_AGAINST_EXAMPLES]: () => + 'Evaluate program against existing code examples...', + [DETECTION_LOG_MESSAGES.AI_AGENT_PROGRAM_CHECK_POSITIVE_EXAMPLES]: () => + 'Now checking against existing positive examples that no violations are found...', + [DETECTION_LOG_MESSAGES.AI_AGENT_RUN_TEST]: (metadata) => { + const testName = metadata?.testName || 'Unknown test'; + return `Running test: ${testName}`; + }, + [DETECTION_LOG_MESSAGES.AI_AGENT_RUN_TEST_NO_NAME]: (metadata) => { + const testContent = metadata?.testContent; + return testContent + ? `Running test with content:\n${testContent}` + : 'Running test...'; + }, + [DETECTION_LOG_MESSAGES.AI_AGENT_TIMEOUT]: () => 'Operation timed out', +}; + +const translateLogMessage = ( + message: string, + metadata?: ExecutionLogMetadata, +): string => { + const translator = + LOG_MESSAGE_TRANSLATIONS[message as DetectionLogMessageType]; + if (translator) { + return translator(metadata); + } + return message; +}; + +const formatLogTimestamp = (timestamp: number): string => { + const date = new Date(Number(timestamp)); + return date.toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +}; + +export const ProgramDetailsDrawer: React.FC<ProgramDetailsDrawerProps> = ({ + isOpen, + onClose, + standardId, + ruleId, + detectionProgramId, + programCode, +}) => { + const { data: metadata, isLoading } = useGetDetectionProgramMetadataQuery( + standardId, + ruleId, + detectionProgramId, + ); + + const sortedLogs = useMemo(() => { + if (!metadata?.logs) { + return []; + } + + return [...metadata.logs].sort((a, b) => a.timestamp - b.timestamp); + }, [metadata?.logs]); + + const handleOpenChange = ({ open }: { open: boolean }) => { + if (!open) { + onClose(); + } + }; + + return ( + <PMDrawer.Root open={isOpen} onOpenChange={handleOpenChange} size="xl"> + <PMPortal> + <PMDrawer.Backdrop /> + <PMDrawer.Positioner> + <PMDrawer.Content> + <PMDrawer.Header> + <PMDrawer.Title>Program Details</PMDrawer.Title> + </PMDrawer.Header> + <PMDrawer.Body> + {isLoading ? ( + <PMBox display="flex" justifyContent="center" p={8}> + <PMSpinner /> + </PMBox> + ) : ( + <PMVStack gap={4} width="full"> + <PMAccordion.Root collapsible defaultValue={['logs']}> + {/* Execution Logs Section */} + <PMAccordion.Item value="logs"> + <PMAccordion.ItemTrigger> + <PMText fontWeight="semibold">Execution Logs</PMText> + <PMAccordion.ItemIndicator /> + </PMAccordion.ItemTrigger> + <PMAccordion.ItemContent> + <PMBox + p={4} + width="full" + maxHeight="300px" + overflowY="auto" + rounded="md" + backgroundColor="background.secondary" + > + {sortedLogs.length === 0 ? ( + <PMText color="faded"> + No execution logs available. + </PMText> + ) : ( + <PMVStack gap={2} width="full" align="flex-start"> + {sortedLogs.map((log, index) => ( + <PMText + key={index} + fontSize="sm" + whiteSpace="pre-wrap" + > + [{formatLogTimestamp(log.timestamp)}] -{' '} + {translateLogMessage( + log.message, + log.metadata, + )} + </PMText> + ))} + </PMVStack> + )} + </PMBox> + </PMAccordion.ItemContent> + </PMAccordion.Item> + + {/* Program Information Section */} + {metadata?.programDescription && ( + <PMAccordion.Item value="info"> + <PMAccordion.ItemTrigger> + <PMText fontWeight="semibold"> + Program Information + </PMText> + <PMAccordion.ItemIndicator /> + </PMAccordion.ItemTrigger> + <PMAccordion.ItemContent> + <PMBox p={4}> + <PMMarkdownViewer + content={metadata.programDescription} + /> + </PMBox> + </PMAccordion.ItemContent> + </PMAccordion.Item> + )} + + {/* Program Code Section */} + <PMAccordion.Item value="code"> + <PMAccordion.ItemTrigger> + <PMText fontWeight="semibold">Program Code</PMText> + <PMAccordion.ItemIndicator /> + </PMAccordion.ItemTrigger> + <PMAccordion.ItemContent> + <PMBox width="full"> + {!programCode || programCode.trim() === '' ? ( + <PMBox p={4}> + <PMText color="faded"> + No program content available. + </PMText> + </PMBox> + ) : ( + <PMCodeMirror + value={programCode} + editable={false} + language="javascript" + height="400px" + basicSetup={{ + lineNumbers: true, + foldGutter: false, + dropCursor: false, + allowMultipleSelections: false, + }} + /> + )} + </PMBox> + </PMAccordion.ItemContent> + </PMAccordion.Item> + </PMAccordion.Root> + </PMVStack> + )} + </PMDrawer.Body> + <PMDrawer.CloseTrigger asChild> + <PMCloseButton size="sm" /> + </PMDrawer.CloseTrigger> + </PMDrawer.Content> + </PMDrawer.Positioner> + </PMPortal> + </PMDrawer.Root> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/ProgramEditor.test.tsx b/apps/frontend/src/domain/detection/components/ProgramEditor.test.tsx new file mode 100644 index 000000000..ad4786806 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ProgramEditor.test.tsx @@ -0,0 +1,389 @@ +import { computeActiveConfigurationState } from './ProgramEditor'; +import { ActiveConfigurationState } from './ActiveConfigurationSection/'; +import { + createActiveDetectionProgramId, + createDetectionProgramId, + createRuleId, + DetectionModeEnum, + DetectionStatus, +} from '@packmind/types'; +import { ProgrammingLanguage } from '@packmind/types'; +import { LanguageDetectionPrograms } from '@packmind/types'; + +describe('computeActiveConfigurationState', () => { + let program: LanguageDetectionPrograms; + + beforeEach(() => { + program = { + id: createActiveDetectionProgramId('program-123'), + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgramVersion: createDetectionProgramId('1'), + ruleId: createRuleId('rule1'), + detectionProgramDraftVersion: null, + detectionProgram: null, + draftDetectionProgram: null, + }; + }); + + describe('when no detection program exists', () => { + beforeEach(() => { + program.detectionProgramVersion = null; + program.detectionProgram = null; + }); + + describe('when there is no draft program', () => { + beforeEach(() => { + program.draftDetectionProgram = null; + }); + + it('returns NO_CONFIG', () => { + const result = computeActiveConfigurationState( + program, + program.detectionProgram, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.NO_CONFIG); + }); + }); + + describe('when there is a draft program', () => { + describe('when draft status is IN_PROGRESS', () => { + beforeEach(() => { + program.draftDetectionProgram = { + id: createDetectionProgramId('draft-123'), + code: 'draft code', + version: 1, + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.IN_PROGRESS, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: createRuleId('rule-456'), + sourceCodeState: 'AST', + }; + }); + + it('returns IN_PROGRESS', () => { + const result = computeActiveConfigurationState( + program, + program.detectionProgram, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.IN_PROGRESS); + }); + }); + + describe('when draft status is READY', () => { + beforeEach(() => { + program.draftDetectionProgram = { + id: createDetectionProgramId('draft-123'), + code: 'draft code', + version: 1, + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.READY, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: createRuleId('rule-456'), + sourceCodeState: 'AST', + }; + }); + + it('returns TO_REVIEW', () => { + const result = computeActiveConfigurationState( + program, + program.detectionProgram, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.TO_REVIEW); + }); + }); + + describe('when draft status is FAILURE', () => { + beforeEach(() => { + program.draftDetectionProgram = { + id: createDetectionProgramId('draft-123'), + code: 'draft code', + version: 1, + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.FAILURE, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: createRuleId('rule-456'), + sourceCodeState: 'AST', + }; + }); + + it('returns ERROR', () => { + const result = computeActiveConfigurationState( + program, + program.detectionProgram, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.ERROR); + }); + }); + + describe('when draft status is ERROR', () => { + beforeEach(() => { + program.draftDetectionProgram = { + id: createDetectionProgramId('draft-123'), + code: 'draft code', + version: 1, + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.ERROR, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: createRuleId('rule-456'), + sourceCodeState: 'AST', + }; + }); + + it('returns ERROR', () => { + const result = computeActiveConfigurationState( + program, + program.detectionProgram, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.ERROR); + }); + }); + }); + }); + + describe('when detection program exists', () => { + beforeEach(() => { + program.detectionProgramVersion = createDetectionProgramId('1'); + program.detectionProgram = { + id: createDetectionProgramId('detection-123'), + code: 'detection code', + version: 1, + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.READY, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: createRuleId('rule-456'), + sourceCodeState: 'AST', + }; + }); + + describe('when detection program status is READY', () => { + beforeEach(() => { + program.detectionProgram!.status = DetectionStatus.READY; + }); + + it('returns OK', () => { + const result = computeActiveConfigurationState( + program, + program.detectionProgram, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.OK); + }); + }); + + describe('when detection program status is IN_PROGRESS', () => { + beforeEach(() => { + program.detectionProgram!.status = DetectionStatus.IN_PROGRESS; + }); + + it('returns IN_PROGRESS', () => { + const result = computeActiveConfigurationState( + program, + program.detectionProgram, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.IN_PROGRESS); + }); + }); + + describe('when detection program status is FAILURE', () => { + beforeEach(() => { + program.detectionProgram!.status = DetectionStatus.FAILURE; + }); + + it('returns ERROR', () => { + const result = computeActiveConfigurationState( + program, + program.detectionProgram, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.ERROR); + }); + }); + + describe('when detection program status is ERROR', () => { + beforeEach(() => { + program.detectionProgram!.status = DetectionStatus.ERROR; + }); + + it('returns ERROR', () => { + const result = computeActiveConfigurationState( + program, + program.detectionProgram, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.ERROR); + }); + }); + + describe('when detection program status is TO_REVIEW', () => { + beforeEach(() => { + program.detectionProgram!.status = DetectionStatus.TO_REVIEW; + }); + + it('returns TO_REVIEW', () => { + const result = computeActiveConfigurationState( + program, + program.detectionProgram, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.TO_REVIEW); + }); + }); + + describe('when detection program status is unknown', () => { + beforeEach(() => { + program.detectionProgram!.status = 'UNKNOWN_STATUS' as DetectionStatus; + }); + + it('returns ERROR as fallback', () => { + const result = computeActiveConfigurationState( + program, + program.detectionProgram, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.ERROR); + }); + }); + }); + + describe('when detection program version exists but detection program is null', () => { + beforeEach(() => { + program.detectionProgramVersion = createDetectionProgramId('1'); + program.detectionProgram = null; + }); + + describe('when there is no draft program', () => { + beforeEach(() => { + program.draftDetectionProgram = null; + }); + + it('returns NO_CONFIG', () => { + const result = computeActiveConfigurationState( + program, + null, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.NO_CONFIG); + }); + }); + + describe('when there is a draft program', () => { + describe('when draft status is IN_PROGRESS', () => { + beforeEach(() => { + program.draftDetectionProgram = { + id: createDetectionProgramId('draft-123'), + code: 'draft code', + version: 1, + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.IN_PROGRESS, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: createRuleId('rule-456'), + sourceCodeState: 'AST', + }; + }); + + it('returns IN_PROGRESS', () => { + const result = computeActiveConfigurationState( + program, + null, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.IN_PROGRESS); + }); + }); + + describe('when draft status is READY', () => { + beforeEach(() => { + program.draftDetectionProgram = { + id: createDetectionProgramId('draft-123'), + code: 'draft code', + version: 1, + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.READY, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: createRuleId('rule-456'), + sourceCodeState: 'AST', + }; + }); + + it('returns TO_REVIEW', () => { + const result = computeActiveConfigurationState( + program, + null, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.TO_REVIEW); + }); + }); + + describe('when draft status is FAILURE', () => { + beforeEach(() => { + program.draftDetectionProgram = { + id: createDetectionProgramId('draft-123'), + code: 'draft code', + version: 1, + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.FAILURE, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: createRuleId('rule-456'), + sourceCodeState: 'AST', + }; + }); + + it('returns ERROR', () => { + const result = computeActiveConfigurationState( + program, + null, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.ERROR); + }); + }); + + describe('when draft status is ERROR', () => { + beforeEach(() => { + program.draftDetectionProgram = { + id: createDetectionProgramId('draft-123'), + code: 'draft code', + version: 1, + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.ERROR, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: createRuleId('rule-456'), + sourceCodeState: 'AST', + }; + }); + + it('returns ERROR', () => { + const result = computeActiveConfigurationState( + program, + null, + program.draftDetectionProgram, + ); + + expect(result).toBe(ActiveConfigurationState.ERROR); + }); + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/detection/components/ProgramEditor.tsx b/apps/frontend/src/domain/detection/components/ProgramEditor.tsx new file mode 100644 index 000000000..1a054290f --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ProgramEditor.tsx @@ -0,0 +1,660 @@ +import React, { useMemo, useState, useCallback } from 'react'; +import { + PMButton, + PMHStack, + PMText, + PMVStack, + PMBox, + PMHeading, + PMDialog, + PMCodeMirror, + PMBadge, + PMAlert, + DEFAULT_FEATURE_DOMAIN_MAP, + isFeatureFlagEnabled, +} from '@packmind/ui'; +import { LanguageDetectionPrograms, RuleExample } from '@packmind/types'; +import { + DetectionProgramId, + DetectionStatus, + StandardId, +} from '@packmind/types'; +import { useQueryClient } from '@tanstack/react-query'; +import { + useGetActiveDetectionProgramsQuery, + useGenerateProgramMutation, + useActivateDetectionDraftMutation, + useTestProgramExecutionMutation, +} from '../api/queries/DetectionProgramQueries'; +import { + GET_ACTIVE_DETECTION_PROGRAMS_KEY, + GET_ALL_DETECTION_PROGRAMS_KEY, +} from '../api/queryKeys'; +import { DraftCardData } from './DetectionDraftCard/DetectionDraftCard'; +import { + ActiveConfigurationSectionData as ActiveConfigurationCardData, + ActiveConfigurationState, +} from './ActiveConfigurationSection/'; +import { useGetStandardByIdQuery } from '../../standards/api/queries/StandardsQueries'; +import { CopiableTextField } from '../../../shared/components/inputs/CopiableTextField'; +import { DetectionProgramConfiguration } from './DetectionProgramConfiguration'; +import { useGetMeQuery } from '../../accounts/api/queries/UserQueries'; + +interface ProgramEditorProps { + standardId: string; + ruleId: string; + detectionLanguages?: string[]; + selectedLanguage: string; + onNavigateToExamples?: () => void; + ruleExamples?: RuleExample[]; +} + +export function computeActiveConfigurationState( + program: LanguageDetectionPrograms, + detectionProgram: LanguageDetectionPrograms['detectionProgram'] | null, + draftProgram: LanguageDetectionPrograms['draftDetectionProgram'] | null, + isToReview?: boolean, +): ActiveConfigurationState { + if (!program.detectionProgramVersion || !detectionProgram) { + if (!draftProgram) { + return ActiveConfigurationState.NO_CONFIG; + } + + // Only show IN_PROGRESS when draft is actually being generated + if (draftProgram.status === DetectionStatus.IN_PROGRESS) { + return ActiveConfigurationState.IN_PROGRESS; + } + + // Draft is ready for review/activation + if (draftProgram.status === DetectionStatus.READY) { + return ActiveConfigurationState.TO_REVIEW; + } + + // For FAILURE, ERROR statuses + if ( + draftProgram.status === DetectionStatus.FAILURE || + draftProgram.status === DetectionStatus.ERROR + ) { + return ActiveConfigurationState.ERROR; + } + + // Default fallback for other statuses (e.g., TO_REVIEW) + return ActiveConfigurationState.TO_REVIEW; + } + + // Check if program needs review first (due to changes in rules/examples) + if (isToReview) { + return ActiveConfigurationState.TO_REVIEW; + } + + switch (detectionProgram.status) { + case DetectionStatus.READY: + return ActiveConfigurationState.OK; + + case DetectionStatus.IN_PROGRESS: + return ActiveConfigurationState.IN_PROGRESS; + + case DetectionStatus.FAILURE: + case DetectionStatus.ERROR: + return ActiveConfigurationState.ERROR; + + case DetectionStatus.TO_REVIEW: + return ActiveConfigurationState.TO_REVIEW; + + default: + return ActiveConfigurationState.ERROR; + } +} + +/** + * Detects if a program needs review based on rule specifications or examples changes. + * In a real scenario, this would check if rule specifications or examples + * have been modified after the program creation date. + * + * Current implementation checks if the detection program has a "toReview" marker + * or if the program data indicates it needs regeneration (from backend). + */ +const MAX_SANDBOX_CHARS = 10000; +const numberFormatter = new Intl.NumberFormat('en-US'); + +function checkIfProgramNeedsReview( + detectionProgram: LanguageDetectionPrograms['detectionProgram'] | null, + draftProgram: LanguageDetectionPrograms['draftDetectionProgram'] | null, +): boolean { + if (!detectionProgram) { + return false; + } + + // Don't mark as needing review if there's a ready draft with a higher version + // In this case, the draft should be shown separately + if ( + draftProgram && + draftProgram.status === DetectionStatus.READY && + draftProgram.version > detectionProgram.version + ) { + return false; + } + + // Check if the backend has marked this program as needing review + // This would typically be set when rule specifications or examples are modified + // after the program was generated + const programData = detectionProgram as unknown as { + isToReview?: boolean; + needsRegeneration?: boolean; + }; + if (programData?.isToReview || programData?.needsRegeneration) { + return true; + } + + return false; +} + +const ProgramEditor: React.FC<ProgramEditorProps> = ({ + standardId, + ruleId, + detectionLanguages = [], + selectedLanguage, + onNavigateToExamples, + ruleExamples = [], +}) => { + const queryClient = useQueryClient(); + const { data: meData } = useGetMeQuery(); + const userEmail = meData?.authenticated === true ? meData.user.email : null; + + const [activatingDraftId, setActivatingDraftId] = useState<string | null>( + null, + ); + const [isTestModalOpen, setIsTestModalOpen] = useState(false); + const [selectedProgramForTest, setSelectedProgramForTest] = useState< + DraftCardData | ActiveConfigurationCardData | null + >(null); + const [sandboxCode, setSandboxCode] = useState(''); + const [testResults, setTestResults] = useState< + { line: number; character: number; rule: string; standard: string }[] | null + >(null); + const [sandboxValidationError, setSandboxValidationError] = useState< + string | null + >(null); + + const generateProgram = useGenerateProgramMutation(); + const activateDraft = useActivateDetectionDraftMutation(); + const testProgramExecution = useTestProgramExecutionMutation(); + + const { + data: activePrograms, + refetch: refetchPrograms, + isLoading: isLoadingActivePrograms, + isError: isActiveProgramsError, + } = useGetActiveDetectionProgramsQuery(standardId, ruleId); + + const { data: standardData } = useGetStandardByIdQuery( + standardId as StandardId, + ); + const standardSlug = standardData?.standard?.slug ?? null; + const standardName = standardData?.standard?.name ?? undefined; + + const normalizedPrograms = useMemo<LanguageDetectionPrograms[]>(() => { + if (!Array.isArray(activePrograms)) { + return []; + } + + return activePrograms as LanguageDetectionPrograms[]; + }, [activePrograms]); + + const activeConfigurations = useMemo<ActiveConfigurationCardData[]>(() => { + if (!normalizedPrograms.length) { + return []; + } + + return normalizedPrograms + .filter((program) => { + const detectionProgram = program.detectionProgram ?? null; + const lang = detectionProgram?.language ?? program.language; + return lang === selectedLanguage; + }) + .map((program) => { + const detectionProgram = program.detectionProgram ?? null; + const draftProgram = program.draftDetectionProgram ?? null; + + // Check if program needs review + const isToReview = checkIfProgramNeedsReview( + detectionProgram, + draftProgram, + ); + + const state = computeActiveConfigurationState( + program, + detectionProgram, + draftProgram, + isToReview, + ); + + return { + id: program.id, + language: detectionProgram?.language ?? program.language, + detectionProgram, + draftProgram, + state, + isExampleOnly: program.isExampleOnly ?? false, + isToReview, + severity: program.severity, + }; + }); + }, [normalizedPrograms, selectedLanguage]); + + const draftPrograms = useMemo<DraftCardData[]>(() => { + if (!normalizedPrograms.length) { + return []; + } + + return normalizedPrograms.flatMap((program) => { + if (!program.draftDetectionProgram) { + return []; + } + + const draftProgram = program.draftDetectionProgram; + const lang = draftProgram.language ?? program.language; + + if (lang !== selectedLanguage) { + return []; + } + + return [ + { + id: `${program.id}-draft-${draftProgram.id}`, + language: lang, + activeDetectionProgramId: program.id, + hasActiveProgram: !!program.detectionProgram, + draftProgram, + status: draftProgram.status, + mode: draftProgram.mode, + version: draftProgram.version, + }, + ]; + }); + }, [normalizedPrograms, selectedLanguage]); + + const handleGenerateProgram = useCallback( + (language?: string) => { + generateProgram.mutate( + { standardId, ruleId, language }, + { + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [ + ...GET_ACTIVE_DETECTION_PROGRAMS_KEY, + standardId, + ruleId, + ], + }), + queryClient.invalidateQueries({ + queryKey: [ + ...GET_ALL_DETECTION_PROGRAMS_KEY, + standardId, + ruleId, + ], + }), + ]); + await refetchPrograms(); + }, + onError: (error) => { + console.error('Failed to generate program', error, { + standardId, + ruleId, + language, + }); + }, + }, + ); + }, + [generateProgram, queryClient, refetchPrograms, ruleId, standardId], + ); + + const handleMakeDraftActive = useCallback( + (draft: DraftCardData) => { + const detectionProgramId = draft.draftProgram?.id; + + if (!detectionProgramId) { + console.warn('Missing detection program id for draft activation', { + draftId: draft.id, + }); + return; + } + + setActivatingDraftId(draft.id); + activateDraft.mutate( + { + standardId, + ruleId, + activeDetectionProgramId: draft.activeDetectionProgramId, + detectionProgramId: detectionProgramId as unknown as string, + }, + { + onSuccess: () => { + void refetchPrograms(); + }, + onError: (error) => { + console.error('Failed to activate detection draft', error, { + draftId: draft.id, + }); + }, + onSettled: () => { + setActivatingDraftId(null); + }, + }, + ); + }, + [activateDraft, refetchPrograms, ruleId, standardId], + ); + + const handleTestDraft = useCallback( + (draft: DraftCardData | ActiveConfigurationCardData) => { + setSelectedProgramForTest(draft); + setIsTestModalOpen(true); + }, + [], + ); + + const handleRetryDraftGeneration = useCallback( + (draft: DraftCardData) => { + handleGenerateProgram(draft.language); + }, + [handleGenerateProgram], + ); + + const handleCloseTestModal = useCallback(() => { + setIsTestModalOpen(false); + setSelectedProgramForTest(null); + setSandboxCode(''); + setSandboxValidationError(null); + setTestResults(null); + }, []); + + const handleSandboxCodeChange = useCallback((value: string) => { + setSandboxCode(value); + + if (value.length > MAX_SANDBOX_CHARS) { + setSandboxValidationError( + `Code exceeds ${numberFormatter.format(MAX_SANDBOX_CHARS)} character limit (${numberFormatter.format(value.length)} characters)`, + ); + } else { + setSandboxValidationError(null); + } + }, []); + + const handleTestSandbox = useCallback(() => { + function getDetectionProgramId(): DetectionProgramId | undefined { + if ( + isActiveConfigurationCardData(selectedProgramForTest) && + selectedProgramForTest.detectionProgram + ) + return selectedProgramForTest.detectionProgram.id; + if (isDraftCardData(selectedProgramForTest)) + return selectedProgramForTest.draftProgram?.id; + } + + const detectionProgramId = getDetectionProgramId(); + + if (!detectionProgramId) { + console.error('No program selected for testing'); + return; + } + + testProgramExecution.mutate( + { + standardId, + ruleId, + detectionProgramId, + sandboxCode, + }, + { + onSuccess: (violations) => { + setTestResults(violations); + }, + onError: (error) => { + console.error('Failed to test program execution:', error); + setTestResults([]); + }, + }, + ); + }, [ + selectedProgramForTest, + sandboxCode, + testProgramExecution, + standardId, + ruleId, + ]); + + const testDraftCommand = useMemo(() => { + if (!selectedProgramForTest || !standardSlug) { + return null; + } + + const rawLanguage = + selectedProgramForTest.draftProgram?.language ?? + selectedProgramForTest.language; + + const normalizedLanguage = + typeof rawLanguage === 'string' ? rawLanguage.toLowerCase() : undefined; + + if (!normalizedLanguage) { + return null; + } + + const isDraft = !isActiveConfigurationCardData(selectedProgramForTest); + + return `packmind-cli lint ${isDraft ? '--draft ' : ''}--rule=@${standardSlug}/${ruleId} --language=${normalizedLanguage}`; + }, [selectedProgramForTest, standardSlug, ruleId]); + + const sandboxLanguage = useMemo(() => { + if (!selectedProgramForTest) { + return undefined; + } + + const rawLanguage = + selectedProgramForTest.draftProgram?.language ?? + selectedProgramForTest.language; + + return typeof rawLanguage === 'string' ? rawLanguage : undefined; + }, [selectedProgramForTest]); + + // Get modal title with version and type + const testModalTitle = useMemo(() => { + if (!selectedProgramForTest) { + return 'Test Program'; + } + + const isDraft = isDraftCardData(selectedProgramForTest); + const version = isDraft + ? selectedProgramForTest.draftProgram?.version + : selectedProgramForTest.detectionProgram?.version; + + if (isDraft) { + return `Test Draft Program${version ? ` (v${version})` : ''}`; + } + return `Test Active Version${version ? ` (v${version})` : ''}`; + }, [selectedProgramForTest]); + + return ( + <PMVStack alignItems="stretch" gap={6} width="full"> + <DetectionProgramConfiguration + standardId={standardId} + standardName={standardName} + ruleId={ruleId} + detectionLanguages={detectionLanguages} + selectedLanguage={selectedLanguage} + activeConfigurations={activeConfigurations} + draftPrograms={draftPrograms} + isLoadingActivePrograms={isLoadingActivePrograms} + isActiveProgramsError={isActiveProgramsError} + onGenerateProgram={handleGenerateProgram} + isGeneratingProgram={generateProgram.isPending} + onTestProgram={handleTestDraft} + onActivateDraft={handleMakeDraftActive} + activatingDraftId={activatingDraftId} + isActivatingDraft={activateDraft.isPending} + onRetryDraft={handleRetryDraftGeneration} + onNavigateToExamples={onNavigateToExamples} + ruleExamples={ruleExamples} + /> + + <PMDialog.Root + open={isTestModalOpen} + onOpenChange={(details) => { + if (!details.open) { + handleCloseTestModal(); + } + }} + > + <PMDialog.Backdrop /> + <PMDialog.Positioner> + <PMDialog.Content maxW="800px"> + <PMDialog.Header> + <PMHStack gap={3} alignItems="center"> + <PMDialog.Title>{testModalTitle}</PMDialog.Title> + {selectedProgramForTest && + isDraftCardData(selectedProgramForTest) && ( + <PMBadge colorPalette="gray" size="sm"> + Draft + </PMBadge> + )} + {selectedProgramForTest && + isActiveConfigurationCardData(selectedProgramForTest) && ( + <PMBadge colorPalette="green" size="sm"> + Active + </PMBadge> + )} + </PMHStack> + <PMDialog.CloseTrigger onClick={handleCloseTestModal} /> + </PMDialog.Header> + <PMDialog.Body> + <PMVStack alignItems="stretch" gap={6}> + <PMVStack alignItems="stretch" gap={3}> + <PMText> + Copy and run this command in your terminal to test the + {isDraftCardData(selectedProgramForTest) + ? ' draft' + : ' active version'} + . + </PMText> + {testDraftCommand ? ( + <CopiableTextField value={testDraftCommand} readOnly /> + ) : ( + <PMText color="error" fontSize="sm"> + Unable to build the CLI command. Ensure the standard + information is available and try again. + </PMText> + )} + </PMVStack> + + <PMVStack alignItems="stretch" gap={3}> + <PMHStack justify="space-between" align="center"> + <PMHeading level="h5">Sandbox</PMHeading> + <PMText color="faded" fontSize="sm"> + {numberFormatter.format(sandboxCode.length)} /{' '} + {numberFormatter.format(MAX_SANDBOX_CHARS)} characters + </PMText> + </PMHStack> + <PMBox> + <PMCodeMirror + value={sandboxCode} + onChange={handleSandboxCodeChange} + editable={true} + language={sandboxLanguage} + placeholder="Enter code to test..." + height="300px" + basicSetup={{ + lineNumbers: true, + foldGutter: false, + dropCursor: false, + allowMultipleSelections: false, + indentOnInput: true, + bracketMatching: true, + closeBrackets: true, + autocompletion: false, + searchKeymap: false, + }} + /> + </PMBox> + {sandboxValidationError && ( + <PMAlert.Root status="error" mt={2}> + <PMAlert.Indicator /> + <PMAlert.Content> + <PMAlert.Description> + {sandboxValidationError} + </PMAlert.Description> + </PMAlert.Content> + </PMAlert.Root> + )} + {testResults !== null && ( + <PMBox> + <PMHeading level="h6" mb={2}> + Test Results + </PMHeading> + <PMVStack alignItems="stretch" gap={2}> + <PMText> + {testResults.length} violation + {testResults.length !== 1 ? 's' : ''} detected. + </PMText> + {testResults.length > 0 && ( + <PMText fontSize="sm"> + Errors found line(s){' '} + {testResults.map((v) => v.line + 1).join(', ')} + </PMText> + )} + </PMVStack> + </PMBox> + )} + </PMVStack> + </PMVStack> + </PMDialog.Body> + <PMDialog.Footer> + <PMHStack gap={2}> + <PMButton + width="min" + onClick={handleTestSandbox} + disabled={ + sandboxCode.length === 0 || sandboxValidationError !== null + } + loading={testProgramExecution.isPending} + > + Test + </PMButton> + <PMButton + width="min" + variant="outline" + onClick={handleCloseTestModal} + > + Close + </PMButton> + </PMHStack> + </PMDialog.Footer> + </PMDialog.Content> + </PMDialog.Positioner> + </PMDialog.Root> + </PMVStack> + ); +}; + +function isDraftCardData(tbd: unknown): tbd is DraftCardData { + return ( + tbd !== null && + typeof tbd === 'object' && + (tbd as DraftCardData).draftProgram !== undefined && + (tbd as DraftCardData).draftProgram !== null && + 'activeDetectionProgramId' in (tbd as object) + ); +} + +function isActiveConfigurationCardData( + tbd: unknown, +): tbd is ActiveConfigurationCardData { + return ( + tbd !== null && + typeof tbd === 'object' && + (tbd as ActiveConfigurationCardData).detectionProgram !== undefined + ); +} + +export { ProgramEditor }; diff --git a/apps/frontend/src/domain/detection/components/ProgramStateSummary.tsx b/apps/frontend/src/domain/detection/components/ProgramStateSummary.tsx new file mode 100644 index 000000000..0ef9ad3b3 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ProgramStateSummary.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { + PMHStack, + PMIcon, + PMText, + PMBadge, + PMTooltip, + PMMenu, + PMVStack, +} from '@packmind/ui'; +import { LuCircleAlert, LuCircleCheck, LuCheck } from 'react-icons/lu'; +import { FaChevronDown } from 'react-icons/fa'; +import { DetectionStatus } from '@packmind/types'; + +interface ProgramStateSummaryProps { + version?: number; + status?: DetectionStatus; + isToReview?: boolean; + hasDraftAvailable?: boolean; + showDropdown?: boolean; + createdAt?: Date; +} + +const formatDate = (date: Date): string => { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +export const ProgramStateSummary: React.FC<ProgramStateSummaryProps> = ({ + version, + status, + isToReview, + hasDraftAvailable, + showDropdown = false, + createdAt, +}) => { + if (!version || !status) { + return ( + <PMText fontSize="sm" color="faded"> + No active configuration + </PMText> + ); + } + + return ( + <PMHStack gap={2} alignItems="center"> + <PMText fontSize="sm" color="secondary"> + v{version} + </PMText> + + {status === DetectionStatus.READY && + !isToReview && + (showDropdown ? ( + <PMMenu.Root> + <PMMenu.Trigger asChild> + <PMBadge + colorPalette="green" + size="sm" + cursor="pointer" + _hover={{ opacity: 0.8 }} + > + <PMHStack gap={1} alignItems="center"> + Active + <PMIcon size="xs" as={FaChevronDown} /> + </PMHStack> + </PMBadge> + </PMMenu.Trigger> + <PMMenu.Content> + <PMVStack gap={0} alignItems="stretch"> + <PMMenu.Item value="status" disabled> + <PMHStack gap={2}> + <PMIcon color="text.success"> + <LuCheck /> + </PMIcon> + <PMText fontWeight="medium">Active</PMText> + </PMHStack> + </PMMenu.Item> + <PMMenu.Separator /> + <PMMenu.Item value="version" disabled> + <PMVStack gap={1} alignItems="flex-start"> + <PMText fontSize="xs" fontWeight="medium" color="secondary"> + Version + </PMText> + <PMText fontSize="sm">{version}</PMText> + </PMVStack> + </PMMenu.Item> + {createdAt && ( + <PMMenu.Item value="generation" disabled> + <PMVStack gap={1} alignItems="flex-start"> + <PMText + fontSize="xs" + fontWeight="medium" + color="secondary" + > + Generation details + </PMText> + <PMText fontSize="sm">{formatDate(createdAt)}</PMText> + </PMVStack> + </PMMenu.Item> + )} + </PMVStack> + </PMMenu.Content> + </PMMenu.Root> + ) : ( + <PMBadge colorPalette="green" size="sm"> + Active + </PMBadge> + ))} + + {isToReview && ( + <PMTooltip + label="Active program needs review. Rule specifications have changed." + placement="top" + > + <PMHStack gap={1} alignItems="center"> + <PMIcon color="text.warning" size="xs"> + <LuCircleAlert /> + </PMIcon> + <PMBadge colorPalette="orange" size="sm"> + To Review + </PMBadge> + </PMHStack> + </PMTooltip> + )} + + {hasDraftAvailable && !isToReview && ( + <PMTooltip label="New draft available for review" placement="top"> + <PMHStack gap={1} alignItems="center"> + <PMIcon color="text.success" size="xs"> + <LuCircleCheck /> + </PMIcon> + <PMBadge colorPalette="gray" size="sm"> + Draft Available + </PMBadge> + </PMHStack> + </PMTooltip> + )} + </PMHStack> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/ProgramVersionBadge.tsx b/apps/frontend/src/domain/detection/components/ProgramVersionBadge.tsx new file mode 100644 index 000000000..aa50e1ea4 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/ProgramVersionBadge.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { PMBadge, PMHStack, PMText } from '@packmind/ui'; +import { DetectionStatus } from '@packmind/types'; + +export type ProgramState = 'active' | 'draft' | 'toReview'; + +interface ProgramVersionBadgeProps { + version: number; + createdAt?: Date | string; + programState: ProgramState; + status: DetectionStatus; +} + +const PROGRAM_STATE_CONFIG: Record< + ProgramState, + { label: string; colorPalette: string } +> = { + active: { label: 'Active', colorPalette: 'green' }, + draft: { label: 'Draft', colorPalette: 'gray' }, + toReview: { label: 'To Review', colorPalette: 'orange' }, +}; + +const formatDate = (date: Date | string): string => { + const dateObj = typeof date === 'string' ? new Date(date) : date; + return dateObj.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); +}; + +export const ProgramVersionBadge: React.FC<ProgramVersionBadgeProps> = ({ + version, + createdAt, + programState, +}) => { + const stateConfig = PROGRAM_STATE_CONFIG[programState]; + const formattedDate = createdAt ? formatDate(createdAt) : null; + + return ( + <PMHStack gap={2} alignItems="center"> + <PMBadge colorPalette={stateConfig.colorPalette} size="sm"> + {stateConfig.label} + </PMBadge> + <PMText fontSize="sm" color="faded"> + Version {version} + {formattedDate && ` • Generated on ${formattedDate}`} + </PMText> + </PMHStack> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/SeverityDropdownBadge.test.tsx b/apps/frontend/src/domain/detection/components/SeverityDropdownBadge.test.tsx new file mode 100644 index 000000000..33d54ad50 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/SeverityDropdownBadge.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DetectionSeverity } from '@packmind/types'; +import { UIProvider } from '@packmind/ui'; +import { SeverityDropdownBadge } from './SeverityDropdownBadge'; + +describe('SeverityDropdownBadge', () => { + const defaultProps = { + severity: DetectionSeverity.ERROR, + onSeverityChange: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the current severity text', () => { + render( + <UIProvider> + <SeverityDropdownBadge {...defaultProps} /> + </UIProvider>, + ); + + expect(screen.getByText('Report as error')).toBeInTheDocument(); + }); + + it('renders warning text when severity is WARNING', () => { + render( + <UIProvider> + <SeverityDropdownBadge + {...defaultProps} + severity={DetectionSeverity.WARNING} + /> + </UIProvider>, + ); + + expect(screen.getByText('Report as warning')).toBeInTheDocument(); + }); + + describe('when clicking the badge and selecting a different severity', () => { + it('calls onSeverityChange with the new severity', async () => { + const onSeverityChange = jest.fn(); + const user = userEvent.setup(); + + render( + <UIProvider> + <SeverityDropdownBadge + {...defaultProps} + onSeverityChange={onSeverityChange} + /> + </UIProvider>, + ); + + await user.click(screen.getByText('Report as error')); + await user.click(screen.getByText('Report as warning')); + + expect(onSeverityChange).toHaveBeenCalledWith(DetectionSeverity.WARNING); + }); + }); + + describe('when disabled', () => { + it('does not open menu on click', async () => { + const user = userEvent.setup(); + + render( + <UIProvider> + <SeverityDropdownBadge {...defaultProps} isDisabled={true} /> + </UIProvider>, + ); + + await user.click(screen.getByText('Report as error')); + + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/frontend/src/domain/detection/components/SeverityDropdownBadge.tsx b/apps/frontend/src/domain/detection/components/SeverityDropdownBadge.tsx new file mode 100644 index 000000000..767afde8a --- /dev/null +++ b/apps/frontend/src/domain/detection/components/SeverityDropdownBadge.tsx @@ -0,0 +1,109 @@ +import { type ComponentType, type FC, type MouseEvent } from 'react'; +import { PMButton, PMMenu, PMPortal, PMIcon } from '@packmind/ui'; +import type { PMButtonVariants } from '@packmind/ui'; +import { LuChevronDown, LuOctagonAlert, LuTriangleAlert } from 'react-icons/lu'; +import { DetectionSeverity } from '@packmind/types'; + +interface SeverityDropdownBadgeProps { + severity: DetectionSeverity; + onSeverityChange: (severity: DetectionSeverity) => void; + isDisabled?: boolean; +} + +interface SeverityOptionConfig { + text: string; + variant: PMButtonVariants; + Icon: ComponentType; + iconColor: string; +} + +const SEVERITY_CONFIG: Record<DetectionSeverity, SeverityOptionConfig> = { + [DetectionSeverity.ERROR]: { + text: 'Report as error', + variant: 'danger', + Icon: LuOctagonAlert, + iconColor: 'red.300', + }, + [DetectionSeverity.WARNING]: { + text: 'Report as warning', + variant: 'warning', + Icon: LuTriangleAlert, + iconColor: 'yellow.500', + }, +}; + +const SEVERITY_OPTIONS = [ + { value: DetectionSeverity.ERROR, label: 'Report as error' }, + { value: DetectionSeverity.WARNING, label: 'Report as warning' }, +]; + +export const SeverityDropdownBadge: FC<SeverityDropdownBadgeProps> = ({ + severity, + onSeverityChange, + isDisabled = false, +}) => { + const config = SEVERITY_CONFIG[severity]; + + if (isDisabled) { + return ( + <PMButton + variant={config.variant} + size="2xs" + disabled + minWidth="100px" + justifyContent="flex-start" + > + {config.text} + </PMButton> + ); + } + + return ( + <PMMenu.Root> + <PMMenu.Trigger asChild> + <PMButton + variant={config.variant} + size="2xs" + minWidth="100px" + justifyContent="flex-start" + onClick={(e: MouseEvent) => { + e.stopPropagation(); + }} + > + {config.text} + <PMIcon size="xs"> + <LuChevronDown /> + </PMIcon> + </PMButton> + </PMMenu.Trigger> + <PMPortal> + <PMMenu.Positioner> + <PMMenu.Content> + {SEVERITY_OPTIONS.map((option) => { + const { Icon, iconColor } = SEVERITY_CONFIG[option.value]; + return ( + <PMMenu.Item + key={option.value} + value={option.value} + cursor="pointer" + onClick={(e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (option.value !== severity) { + onSeverityChange(option.value); + } + }} + > + <PMIcon size="sm" color={iconColor} mr={2}> + <Icon /> + </PMIcon> + {option.label} + </PMMenu.Item> + ); + })} + </PMMenu.Content> + </PMMenu.Positioner> + </PMPortal> + </PMMenu.Root> + ); +}; diff --git a/apps/frontend/src/domain/detection/components/index.ts b/apps/frontend/src/domain/detection/components/index.ts new file mode 100644 index 000000000..993e46b05 --- /dev/null +++ b/apps/frontend/src/domain/detection/components/index.ts @@ -0,0 +1 @@ +export { ProgramEditor } from './ProgramEditor'; diff --git a/apps/frontend/src/domain/detection/hooks/useStandardEditionFeatures.ts b/apps/frontend/src/domain/detection/hooks/useStandardEditionFeatures.ts new file mode 100644 index 000000000..92d11ff83 --- /dev/null +++ b/apps/frontend/src/domain/detection/hooks/useStandardEditionFeatures.ts @@ -0,0 +1,248 @@ +import { useCallback, useMemo } from 'react'; +import type { Standard } from '@packmind/types'; +import { useQueryClient } from '@tanstack/react-query'; +import { + GET_ACTIVE_DETECTION_PROGRAMS_KEY, + GET_ALL_DETECTION_PROGRAMS_KEY, + GET_DETECTION_HEURISTICS_KEY, + GET_DETECTION_PROGRAM_METADATA_KEY, + GET_RULE_DETECTION_ASSESSMENT_KEY, + GET_RULE_LANGUAGE_DETECTION_STATUS_KEY, + GET_STANDARD_RULES_DETECTION_STATUS_KEY, +} from '../api/queryKeys'; +import { useGetStandardRulesDetectionStatusQuery } from '../api/queries/DetectionProgramQueries'; +import { useSSESubscription } from '../../sse'; + +export { useGetStandardRulesDetectionStatusQuery }; + +export type StandardEditionFeatures = { + ruleLanguages: Record<string, string[]>; +}; + +export const useStandardEditionFeatures = ( + standardId: Standard['id'], +): StandardEditionFeatures => { + const queryClient = useQueryClient(); + const { data: rulesDetectionStatuses } = + useGetStandardRulesDetectionStatusQuery(standardId); + + const { ruleLanguages, ruleLanguageSubscriptionParams } = useMemo<{ + ruleLanguages: Record<string, string[]>; + ruleLanguageSubscriptionParams: string[][]; + }>(() => { + const languagesByRule: Record<string, string[]> = {}; + const combos = new Set<string>(); + + if (!Array.isArray(rulesDetectionStatuses)) { + return { + ruleLanguages: languagesByRule, + ruleLanguageSubscriptionParams: [], + }; + } + + for (const { ruleId, languages } of rulesDetectionStatuses) { + const ruleIdString = ruleId ? String(ruleId) : ''; + if (!ruleIdString || !Array.isArray(languages)) { + continue; + } + + const languageSet = new Set<string>(languagesByRule[ruleIdString] ?? []); + for (const { language } of languages) { + const languageString = language ? String(language).toUpperCase() : ''; + if (languageString && !languageSet.has(languageString)) { + languageSet.add(languageString); + combos.add(`${ruleIdString}:${languageString}`); + } + } + + if (!languageSet.size) { + continue; + } + + languagesByRule[ruleIdString] = Array.from(languageSet).sort( + (first, second) => + first.localeCompare(second, undefined, { sensitivity: 'base' }), + ); + } + + const sortedCombos = Array.from(combos) + .sort((first, second) => + first.localeCompare(second, undefined, { sensitivity: 'base' }), + ) + .map((entry) => entry.split(':')); + + return { + ruleLanguages: languagesByRule, + ruleLanguageSubscriptionParams: sortedCombos, + }; + }, [rulesDetectionStatuses]); + + const invalidateDetectionQueries = useCallback( + async (ruleId: string, language?: string | null) => { + const standardIdString = String(standardId); + const ruleIdString = String(ruleId); + + const tasks: Array<Promise<unknown>> = [ + queryClient.invalidateQueries({ + queryKey: [ + ...GET_ACTIVE_DETECTION_PROGRAMS_KEY, + standardIdString, + ruleIdString, + ], + }), + queryClient.invalidateQueries({ + queryKey: [ + ...GET_ALL_DETECTION_PROGRAMS_KEY, + standardIdString, + ruleIdString, + ], + }), + queryClient.invalidateQueries({ + queryKey: [ + ...GET_STANDARD_RULES_DETECTION_STATUS_KEY, + standardIdString, + ], + }), + // Invalidate all metadata queries for this rule (any detectionProgramId) + queryClient.invalidateQueries({ + queryKey: [ + ...GET_DETECTION_PROGRAM_METADATA_KEY, + standardIdString, + ruleIdString, + ], + }), + ]; + + if (language) { + const languageString = String(language); + tasks.push( + queryClient.invalidateQueries({ + queryKey: [ + ...GET_RULE_LANGUAGE_DETECTION_STATUS_KEY, + standardIdString, + ruleIdString, + languageString, + ], + }), + ); + tasks.push( + queryClient.invalidateQueries({ + queryKey: [ + ...GET_RULE_DETECTION_ASSESSMENT_KEY, + standardIdString, + ruleIdString, + languageString, + ], + }), + ); + tasks.push( + queryClient.invalidateQueries({ + queryKey: [ + ...GET_DETECTION_HEURISTICS_KEY, + standardIdString, + ruleIdString, + languageString, + ], + }), + ); + } + + await Promise.all(tasks); + }, + [queryClient, standardId], + ); + + const handleProgramStatusEvent = useCallback( + (event: MessageEvent) => { + try { + if (event?.type !== 'PROGRAM_STATUS_CHANGE') { + return; + } + + const parsed = JSON.parse(event.data) as { + ruleId?: string | number; + language?: string; + }; + + const ruleIdFromEvent = parsed?.ruleId ? String(parsed.ruleId) : ''; + const languageFromEvent = parsed?.language + ? String(parsed.language) + : ''; + + if (!ruleIdFromEvent) { + return; + } + + void invalidateDetectionQueries( + ruleIdFromEvent, + languageFromEvent || null, + ).catch((error) => { + console.error('SSE: Failed to handle program status event', { + error, + }); + }); + } catch (error) { + console.error('SSE: Failed to handle program status event', { + error, + }); + } + }, + [invalidateDetectionQueries], + ); + + const handleAssessmentStatusEvent = useCallback( + (event: MessageEvent) => { + try { + if (event?.type !== 'ASSESSMENT_STATUS_CHANGE') { + return; + } + + const parsed = JSON.parse(event.data) as { + ruleId?: string | number; + language?: string; + }; + + const ruleIdFromEvent = parsed?.ruleId ? String(parsed.ruleId) : ''; + const languageFromEvent = parsed?.language + ? String(parsed.language) + : ''; + + if (!ruleIdFromEvent) { + return; + } + + void invalidateDetectionQueries( + ruleIdFromEvent, + languageFromEvent || null, + ).catch((error) => { + console.error('SSE: Failed to handle assessment status event', { + error, + }); + }); + } catch (error) { + console.error('SSE: Failed to handle assessment status event', { + error, + }); + } + }, + [invalidateDetectionQueries], + ); + + useSSESubscription({ + eventType: 'PROGRAM_STATUS_CHANGE', + paramsList: ruleLanguageSubscriptionParams, + onEvent: handleProgramStatusEvent, + enabled: ruleLanguageSubscriptionParams.length > 0, + }); + + useSSESubscription({ + eventType: 'ASSESSMENT_STATUS_CHANGE', + paramsList: ruleLanguageSubscriptionParams, + onEvent: handleAssessmentStatusEvent, + enabled: ruleLanguageSubscriptionParams.length > 0, + }); + + return { + ruleLanguages, + }; +}; diff --git a/apps/frontend/src/domain/detection/index.ts b/apps/frontend/src/domain/detection/index.ts new file mode 100644 index 000000000..dca724d03 --- /dev/null +++ b/apps/frontend/src/domain/detection/index.ts @@ -0,0 +1,6 @@ +// API exports +export * from './api/gateways'; +export * from './api/queries'; + +// Component exports +export * from './components'; diff --git a/apps/frontend/src/domain/editions/stubs/domain/change-proposals/api/queries/ChangeProposalsQueries.ts b/apps/frontend/src/domain/editions/stubs/domain/change-proposals/api/queries/ChangeProposalsQueries.ts deleted file mode 100644 index 19a8e35e0..000000000 --- a/apps/frontend/src/domain/editions/stubs/domain/change-proposals/api/queries/ChangeProposalsQueries.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function */ -const noopQuery: { data: any; isLoading: boolean; isError: boolean } = { - data: undefined, - isLoading: false, - isError: false, -}; -const noopMutation: { - mutate: (...args: any[]) => void; - mutateAsync: (...args: any[]) => Promise<any>; - isPending: boolean; -} = { - mutate: () => {}, - mutateAsync: async () => {}, - isPending: false, -}; - -export const useGetGroupedChangeProposalsQuery = (_spaceId?: string) => - noopQuery; -export const useListChangeProposalsByStandardQuery = (_id?: string) => - noopQuery; -export const useListChangeProposalsByRecipeQuery = (_id?: string) => noopQuery; -export const useListChangeProposalsBySkillQuery = (_id?: string) => noopQuery; -export const useCreateChangeProposalMutation = () => noopMutation; -export const useApplyRecipeChangeProposalsMutation = () => noopMutation; -export const useApplyStandardChangeProposalsMutation = () => noopMutation; -export const useApplySkillChangeProposalsMutation = () => noopMutation; -export const useApplyCreationChangeProposalsMutation = () => noopMutation; diff --git a/apps/frontend/src/domain/editions/stubs/domain/change-proposals/api/queryKeys.ts b/apps/frontend/src/domain/editions/stubs/domain/change-proposals/api/queryKeys.ts deleted file mode 100644 index a15ce2c25..000000000 --- a/apps/frontend/src/domain/editions/stubs/domain/change-proposals/api/queryKeys.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const CHANGE_PROPOSALS_QUERY_SCOPE = 'changeProposals'; -export const GET_GROUPED_CHANGE_PROPOSALS_KEY = []; -export const GET_CHANGE_PROPOSALS_BY_RECIPE_KEY = []; -export const GET_CHANGE_PROPOSALS_BY_SKILL_KEY = []; -export const GET_CHANGE_PROPOSALS_BY_STANDARD_KEY = []; -export const CREATE_CHANGE_PROPOSAL_MUTATION_KEY = []; -export const APPLY_RECIPE_CHANGE_PROPOSALS_MUTATION_KEY = []; -export const APPLY_STANDARD_CHANGE_PROPOSALS_MUTATION_KEY = []; -export const APPLY_SKILL_CHANGE_PROPOSALS_MUTATION_KEY = []; -export const APPLY_CREATION_CHANGE_PROPOSALS_MUTATION_KEY = []; -export const RECOMPUTE_CONFLICTS_KEY = []; diff --git a/apps/frontend/src/domain/editions/stubs/domain/change-proposals/components/ChangeProposalUpdateSubscription.tsx b/apps/frontend/src/domain/editions/stubs/domain/change-proposals/components/ChangeProposalUpdateSubscription.tsx deleted file mode 100644 index 973257d48..000000000 --- a/apps/frontend/src/domain/editions/stubs/domain/change-proposals/components/ChangeProposalUpdateSubscription.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function ChangeProposalUpdateSubscription(): null { - return null; -} diff --git a/apps/frontend/src/domain/editions/stubs/domain/change-proposals/utils/buildUnifiedMarkdownDiff.ts b/apps/frontend/src/domain/editions/stubs/domain/change-proposals/utils/buildUnifiedMarkdownDiff.ts deleted file mode 100644 index 0d21565d4..000000000 --- a/apps/frontend/src/domain/editions/stubs/domain/change-proposals/utils/buildUnifiedMarkdownDiff.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface MarkdownBlock { - html: string; - isChanged: boolean; - diffHtml?: string; - type: 'paragraph' | 'heading' | 'list' | 'code' | 'other'; -} - -export function buildUnifiedMarkdownDiff( - _oldValue: string, - _newValue: string, -): MarkdownBlock[] { - return []; -} diff --git a/apps/frontend/src/domain/editions/stubs/domain/spaces-management/components/BrowseSpaces.tsx b/apps/frontend/src/domain/editions/stubs/domain/spaces-management/components/BrowseSpaces.tsx index 261ae98b8..bd0c689f9 100644 --- a/apps/frontend/src/domain/editions/stubs/domain/spaces-management/components/BrowseSpaces.tsx +++ b/apps/frontend/src/domain/editions/stubs/domain/spaces-management/components/BrowseSpaces.tsx @@ -1,3 +1,5 @@ -export function BrowseSpaces(): React.ReactElement | null { +export function BrowseSpaces( + _props: Record<string, unknown> = {}, +): React.ReactElement | null { return null; } diff --git a/apps/frontend/src/domain/editions/stubs/domain/spaces-management/components/CustomSpacesNavBlock.tsx b/apps/frontend/src/domain/editions/stubs/domain/spaces-management/components/CustomSpacesNavBlock.tsx index dbddde9b0..7aafa32ce 100644 --- a/apps/frontend/src/domain/editions/stubs/domain/spaces-management/components/CustomSpacesNavBlock.tsx +++ b/apps/frontend/src/domain/editions/stubs/domain/spaces-management/components/CustomSpacesNavBlock.tsx @@ -6,6 +6,7 @@ interface CustomSpacesNavBlockProps { currentSpaceSlug: string | undefined; selectedSpaceId: string | null; onSpaceClick: (space: Space) => void; + onBrowseMySpaces?: () => void; } export function CustomSpacesNavBlock( diff --git a/apps/frontend/src/domain/git/components/ConnectionDrawer/ConnectionDrawer.tsx b/apps/frontend/src/domain/git/components/ConnectionDrawer/ConnectionDrawer.tsx index 6b70c6a6b..54afe4948 100644 --- a/apps/frontend/src/domain/git/components/ConnectionDrawer/ConnectionDrawer.tsx +++ b/apps/frontend/src/domain/git/components/ConnectionDrawer/ConnectionDrawer.tsx @@ -12,8 +12,19 @@ import { PMVStack, pmToaster, } from '@packmind/ui'; -import { LuArrowLeft, LuGitBranch, LuPencil, LuTrash2 } from 'react-icons/lu'; -import { GitProviderVendor, OrganizationId } from '@packmind/types'; +import { + LuArrowLeft, + LuArrowRight, + LuGitBranch, + LuPencil, + LuTrash2, +} from 'react-icons/lu'; +import { Link } from 'react-router'; +import { + GitProviderVendor, + MarketplaceListItem, + OrganizationId, +} from '@packmind/types'; import { GitProviderUI } from '../../types/GitProviderTypes'; import { useAddRepositoryMutation, @@ -24,6 +35,8 @@ import { useRemoveRepositoryMutation, useUpdateGitProviderMutation, } from '../../api/queries'; +import { useMarketplaces } from '../../../marketplaces/api/queries'; +import { useAuthContext } from '../../../accounts/hooks'; import { extractErrorMessage } from '../../utils/errorUtils'; import { redirectTo } from '../../../../shared/utils/navigation'; import { DisplayNameEditor } from './DisplayNameEditor'; @@ -151,6 +164,15 @@ const DrawerBody: React.FC<DrawerBodyProps> = ({ const updateMutation = useUpdateGitProviderMutation(); const installUrlMutation = useGithubAppInstallUrlMutation(); + const { data: marketplacesData } = useMarketplaces(organizationId); + const marketplacesForConnection = useMemo<MarketplaceListItem[]>( + () => + (marketplacesData ?? []).filter( + (m) => m.repository?.gitProviderId === connection.id, + ), + [marketplacesData, connection.id], + ); + const diff = useMemo(() => { const before = new Set(initialSelection.tuples.map(tupleKey)); const after = new Set(selection.tuples.map(tupleKey)); @@ -332,6 +354,7 @@ const DrawerBody: React.FC<DrawerBodyProps> = ({ (r) => `${r.owner}/${r.repo}`, ), ).size; + const marketplaceCount = marketplacesForConnection.length; const applying = progress?.phase === 'running'; const placeholder = vendorPlaceholder(connection.source); const headerTitle = connection.displayName.trim() || placeholder; @@ -388,6 +411,7 @@ const DrawerBody: React.FC<DrawerBodyProps> = ({ <ViewMode connection={connection} repoCount={repoCount} + marketplaces={marketplacesForConnection} placeholder={placeholder} otherNames={otherNames} onSaveDisplayName={submitDisplayName} @@ -433,7 +457,7 @@ const DrawerBody: React.FC<DrawerBodyProps> = ({ variant="ghost" size="sm" onClick={() => onDelete(connection)} - disabled={repoCount > 0} + disabled={repoCount > 0 || marketplaceCount > 0} data-testid="connection-drawer-delete" > <PMIcon fontSize="sm" color="red.500"> @@ -443,9 +467,9 @@ const DrawerBody: React.FC<DrawerBodyProps> = ({ Delete connection </PMText> </PMButton> - {repoCount > 0 && ( + {deleteBlockedReason(repoCount, marketplaceCount) && ( <PMText fontSize="xs" color="faded"> - Remove tracked repos first to delete. + {deleteBlockedReason(repoCount, marketplaceCount)} </PMText> )} </PMHStack> @@ -534,6 +558,7 @@ const DrawerBody: React.FC<DrawerBodyProps> = ({ interface ViewModeProps { connection: GitProviderUI; repoCount: number; + marketplaces: MarketplaceListItem[]; placeholder: string; otherNames: string[]; onSaveDisplayName: (next: string) => Promise<void>; @@ -547,6 +572,7 @@ interface ViewModeProps { const ViewMode: React.FC<ViewModeProps> = ({ connection, repoCount, + marketplaces, placeholder, otherNames, onSaveDisplayName, @@ -573,6 +599,10 @@ const ViewMode: React.FC<ViewModeProps> = ({ isManagingApp={isManagingApp} /> + {marketplaces.length > 0 && ( + <MarketplacesPreview marketplaces={marketplaces} /> + )} + <PMVStack gap={2} align="stretch" flex={1} minH={0}> <PMHStack justify="space-between" align="baseline"> <PMText @@ -789,6 +819,109 @@ const RepositoriesPreview: React.FC<{ ); }; +const MarketplacesPreview: React.FC<{ + marketplaces: MarketplaceListItem[]; +}> = ({ marketplaces }) => { + const { organization } = useAuthContext(); + const orgSlug = organization?.slug; + + const sorted = useMemo( + () => + [...marketplaces].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }), + ), + [marketplaces], + ); + + return ( + <PMVStack + gap={2} + align="stretch" + data-testid="connection-drawer-marketplaces" + > + <PMHStack justify="space-between" align="baseline"> + <PMText + fontSize="xs" + color="faded" + textTransform="uppercase" + letterSpacing="wider" + fontWeight="semibold" + > + Marketplaces ({sorted.length}) + </PMText> + {orgSlug && ( + <Link + to={`/org/${orgSlug}/marketplaces`} + data-testid="connection-drawer-marketplaces-manage" + > + <PMHStack + gap={1.5} + align="center" + color="secondary" + _hover={{ color: 'branding.primary' }} + > + <PMText fontSize="xs" fontWeight="medium"> + Manage in Marketplaces + </PMText> + <PMIcon fontSize="xs"> + <LuArrowRight /> + </PMIcon> + </PMHStack> + </Link> + )} + </PMHStack> + <PMBox + borderWidth="1px" + borderColor="border.tertiary" + borderRadius="md" + bg="background.secondary" + overflow="hidden" + > + {sorted.map((marketplace, idx) => ( + <PMVStack + key={marketplace.id} + gap={0.5} + align="stretch" + paddingX={3} + paddingY={2} + borderBottom={idx === sorted.length - 1 ? undefined : '1px solid'} + borderColor="border.tertiary" + > + <PMText fontSize="sm" color="primary" fontWeight="medium" truncate> + {marketplace.name} + </PMText> + <PMText fontSize="xs" color="secondary" truncate> + {marketplaceCoordinates(marketplace)} + </PMText> + </PMVStack> + ))} + </PMBox> + </PMVStack> + ); +}; + +function deleteBlockedReason( + repoCount: number, + marketplaceCount: number, +): string { + if (repoCount > 0 && marketplaceCount > 0) { + return 'Detach repos and unlink marketplaces first to delete.'; + } + if (repoCount > 0) { + return 'Remove tracked repos first to delete.'; + } + if (marketplaceCount > 0) { + return 'Unlink marketplaces (managed in Marketplaces) first to delete.'; + } + return ''; +} + +function marketplaceCoordinates(marketplace: MarketplaceListItem): string { + const repository = marketplace.repository; + if (!repository) return 'Repository unavailable'; + return `${repository.owner}/${repository.repo} · ${repository.branch}`; +} + function diffSummary(adds: number, removes: number): string { const parts: string[] = []; if (adds > 0) parts.push(`${adds} added`); diff --git a/apps/frontend/src/domain/git/components/GitProvidersList.tsx b/apps/frontend/src/domain/git/components/GitProvidersList.tsx index c0294b3c2..21851788d 100644 --- a/apps/frontend/src/domain/git/components/GitProvidersList.tsx +++ b/apps/frontend/src/domain/git/components/GitProvidersList.tsx @@ -18,6 +18,7 @@ import { useDeleteGitProviderMutation, useGetGitProvidersQuery, } from '../api/queries'; +import { useMarketplaces } from '../../marketplaces/api/queries'; import { GitProviderUI } from '../types/GitProviderTypes'; import { GIT_MESSAGES } from '../constants/messages'; import { AddConnectionDrawer } from './AddConnection/AddConnectionDrawer'; @@ -43,6 +44,19 @@ export const GitProvidersList: React.FC<GitProvidersListProps> = ({ const providers = providersResponse?.providers; const deleteProviderMutation = useDeleteGitProviderMutation(); + // Marketplaces are fetched here (instead of in the table) so the count can + // be reused by the deletion-blocking rule and the drawer. + const { data: marketplaces } = useMarketplaces(organizationId); + const marketplaceCountByProviderId = useMemo(() => { + const map = new Map<GitProviderId, number>(); + for (const marketplace of marketplaces ?? []) { + const providerId = marketplace.repository?.gitProviderId; + if (!providerId) continue; + map.set(providerId, (map.get(providerId) ?? 0) + 1); + } + return map; + }, [marketplaces]); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [providerToDelete, setProviderToDelete] = useState<GitProviderUI | null>(null); @@ -142,6 +156,7 @@ export const GitProvidersList: React.FC<GitProvidersListProps> = ({ ) : ( <ConnectionsTable connections={userConfigured} + marketplaceCountByProviderId={marketplaceCountByProviderId} onEdit={(connection) => { setEditingProviderId(connection.id); }} @@ -149,6 +164,11 @@ export const GitProvidersList: React.FC<GitProvidersListProps> = ({ if ((connection.repos?.length ?? 0) > 0) { return; } + if ( + (marketplaceCountByProviderId.get(connection.id) ?? 0) > 0 + ) { + return; + } setProviderToDelete(connection); setDeleteDialogOpen(true); }} diff --git a/apps/frontend/src/domain/git/components/list/ConnectionsTable.tsx b/apps/frontend/src/domain/git/components/list/ConnectionsTable.tsx index f73fe91bc..b00d565b5 100644 --- a/apps/frontend/src/domain/git/components/list/ConnectionsTable.tsx +++ b/apps/frontend/src/domain/git/components/list/ConnectionsTable.tsx @@ -11,7 +11,7 @@ import { } from '@packmind/ui'; import { LuEllipsis, LuPenLine, LuRefreshCw, LuTrash2 } from 'react-icons/lu'; import { format, formatDistanceToNowStrict } from 'date-fns'; -import { GitProviderVendor } from '@packmind/types'; +import { GitProviderId, GitProviderVendor } from '@packmind/types'; import { GitProviderUI } from '../../types/GitProviderTypes'; import { useCheckProviderAuthQuery } from '../../api/queries'; import { VendorMark, vendorLabel } from '../shared/VendorMark'; @@ -23,12 +23,14 @@ import { interface ConnectionsTableProps { connections: GitProviderUI[]; + marketplaceCountByProviderId: Map<GitProviderId, number>; onEdit: (connection: GitProviderUI) => void; onDelete: (connection: GitProviderUI) => void; } export const ConnectionsTable: React.FC<ConnectionsTableProps> = ({ connections, + marketplaceCountByProviderId, onEdit, onDelete, }) => { @@ -45,6 +47,9 @@ export const ConnectionsTable: React.FC<ConnectionsTableProps> = ({ <ConnectionRow key={connection.id} connection={connection} + marketplaceCount={ + marketplaceCountByProviderId.get(connection.id) ?? 0 + } isLast={idx === connections.length - 1} onEdit={() => onEdit(connection)} onDelete={() => onDelete(connection)} @@ -72,16 +77,20 @@ const TableHeader: React.FC = () => ( Connection </PMBox> <PMBox width="160px">Status</PMBox> - <PMBox width="70px" textAlign="right"> + <PMBox width="60px" textAlign="right"> Repos </PMBox> <PMBox width="140px">Last distribution</PMBox> + <PMBox width="110px" textAlign="right"> + Marketplaces + </PMBox> <PMBox width="90px" /> </PMHStack> ); interface ConnectionRowProps { connection: GitProviderUI; + marketplaceCount: number; isLast: boolean; onEdit: () => void; onDelete: () => void; @@ -89,6 +98,7 @@ interface ConnectionRowProps { const ConnectionRow: React.FC<ConnectionRowProps> = ({ connection, + marketplaceCount, isLast, onEdit, onDelete, @@ -120,6 +130,7 @@ const ConnectionRow: React.FC<ConnectionRowProps> = ({ data-url={connection.url ?? ''} data-repo-count={repoCount} data-status={bucket} + data-marketplace-count={marketplaceCount} gap={3} paddingX={4} paddingY={3} @@ -155,9 +166,9 @@ const ConnectionRow: React.FC<ConnectionRowProps> = ({ </PMBox> <PMText - width="70px" + width="60px" fontSize="sm" - color="secondary" + color={repoCount > 0 ? 'secondary' : 'faded'} textAlign="right" fontVariantNumeric="tabular-nums" > @@ -170,6 +181,16 @@ const ConnectionRow: React.FC<ConnectionRowProps> = ({ /> </PMBox> + <PMText + width="110px" + fontSize="sm" + color={marketplaceCount > 0 ? 'secondary' : 'faded'} + textAlign="right" + fontVariantNumeric="tabular-nums" + > + {marketplaceCount} + </PMText> + <PMHStack width="90px" gap={1} justify="flex-end"> <RefreshStatusButton isFetching={probe.isFetching} @@ -180,7 +201,11 @@ const ConnectionRow: React.FC<ConnectionRowProps> = ({ <RowActionsMenu onEdit={onEdit} onDelete={onDelete} - deleteDisabled={repoCount > 0} + deleteDisabled={repoCount > 0 || marketplaceCount > 0} + deleteDisabledReason={deleteDisabledReason( + repoCount, + marketplaceCount, + )} /> </PMHStack> </PMHStack> @@ -265,15 +290,14 @@ interface RowActionsMenuProps { onEdit: () => void; onDelete: () => void; deleteDisabled: boolean; + deleteDisabledReason: string; } -const DELETE_DISABLED_TOOLTIP = - 'Detach all repositories from this connection before deleting it.'; - const RowActionsMenu: React.FC<RowActionsMenuProps> = ({ onEdit, onDelete, deleteDisabled, + deleteDisabledReason, }) => ( <PMMenu.Root positioning={{ placement: 'bottom-end' }}> <PMMenu.Trigger asChild> @@ -308,7 +332,11 @@ const RowActionsMenu: React.FC<RowActionsMenuProps> = ({ Edit </PMText> </PMMenu.Item> - <DeleteMenuItem onDelete={onDelete} deleteDisabled={deleteDisabled} /> + <DeleteMenuItem + onDelete={onDelete} + deleteDisabled={deleteDisabled} + deleteDisabledReason={deleteDisabledReason} + /> </PMMenu.Content> </PMMenu.Positioner> </PMPortal> @@ -318,11 +346,13 @@ const RowActionsMenu: React.FC<RowActionsMenuProps> = ({ interface DeleteMenuItemProps { onDelete: () => void; deleteDisabled: boolean; + deleteDisabledReason: string; } const DeleteMenuItem: React.FC<DeleteMenuItemProps> = ({ onDelete, deleteDisabled, + deleteDisabledReason, }) => { const item = ( <PMMenu.Item @@ -358,12 +388,28 @@ const DeleteMenuItem: React.FC<DeleteMenuItemProps> = ({ } return ( - <PMTooltip label={DELETE_DISABLED_TOOLTIP} placement="left"> + <PMTooltip label={deleteDisabledReason} placement="left"> <PMBox width="full">{item}</PMBox> </PMTooltip> ); }; +function deleteDisabledReason( + repoCount: number, + marketplaceCount: number, +): string { + if (repoCount > 0 && marketplaceCount > 0) { + return 'Detach all repositories and unlink marketplaces (managed in Marketplaces) from this connection before deleting it.'; + } + if (repoCount > 0) { + return 'Detach all repositories from this connection before deleting it.'; + } + if (marketplaceCount > 0) { + return 'Unlink marketplaces (managed in Marketplaces) from this connection before deleting it.'; + } + return ''; +} + function vendorPlaceholder(vendor: GitProviderVendor): string { if (vendor === 'github') return 'Unnamed GitHub connection'; if (vendor === 'gitlab') return 'Unnamed GitLab connection'; diff --git a/apps/frontend/src/domain/marketplaces/api/gateways/IMarketplaceGateway.ts b/apps/frontend/src/domain/marketplaces/api/gateways/IMarketplaceGateway.ts new file mode 100644 index 000000000..415cd69dc --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/api/gateways/IMarketplaceGateway.ts @@ -0,0 +1,128 @@ +import { + GetMarketplaceDistributionChangesResponse, + LinkMarketplaceResponse, + ListMarketplaceDistributionsResponse, + ListMarketplacePluginInstallsResponse, + MarketplaceDistributionId, + MarketplaceId, + MarketplaceListItem, + MarkPluginForRemovalResponse, + OrganizationId, + PackageId, + SyncMarketplaceNowResponse, + ValidateMarketplaceUrlResponse, +} from '@packmind/types'; + +/** + * Body sent to `linkMarketplace`. Mirrors the API DTO accepted by + * `POST /organizations/:orgId/marketplaces`. + */ +export type LinkMarketplaceRequestBody = { + gitProviderId: string; + owner: string; + repo: string; + branch: string; + name: string; +}; + +export type UnlinkMarketplaceResult = { + marketplaceId: MarketplaceId; +}; + +export interface IMarketplaceGateway { + /** + * Returns the list of marketplaces linked to the organization, enriched with + * `addedByUserName` and a denormalized `pluginCount`. + */ + listMarketplaces( + organizationId: OrganizationId, + ): Promise<MarketplaceListItem[]>; + + /** + * Links a Git repository as a marketplace via a connected `GitProvider` + * (private path). + */ + linkMarketplace( + organizationId: OrganizationId, + body: LinkMarketplaceRequestBody, + ): Promise<LinkMarketplaceResponse>; + + /** + * Unlinks an enrolled marketplace. The underlying Git repo is never touched. + */ + unlinkMarketplace( + organizationId: OrganizationId, + marketplaceId: MarketplaceId, + ): Promise<UnlinkMarketplaceResult>; + + /** + * Pre-flight validates a public marketplace URL through a tokenless + * `GitProvider` before submitting the link form. + */ + validateMarketplaceUrl( + organizationId: OrganizationId, + url: string, + ): Promise<ValidateMarketplaceUrlResponse>; + + /** + * Returns the list of plugin distributions for a given marketplace, enriched + * with `packageName` and `authorName` per + * `IListMarketplaceDistributionsUseCase`. + */ + listDistributions( + organizationId: OrganizationId, + marketplaceId: MarketplaceId, + ): Promise<ListMarketplaceDistributionsResponse>; + + /** + * Returns the artifact-level diff between a distribution's captured + * VersionFingerprint and the source package's current state. Drives the + * plugin detail "Changes" tab — returns an empty list when in sync. + */ + getDistributionChanges( + organizationId: OrganizationId, + marketplaceId: MarketplaceId, + distributionId: MarketplaceDistributionId, + ): Promise<GetMarketplaceDistributionChangesResponse>; + + /** + * Marks a published plugin distribution as `to_be_removed`. Targets the row + * directly by `distributionId`. + */ + markPluginForRemovalByDistribution( + organizationId: OrganizationId, + marketplaceId: MarketplaceId, + distributionId: MarketplaceDistributionId, + ): Promise<MarkPluginForRemovalResponse>; + + /** + * Marks the latest successful plugin distribution for a given Packmind + * package on the given marketplace as `to_be_removed`. + */ + markPluginForRemovalByPackage( + organizationId: OrganizationId, + marketplaceId: MarketplaceId, + packageId: PackageId, + ): Promise<MarkPluginForRemovalResponse>; + + /** + * Triggers an immediate, on-demand reconciliation of the marketplace and + * returns its freshly computed state. Lets a member refresh marketplace + * state without waiting for the next scheduled reconciliation sweep. + */ + syncMarketplaceNow( + organizationId: OrganizationId, + marketplaceId: MarketplaceId, + ): Promise<SyncMarketplaceNowResponse>; + + /** + * Returns all tracked plugin installation rows for the given marketplace, + * enriched with user display names. + * + * Route: `GET /organizations/:orgId/marketplaces/:marketplaceId/plugin-installs` + */ + listPluginInstalls( + organizationId: OrganizationId, + marketplaceId: MarketplaceId, + ): Promise<ListMarketplacePluginInstallsResponse>; +} diff --git a/apps/frontend/src/domain/marketplaces/api/gateways/MarketplaceGatewayApi.ts b/apps/frontend/src/domain/marketplaces/api/gateways/MarketplaceGatewayApi.ts new file mode 100644 index 000000000..1f6a75598 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/api/gateways/MarketplaceGatewayApi.ts @@ -0,0 +1,138 @@ +import { + GetMarketplaceDistributionChangesResponse, + LinkMarketplaceResponse, + ListMarketplaceDistributionsResponse, + ListMarketplacePluginInstallsResponse, + MarketplaceDistributionId, + MarketplaceId, + MarketplaceListItem, + MarkPluginForRemovalResponse, + OrganizationId, + PackageId, + SyncMarketplaceNowResponse, + ValidateMarketplaceUrlResponse, +} from '@packmind/types'; +import { PackmindGateway } from '../../../../shared/PackmindGateway'; +import { + IMarketplaceGateway, + LinkMarketplaceRequestBody, + UnlinkMarketplaceResult, +} from './IMarketplaceGateway'; + +/** + * Axios-backed gateway for the marketplace HTTP surface (group J). + * + * Routes: + * - `POST /organizations/:orgId/marketplaces` + * - `DELETE /organizations/:orgId/marketplaces/:marketplaceId` + * - `GET /organizations/:orgId/marketplaces` + * - `GET /organizations/:orgId/marketplaces/validate-url?url=...` + * + * Error handling delegates to the shared `ApiService`, which surfaces + * `PackmindError`/`PackmindConflictError` for typed handling upstream. + */ +export class MarketplaceGatewayApi + extends PackmindGateway + implements IMarketplaceGateway +{ + constructor() { + super('/organizations'); + } + + async listMarketplaces( + organizationId: OrganizationId, + ): Promise<MarketplaceListItem[]> { + return this._api.get<MarketplaceListItem[]>( + `${this._endpoint}/${organizationId}/marketplaces`, + ); + } + + async linkMarketplace( + organizationId: OrganizationId, + body: LinkMarketplaceRequestBody, + ): Promise<LinkMarketplaceResponse> { + return this._api.post<LinkMarketplaceResponse>( + `${this._endpoint}/${organizationId}/marketplaces`, + body, + ); + } + + async unlinkMarketplace( + organizationId: OrganizationId, + marketplaceId: MarketplaceId, + ): Promise<UnlinkMarketplaceResult> { + return this._api.delete<UnlinkMarketplaceResult>( + `${this._endpoint}/${organizationId}/marketplaces/${marketplaceId}`, + ); + } + + async validateMarketplaceUrl( + organizationId: OrganizationId, + url: string, + ): Promise<ValidateMarketplaceUrlResponse> { + return this._api.get<ValidateMarketplaceUrlResponse>( + `${this._endpoint}/${organizationId}/marketplaces/validate-url`, + { params: { url } }, + ); + } + + async listDistributions( + organizationId: OrganizationId, + marketplaceId: MarketplaceId, + ): Promise<ListMarketplaceDistributionsResponse> { + return this._api.get<ListMarketplaceDistributionsResponse>( + `${this._endpoint}/${organizationId}/marketplaces/${marketplaceId}/distributions`, + ); + } + + async getDistributionChanges( + organizationId: OrganizationId, + marketplaceId: MarketplaceId, + distributionId: MarketplaceDistributionId, + ): Promise<GetMarketplaceDistributionChangesResponse> { + return this._api.get<GetMarketplaceDistributionChangesResponse>( + `${this._endpoint}/${organizationId}/marketplaces/${marketplaceId}/distributions/${distributionId}/changes`, + ); + } + + async markPluginForRemovalByDistribution( + organizationId: OrganizationId, + marketplaceId: MarketplaceId, + distributionId: MarketplaceDistributionId, + ): Promise<MarkPluginForRemovalResponse> { + return this._api.post<MarkPluginForRemovalResponse>( + `${this._endpoint}/${organizationId}/marketplaces/${marketplaceId}/distributions/${distributionId}/removal`, + {}, + ); + } + + async markPluginForRemovalByPackage( + organizationId: OrganizationId, + marketplaceId: MarketplaceId, + packageId: PackageId, + ): Promise<MarkPluginForRemovalResponse> { + return this._api.post<MarkPluginForRemovalResponse>( + `${this._endpoint}/${organizationId}/marketplaces/${marketplaceId}/packages/${packageId}/removal`, + {}, + ); + } + + async syncMarketplaceNow( + organizationId: OrganizationId, + marketplaceId: MarketplaceId, + ): Promise<SyncMarketplaceNowResponse> { + return this._api.post<SyncMarketplaceNowResponse>( + `${this._endpoint}/${organizationId}/marketplaces/${marketplaceId}/reconcile`, + {}, + ); + } + + async listPluginInstalls( + organizationId: OrganizationId, + marketplaceId: MarketplaceId, + ): Promise<ListMarketplacePluginInstallsResponse> { + return this._api.get<ListMarketplacePluginInstallsResponse>( + `${this._endpoint}/${organizationId}/marketplaces/${marketplaceId}/plugin-installs`, + ); + } +} diff --git a/apps/frontend/src/domain/marketplaces/api/gateways/index.ts b/apps/frontend/src/domain/marketplaces/api/gateways/index.ts new file mode 100644 index 000000000..017e6a7cb --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/api/gateways/index.ts @@ -0,0 +1,8 @@ +import { IMarketplaceGateway } from './IMarketplaceGateway'; +import { MarketplaceGatewayApi } from './MarketplaceGatewayApi'; + +export * from './IMarketplaceGateway'; +export * from './MarketplaceGatewayApi'; + +export const marketplaceGateway: IMarketplaceGateway = + new MarketplaceGatewayApi(); diff --git a/apps/frontend/src/domain/marketplaces/api/queries/MarketplaceQueries.spec.ts b/apps/frontend/src/domain/marketplaces/api/queries/MarketplaceQueries.spec.ts new file mode 100644 index 000000000..9a32a76e6 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/api/queries/MarketplaceQueries.spec.ts @@ -0,0 +1,322 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { createElement, type ReactNode } from 'react'; +import type { + ListMarketplaceDistributionsResponse, + ListMarketplacePluginInstallsResponse, + MarketplaceDistribution, + MarketplaceDistributionId, + MarketplaceId, + MarkPluginForRemovalResponse, + OrganizationId, + PackageId, +} from '@packmind/types'; +import { marketplaceGateway } from '../gateways'; +import { + marketplaceQueries, + marketplaceQueryKeys, + useMarkPluginForRemovalByDistribution, + useMarkPluginForRemovalByPackage, + useMarketplacePluginInstalls, +} from './MarketplaceQueries'; + +jest.mock('../gateways', () => ({ + marketplaceGateway: { + listMarketplaces: jest.fn(), + linkMarketplace: jest.fn(), + unlinkMarketplace: jest.fn(), + validateMarketplaceUrl: jest.fn(), + listDistributions: jest.fn(), + markPluginForRemovalByDistribution: jest.fn(), + markPluginForRemovalByPackage: jest.fn(), + listPluginInstalls: jest.fn(), + }, +})); + +const mockGateway = marketplaceGateway as unknown as { + listMarketplaces: jest.Mock; + linkMarketplace: jest.Mock; + unlinkMarketplace: jest.Mock; + validateMarketplaceUrl: jest.Mock; + listDistributions: jest.Mock; + markPluginForRemovalByDistribution: jest.Mock; + markPluginForRemovalByPackage: jest.Mock; + listPluginInstalls: jest.Mock; +}; + +const orgId = 'org-1' as OrganizationId; +const marketplaceId = 'mkt-1' as MarketplaceId; +const distributionId = 'dist-1' as MarketplaceDistributionId; +const packageId = 'pkg-1' as PackageId; + +const stubDistribution: MarketplaceDistribution = { + id: distributionId, + organizationId: orgId, + marketplaceId, + packageId, + pluginSlug: 'plugin-slug', + authorId: 'user-1' as MarketplaceDistribution['authorId'], + status: 'to_be_removed' as MarketplaceDistribution['status'], + source: 'manual' as MarketplaceDistribution['source'], + createdAt: new Date('2026-04-01T00:00:00Z'), + updatedAt: new Date('2026-04-01T00:00:00Z'), + deletedAt: null, +}; + +const stubMarkResponse: MarkPluginForRemovalResponse = { + distribution: stubDistribution, +}; + +const stubListResponse: ListMarketplaceDistributionsResponse = []; + +function createWrapper(client: QueryClient) { + return ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client }, children); +} + +function createClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +describe('marketplaceQueryKeys.distributions', () => { + it('nests the distributions key under the marketplaces scope', () => { + const key = marketplaceQueryKeys.distributions(); + const all = marketplaceQueryKeys.all(); + expect(key.slice(0, all.length)).toEqual([...all]); + }); + + it('produces a unique key per (orgId, marketplaceId) pair', () => { + const a = marketplaceQueryKeys.distributionList(orgId, marketplaceId); + const b = marketplaceQueryKeys.distributionList( + orgId, + 'other-mkt' as MarketplaceId, + ); + expect(a).not.toEqual(b); + }); +}); + +describe('marketplaceQueries.distributions', () => { + it('uses the distributionList query key and calls listDistributions', () => { + const options = marketplaceQueries.distributions({ + orgId, + marketplaceId, + }); + expect(options.queryKey).toEqual( + marketplaceQueryKeys.distributionList(orgId, marketplaceId), + ); + }); + + describe('when orgId is missing', () => { + it('is disabled', () => { + const options = marketplaceQueries.distributions({ + orgId: '', + marketplaceId, + }); + expect(options.enabled).toBe(false); + }); + }); +}); + +describe('useMarkPluginForRemovalByDistribution', () => { + beforeEach(() => { + mockGateway.markPluginForRemovalByDistribution.mockResolvedValue( + stubMarkResponse, + ); + mockGateway.listDistributions.mockResolvedValue(stubListResponse); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('calls the gateway with the right arguments', async () => { + const client = createClient(); + const { result } = renderHook( + () => useMarkPluginForRemovalByDistribution(orgId, marketplaceId), + { wrapper: createWrapper(client) }, + ); + + await act(async () => { + await result.current.mutateAsync(distributionId); + }); + + expect(mockGateway.markPluginForRemovalByDistribution).toHaveBeenCalledWith( + orgId, + marketplaceId, + distributionId, + ); + }); + + it('invalidates the distributions query key on success', async () => { + const client = createClient(); + const invalidateSpy = jest.spyOn(client, 'invalidateQueries'); + + const { result } = renderHook( + () => useMarkPluginForRemovalByDistribution(orgId, marketplaceId), + { wrapper: createWrapper(client) }, + ); + + await act(async () => { + await result.current.mutateAsync(distributionId); + }); + + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: marketplaceQueryKeys.distributions(), + }); + }); + }); +}); + +describe('useMarkPluginForRemovalByPackage', () => { + beforeEach(() => { + mockGateway.markPluginForRemovalByPackage.mockResolvedValue( + stubMarkResponse, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('invalidates the distributions key and the package detail keys', async () => { + const client = createClient(); + const invalidateSpy = jest.spyOn(client, 'invalidateQueries'); + + const { result } = renderHook( + () => useMarkPluginForRemovalByPackage(orgId, marketplaceId), + { wrapper: createWrapper(client) }, + ); + + await act(async () => { + await result.current.mutateAsync(packageId); + }); + + expect(mockGateway.markPluginForRemovalByPackage).toHaveBeenCalledWith( + orgId, + marketplaceId, + packageId, + ); + + await waitFor(() => { + const calls = invalidateSpy.mock.calls.map(([arg]) => arg); + const hasDistributions = calls.some( + (arg) => + JSON.stringify(arg?.queryKey) === + JSON.stringify(marketplaceQueryKeys.distributions()), + ); + const hasPackageDetailInvalidation = calls.some((arg) => { + const key = arg?.queryKey; + return ( + Array.isArray(key) && key.includes(packageId as unknown as string) + ); + }); + expect(hasDistributions).toBe(true); + expect(hasPackageDetailInvalidation).toBe(true); + }); + }); +}); + +describe('marketplaceQueryKeys.pluginInstalls', () => { + it('nests the plugin-installs key under the marketplaces scope', () => { + const key = marketplaceQueryKeys.pluginInstalls(); + const all = marketplaceQueryKeys.all(); + expect(key.slice(0, all.length)).toEqual([...all]); + }); + + it('produces a unique key per (orgId, marketplaceId) pair', () => { + const a = marketplaceQueryKeys.pluginInstallList(orgId, marketplaceId); + const b = marketplaceQueryKeys.pluginInstallList( + orgId, + 'other-mkt' as MarketplaceId, + ); + expect(a).not.toEqual(b); + }); +}); + +describe('marketplaceQueries.pluginInstalls', () => { + it('uses the pluginInstallList query key', () => { + const options = marketplaceQueries.pluginInstalls({ + orgId, + marketplaceId, + }); + expect(options.queryKey).toEqual( + marketplaceQueryKeys.pluginInstallList(orgId, marketplaceId), + ); + }); + + describe('when orgId is empty', () => { + it('is disabled', () => { + const options = marketplaceQueries.pluginInstalls({ + orgId: '', + marketplaceId, + }); + expect(options.enabled).toBe(false); + }); + }); + + describe('when enabled flag is false', () => { + it('is disabled', () => { + const options = marketplaceQueries.pluginInstalls({ + orgId, + marketplaceId, + enabled: false, + }); + expect(options.enabled).toBe(false); + }); + }); + + it('has a staleTime of 10 minutes', () => { + const options = marketplaceQueries.pluginInstalls({ + orgId, + marketplaceId, + }); + expect(options.staleTime).toBe(10 * 60 * 1000); + }); +}); + +describe('useMarketplacePluginInstalls', () => { + const stubInstallsResponse: ListMarketplacePluginInstallsResponse = []; + + beforeEach(() => { + mockGateway.listPluginInstalls.mockResolvedValue(stubInstallsResponse); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when drawerOpen is true', () => { + it('calls the gateway with the correct arguments', async () => { + const client = createClient(); + const { result } = renderHook( + () => useMarketplacePluginInstalls(orgId, marketplaceId, true), + { wrapper: createWrapper(client) }, + ); + + await waitFor(() => result.current.isSuccess); + + expect(mockGateway.listPluginInstalls).toHaveBeenCalledWith( + orgId, + marketplaceId, + ); + }); + }); + + describe('when drawerOpen is false', () => { + it('does not call the gateway', () => { + const client = createClient(); + renderHook( + () => useMarketplacePluginInstalls(orgId, marketplaceId, false), + { wrapper: createWrapper(client) }, + ); + + expect(mockGateway.listPluginInstalls).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/frontend/src/domain/marketplaces/api/queries/MarketplaceQueries.ts b/apps/frontend/src/domain/marketplaces/api/queries/MarketplaceQueries.ts new file mode 100644 index 000000000..526472ef9 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/api/queries/MarketplaceQueries.ts @@ -0,0 +1,499 @@ +import { + queryOptions, + QueryClient, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import { pmToaster } from '@packmind/ui'; +import { + GetMarketplaceDistributionChangesResponse, + ListMarketplaceDistributionsResponse, + ListMarketplacePluginInstallsResponse, + MarketplaceDistributionId, + MarketplaceId, + MarketplaceListItem, + MarketplaceState, + OrganizationId, + PackageId, + SyncMarketplaceNowResponse, +} from '@packmind/types'; +import { ORGANIZATION_QUERY_SCOPE } from '../../../organizations/api/queryKeys'; +import { + GET_PACKAGE_BY_ID_KEY, + LIST_PACKAGE_DEPLOYMENTS_KEY, +} from '../../../deployments/api/queryKeys'; +import { + IMarketplaceGateway, + LinkMarketplaceRequestBody, + marketplaceGateway, +} from '../gateways'; + +const MARKETPLACES_SCOPE = 'marketplaces' as const; + +export enum MarketplaceQueryKey { + LIST = 'list', + VALIDATE_URL = 'validate-url', + DISTRIBUTIONS = 'distributions', + DISTRIBUTION_CHANGES = 'distribution-changes', + PLUGIN_INSTALLS = 'plugin-installs', +} + +// Factory for marketplace query keys. Lives under the per-organization scope so +// an org switch can blast the whole subtree in one invalidate call. +export const marketplaceQueryKeys = { + all: () => [ORGANIZATION_QUERY_SCOPE, MARKETPLACES_SCOPE] as const, + lists: () => + [...marketplaceQueryKeys.all(), MarketplaceQueryKey.LIST] as const, + list: (organizationId: OrganizationId | string) => + [...marketplaceQueryKeys.lists(), organizationId] as const, + validations: () => + [...marketplaceQueryKeys.all(), MarketplaceQueryKey.VALIDATE_URL] as const, + validation: (organizationId: OrganizationId | string, url: string) => + [...marketplaceQueryKeys.validations(), organizationId, url] as const, + distributions: () => + [...marketplaceQueryKeys.all(), MarketplaceQueryKey.DISTRIBUTIONS] as const, + distributionList: ( + organizationId: OrganizationId | string, + marketplaceId: MarketplaceId | string, + ) => + [ + ...marketplaceQueryKeys.distributions(), + organizationId, + marketplaceId, + ] as const, + distributionChangesAll: () => + [ + ...marketplaceQueryKeys.all(), + MarketplaceQueryKey.DISTRIBUTION_CHANGES, + ] as const, + distributionChangesForMarketplace: ( + organizationId: OrganizationId | string, + marketplaceId: MarketplaceId | string, + ) => + [ + ...marketplaceQueryKeys.distributionChangesAll(), + organizationId, + marketplaceId, + ] as const, + distributionChanges: ( + organizationId: OrganizationId | string, + marketplaceId: MarketplaceId | string, + distributionId: MarketplaceDistributionId | string, + ) => + [ + ...marketplaceQueryKeys.distributionChangesAll(), + organizationId, + marketplaceId, + distributionId, + ] as const, + pluginInstalls: () => + [ + ...marketplaceQueryKeys.all(), + MarketplaceQueryKey.PLUGIN_INSTALLS, + ] as const, + pluginInstallList: ( + organizationId: OrganizationId | string, + marketplaceId: MarketplaceId | string, + ) => + [ + ...marketplaceQueryKeys.pluginInstalls(), + organizationId, + marketplaceId, + ] as const, +}; + +/** + * Merge a reconcile result into the cached marketplace list row in place. Used + * by the on-open refresh hook so each row's live state updates as its + * per-row `syncMarketplaceNow` resolves, without re-fetching the whole list. + */ +export function patchMarketplaceInCache( + queryClient: QueryClient, + organizationId: OrganizationId | string, + marketplaceId: MarketplaceId | string, + patch: SyncMarketplaceNowResponse, +): void { + queryClient.setQueryData<MarketplaceListItem[]>( + marketplaceQueryKeys.list(organizationId), + (rows) => + rows?.map((row) => + row.id === marketplaceId + ? { + ...row, + state: patch.state, + lastValidatedAt: patch.lastValidatedAt, + errorKind: patch.errorKind, + errorDetail: patch.errorDetail, + pendingPrUrl: patch.pendingPrUrl, + outdatedPluginSlugs: patch.outdatedPluginSlugs, + } + : row, + ), + ); +} + +// Query-options factory exposed as marketplaceQueries.list(...) so route +// clientLoaders can call queryClient.ensureQueryData(marketplaceQueries.list({ orgId })) +// per the frontend data-flow standard. +export const marketplaceQueries = { + list: ({ + orgId, + gateway = marketplaceGateway, + }: { + orgId: OrganizationId | string; + gateway?: IMarketplaceGateway; + }) => + queryOptions({ + queryKey: marketplaceQueryKeys.list(orgId), + queryFn: () => + gateway.listMarketplaces(orgId as OrganizationId) as Promise< + MarketplaceListItem[] + >, + enabled: !!orgId, + }), + distributions: ({ + orgId, + marketplaceId, + gateway = marketplaceGateway, + }: { + orgId: OrganizationId | string; + marketplaceId: MarketplaceId | string; + gateway?: IMarketplaceGateway; + }) => + queryOptions({ + queryKey: marketplaceQueryKeys.distributionList(orgId, marketplaceId), + queryFn: () => + gateway.listDistributions( + orgId as OrganizationId, + marketplaceId as MarketplaceId, + ) as Promise<ListMarketplaceDistributionsResponse>, + enabled: !!orgId && !!marketplaceId, + }), + distributionChanges: ({ + orgId, + marketplaceId, + distributionId, + enabled = true, + gateway = marketplaceGateway, + }: { + orgId: OrganizationId | string; + marketplaceId: MarketplaceId | string; + distributionId: MarketplaceDistributionId | string; + enabled?: boolean; + gateway?: IMarketplaceGateway; + }) => + queryOptions({ + queryKey: marketplaceQueryKeys.distributionChanges( + orgId, + marketplaceId, + distributionId, + ), + queryFn: () => + gateway.getDistributionChanges( + orgId as OrganizationId, + marketplaceId as MarketplaceId, + distributionId as MarketplaceDistributionId, + ) as Promise<GetMarketplaceDistributionChangesResponse>, + enabled: enabled && !!orgId && !!marketplaceId && !!distributionId, + }), + pluginInstalls: ({ + orgId, + marketplaceId, + enabled = true, + gateway = marketplaceGateway, + }: { + orgId: OrganizationId | string; + marketplaceId: MarketplaceId | string; + /** Set to false to defer fetching until e.g. the drill-down drawer is open. */ + enabled?: boolean; + gateway?: IMarketplaceGateway; + }) => + queryOptions({ + queryKey: marketplaceQueryKeys.pluginInstallList(orgId, marketplaceId), + queryFn: () => + gateway.listPluginInstalls( + orgId as OrganizationId, + marketplaceId as MarketplaceId, + ) as Promise<ListMarketplacePluginInstallsResponse>, + enabled: !!orgId && !!marketplaceId && enabled, + // 10-minute staleTime: matches page-level convention; e2e tests must + // call page.reload() after seeding heartbeats for results to appear. + staleTime: 10 * 60 * 1000, + }), +}; + +// Hook variant of marketplaceQueries.pluginInstalls. The `drawerOpen` flag +// defers the fetch until the drill-down drawer is opened so the main table +// doesn't trigger N queries on page load. +export const useMarketplacePluginInstalls = ( + organizationId: OrganizationId | string, + marketplaceId: MarketplaceId | string, + drawerOpen = false, +) => + useQuery( + marketplaceQueries.pluginInstalls({ + orgId: organizationId, + marketplaceId, + enabled: drawerOpen, + }), + ); + +// Hook variant of marketplaceQueries.distributions. Mirrors the existing +// TanStack Query patterns under apps/frontend/src/domain/{domain}/api/queries. +export const useMarketplaceDistributions = ( + organizationId: OrganizationId | string, + marketplaceId: MarketplaceId | string, +) => + useQuery( + marketplaceQueries.distributions({ + orgId: organizationId, + marketplaceId, + }), + ); + +// Hook variant of marketplaceQueries.distributionChanges. Caller passes +// `enabled` so the request only fires when the Changes tab is opened on an +// outdated plugin. +export const useMarketplaceDistributionChanges = ( + organizationId: OrganizationId | string, + marketplaceId: MarketplaceId | string, + distributionId: MarketplaceDistributionId | string, + enabled: boolean, +) => + useQuery( + marketplaceQueries.distributionChanges({ + orgId: organizationId, + marketplaceId, + distributionId, + enabled, + }), + ); + +// Hook variant of marketplaceQueries.list. Mirrors the existing TanStack +// Query patterns under apps/frontend/src/domain/{domain}/api/queries. +export const useMarketplaces = (organizationId: OrganizationId | string) => + useQuery(marketplaceQueries.list({ orgId: organizationId })); + +// Mutation hook for linking a marketplace. Invalidates the marketplace list on +// success and surfaces a success toast. +export const useLinkMarketplace = (organizationId: OrganizationId | string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: LinkMarketplaceRequestBody) => + marketplaceGateway.linkMarketplace( + organizationId as OrganizationId, + body, + ), + onSuccess: async (response) => { + await queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.list(organizationId), + }); + pmToaster.success({ + title: 'Marketplace linked', + description: response.name + ' is now linked to your organization.', + }); + }, + onError: (error) => { + console.error('Error linking marketplace:', error); + }, + }); +}; + +// Mutation hook for unlinking a marketplace. Invalidates the marketplace list +// on success. +export const useUnlinkMarketplace = ( + organizationId: OrganizationId | string, +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (marketplaceId: MarketplaceId) => + marketplaceGateway.unlinkMarketplace( + organizationId as OrganizationId, + marketplaceId, + ), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.list(organizationId), + }); + }, + onError: (error) => { + console.error('Error unlinking marketplace:', error); + }, + }); +}; + +// Debounced URL validation hook used by PublicLinkForm. Caller is expected to +// pass an already-debounced (or empty) url; this hook simply guards execution +// via TanStack Query enabled flag. +export const useValidateMarketplaceUrl = ( + organizationId: OrganizationId | string, + url: string, +) => + useQuery({ + queryKey: marketplaceQueryKeys.validation(organizationId, url), + queryFn: () => + marketplaceGateway.validateMarketplaceUrl( + organizationId as OrganizationId, + url, + ), + enabled: !!organizationId && isLikelyValidMarketplaceUrl(url), + // We want fresh validation each time the user pauses typing on a new URL. + staleTime: 0, + retry: false, + }); + +// Lightweight shape check - keeps the network quiet until the user has at +// least typed something that looks like an HTTP(S) URL. Full validation lives +// in the backend (ValidateMarketplaceUrlUseCase). +function isLikelyValidMarketplaceUrl(url: string): boolean { + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +// Mutation hook: marks a plugin distribution for removal by distributionId. +// Invalidates the distributions list on success so the UI swaps to the +// `to_be_removed` row state. +export const useMarkPluginForRemovalByDistribution = ( + organizationId: OrganizationId | string, + marketplaceId: MarketplaceId | string, +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (distributionId: MarketplaceDistributionId) => + marketplaceGateway.markPluginForRemovalByDistribution( + organizationId as OrganizationId, + marketplaceId as MarketplaceId, + distributionId, + ), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.distributions(), + }); + pmToaster.success({ + title: 'Removal requested', + description: + 'Packmind is preparing the deletion PR on the marketplace repository.', + }); + }, + onError: (error) => { + console.error('Error marking plugin for removal:', error); + pmToaster.error({ + title: 'Could not request removal', + description: 'Please refresh the list and try again.', + }); + }, + }); +}; + +// Mutation hook: marks the latest successful distribution for a Packmind +// package on the target marketplace for removal. Additionally invalidates the +// package detail and deployments queries so the package details page reflects +// the new pending-removal state. +export const useMarkPluginForRemovalByPackage = ( + organizationId: OrganizationId | string, + marketplaceId: MarketplaceId | string, +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (packageId: PackageId) => + marketplaceGateway.markPluginForRemovalByPackage( + organizationId as OrganizationId, + marketplaceId as MarketplaceId, + packageId, + ), + onSuccess: async (_response, packageId) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.distributions(), + }), + queryClient.invalidateQueries({ + queryKey: [...GET_PACKAGE_BY_ID_KEY, packageId], + }), + queryClient.invalidateQueries({ + queryKey: [...LIST_PACKAGE_DEPLOYMENTS_KEY, packageId], + }), + ]); + pmToaster.success({ + title: 'Removal requested', + description: + 'Packmind is preparing the deletion PR on the marketplace repository.', + }); + }, + onError: (error) => { + console.error('Error marking plugin for removal:', error); + pmToaster.error({ + title: 'Could not request removal', + description: 'Please refresh the list and try again.', + }); + }, + }); +}; + +// Mutation hook: triggers an immediate marketplace reconciliation ("Sync now"). +// Refreshes both the marketplace list (state badge + drift panel) and the +// distributions list (so merged deletions flip `to_be_removed` → `removed` +// without waiting for the next scheduled sweep). +export const useSyncMarketplaceNow = ( + organizationId: OrganizationId | string, + marketplaceId: MarketplaceId | string, +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => + marketplaceGateway.syncMarketplaceNow( + organizationId as OrganizationId, + marketplaceId as MarketplaceId, + ), + onSuccess: async (response) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.list(organizationId), + }), + queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.distributions(), + }), + queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.distributionChangesAll(), + }), + ]); + pmToaster.success({ + title: 'Marketplace synced', + description: syncStateDescription(response.state), + }); + }, + onError: (error) => { + console.error('Error syncing marketplace:', error); + pmToaster.error({ + title: 'Sync failed', + description: 'Could not reconcile the marketplace. Please try again.', + }); + }, + }); +}; + +// Human-readable summary of a reconciliation outcome for the "Sync now" toast. +function syncStateDescription(state: MarketplaceState): string { + switch (state) { + case 'healthy': + return 'Everything is up to date.'; + case 'drift': + return 'Drift detected — check the flagged plugins below.'; + case 'unreachable': + return 'The marketplace repository could not be reached.'; + case 'bad_format': + return 'The marketplace descriptor is missing or invalid.'; + default: + return 'Marketplace state refreshed.'; + } +} diff --git a/apps/frontend/src/domain/marketplaces/api/queries/index.ts b/apps/frontend/src/domain/marketplaces/api/queries/index.ts new file mode 100644 index 000000000..f491a3f2d --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/api/queries/index.ts @@ -0,0 +1 @@ +export * from './MarketplaceQueries'; diff --git a/apps/frontend/src/domain/marketplaces/components/AgentsFieldset.spec.tsx b/apps/frontend/src/domain/marketplaces/components/AgentsFieldset.spec.tsx new file mode 100644 index 000000000..a3e920832 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/AgentsFieldset.spec.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { UIProvider } from '@packmind/ui'; +import { AgentsFieldset } from './AgentsFieldset'; + +describe('AgentsFieldset', () => { + it('renders the Claude Code badge for the anthropic vendor', () => { + render( + <UIProvider> + <AgentsFieldset vendor="anthropic" /> + </UIProvider>, + ); + + expect( + screen.getByTestId('agents-fieldset-vendor-anthropic'), + ).toBeInTheDocument(); + expect(screen.getByText('Claude Code')).toBeInTheDocument(); + expect(screen.getByText('Render plugins for')).toBeInTheDocument(); + }); + + it('renders the default helper copy when no override is passed', () => { + render( + <UIProvider> + <AgentsFieldset vendor="anthropic" /> + </UIProvider>, + ); + + expect(screen.getByText(/native format/i)).toBeInTheDocument(); + }); + + it('honors a custom helper copy', () => { + render( + <UIProvider> + <AgentsFieldset vendor="anthropic" helper="Custom helper text." /> + </UIProvider>, + ); + + expect(screen.getByText('Custom helper text.')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/domain/marketplaces/components/AgentsFieldset.tsx b/apps/frontend/src/domain/marketplaces/components/AgentsFieldset.tsx new file mode 100644 index 000000000..9e934d57b --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/AgentsFieldset.tsx @@ -0,0 +1,55 @@ +import { PMBadge, PMHStack, PMText, PMVStack } from '@packmind/ui'; +import type { MarketplaceVendor } from '@packmind/types'; + +export interface AgentsFieldsetProps { + /** + * The marketplace vendor whose agent badge is rendered. v1 only supports + * `'anthropic'` (Claude Code), but the prop accepts the union so we can + * extend without touching the API. + */ + vendor: MarketplaceVendor; + /** + * Helper text shown under the label. Defaults to the v1 copy. + */ + helper?: string; +} + +const VENDOR_LABEL: Record<MarketplaceVendor, string> = { + anthropic: 'Claude Code', +}; + +/** + * Renders the vendor badge for a marketplace's link form. Read-only in v1 + * because only Claude Code is supported — the badge ships as a static + * indicator rather than a selector. + * + * Future-proofed: when additional vendors land, swap the badge for a + * `PMSelect` or checkbox group keyed off the same prop shape. + */ +export const AgentsFieldset = ({ + vendor, + helper = 'Marketplace plugins render in this agent’s native format.', +}: Readonly<AgentsFieldsetProps>) => { + return ( + <PMVStack align="stretch" gap={1}> + <PMText variant="small-important" color="secondary"> + Render plugins for + </PMText> + <PMHStack gap={2} align="center"> + <PMBadge + colorPalette="blue" + size="md" + data-testid={`agents-fieldset-vendor-${vendor}`} + > + {VENDOR_LABEL[vendor]} + </PMBadge> + <PMText variant="small" color="faded"> + Only supported agent in this release. + </PMText> + </PMHStack> + <PMText variant="small" color="faded"> + {helper} + </PMText> + </PMVStack> + ); +}; diff --git a/apps/frontend/src/domain/marketplaces/components/DistributionStatusBadge.spec.tsx b/apps/frontend/src/domain/marketplaces/components/DistributionStatusBadge.spec.tsx new file mode 100644 index 000000000..6b5ec4770 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/DistributionStatusBadge.spec.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { UIProvider } from '@packmind/ui'; +import { DistributionStatus } from '@packmind/types'; +import { DistributionStatusBadge } from './DistributionStatusBadge'; + +describe('DistributionStatusBadge', () => { + const renderBadge = (status: DistributionStatus) => + render( + <UIProvider> + <DistributionStatusBadge status={status} /> + </UIProvider>, + ); + + it.each([ + [DistributionStatus.in_progress, 'In progress'], + [DistributionStatus.pending_merge, 'Pending PR review'], + [DistributionStatus.success, 'Published'], + [DistributionStatus.failure, 'Failed'], + [DistributionStatus.no_changes, 'No changes'], + [DistributionStatus.to_be_removed, 'To be removed'], + [DistributionStatus.removed, 'Removed'], + ])('renders %s with the expected label %s', (status, label) => { + renderBadge(status); + + expect( + screen.getByTestId(`distribution-status-badge-${status}`), + ).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + describe('when a removal has been requested on a still-published row', () => { + it('renders the removal-pending state instead of Published', () => { + render( + <UIProvider> + <DistributionStatusBadge + status={DistributionStatus.success} + removalRequestedAt={new Date('2026-06-10T12:00:00Z')} + /> + </UIProvider>, + ); + + expect( + screen.getByTestId('distribution-status-badge-removal_pending'), + ).toBeInTheDocument(); + expect(screen.getByText('Removal pending')).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/frontend/src/domain/marketplaces/components/DistributionStatusBadge.tsx b/apps/frontend/src/domain/marketplaces/components/DistributionStatusBadge.tsx new file mode 100644 index 000000000..7e896ae27 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/DistributionStatusBadge.tsx @@ -0,0 +1,120 @@ +import { PMBadge, PMHStack, PMIcon, PMTooltip } from '@packmind/ui'; +import { + LuCheck, + LuCircleDashed, + LuCircleX, + LuClock, + LuLoader, + LuTrash2, +} from 'react-icons/lu'; +import { DistributionStatus } from '@packmind/types'; +import type { IconType } from 'react-icons'; + +/** + * Renders a `DistributionStatus` as a WCAG AA badge using both an icon and a + * label (never colour alone). Used by the marketplace details distributions + * table and the package details marketplace-distributions list. + */ +export interface DistributionStatusBadgeProps { + status: DistributionStatus; + /** + * Set while a removal has been requested but the deletion has not yet landed + * on the sync branch (status is still `success`). When present, the badge + * renders a distinct "Removal pending" state so the request is visible + * immediately, before the background job flips the status to `to_be_removed`. + */ + removalRequestedAt?: Date | string | null; +} + +type StatusPresentation = { + label: string; + colorPalette: 'green' | 'orange' | 'red' | 'gray' | 'blue'; + tooltip: string; + icon: IconType; +}; + +// Synthetic presentation for the pre-commit window (status `success` with a +// `removalRequestedAt` marker). Not a real `DistributionStatus` — the status +// only flips to `to_be_removed` once Packmind has opened the deletion PR. +const REMOVAL_PENDING_PRESENTATION: StatusPresentation = { + label: 'Removal pending', + colorPalette: 'orange', + tooltip: + 'Removal requested — Packmind is preparing the deletion PR on the marketplace repo.', + icon: LuClock, +}; + +const STATUS_PRESENTATION: Record<DistributionStatus, StatusPresentation> = { + [DistributionStatus.in_progress]: { + label: 'In progress', + colorPalette: 'blue', + tooltip: 'Publication is running on Packmind.', + icon: LuLoader, + }, + [DistributionStatus.pending_merge]: { + label: 'Pending PR review', + colorPalette: 'blue', + tooltip: + 'The publish landed on the Packmind sync PR — the plugin goes live once the PR is merged on the marketplace repo.', + icon: LuClock, + }, + [DistributionStatus.success]: { + label: 'Published', + colorPalette: 'green', + tooltip: 'Plugin is published on the marketplace.', + icon: LuCheck, + }, + [DistributionStatus.failure]: { + label: 'Failed', + colorPalette: 'red', + tooltip: 'The last publication attempt failed.', + icon: LuCircleX, + }, + [DistributionStatus.no_changes]: { + label: 'No changes', + colorPalette: 'gray', + tooltip: + 'No content changes since the last publication — nothing was pushed.', + icon: LuCircleDashed, + }, + [DistributionStatus.to_be_removed]: { + label: 'To be removed', + colorPalette: 'orange', + tooltip: + 'Marked for removal. Packmind opened the deletion PR on the marketplace repo — merge it to complete removal.', + icon: LuClock, + }, + [DistributionStatus.removed]: { + label: 'Removed', + colorPalette: 'gray', + tooltip: 'Plugin has been removed from the marketplace.', + icon: LuTrash2, + }, +}; + +export const DistributionStatusBadge = ({ + status, + removalRequestedAt, +}: Readonly<DistributionStatusBadgeProps>) => { + const isPendingRemoval = + status === DistributionStatus.success && !!removalRequestedAt; + const presentation = isPendingRemoval + ? REMOVAL_PENDING_PRESENTATION + : STATUS_PRESENTATION[status]; + const testIdStatus = isPendingRemoval ? 'removal_pending' : status; + + return ( + <PMTooltip label={presentation.tooltip}> + <PMBadge + size="sm" + colorPalette={presentation.colorPalette} + data-testid={`distribution-status-badge-${testIdStatus}`} + > + <PMHStack gap={1} align="center"> + <PMIcon as={presentation.icon} aria-hidden="true" /> + <span>{presentation.label}</span> + </PMHStack> + </PMBadge> + </PMTooltip> + ); +}; diff --git a/apps/frontend/src/domain/marketplaces/components/GitNotConnectedNotice.spec.tsx b/apps/frontend/src/domain/marketplaces/components/GitNotConnectedNotice.spec.tsx new file mode 100644 index 000000000..786bfae19 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/GitNotConnectedNotice.spec.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter } from 'react-router'; +import { UIProvider } from '@packmind/ui'; +import { GitNotConnectedNotice } from './GitNotConnectedNotice'; + +describe('GitNotConnectedNotice', () => { + const renderNotice = (orgSlug = 'acme') => + render( + <UIProvider> + <MemoryRouter> + <GitNotConnectedNotice orgSlug={orgSlug} /> + </MemoryRouter> + </UIProvider>, + ); + + it('renders the add-a-Git-connection call to action', () => { + renderNotice('acme'); + + expect(screen.getByTestId('git-not-connected-notice')).toBeInTheDocument(); + expect(screen.getByText('Add a Git connection first')).toBeInTheDocument(); + }); + + it('deep-links to the Git settings page for the org', () => { + renderNotice('acme'); + + const link = screen.getByRole('link', { + name: 'Add a Git connection', + }); + expect(link).toHaveAttribute('href', '/org/acme/settings/git'); + }); +}); diff --git a/apps/frontend/src/domain/marketplaces/components/GitNotConnectedNotice.tsx b/apps/frontend/src/domain/marketplaces/components/GitNotConnectedNotice.tsx new file mode 100644 index 000000000..fcc9a5e13 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/GitNotConnectedNotice.tsx @@ -0,0 +1,40 @@ +import { PMAlert, PMButton, PMHStack, PMVStack } from '@packmind/ui'; +import { Link } from 'react-router'; +import { routes } from '../../../shared/utils/routes'; + +export interface GitNotConnectedNoticeProps { + /** + * Current organization slug — used to build the deep link to the Git + * provider settings page. + */ + orgSlug: string; +} + +/** + * Shown inside `PrivateLinkForm` when the organization has no user-configured + * Git connection yet. Deep-links to the Git settings page so the admin can fix + * the prerequisite without losing the link-marketplace flow context. + */ +export const GitNotConnectedNotice = ({ + orgSlug, +}: Readonly<GitNotConnectedNoticeProps>) => { + return ( + <PMAlert.Root status="info" data-testid="git-not-connected-notice"> + <PMAlert.Indicator /> + <PMVStack align="start" gap={2}> + <PMAlert.Title>Add a Git connection first</PMAlert.Title> + <PMAlert.Description> + Linking a private marketplace requires a Git connection with read + access to the repository. Add one to continue. + </PMAlert.Description> + <PMHStack gap={2} paddingTop={1}> + <PMButton variant="primary" size="sm" asChild> + <Link to={routes.org.toSettingsGit(orgSlug)}> + Add a Git connection + </Link> + </PMButton> + </PMHStack> + </PMVStack> + </PMAlert.Root> + ); +}; diff --git a/apps/frontend/src/domain/marketplaces/components/LinkMarketplacePanel.spec.tsx b/apps/frontend/src/domain/marketplaces/components/LinkMarketplacePanel.spec.tsx new file mode 100644 index 000000000..e0ff1ea65 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/LinkMarketplacePanel.spec.tsx @@ -0,0 +1,102 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UIProvider } from '@packmind/ui'; +import { LinkMarketplacePanel } from './LinkMarketplacePanel'; + +jest.mock('../../git/api/queries', () => ({ + useGetGitProvidersQuery: () => ({ + data: { + providers: [ + { + id: 'provider-1', + source: 'github', + url: 'https://github.com', + hasAuth: true, + displayName: '', + }, + ], + }, + isLoading: false, + }), + useGetAvailableRepositoriesQuery: () => ({ + data: [], + isLoading: false, + isError: false, + }), +})); + +jest.mock('../api/queries', () => ({ + useLinkMarketplace: () => ({ + mutateAsync: jest.fn(), + isPending: false, + }), + useValidateMarketplaceUrl: () => ({ + data: undefined, + isLoading: false, + isFetching: false, + isError: false, + error: undefined, + }), +})); + +function renderPanel() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + <UIProvider> + <QueryClientProvider client={queryClient}> + <MemoryRouter> + <LinkMarketplacePanel organizationId="org-1" orgSlug="acme" /> + </MemoryRouter> + </QueryClientProvider> + </UIProvider>, + ); +} + +describe('LinkMarketplacePanel', () => { + it('renders the "Link a marketplace" trigger button', () => { + renderPanel(); + expect( + screen.getByRole('button', { name: 'Link a marketplace' }), + ).toBeInTheDocument(); + }); + + it('opens the drawer straight onto the private link form', async () => { + renderPanel(); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'Link a marketplace' }), + ); + }); + + expect( + await screen.findByRole('form', { name: 'Link a private marketplace' }), + ).toBeInTheDocument(); + }); + + it('does not expose the public marketplace path', async () => { + renderPanel(); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'Link a marketplace' }), + ); + }); + + await screen.findByRole('form', { name: 'Link a private marketplace' }); + expect( + screen.queryByRole('tab', { name: 'Public' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('form', { name: 'Link a public marketplace' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/domain/marketplaces/components/LinkMarketplacePanel.tsx b/apps/frontend/src/domain/marketplaces/components/LinkMarketplacePanel.tsx new file mode 100644 index 000000000..a20a2f453 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/LinkMarketplacePanel.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { + PMButton, + PMCloseButton, + PMDrawer, + PMHeading, + PMHStack, + PMPortal, + PMText, + pmToaster, + PMVStack, +} from '@packmind/ui'; +import type { OrganizationId } from '@packmind/types'; +import { PrivateLinkForm } from './PrivateLinkForm'; + +export interface LinkMarketplacePanelProps { + organizationId: OrganizationId | string; + /** Org slug used for deep links from the empty-providers branch. */ + orgSlug?: string; +} + +/** + * Drawer entry point for the link-marketplace flow. + * + * The drawer owns the open/close state so the route module stays a thin + * composition. On a successful link, the drawer closes and a success toast + * surfaces; the underlying `useLinkMarketplace` hook already invalidates the + * marketplace list, so the index refreshes on its own. + * + * Only the private (connected-provider) path is exposed. The public-URL path + * (`PublicLinkForm`) is intentionally not rendered: it submits an empty + * `gitProviderId`, which the backend `LinkMarketplaceUseCase` rejects, so the + * flow is a dead end until tokenless-provider resolution lands. The form is + * kept in the tree so the tab can return once the backend supports it. + */ +export const LinkMarketplacePanel = ({ + organizationId, + orgSlug, +}: Readonly<LinkMarketplacePanelProps>) => { + const [open, setOpen] = useState(false); + + const handleLinked = () => { + setOpen(false); + pmToaster.create({ + type: 'success', + title: 'Marketplace linked', + description: + 'Plugins from this marketplace are now available to your organization.', + }); + }; + + return ( + <> + <PMHStack justify="end" paddingY={3}> + <PMButton variant="primary" onClick={() => setOpen(true)}> + Link a marketplace + </PMButton> + </PMHStack> + + <PMDrawer.Root + open={open} + onOpenChange={(event) => setOpen(event.open)} + placement="end" + size="md" + > + <PMPortal> + <PMDrawer.Backdrop /> + <PMDrawer.Positioner> + <PMDrawer.Content> + <PMDrawer.Header borderBottomWidth="1px"> + <PMVStack align="start" gap={1}> + <PMHeading level="h4">Link a marketplace</PMHeading> + <PMText variant="small" color="secondary"> + Point Packmind at a Git repository that hosts a + marketplace.json descriptor. Plugins from that descriptor + become available to your organization. + </PMText> + </PMVStack> + </PMDrawer.Header> + + <PMDrawer.Body paddingTop={4}> + <PrivateLinkForm + organizationId={organizationId} + orgSlug={orgSlug ?? ''} + onLinked={handleLinked} + onCancel={() => setOpen(false)} + /> + </PMDrawer.Body> + + <PMDrawer.CloseTrigger asChild> + <PMCloseButton size="sm" /> + </PMDrawer.CloseTrigger> + </PMDrawer.Content> + </PMDrawer.Positioner> + </PMPortal> + </PMDrawer.Root> + </> + ); +}; diff --git a/apps/frontend/src/domain/marketplaces/components/MarketplaceDetailAlerts.tsx b/apps/frontend/src/domain/marketplaces/components/MarketplaceDetailAlerts.tsx new file mode 100644 index 000000000..c1a0f8881 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/MarketplaceDetailAlerts.tsx @@ -0,0 +1,128 @@ +import { PMAlert, PMBox, PMLink, PMText, PMVStack } from '@packmind/ui'; +import { + DistributionStatus, + type MarketplaceListItem, + type OrganizationId, +} from '@packmind/types'; +import { useMarketplaceDistributions } from '../api/queries'; + +export interface MarketplaceDetailAlertsProps { + organizationId: OrganizationId | string; + marketplace: MarketplaceListItem; +} + +/** + * Inline alert strip for the marketplace detail page. Surfaces conditions a + * user must act on: + * - the marketplace descriptor was edited outside Packmind (warning), + * - an open Packmind sync PR awaiting review (info), + * - pending publishes/removals with no open sync PR (warning). + * + * Per-row outdated-plugin signals live elsewhere (rail / detail pane), so + * they do not appear here. Returns `null` when nothing applies. + */ +export const MarketplaceDetailAlerts = ({ + organizationId, + marketplace, +}: Readonly<MarketplaceDetailAlertsProps>) => { + const pendingPrUrl = marketplace.pendingPrUrl; + const showDriftPanel = marketplace.state === 'drift'; + const driftedPluginSlugs = marketplace.descriptor?.driftedPluginSlugs ?? []; + + const { data: distributions } = useMarketplaceDistributions( + organizationId, + marketplace.id, + ); + const hasPendingSyncChanges = (distributions ?? []).some( + (distribution) => + distribution.status === DistributionStatus.pending_merge || + distribution.status === DistributionStatus.to_be_removed, + ); + const showNoSyncPrPanel = hasPendingSyncChanges && !pendingPrUrl; + + if (!pendingPrUrl && !showNoSyncPrPanel && !showDriftPanel) return null; + + return ( + <PMVStack align="stretch" gap={3}> + {showDriftPanel && ( + <PMAlert.Root status="warning" data-testid="marketplace-drift-panel"> + <PMAlert.Indicator /> + <PMBox> + <PMAlert.Title> + Marketplace descriptor changed outside Packmind + </PMAlert.Title> + <PMAlert.Description> + <PMText variant="small"> + The marketplace descriptor file on the repository was edited + directly — Packmind did not make this change. Confirm the edit + is intentional, or publish a package again to bring the + marketplace back in line with Packmind. + </PMText> + {driftedPluginSlugs.length > 0 && ( + <> + <PMText variant="small" marginTop={2}> + The following plugins were published by Packmind but are no + longer listed in the descriptor: + </PMText> + <PMBox as="ul" marginTop={1} paddingLeft={4}> + {driftedPluginSlugs.map((slug) => ( + <PMBox + as="li" + key={slug} + data-testid={`marketplace-drift-panel-slug-${slug}`} + > + <PMText variant="small">{slug}</PMText> + </PMBox> + ))} + </PMBox> + </> + )} + </PMAlert.Description> + </PMBox> + </PMAlert.Root> + )} + {pendingPrUrl && ( + <PMAlert.Root status="info" data-testid="marketplace-pending-pr-panel"> + <PMAlert.Indicator /> + <PMBox> + <PMAlert.Title>PR waiting for validation</PMAlert.Title> + <PMAlert.Description> + <PMText variant="small"> + A Packmind sync pull request is open on the marketplace repo.{' '} + <PMLink + href={pendingPrUrl} + target="_blank" + rel="noopener noreferrer" + variant="underline" + > + Review the pull request + </PMLink> + </PMText> + </PMAlert.Description> + </PMBox> + </PMAlert.Root> + )} + {showNoSyncPrPanel && ( + <PMAlert.Root + status="warning" + data-testid="marketplace-no-sync-pr-panel" + > + <PMAlert.Indicator /> + <PMBox> + <PMAlert.Title> + Pending changes without an open sync PR + </PMAlert.Title> + <PMAlert.Description> + <PMText variant="small"> + Some publishes or removals are awaiting merge, but no Packmind + sync pull request is open on the marketplace repo — it may have + been closed without merging, or failed to open. Publishing again + reopens it. + </PMText> + </PMAlert.Description> + </PMBox> + </PMAlert.Root> + )} + </PMVStack> + ); +}; diff --git a/apps/frontend/src/domain/marketplaces/components/MarketplaceDetailLayout.tsx b/apps/frontend/src/domain/marketplaces/components/MarketplaceDetailLayout.tsx new file mode 100644 index 000000000..8e37bcf59 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/MarketplaceDetailLayout.tsx @@ -0,0 +1,1491 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link as RouterLink } from 'react-router'; +import { + DEFAULT_FEATURE_DOMAIN_MAP, + MARKETPLACE_PLUGIN_REMOVAL_FEATURE_KEY, + PMBadge, + PMBox, + PMButton, + PMEmptyState, + PMFeatureFlag, + PMHStack, + PMHeading, + PMIcon, + PMInput, + PMLink, + PMMenu, + PMPortal, + PMSpinner, + PMStatus, + PMText, + PMTooltip, + PMVStack, +} from '@packmind/ui'; +import { + DistributionStatus, + type MarketplaceArtifactKind, + type MarketplaceDistributionId, + type MarketplaceDistributionListItem, + type MarketplaceListItem, + type OrganizationId, + type SourcePackageChange, +} from '@packmind/types'; +import { + LuChevronRight, + LuEllipsis, + LuExternalLink, + LuInbox, + LuMinus, + LuPencil, + LuPencilLine, + LuPlug, + LuPlus, + LuRotateCw, + LuSearch, + LuTrash2, + LuTriangleAlert, +} from 'react-icons/lu'; +import type { IconType } from 'react-icons'; +import { + useMarketplaceDistributionChanges, + useMarkPluginForRemovalByDistribution, + useMarketplaceDistributions, + useSyncMarketplaceNow, +} from '../api/queries'; +import { marketplaceQueryKeys } from '../api/queries/MarketplaceQueries'; +import { useQueryClient } from '@tanstack/react-query'; +import { + INVALID_TOKEN_MESSAGE, + mapPublishError, + useMarketplacePublishMutation, +} from '../../deployments/api/queries/useMarketplacePublishMutation'; +import { useAuthContext } from '../../accounts/hooks/useAuthContext'; +import { pmToaster } from '@packmind/ui'; +import { RemovePluginButton } from './RemovePluginButton'; +import { MarketplaceStateBadge } from './MarketplaceStateBadge'; +import { PluginAdoptionTab } from './PluginAdoptionTab'; +import { PluginOverviewTab } from './PluginOverviewTab'; + +const PUBLISH_FAILURE_MESSAGES: Record<string, string> = { + invalid_token: INVALID_TOKEN_MESSAGE, + name_conflict_unmanaged: + 'The package could not be published. Reason: another plugin with the same name already exists on this marketplace.', + descriptor_missing: + 'The package could not be published. Reason: the marketplace descriptor (marketplace.json) is missing or malformed.', + other: + 'The package could not be published. Please try again — if the problem persists, contact an organization admin.', +}; + +export interface MarketplaceDetailLayoutProps { + organizationId: OrganizationId | string; + marketplace: MarketplaceListItem; +} + +type ActiveTab = 'plugins' | 'suggestions'; + +/** + * Marketplace detail body, redesigned around the plugin-centric layout from + * `apps/playground/src/prototypes/marketplace-detail/`. Renders the section + * tabs (Plugins / Suggestions) plus a master-detail surface fed by the + * existing `useMarketplaceDistributions` query — one distribution row per + * package, which the backend already collapses to its latest status. + * + * The Suggestions tab is a placeholder until the backend exposes proposals. + */ +export const MarketplaceDetailLayout = ({ + organizationId, + marketplace, +}: Readonly<MarketplaceDetailLayoutProps>) => { + const { data: distributions, isLoading } = useMarketplaceDistributions( + organizationId, + marketplace.id, + ); + const [tab, setTab] = useState<ActiveTab>('plugins'); + + const items = useMemo(() => distributions ?? [], [distributions]); + const outdatedSlugSet = useMemo( + () => new Set(marketplace.outdatedPluginSlugs ?? []), + [marketplace.outdatedPluginSlugs], + ); + // `version` lives on each PluginRef inside the descriptor, keyed by slug. + // Older descriptors may omit it — callers default to '—' when missing. + const versionBySlug = useMemo(() => { + const map = new Map<string, string>(); + for (const plugin of marketplace.descriptor?.plugins ?? []) { + if (plugin.version) { + map.set(plugin.slug, plugin.version); + } + } + return map; + }, [marketplace.descriptor?.plugins]); + + return ( + <PMVStack gap={4} align="stretch"> + <SectionTabs + active={tab} + onChange={setTab} + pluginCount={items.length} + suggestionsTotal={0} + pendingCount={0} + /> + <ContainerFrame> + {tab === 'plugins' ? ( + <PluginsSurface + organizationId={organizationId} + marketplace={marketplace} + distributions={items} + isLoading={isLoading} + outdatedSlugSet={outdatedSlugSet} + versionBySlug={versionBySlug} + /> + ) : ( + <SuggestionsPlaceholder /> + )} + </ContainerFrame> + </PMVStack> + ); +}; + +interface PluginsSurfaceProps { + organizationId: OrganizationId | string; + marketplace: MarketplaceListItem; + distributions: MarketplaceDistributionListItem[]; + isLoading: boolean; + outdatedSlugSet: Set<string>; + versionBySlug: Map<string, string>; +} + +function PluginsSurface({ + organizationId, + marketplace, + distributions, + isLoading, + outdatedSlugSet, + versionBySlug, +}: Readonly<PluginsSurfaceProps>) { + const [selectedId, setSelectedId] = + useState<MarketplaceDistributionId | null>(null); + + useEffect(() => { + if (distributions.length === 0) { + setSelectedId(null); + return; + } + const stillExists = distributions.some((d) => d.id === selectedId); + if (!stillExists) { + setSelectedId(distributions[0].id); + } + }, [distributions, selectedId]); + + if (isLoading) { + return ( + <PMBox paddingX={6} paddingY={10}> + <PMEmptyState + icon={<PMSpinner />} + title="Loading distributions" + description="Fetching the plugins published to this marketplace." + /> + </PMBox> + ); + } + + if (distributions.length === 0) { + return <EmptyMarketplaceState />; + } + + const selectedDistribution = + distributions.find((d) => d.id === selectedId) ?? distributions[0]; + + return ( + <PMHStack gap={0} align="stretch" minH="640px"> + <PluginMasterRail + distributions={distributions} + selectedId={selectedDistribution.id} + onSelect={setSelectedId} + outdatedSlugSet={outdatedSlugSet} + versionBySlug={versionBySlug} + /> + <PMBox flex="1" minW={0} bg="background.primary"> + <PluginDetailPane + key={selectedDistribution.id} + distribution={selectedDistribution} + marketplaceName={marketplace.name} + organizationId={organizationId} + marketplaceId={marketplace.id} + isOutdated={outdatedSlugSet.has(selectedDistribution.pluginSlug)} + version={versionBySlug.get(selectedDistribution.pluginSlug) ?? null} + /> + </PMBox> + </PMHStack> + ); +} + +interface PluginMasterRailProps { + distributions: MarketplaceDistributionListItem[]; + selectedId: MarketplaceDistributionId; + onSelect: (id: MarketplaceDistributionId) => void; + outdatedSlugSet: Set<string>; + versionBySlug: Map<string, string>; +} + +function PluginMasterRail({ + distributions, + selectedId, + onSelect, + outdatedSlugSet, + versionBySlug, +}: Readonly<PluginMasterRailProps>) { + const [query, setQuery] = useState(''); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return distributions; + return distributions.filter( + (d) => + d.packageName.toLowerCase().includes(q) || + d.pluginSlug.toLowerCase().includes(q) || + d.authorName.toLowerCase().includes(q) || + (d.space?.name.toLowerCase().includes(q) ?? false), + ); + }, [distributions, query]); + + return ( + <PMBox + width="320px" + flexShrink={0} + bg="background.primary" + borderRightWidth="1px" + borderColor="border.tertiary" + display="flex" + flexDirection="column" + minH={0} + > + <PMVStack + gap={2} + paddingX={3} + paddingY={3} + align="stretch" + borderBottomWidth="1px" + borderColor="border.tertiary" + > + <PMHStack justify="space-between" align="center"> + <PMText + fontSize="11px" + color="faded" + textTransform="uppercase" + letterSpacing="wider" + fontWeight="semibold" + > + Plugins + </PMText> + <PMText + fontSize="11px" + color="faded" + fontVariantNumeric="tabular-nums" + > + {distributions.length} + </PMText> + </PMHStack> + <PMBox position="relative"> + <PMBox + position="absolute" + left="10px" + top="50%" + transform="translateY(-50%)" + color="text.faded" + pointerEvents="none" + display="flex" + alignItems="center" + > + <PMIcon fontSize="sm"> + <LuSearch /> + </PMIcon> + </PMBox> + <PMInput + placeholder="Filter plugins" + value={query} + onChange={(e) => setQuery(e.target.value)} + size="sm" + paddingLeft="32px" + /> + </PMBox> + </PMVStack> + + <PMBox flex="1" overflow="auto" minH={0}> + {filtered.length === 0 ? ( + <PMVStack gap={2} align="start" padding={4}> + <PMText fontSize="xs" color="secondary"> + No plugins match “{query}”. + </PMText> + <PMBox + as="button" + fontSize="xs" + color="branding.primary" + bg="transparent" + border="none" + cursor="pointer" + padding={0} + onClick={() => setQuery('')} + > + Clear filter + </PMBox> + </PMVStack> + ) : ( + filtered.map((d) => ( + <PluginRailRow + key={d.id} + distribution={d} + selected={d.id === selectedId} + outdated={outdatedSlugSet.has(d.pluginSlug)} + version={versionBySlug.get(d.pluginSlug) ?? null} + onSelect={() => onSelect(d.id)} + /> + )) + )} + </PMBox> + </PMBox> + ); +} + +interface PluginRailRowProps { + distribution: MarketplaceDistributionListItem; + selected: boolean; + outdated: boolean; + version: string | null; + onSelect: () => void; +} + +function PluginRailRow({ + distribution, + selected, + outdated, + version, + onSelect, +}: Readonly<PluginRailRowProps>) { + return ( + <PMBox + as="button" + onClick={onSelect} + width="100%" + textAlign="left" + bg={selected ? 'background.secondary' : 'transparent'} + border="none" + cursor="pointer" + paddingY={2.5} + paddingLeft={3} + paddingRight={3} + position="relative" + borderBottom="1px solid" + borderColor="border.tertiary" + transition="background-color 120ms ease-out" + _hover={selected ? undefined : { bg: 'background.tertiary' }} + aria-pressed={selected} + aria-label={ + version + ? `Plugin ${distribution.packageName} version ${version}` + : `Plugin ${distribution.packageName}` + } + > + {selected && ( + <PMBox + position="absolute" + left={0} + top={0} + bottom={0} + width="2px" + bg="branding.primary" + aria-hidden + /> + )} + <PMVStack gap={1} align="stretch"> + <PMHStack gap={2} align="center" justify="space-between"> + <PMText + fontSize="sm" + fontWeight={selected ? 'semibold' : 'medium'} + color="primary" + truncate + > + {distribution.packageName || distribution.pluginSlug} + </PMText> + <PMHStack gap={1.5} align="center" flexShrink={0}> + {outdated && ( + <PMTooltip label="Built from a package that changed since it was last published. Republish to update."> + <PMBox + width="6px" + height="6px" + borderRadius="full" + bg="orange.500" + cursor="help" + aria-label="Update available" + flexShrink={0} + /> + </PMTooltip> + )} + <PMText + fontSize="xs" + color="faded" + fontVariantNumeric="tabular-nums" + > + {version ? `v${version}` : 'v—'} + </PMText> + </PMHStack> + </PMHStack> + <PMHStack gap={2} align="center"> + {distribution.space ? ( + <PMBadge size="sm" maxW="100%" minW={0}> + <PMStatus.Root + colorPalette={distribution.space.color} + flexShrink={0} + > + <PMStatus.Indicator /> + </PMStatus.Root> + <PMBox as="span" truncate> + {distribution.space.name} + </PMBox> + </PMBadge> + ) : ( + <PMText fontSize="xs" color="faded"> + space removed + </PMText> + )} + </PMHStack> + </PMVStack> + </PMBox> + ); +} + +interface PluginDetailPaneProps { + distribution: MarketplaceDistributionListItem; + marketplaceName: string; + organizationId: OrganizationId | string; + marketplaceId: MarketplaceListItem['id']; + isOutdated: boolean; + version: string | null; +} + +type DetailTabId = 'overview' | 'changes' | 'adoption'; + +function PluginDetailPane({ + distribution, + marketplaceName, + organizationId, + marketplaceId, + isOutdated, + version, +}: Readonly<PluginDetailPaneProps>) { + const [tab, setTab] = useState<DetailTabId>('overview'); + const tabsAnchorId = `plugin-detail-tabs-${distribution.id}`; + + // Eager fetch when the plugin is outdated so the connector pill can show + // the change count (e.g. "3 changes ready") and the "View N changes" CTA + // without waiting on the user to open the Changes tab. The Changes tab + // reuses the same query under the hood — TanStack Query dedupes by key. + const changesQuery = useMarketplaceDistributionChanges( + organizationId, + marketplaceId, + distribution.id, + isOutdated, + ); + const changesCount = changesQuery.data?.changes.length ?? 0; + + const handleViewChanges = () => { + setTab('changes'); + document + .getElementById(tabsAnchorId) + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + return ( + <PMBox paddingX={8} paddingY={6} maxW="960px"> + <PMVStack gap={5} align="stretch"> + <PMVStack gap={2} align="start"> + <PMHeading size="lg" color="primary"> + {distribution.packageName || distribution.pluginSlug} + </PMHeading> + <PMHStack gap={2} align="center" wrap="wrap"> + {distribution.space ? ( + <PMBadge size="md"> + <PMStatus.Root + colorPalette={distribution.space.color} + flexShrink={0} + > + <PMStatus.Indicator /> + </PMStatus.Root> + {distribution.space.name} + </PMBadge> + ) : ( + <PMText fontSize="xs" color="faded"> + space removed + </PMText> + )} + <PMText fontSize="xs" color="faded" aria-hidden> + · + </PMText> + <PMText fontSize="xs" color="faded"> + {formatPublicationStatus(distribution)} + </PMText> + </PMHStack> + </PMVStack> + + <VersionTrail + version={version} + isOutdated={isOutdated} + changesCount={changesCount} + onViewChanges={handleViewChanges} + /> + + <PMBox id={tabsAnchorId} scrollMarginTop="16px"> + <DetailTabs active={tab} onChange={setTab} /> + </PMBox> + + {tab === 'overview' && ( + <PluginOverviewTab + organizationId={organizationId} + packageSlug={distribution.packageSlug} + /> + )} + + {tab === 'changes' && ( + <ChangesTab + organizationId={organizationId} + marketplaceId={marketplaceId} + distribution={distribution} + marketplaceName={marketplaceName} + isOutdated={isOutdated} + active={tab === 'changes'} + /> + )} + + {tab === 'adoption' && ( + <PluginAdoptionTab + organizationId={organizationId} + marketplaceId={marketplaceId} + pluginSlug={distribution.pluginSlug} + publishedVersion={version} + active={tab === 'adoption'} + /> + )} + </PMVStack> + </PMBox> + ); +} + +interface ChangesTabProps { + organizationId: OrganizationId | string; + marketplaceId: MarketplaceListItem['id']; + distribution: MarketplaceDistributionListItem; + marketplaceName: string; + isOutdated: boolean; + active: boolean; +} + +function ChangesTab({ + organizationId, + marketplaceId, + distribution, + marketplaceName, + isOutdated, + active, +}: Readonly<ChangesTabProps>) { + const { user } = useAuthContext(); + const queryClient = useQueryClient(); + const { data, isLoading, isError } = useMarketplaceDistributionChanges( + organizationId, + marketplaceId, + distribution.id, + active && isOutdated, + ); + const markMutation = useMarkPluginForRemovalByDistribution( + organizationId, + marketplaceId, + ); + const publishMutation = useMarketplacePublishMutation(); + + const pendingRemoval = + !!distribution.removalRequestedAt || + distribution.status === DistributionStatus.to_be_removed; + const removable = + distribution.status === DistributionStatus.success || + distribution.status === DistributionStatus.pending_merge; + + const changes = data?.changes ?? []; + const publishState = data?.state; + const prUrl = data?.prUrl ?? null; + // Publish is offered when the marketplace can usefully accept new content: + // either the package has never reached the rolling sync PR (outdated_main) + // or the source has drifted further since the PR was opened (outdated_pr). + // The other two states either have nothing to publish or are already + // covered by an open PR — see SourcePackagePublishState in the contract. + const canPublish = + publishState === 'outdated_main' || publishState === 'outdated_pr'; + const canViewPr = + (publishState === 'in_sync_pr' || publishState === 'outdated_pr') && + !!prUrl; + + const handlePublish = async () => { + try { + const response = await publishMutation.mutateAsync({ + organizationId: organizationId as OrganizationId, + marketplaceId, + packageId: distribution.packageId, + }); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.distributions(), + }), + queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.list(organizationId), + }), + queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.distributionChangesAll(), + }), + ]); + pmToaster.success({ + title: 'Publish started', + description: `Packmind is publishing "${distribution.packageName}" as "${response.pluginSlug}". You'll see the pull request on the marketplace repository shortly.`, + }); + } catch (error) { + const reason = mapPublishError(error); + pmToaster.error({ + title: 'Publish failed', + description: + PUBLISH_FAILURE_MESSAGES[reason] ?? PUBLISH_FAILURE_MESSAGES.other, + }); + } + }; + + const footer = ( + <PMHStack gap={2} align="center"> + {canPublish && ( + <PMButton + variant="primary" + size="sm" + loading={publishMutation.isPending} + onClick={handlePublish} + > + <PMIcon fontSize="sm"> + <LuRotateCw /> + </PMIcon> + Publish + </PMButton> + )} + {!canPublish && canViewPr && prUrl && ( + <PMButton + variant="primary" + size="sm" + onClick={() => window.open(prUrl, '_blank', 'noopener,noreferrer')} + > + <PMIcon fontSize="sm"> + <LuExternalLink /> + </PMIcon> + View pull request + </PMButton> + )} + <PMFeatureFlag + featureKeys={[MARKETPLACE_PLUGIN_REMOVAL_FEATURE_KEY]} + featureDomainMap={DEFAULT_FEATURE_DOMAIN_MAP} + userEmail={user?.email} + > + {removable && !pendingRemoval && ( + <RemovePluginButton + pluginSlug={distribution.pluginSlug} + packageName={distribution.packageName} + marketplaceName={marketplaceName} + onMark={() => markMutation.mutate(distribution.id)} + isMarking={ + markMutation.isPending && + (markMutation.variables as MarketplaceDistributionId) === + distribution.id + } + /> + )} + </PMFeatureFlag> + </PMHStack> + ); + + const body = (() => { + if (!isOutdated) { + return <ChangesInSync />; + } + if (isLoading) { + return ( + <PMHStack gap={2} align="center"> + <PMSpinner size="sm" /> + <PMText fontSize="sm" color="secondary"> + Loading changes… + </PMText> + </PMHStack> + ); + } + if (isError) { + return ( + <PMText fontSize="sm" color="error"> + Could not load changes. Refresh the page and try again. + </PMText> + ); + } + if (changes.length === 0) { + return <ChangesInSync />; + } + const countLabel = + changes.length === 1 ? '1 change' : `${changes.length} changes`; + return ( + <PMVStack gap={4} align="stretch"> + <PMText fontSize="md" color="primary" fontWeight="medium"> + {countLabel} ready to publish + </PMText> + <PMVStack gap={2.5} align="stretch"> + {changes.map((change) => ( + <ChangeRow + key={`${change.artifactKind}-${change.slug}-${change.kind}`} + change={change} + /> + ))} + </PMVStack> + </PMVStack> + ); + })(); + + return ( + <PMVStack gap={5} align="stretch"> + {body} + {footer} + </PMVStack> + ); +} + +function ChangesInSync() { + return ( + <PMHStack gap={2} align="center"> + <PMBox + width="6px" + height="6px" + borderRadius="full" + bg="green.500" + aria-hidden + /> + <PMText fontSize="sm" color="secondary"> + In sync with the marketplace. + </PMText> + </PMHStack> + ); +} + +const CHANGE_STYLE: Record< + SourcePackageChange['kind'], + { Icon: IconType; color: string; verb: string } +> = { + added: { Icon: LuPlus, color: 'green.500', verb: 'Added' }, + removed: { Icon: LuMinus, color: 'red.500', verb: 'Removed' }, + updated: { Icon: LuPencil, color: 'text.secondary', verb: 'Updated' }, +}; + +const ARTIFACT_KIND_LABEL: Record<MarketplaceArtifactKind, string> = { + command: 'Command', + standard: 'Standard', + skill: 'Skill', +}; + +function ChangeRow({ change }: Readonly<{ change: SourcePackageChange }>) { + const { Icon, color, verb } = CHANGE_STYLE[change.kind]; + return ( + <PMBox + display="grid" + gridTemplateColumns="16px 96px 1fr auto" + alignItems="center" + columnGap={3} + > + <PMBox + as="span" + display="inline-flex" + alignItems="center" + justifyContent="center" + color={color} + aria-label={verb} + > + <PMIcon fontSize="sm"> + <Icon /> + </PMIcon> + </PMBox> + <PMBox> + <PMBadge size="sm" colorPalette="gray"> + {ARTIFACT_KIND_LABEL[change.artifactKind]} + </PMBadge> + </PMBox> + <PMText fontSize="sm" color="primary" fontWeight="medium" truncate> + {change.name} + </PMText> + <ChangeVersionBadge change={change} /> + </PMBox> + ); +} + +function ChangeVersionBadge({ + change, +}: Readonly<{ change: SourcePackageChange }>) { + if (change.kind === 'added') { + return ( + <PMBadge size="sm" colorPalette="green"> + v{change.currentVersion} + </PMBadge> + ); + } + if (change.kind === 'removed') { + return ( + <PMBadge size="sm" colorPalette="red"> + v{change.publishedVersion} + </PMBadge> + ); + } + return ( + <PMHStack gap={2} align="center"> + <PMBadge size="sm" colorPalette="gray"> + v{change.publishedVersion} + </PMBadge> + <PMText fontSize="xs" color="faded" aria-hidden> + → + </PMText> + <PMBadge size="sm" colorPalette="orange"> + v{change.currentVersion} + </PMBadge> + </PMHStack> + ); +} + +interface DetailTabsProps { + active: DetailTabId; + onChange: (next: DetailTabId) => void; +} + +/** + * Tab strip inside the plugin detail pane. Overview, Changes and Adoption are + * wired today; Settings from the prototype will join once the backend exposes + * the data it needs. + */ +function DetailTabs({ active, onChange }: Readonly<DetailTabsProps>) { + return ( + <PMHStack + gap={6} + align="center" + borderBottom="1px solid" + borderColor="border.tertiary" + > + <DetailTabButton + active={active === 'overview'} + onClick={() => onChange('overview')} + > + Overview + </DetailTabButton> + <DetailTabButton + active={active === 'changes'} + onClick={() => onChange('changes')} + > + Changes + </DetailTabButton> + <DetailTabButton + active={active === 'adoption'} + onClick={() => onChange('adoption')} + > + Adoption + </DetailTabButton> + </PMHStack> + ); +} + +interface DetailTabButtonProps { + active: boolean; + onClick: () => void; + children: React.ReactNode; +} + +function DetailTabButton({ + active, + onClick, + children, +}: Readonly<DetailTabButtonProps>) { + return ( + <PMBox + as="button" + onClick={onClick} + bg="transparent" + border="none" + cursor="pointer" + paddingY={3} + paddingX={0} + fontSize="sm" + fontWeight="medium" + color={active ? 'text.primary' : 'text.faded'} + borderBottom="2px solid" + borderColor={active ? 'branding.primary' : 'transparent'} + marginBottom="-1px" + transition="color 150ms ease-out" + _hover={active ? undefined : { color: 'text.primary' }} + aria-pressed={active} + > + {children} + </PMBox> + ); +} + +interface VersionTrailProps { + version: string | null; + isOutdated: boolean; + changesCount: number; + onViewChanges: () => void; +} + +/** + * Simplified version trail: a "changes ready" drift indicator on the left and + * the marketplace-published tier on the right. The prototype also ships a + * Curated tier (source-package version) and an Installed tier (repo adoption) + * with a second connector; both are gated on backend data we do not have yet + * and will join the trail later. + */ +function VersionTrail({ + version, + isOutdated, + changesCount, + onViewChanges, +}: Readonly<VersionTrailProps>) { + return ( + <PMBox + display="grid" + gridTemplateColumns="minmax(140px, auto) 1fr" + alignItems="start" + columnGap={4} + paddingY={5} + borderTopWidth="1px" + borderBottomWidth="1px" + borderColor="border.tertiary" + > + <TrailConnector + isOutdated={isOutdated} + changesCount={changesCount} + onViewChanges={onViewChanges} + /> + <TrailTier + label="Published" + value={version ? `v${version}` : 'v—'} + sub="in marketplace" + /> + </PMBox> + ); +} + +interface TrailTierProps { + label: string; + value: string; + sub: string; +} + +function TrailTier({ label, value, sub }: Readonly<TrailTierProps>) { + return ( + <PMVStack gap={1} align="start" minW="100px"> + <PMText + fontSize="11px" + color="faded" + textTransform="uppercase" + letterSpacing="wider" + fontWeight="semibold" + > + {label} + </PMText> + <PMText + fontSize="md" + color="primary" + fontWeight="medium" + fontVariantNumeric="tabular-nums" + > + {value} + </PMText> + <PMText fontSize="11px" color="faded"> + {sub} + </PMText> + </PMVStack> + ); +} + +interface TrailConnectorProps { + isOutdated: boolean; + changesCount: number; + onViewChanges: () => void; +} + +function TrailConnector({ + isOutdated, + changesCount, + onViewChanges, +}: Readonly<TrailConnectorProps>) { + const driftLabel = + changesCount === 0 + ? 'Changes ready' + : changesCount === 1 + ? '1 change ready' + : `${changesCount} changes ready`; + const linkLabel = + changesCount === 0 + ? 'View changes' + : changesCount === 1 + ? 'View 1 change' + : `View ${changesCount} changes`; + + return ( + <PMVStack gap={2} align="center" minW="120px" paddingTop="18px"> + <PMIcon + fontSize="lg" + color={isOutdated ? 'orange.500' : 'text.faded'} + aria-hidden + > + <LuChevronRight /> + </PMIcon> + {isOutdated ? ( + <PMVStack gap={1} align="center"> + <PMHStack gap={1.5} align="center"> + <PMIcon fontSize="xs" color="orange.500"> + <LuTriangleAlert /> + </PMIcon> + <PMText + fontSize="xs" + color="primary" + fontWeight="medium" + fontVariantNumeric="tabular-nums" + > + {driftLabel} + </PMText> + </PMHStack> + <PMBox + as="button" + fontSize="11px" + color="branding.primary" + bg="transparent" + border="none" + cursor="pointer" + padding={0} + onClick={onViewChanges} + transition="color 120ms ease-out" + _hover={{ color: 'blue.300' }} + _focusVisible={{ + outline: '2px solid', + outlineColor: 'branding.primary', + outlineOffset: '2px', + borderRadius: 'sm', + }} + > + {linkLabel} → + </PMBox> + </PMVStack> + ) : ( + <PMHStack gap={1.5} align="center"> + <PMBox + width="6px" + height="6px" + borderRadius="full" + bg="green.500" + aria-hidden + /> + <PMText fontSize="xs" color="faded"> + in sync + </PMText> + </PMHStack> + )} + </PMVStack> + ); +} + +function EmptyMarketplaceState() { + return ( + <PMBox paddingX={10} paddingY={12}> + <PMVStack gap={5} align="start" maxW="560px"> + <PMBox + width="44px" + height="44px" + borderRadius="md" + bg="background.tertiary" + display="flex" + alignItems="center" + justifyContent="center" + color="branding.primary" + > + <PMIcon fontSize="lg"> + <LuPlug /> + </PMIcon> + </PMBox> + <PMVStack gap={2} align="start"> + <PMText fontSize="md" fontWeight="semibold" color="primary"> + No plugins yet + </PMText> + <PMText fontSize="sm" color="secondary" lineHeight={1.55}> + A plugin bundles the standards, commands, and skills this + marketplace publishes to consuming repos. Publish a package from a + space to see it appear here. + </PMText> + </PMVStack> + </PMVStack> + </PMBox> + ); +} + +function SuggestionsPlaceholder() { + return ( + <PMHStack gap={0} align="stretch" minH="640px"> + <PMBox + width="320px" + flexShrink={0} + bg="background.primary" + borderRightWidth="1px" + borderColor="border.tertiary" + display="flex" + flexDirection="column" + > + <PMVStack + gap={2} + paddingX={3} + paddingY={3} + align="stretch" + borderBottomWidth="1px" + borderColor="border.tertiary" + > + <PMText + fontSize="11px" + color="faded" + textTransform="uppercase" + letterSpacing="wider" + fontWeight="semibold" + > + Suggestions + </PMText> + </PMVStack> + </PMBox> + <PMBox flex="1" minW={0} paddingX={10} paddingY={12}> + <PMVStack gap={5} align="start" maxW="560px"> + <PMBox + width="44px" + height="44px" + borderRadius="md" + bg="background.tertiary" + display="flex" + alignItems="center" + justifyContent="center" + color="branding.primary" + > + <PMIcon fontSize="lg"> + <LuInbox /> + </PMIcon> + </PMBox> + <PMVStack gap={2} align="start"> + <PMText fontSize="md" fontWeight="semibold" color="primary"> + No suggestions yet + </PMText> + <PMText fontSize="sm" color="secondary" lineHeight={1.55}> + When a space member proposes a plugin for this marketplace, it + appears here for your review. Backend support for this flow is not + in place yet — this surface is a placeholder. + </PMText> + </PMVStack> + </PMVStack> + </PMBox> + </PMHStack> + ); +} + +interface SectionTabsProps { + active: ActiveTab; + onChange: (next: ActiveTab) => void; + pluginCount: number; + suggestionsTotal: number; + pendingCount: number; +} + +function SectionTabs({ + active, + onChange, + pluginCount, + suggestionsTotal, + pendingCount, +}: Readonly<SectionTabsProps>) { + return ( + <PMHStack + gap={6} + align="center" + borderBottom="1px solid" + borderColor="border.tertiary" + paddingX={1} + > + <TabButton + active={active === 'plugins'} + onClick={() => onChange('plugins')} + label="Plugins" + count={pluginCount} + countAccent={false} + /> + <TabButton + active={active === 'suggestions'} + onClick={() => onChange('suggestions')} + label="Suggestions" + count={suggestionsTotal} + countAccent={pendingCount > 0} + countLabel={pendingCount > 0 ? `${pendingCount} pending` : undefined} + /> + </PMHStack> + ); +} + +interface TabButtonProps { + active: boolean; + onClick: () => void; + label: string; + count: number; + countAccent: boolean; + countLabel?: string; +} + +function TabButton({ + active, + onClick, + label, + count, + countAccent, + countLabel, +}: Readonly<TabButtonProps>) { + return ( + <PMBox + as="button" + onClick={onClick} + bg="transparent" + border="none" + cursor="pointer" + paddingY={3} + paddingX={0} + borderBottom="2px solid" + borderColor={active ? 'branding.primary' : 'transparent'} + marginBottom="-1px" + transition="color 150ms ease-out" + _hover={active ? undefined : { color: 'text.primary' }} + aria-pressed={active} + aria-label={countLabel ? `${label}, ${countLabel}` : label} + > + <PMHStack gap={2} align="center"> + <PMText + fontSize="sm" + fontWeight="medium" + color={active ? 'primary' : 'secondary'} + > + {label} + </PMText> + <PMBox + paddingX="6px" + paddingY="1px" + borderRadius="sm" + bg={countAccent ? 'branding.primary' : 'background.tertiary'} + color={countAccent ? 'beige.1000' : 'text.faded'} + fontSize="11px" + fontWeight="semibold" + fontVariantNumeric="tabular-nums" + lineHeight="1.4" + > + {count} + </PMBox> + </PMHStack> + </PMBox> + ); +} + +function ContainerFrame({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + <PMBox + bg="background.primary" + borderWidth="1px" + borderColor="border.tertiary" + borderRadius="md" + overflow="hidden" + > + {children} + </PMBox> + ); +} + +function formatPublishedAt(value: Date | string | null | undefined): string { + if (!value) return '—'; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + + const diffMinutes = Math.round((Date.now() - date.getTime()) / 60000); + if (diffMinutes < 1) return 'Just now'; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + + const diffHours = Math.round(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + + const diffDays = Math.round(diffHours / 24); + if (diffDays < 30) return `${diffDays}d ago`; + + return date.toLocaleDateString(); +} + +// Picks the right sentence for the publication-state line in the detail +// header. Reads `lastPublishedOnMainAt` (the most recent successful publish +// for this package on this marketplace) rather than the row's own +// `publishConfirmedAt`, so a re-publish whose latest row is still in PR +// keeps showing the prior on-main date instead of falling back to "—". +function formatPublicationStatus( + distribution: MarketplaceDistributionListItem, +): string { + if (distribution.lastPublishedOnMainAt) { + return `last published ${formatPublishedAt(distribution.lastPublishedOnMainAt)}`; + } + if (distribution.status === DistributionStatus.pending_merge) { + return 'Still waiting for PR validation'; + } + return 'last published —'; +} + +export interface MarketplaceDetailBacklinkProps { + to: string; +} + +export function MarketplaceDetailBacklink({ + to, +}: Readonly<MarketplaceDetailBacklinkProps>) { + return ( + <PMLink + asChild + variant="plain" + aria-label="Back to marketplaces" + color="text.faded" + _hover={{ color: 'text.primary' }} + > + <RouterLink to={to}> + <PMHStack gap="6px" align="center"> + <PMIcon fontSize="sm"> + <LuChevronRight style={{ transform: 'rotate(180deg)' }} /> + </PMIcon> + <PMText fontSize="sm" color="faded"> + Marketplaces + </PMText> + </PMHStack> + </RouterLink> + </PMLink> + ); +} + +export interface MarketplaceDetailHeaderActionsProps { + organizationId: OrganizationId | string; + marketplace: MarketplaceListItem; +} + +/** + * Page-level actions for the marketplace detail route. Sync is wired to the + * existing `useSyncMarketplaceNow` mutation; "Open on GitHub" links to the + * backing repo when known; rename/delete are placeholders pending dedicated + * use cases. + */ +export function MarketplaceDetailHeaderActions({ + organizationId, + marketplace, +}: Readonly<MarketplaceDetailHeaderActionsProps>) { + const syncMarketplace = useSyncMarketplaceNow(organizationId, marketplace.id); + const repoUrl = marketplace.repository?.url ?? null; + + // State chip surfaces only when the marketplace is unreachable or its + // descriptor is unparseable — actionable, repo-level failures the user must + // see at the page top. Drift (descriptor edited outside Packmind) is shown + // as a banner above the package list by `MarketplaceDetailAlerts`, where it + // can carry a proper explanation and the list of affected plugins. Healthy + // is the default and adds no information. + const showStateBadge = + marketplace.state === 'unreachable' || marketplace.state === 'bad_format'; + + return ( + <PMHStack gap={3} align="center"> + {showStateBadge && ( + <MarketplaceStateBadge + state={marketplace.state} + errorKind={marketplace.errorKind} + errorDetail={marketplace.errorDetail} + /> + )} + <PMButton + variant="primary" + size="sm" + loading={syncMarketplace.isPending} + onClick={() => syncMarketplace.mutate()} + data-testid="marketplace-sync-now" + > + <PMIcon fontSize="sm"> + <LuRotateCw /> + </PMIcon> + Sync + </PMButton> + <PMButton + variant="secondary" + size="sm" + disabled={!repoUrl} + onClick={() => { + if (repoUrl) { + window.open(repoUrl, '_blank', 'noopener,noreferrer'); + } + }} + > + <PMIcon fontSize="sm"> + <LuExternalLink /> + </PMIcon> + Open on GitHub + </PMButton> + <OverflowMenu /> + </PMHStack> + ); +} + +function OverflowMenu() { + return ( + <PMMenu.Root positioning={{ placement: 'bottom-end' }}> + <PMMenu.Trigger asChild> + <PMBox + as="button" + width="32px" + height="32px" + display="inline-flex" + alignItems="center" + justifyContent="center" + bg="transparent" + border="1px solid" + borderColor="border.tertiary" + borderRadius="sm" + color="text.secondary" + cursor="pointer" + aria-label="More actions" + > + <PMIcon fontSize="sm"> + <LuEllipsis /> + </PMIcon> + </PMBox> + </PMMenu.Trigger> + <PMPortal> + <PMMenu.Positioner> + <PMMenu.Content minWidth="220px"> + <PMMenu.Item value="rename" cursor="not-allowed" disabled> + <PMHStack gap={2} flex={1} align="center"> + <PMIcon fontSize="sm" color="text.faded"> + <LuPencilLine /> + </PMIcon> + <PMText fontSize="xs" color="primary"> + Rename marketplace + </PMText> + </PMHStack> + </PMMenu.Item> + <PMMenu.Item value="delete" cursor="not-allowed" disabled> + <PMHStack gap={2} flex={1} align="center"> + <PMIcon fontSize="sm" color="red.500"> + <LuTrash2 /> + </PMIcon> + <PMText fontSize="xs" color="error"> + Delete marketplace + </PMText> + </PMHStack> + </PMMenu.Item> + </PMMenu.Content> + </PMMenu.Positioner> + </PMPortal> + </PMMenu.Root> + ); +} diff --git a/apps/frontend/src/domain/marketplaces/components/MarketplaceRow.spec.tsx b/apps/frontend/src/domain/marketplaces/components/MarketplaceRow.spec.tsx new file mode 100644 index 000000000..bfa5fc13b --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/MarketplaceRow.spec.tsx @@ -0,0 +1,288 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter } from 'react-router'; +import { UIProvider } from '@packmind/ui'; +import { + createGitProviderId, + type MarketplaceId, + type MarketplaceListItem, +} from '@packmind/types'; +import { + MarketplaceRow, + RowActionsMenu, + __testables__, +} from './MarketplaceRow'; + +const baseMarketplace: MarketplaceListItem = { + id: 'mkt-1' as MarketplaceId, + organizationId: 'org-1' as MarketplaceListItem['organizationId'], + gitRepoId: 'repo-1' as MarketplaceListItem['gitRepoId'], + name: 'Acme Playbook', + vendor: 'anthropic', + addedBy: 'user-1' as MarketplaceListItem['addedBy'], + linkedAt: new Date('2026-04-01T10:00:00.000Z'), + state: 'healthy', + lastValidatedAt: new Date(Date.now() - 30 * 60 * 1000), + descriptor: { + vendor: 'anthropic', + name: 'Acme Playbook', + plugins: [], + raw: {}, + }, + pluginCount: 7, + createdAt: new Date('2026-04-01T10:00:00.000Z'), + updatedAt: new Date('2026-04-01T10:00:00.000Z'), + deletedAt: null, + addedByUserName: 'Jane Admin', + repository: { + gitProviderId: createGitProviderId('provider-1'), + owner: 'acme', + repo: 'plugins', + branch: 'main', + providerSource: 'github', + url: 'https://github.com/acme/plugins', + }, +}; + +const renderRow = ( + overrides: Partial<MarketplaceListItem> = {}, + rowProps: { + orgSlug?: string; + isUnlinking?: boolean; + isRefreshing?: boolean; + } = {}, +) => { + const onUnlink = jest.fn(); + render( + <UIProvider> + <MemoryRouter> + <MarketplaceRow + marketplace={{ ...baseMarketplace, ...overrides }} + onUnlink={onUnlink} + isUnlinking={rowProps.isUnlinking ?? false} + isRefreshing={rowProps.isRefreshing ?? false} + orgSlug={rowProps.orgSlug} + /> + </MemoryRouter> + </UIProvider>, + ); + return { onUnlink }; +}; + +describe('MarketplaceRow', () => { + it('renders the marketplace name as a link to the details route when orgSlug is set', () => { + renderRow({}, { orgSlug: 'acme' }); + + const link = screen.getByRole('link', { name: /Acme Playbook/ }); + expect(link).toHaveAttribute('href', '/org/acme/marketplaces/mkt-1'); + }); + + it('shows the plugin count with the correct plural', () => { + renderRow(); + expect(screen.getByText('7 plugins')).toBeInTheDocument(); + }); + + it('uses the singular noun when the marketplace has one plugin', () => { + renderRow({ pluginCount: 1 }); + expect(screen.getByText('1 plugin')).toBeInTheDocument(); + }); + + it('renders the refreshing spinner when the row is being refreshed', () => { + renderRow({}, { isRefreshing: true }); + expect(screen.getByLabelText('Checking marketplace')).toBeInTheDocument(); + }); + + it('does not render the state dot when the marketplace is healthy and up to date', () => { + renderRow(); + expect( + screen.queryByTestId('marketplace-state-dot-healthy'), + ).not.toBeInTheDocument(); + }); + + it('renders the orange state dot when the marketplace is unreachable', () => { + renderRow({ state: 'unreachable' }); + expect( + screen.getByTestId('marketplace-state-dot-unreachable'), + ).toBeInTheDocument(); + }); + + it('does not render the state dot when the marketplace is drifting', () => { + renderRow({ state: 'drift' }); + expect( + screen.queryByTestId('marketplace-state-dot-drift'), + ).not.toBeInTheDocument(); + }); + + it('renders the outdated indicator with the drifted plugin count when the marketplace is drifting', () => { + renderRow({ + state: 'drift', + descriptor: { + vendor: 'anthropic', + name: 'Acme Playbook', + plugins: [], + raw: {}, + driftedPluginSlugs: ['plugin-a', 'plugin-b'], + }, + }); + expect(screen.getByText('2 outdated')).toBeInTheDocument(); + }); + + it('renders the outdated indicator with the upstream plugin count when plugins are outdated', () => { + renderRow({ outdatedPluginSlugs: ['plugin-a', 'plugin-b', 'plugin-c'] }); + expect(screen.getByText('3 outdated')).toBeInTheDocument(); + }); + + it('does not render the outdated indicator for a healthy marketplace', () => { + renderRow(); + expect(screen.queryByText(/outdated/i)).not.toBeInTheDocument(); + }); + + it('renders the repo actions trigger labelled with the owner/repo', () => { + renderRow(); + expect( + screen.getByRole('button', { + name: 'Repository actions for acme/plugins', + }), + ).toBeInTheDocument(); + }); + + it('renders a dash for the repository when the backing repository is missing', () => { + renderRow({ repository: null }); + expect( + screen.queryByRole('button', { name: /Repository actions/ }), + ).not.toBeInTheDocument(); + // The repository cell and the coverage placeholder both render an em-dash. + expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1); + }); +}); + +describe('RowActionsMenu', () => { + const renderMenu = (overrides: { isUnlinking?: boolean } = {}) => { + const onUnlink = jest.fn(); + render( + <UIProvider> + <RowActionsMenu + marketplace={baseMarketplace} + onUnlink={onUnlink} + isUnlinking={overrides.isUnlinking ?? false} + /> + </UIProvider>, + ); + return { onUnlink }; + }; + + it('exposes an accessible trigger labelled with the marketplace name', () => { + renderMenu(); + expect( + screen.getByRole('button', { name: 'Actions for Acme Playbook' }), + ).toBeInTheDocument(); + }); + + it('invokes onUnlink when the user confirms the dialog from the menu', async () => { + const { onUnlink } = renderMenu(); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'Actions for Acme Playbook' }), + ); + }); + + const unlinkItem = await screen.findByText('Unlink marketplace'); + await act(async () => { + fireEvent.click(unlinkItem); + }); + + const confirmButton = await screen.findByRole('button', { + name: 'Unlink', + }); + await act(async () => { + fireEvent.click(confirmButton); + }); + + expect(onUnlink).toHaveBeenCalledWith('mkt-1'); + }); +}); + +describe('vendorLabel', () => { + const { vendorLabel } = __testables__; + + it('maps anthropic to "Claude Code"', () => { + expect(vendorLabel('anthropic')).toBe('Claude Code'); + }); + + it('returns the raw vendor when unknown', () => { + expect(vendorLabel('future-vendor')).toBe('future-vendor'); + }); +}); + +describe('providerLabel', () => { + const { providerLabel } = __testables__; + + it('maps github to "GitHub"', () => { + expect(providerLabel('github')).toBe('GitHub'); + }); + + it('maps gitlab to "GitLab"', () => { + expect(providerLabel('gitlab')).toBe('GitLab'); + }); + + it('falls back to "provider" for unrecognized sources', () => { + expect(providerLabel('unknown')).toBe('provider'); + }); +}); + +describe('deriveSshUrl', () => { + const { deriveSshUrl } = __testables__; + + it('derives a GitHub SSH URL from the web URL', () => { + expect(deriveSshUrl('https://github.com/acme/plugins')).toBe( + 'git@github.com:acme/plugins.git', + ); + }); + + it('strips a trailing .git before re-adding it', () => { + expect(deriveSshUrl('https://github.com/acme/plugins.git')).toBe( + 'git@github.com:acme/plugins.git', + ); + }); + + it('handles GitLab subgroup paths', () => { + expect(deriveSshUrl('https://gitlab.com/acme/team/plugins')).toBe( + 'git@gitlab.com:acme/team/plugins.git', + ); + }); + + it('returns null for an empty URL', () => { + expect(deriveSshUrl('')).toBeNull(); + }); + + it('returns null for a non-HTTP URL', () => { + expect(deriveSshUrl('git@github.com:acme/plugins.git')).toBeNull(); + }); +}); + +describe('normalizeHttps', () => { + const { normalizeHttps } = __testables__; + + it('appends .git when missing', () => { + expect(normalizeHttps('https://github.com/acme/plugins')).toBe( + 'https://github.com/acme/plugins.git', + ); + }); + + it('keeps a single trailing .git when already present', () => { + expect(normalizeHttps('https://github.com/acme/plugins.git')).toBe( + 'https://github.com/acme/plugins.git', + ); + }); + + it('returns the raw URL when it is not HTTP(S)', () => { + expect(normalizeHttps('git@github.com:acme/plugins.git')).toBe( + 'git@github.com:acme/plugins.git', + ); + }); + + it('returns null for an empty URL', () => { + expect(normalizeHttps('')).toBeNull(); + }); +}); diff --git a/apps/frontend/src/domain/marketplaces/components/MarketplaceRow.tsx b/apps/frontend/src/domain/marketplaces/components/MarketplaceRow.tsx new file mode 100644 index 000000000..7b616fb2e --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/MarketplaceRow.tsx @@ -0,0 +1,487 @@ +import { useEffect, useState, type MouseEvent } from 'react'; +import { Link } from 'react-router'; +import { + PMBox, + PMConfirmationModal, + PMHStack, + PMIcon, + PMLink, + PMMenu, + PMPortal, + PMSpinner, + PMText, + PMTooltip, + PMVStack, +} from '@packmind/ui'; +import { + LuEllipsis, + LuExternalLink, + LuLink, + LuTerminal, + LuTrash2, + LuTriangleAlert, +} from 'react-icons/lu'; +import { SiClaude } from 'react-icons/si'; +import type { IconType } from 'react-icons'; +import type { MarketplaceId, MarketplaceListItem } from '@packmind/types'; +import { MarketplaceStateDot } from './MarketplaceStateDot'; + +export interface MarketplaceRowProps { + marketplace: MarketplaceListItem; + onUnlink: (marketplaceId: MarketplaceId) => void; + isUnlinking: boolean; + /** + * When true, the row's state cell shows an inline spinner indicating its + * live state is being refreshed on page open. + */ + isRefreshing?: boolean; + /** + * Organization slug used to build the link to the marketplace details + * sub-route. When omitted, the marketplace name renders as plain text. + */ + orgSlug?: string; +} + +export const MarketplaceRow = ({ + marketplace, + onUnlink, + isUnlinking, + isRefreshing = false, + orgSlug, +}: Readonly<MarketplaceRowProps>) => { + const detailsHref = orgSlug + ? `/org/${orgSlug}/marketplaces/${marketplace.id}` + : undefined; + + return ( + <PMHStack + gap={5} + paddingX={4} + paddingY={3} + borderBottom="1px solid" + borderColor="border.tertiary" + align="center" + transition="background-color 150ms ease-out" + _hover={{ bg: 'background.secondary' }} + data-testid={`marketplace-row-${marketplace.id}`} + > + <PMVStack flex={1} minW={0} gap={1} align="start"> + <NameCell marketplace={marketplace} detailsHref={detailsHref} /> + <PMHStack gap={2} align="center" minW={0}> + <RepositoryCell marketplace={marketplace} /> + <MarketplaceStateDot + state={marketplace.state} + errorKind={marketplace.errorKind} + errorDetail={marketplace.errorDetail} + /> + {isRefreshing && ( + <PMSpinner size="xs" aria-label="Checking marketplace" /> + )} + </PMHStack> + </PMVStack> + + <PMVStack width="280px" flexShrink={0} gap={1} align="start" minW={0}> + <PMHStack gap={2} align="center"> + <PMText variant="small" fontWeight="medium"> + {marketplace.pluginCount} plugin + {marketplace.pluginCount === 1 ? '' : 's'} + </PMText> + <PMText variant="small" color="faded"> + · + </PMText> + <VendorBadge vendor={marketplace.vendor} /> + </PMHStack> + <OutdatedIndicator marketplace={marketplace} /> + </PMVStack> + + <PMBox width="180px" flexShrink={0} textAlign="right"> + <PMText variant="small" color="faded"> + — + </PMText> + </PMBox> + + <PMHStack gap={0} flexShrink={0} width="64px" justify="end"> + <RowActionsMenu + marketplace={marketplace} + onUnlink={onUnlink} + isUnlinking={isUnlinking} + /> + </PMHStack> + </PMHStack> + ); +}; + +const OutdatedIndicator = ({ + marketplace, +}: Readonly<{ marketplace: MarketplaceListItem }>) => { + const driftCount = marketplace.descriptor?.driftedPluginSlugs?.length ?? 0; + const outdatedCount = marketplace.outdatedPluginSlugs?.length ?? 0; + const isDrift = marketplace.state === 'drift'; + + if (!isDrift && outdatedCount === 0) { + return null; + } + + const count = Math.max(driftCount, outdatedCount); + const tooltip = + count > 0 + ? `${count} plugin${count === 1 ? '' : 's'} need to be published again.` + : 'Some plugins need to be published again.'; + + return ( + <PMTooltip label={tooltip} showArrow openDelay={200}> + <PMHStack gap={1.5} align="center" cursor="help"> + <PMIcon fontSize="11px" color="orange.500"> + <LuTriangleAlert /> + </PMIcon> + <PMText + variant="small" + color="warning" + fontWeight="medium" + fontVariantNumeric="tabular-nums" + > + {count > 0 ? `${count} outdated` : 'Outdated'} + </PMText> + </PMHStack> + </PMTooltip> + ); +}; + +const NameCell = ({ + marketplace, + detailsHref, +}: Readonly<{ marketplace: MarketplaceListItem; detailsHref?: string }>) => { + if (detailsHref) { + return ( + <PMLink asChild> + <Link to={detailsHref}> + <PMText variant="body-important">{marketplace.name}</PMText> + </Link> + </PMLink> + ); + } + return <PMText variant="body-important">{marketplace.name}</PMText>; +}; + +const RepositoryCell = ({ + marketplace, +}: Readonly<{ marketplace: MarketplaceListItem }>) => { + const repository = marketplace.repository; + if (!repository) { + return ( + <PMText variant="small" color="faded"> + — + </PMText> + ); + } + + return ( + <RepositoryMenu + ownerRepo={`${repository.owner}/${repository.repo}`} + webUrl={repository.url} + providerSource={repository.providerSource} + /> + ); +}; + +interface RepositoryMenuProps { + ownerRepo: string; + webUrl: string; + providerSource: string; +} + +const RepositoryMenu = ({ + ownerRepo, + webUrl, + providerSource, +}: Readonly<RepositoryMenuProps>) => { + const [feedback, setFeedback] = useState<string | null>(null); + + const httpsUrl = normalizeHttps(webUrl); + const sshUrl = deriveSshUrl(webUrl); + const providerName = providerLabel(providerSource); + + useEffect(() => { + if (!feedback) return; + const id = setTimeout(() => setFeedback(null), 1500); + return () => clearTimeout(id); + }, [feedback]); + + const copy = (value: string, label: string) => { + void navigator.clipboard?.writeText(value); + setFeedback(label); + }; + + return ( + <PMMenu.Root positioning={{ placement: 'bottom-start' }}> + <PMMenu.Trigger asChild> + <PMBox + as="button" + bg="transparent" + border="none" + padding={0} + textAlign="left" + cursor="pointer" + fontSize="xs" + color={feedback ? 'green.500' : 'text.faded'} + fontFamily="mono" + transition="color 150ms ease-out" + _hover={{ color: feedback ? 'green.500' : 'text.secondary' }} + _focusVisible={{ + outline: '2px solid', + outlineColor: 'branding.primary', + outlineOffset: '2px', + borderRadius: 'sm', + }} + onClick={(e: MouseEvent) => e.stopPropagation()} + aria-label={`Repository actions for ${ownerRepo}`} + > + {feedback ? `${feedback} copied` : ownerRepo} + </PMBox> + </PMMenu.Trigger> + <PMPortal> + <PMMenu.Positioner> + <PMMenu.Content minWidth="260px"> + {sshUrl && ( + <PMMenu.Item + value="copy-ssh" + cursor="pointer" + onClick={() => copy(sshUrl, 'SSH URL')} + > + <PMHStack gap={2} flex={1} align="center"> + <PMIcon fontSize="sm" color="text.faded"> + <LuTerminal /> + </PMIcon> + <PMVStack gap={0} align="start" flex={1} minW={0}> + <PMText variant="small">Copy SSH URL</PMText> + <PMText + fontSize="10px" + color="faded" + fontFamily="mono" + truncate + > + {sshUrl} + </PMText> + </PMVStack> + </PMHStack> + </PMMenu.Item> + )} + {httpsUrl && ( + <PMMenu.Item + value="copy-https" + cursor="pointer" + onClick={() => copy(httpsUrl, 'HTTPS URL')} + > + <PMHStack gap={2} flex={1} align="center"> + <PMIcon fontSize="sm" color="text.faded"> + <LuLink /> + </PMIcon> + <PMVStack gap={0} align="start" flex={1} minW={0}> + <PMText variant="small">Copy HTTPS URL</PMText> + <PMText + fontSize="10px" + color="faded" + fontFamily="mono" + truncate + > + {httpsUrl} + </PMText> + </PMVStack> + </PMHStack> + </PMMenu.Item> + )} + {webUrl && ( + <PMMenu.Item + value="open-browser" + cursor="pointer" + onClick={() => + window.open(webUrl, '_blank', 'noopener,noreferrer') + } + > + <PMHStack gap={2} flex={1} align="center"> + <PMIcon fontSize="sm" color="text.faded"> + <LuExternalLink /> + </PMIcon> + <PMText variant="small">Open on {providerName}</PMText> + </PMHStack> + </PMMenu.Item> + )} + </PMMenu.Content> + </PMMenu.Positioner> + </PMPortal> + </PMMenu.Root> + ); +}; + +const VENDOR_ICON: Record<string, IconType> = { + anthropic: SiClaude, +}; + +const VendorBadge = ({ vendor }: Readonly<{ vendor: string }>) => { + const Icon = VENDOR_ICON[vendor]; + const label = vendorLabel(vendor); + + if (!Icon) { + return ( + <PMText variant="small" color="faded"> + {label} + </PMText> + ); + } + + return ( + <PMTooltip label={label} showArrow openDelay={200}> + <PMBox + display="inline-flex" + alignItems="center" + justifyContent="center" + bg="background.tertiary" + color="text.secondary" + width="20px" + height="18px" + borderRadius="sm" + aria-label={label} + > + <PMIcon fontSize="11px"> + <Icon /> + </PMIcon> + </PMBox> + </PMTooltip> + ); +}; + +interface RowActionsMenuProps { + marketplace: MarketplaceListItem; + onUnlink: (marketplaceId: MarketplaceId) => void; + isUnlinking: boolean; +} + +export const RowActionsMenu = ({ + marketplace, + onUnlink, + isUnlinking, +}: Readonly<RowActionsMenuProps>) => { + const [confirmOpen, setConfirmOpen] = useState(false); + + const handleConfirm = () => { + onUnlink(marketplace.id); + setConfirmOpen(false); + }; + + return ( + <> + <PMMenu.Root positioning={{ placement: 'bottom-end' }}> + <PMMenu.Trigger asChild> + <PMBox + as="button" + width="32px" + height="32px" + display="flex" + alignItems="center" + justifyContent="center" + bg="transparent" + border="none" + borderRadius="sm" + color="text.faded" + cursor="pointer" + aria-label={`Actions for ${marketplace.name}`} + _hover={{ color: 'text.primary', bg: 'background.tertiary' }} + _focusVisible={{ + outline: '2px solid', + outlineColor: 'branding.primary', + outlineOffset: '1px', + }} + onClick={(e: MouseEvent) => e.stopPropagation()} + > + <PMIcon fontSize="sm"> + <LuEllipsis /> + </PMIcon> + </PMBox> + </PMMenu.Trigger> + <PMPortal> + <PMMenu.Positioner> + <PMMenu.Content minWidth="180px"> + <PMMenu.Item + value="unlink" + cursor="pointer" + color="red.500" + disabled={isUnlinking} + onClick={() => setConfirmOpen(true)} + > + <PMHStack gap={2} flex={1} align="center"> + <PMIcon fontSize="sm"> + <LuTrash2 /> + </PMIcon> + <PMText variant="small">Unlink marketplace</PMText> + </PMHStack> + </PMMenu.Item> + </PMMenu.Content> + </PMMenu.Positioner> + </PMPortal> + </PMMenu.Root> + <PMConfirmationModal + trigger={<span />} + title="Unlink marketplace" + message={`Unlink "${marketplace.name}" from this organization? The underlying Git repository will not be touched.`} + confirmText="Unlink" + confirmColorScheme="red" + open={confirmOpen} + onOpenChange={({ open }) => setConfirmOpen(open)} + onConfirm={handleConfirm} + isLoading={isUnlinking} + /> + </> + ); +}; + +function vendorLabel(vendor: string): string { + switch (vendor) { + case 'anthropic': + return 'Claude Code'; + default: + return vendor; + } +} + +function providerLabel(source: string): string { + switch (source) { + case 'github': + return 'GitHub'; + case 'gitlab': + return 'GitLab'; + default: + return 'provider'; + } +} + +/** + * Strips a trailing `.git` if present and re-adds it. Returns the canonical + * HTTPS URL we suggest copying. Falls back to the raw URL when it does not + * look like an HTTP(S) URL. + */ +function normalizeHttps(webUrl: string): string | null { + if (!webUrl) return null; + if (!/^https?:\/\//.test(webUrl)) return webUrl; + return webUrl.replace(/\.git$/, '') + '.git'; +} + +/** + * Best-effort conversion of the repository's web URL to its SSH clone URL. + * Works for `https://github.com/owner/repo` and the equivalent GitLab / + * Bitbucket URLs — i.e. the providers that surface SSH as `git@host:owner/repo.git`. + * Returns `null` for non-HTTP(S) URLs (we already render those raw via the + * HTTPS copy entry). + */ +function deriveSshUrl(webUrl: string): string | null { + if (!webUrl) return null; + const match = webUrl.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?\/?$/); + if (!match) return null; + return `git@${match[1]}:${match[2]}.git`; +} + +export const __testables__ = { + vendorLabel, + providerLabel, + normalizeHttps, + deriveSshUrl, +}; diff --git a/apps/frontend/src/domain/marketplaces/components/MarketplaceStateBadge.spec.tsx b/apps/frontend/src/domain/marketplaces/components/MarketplaceStateBadge.spec.tsx new file mode 100644 index 000000000..48badf870 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/MarketplaceStateBadge.spec.tsx @@ -0,0 +1,122 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { UIProvider } from '@packmind/ui'; +import { MarketplaceStateBadge } from './MarketplaceStateBadge'; + +describe('MarketplaceStateBadge', () => { + const renderBadge = (state: 'healthy' | 'drift' | 'unreachable') => + render( + <UIProvider> + <MarketplaceStateBadge state={state} /> + </UIProvider>, + ); + + it('renders the healthy label with the green palette', () => { + renderBadge('healthy'); + + expect( + screen.getByTestId('marketplace-state-badge-healthy'), + ).toBeInTheDocument(); + expect(screen.getByText('Healthy')).toBeInTheDocument(); + }); + + it('renders the drift label with the orange palette', () => { + renderBadge('drift'); + + expect( + screen.getByTestId('marketplace-state-badge-drift'), + ).toBeInTheDocument(); + expect(screen.getByText('Drift')).toBeInTheDocument(); + }); + + it('renders the unreachable label with the red palette', () => { + renderBadge('unreachable'); + + expect( + screen.getByTestId('marketplace-state-badge-unreachable'), + ).toBeInTheDocument(); + expect(screen.getByText('Unreachable')).toBeInTheDocument(); + }); + + it('still renders the drift badge when drifted plugin slugs are provided', () => { + render( + <UIProvider> + <MarketplaceStateBadge + state="drift" + driftedPluginSlugs={['orphan-plugin', 'another-drift']} + /> + </UIProvider>, + ); + + expect( + screen.getByTestId('marketplace-state-badge-drift'), + ).toBeInTheDocument(); + expect(screen.getByText('Drift')).toBeInTheDocument(); + }); + + it('renders the drift badge when drifted plugin slugs is empty', () => { + render( + <UIProvider> + <MarketplaceStateBadge state="drift" driftedPluginSlugs={[]} /> + </UIProvider>, + ); + + expect( + screen.getByTestId('marketplace-state-badge-drift'), + ).toBeInTheDocument(); + }); + + describe('when unreachable with a typed errorKind', () => { + it('labels an auth failure "Auth error"', () => { + render( + <UIProvider> + <MarketplaceStateBadge state="unreachable" errorKind="auth_failed" /> + </UIProvider>, + ); + + expect(screen.getByText('Auth error')).toBeInTheDocument(); + }); + + it('labels a missing repo "Repo not found"', () => { + render( + <UIProvider> + <MarketplaceStateBadge + state="unreachable" + errorKind="repo_not_found" + /> + </UIProvider>, + ); + + expect(screen.getByText('Repo not found')).toBeInTheDocument(); + }); + }); + + describe('when the marketplace has outdated plugins', () => { + it('renders the outdated badge alongside the state badge', () => { + render( + <UIProvider> + <MarketplaceStateBadge + state="healthy" + outdatedPluginSlugs={['plugin-one']} + /> + </UIProvider>, + ); + + expect( + screen.getByTestId('marketplace-outdated-badge'), + ).toBeInTheDocument(); + }); + + it('does not render the outdated badge when there are no outdated slugs', () => { + render( + <UIProvider> + <MarketplaceStateBadge state="healthy" outdatedPluginSlugs={[]} /> + </UIProvider>, + ); + + expect( + screen.queryByTestId('marketplace-outdated-badge'), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/frontend/src/domain/marketplaces/components/MarketplaceStateBadge.tsx b/apps/frontend/src/domain/marketplaces/components/MarketplaceStateBadge.tsx new file mode 100644 index 000000000..3280e309f --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/MarketplaceStateBadge.tsx @@ -0,0 +1,165 @@ +import { PMBadge, PMHStack, PMText, PMTooltip, PMVStack } from '@packmind/ui'; +import type { MarketplaceErrorKind, MarketplaceState } from '@packmind/types'; + +/** + * Maps a `MarketplaceState` to the visual presentation used everywhere the + * marketplace health surfaces (list row, detail header, banners). + * + * The reconciliation job (`MarketplaceReconciliationDelayedJob`) is the source + * of truth for these states — this component only renders. + */ +export interface MarketplaceStateBadgeProps { + state: MarketplaceState; + /** + * Sub-classification of an `unreachable` state. When set, the badge label + * and tooltip switch to a credential / repo-gone / transient message. + */ + errorKind?: MarketplaceErrorKind | null; + /** Server-provided, PII-safe failure detail; preferred as the tooltip text when present. */ + errorDetail?: string | null; + /** + * When the marketplace is in `drift`, the slugs whose distribution shows + * `success` on Packmind but whose entry is missing from the marketplace + * descriptor. Surfaced inside the tooltip so the user can scan offending + * plugins without leaving the list view. + */ + driftedPluginSlugs?: string[]; + /** + * Slugs whose served plugin is built from a package that changed upstream + * since last publish. When non-empty, an additional "Outdated" badge renders. + */ + outdatedPluginSlugs?: string[] | null; +} + +const ERROR_KIND_PRESENTATION: Record< + MarketplaceErrorKind, + { label: string; tooltip: string } +> = { + auth_failed: { + label: 'Auth error', + tooltip: + 'The marketplace credentials are invalid or expired. Reconnect the Git provider.', + }, + repo_not_found: { + label: 'Repo not found', + tooltip: + 'The marketplace repository could not be found. It may have been deleted or renamed.', + }, + network_transient: { + label: 'Unreachable', + tooltip: 'The marketplace repository is temporarily unreachable.', + }, +}; + +type StatePresentation = { + label: string; + colorPalette: 'green' | 'orange' | 'red'; + tooltip: string; +}; + +const STATE_PRESENTATION: Record<MarketplaceState, StatePresentation> = { + healthy: { + label: 'Healthy', + colorPalette: 'green', + tooltip: 'Descriptor matches the last successful check.', + }, + drift: { + label: 'Drift', + colorPalette: 'orange', + tooltip: + 'Descriptor changed since it was linked. Review the marketplace to confirm the update.', + }, + unreachable: { + label: 'Unreachable', + colorPalette: 'red', + tooltip: + 'The marketplace repository could not be reached on the last check.', + }, + bad_format: { + label: 'Bad format', + colorPalette: 'red', + tooltip: + 'The marketplace descriptor is missing or unparseable. Fix marketplace.json to publish again.', + }, +}; + +export const MarketplaceStateBadge = ({ + state, + errorKind, + errorDetail, + driftedPluginSlugs, + outdatedPluginSlugs, +}: Readonly<MarketplaceStateBadgeProps>) => { + const presentation = STATE_PRESENTATION[state]; + + // An `unreachable` marketplace refines its label + tooltip from the typed + // errorKind (credential vs repo-gone vs transient). Other states keep the + // default state presentation. + const errorPresentation = + state === 'unreachable' && errorKind + ? ERROR_KIND_PRESENTATION[errorKind] + : null; + + const label = errorPresentation?.label ?? presentation.label; + const baseTooltipText = errorPresentation + ? (errorDetail ?? errorPresentation.tooltip) + : presentation.tooltip; + + const showDriftList = + state === 'drift' && + Array.isArray(driftedPluginSlugs) && + driftedPluginSlugs.length > 0; + + const isOutdated = + Array.isArray(outdatedPluginSlugs) && outdatedPluginSlugs.length > 0; + + const tooltipLabel = showDriftList ? ( + <PMVStack align="start" gap={1}> + <PMText variant="small">{baseTooltipText}</PMText> + <PMText variant="small" fontWeight="medium"> + Drifted plugins: + </PMText> + <PMVStack align="start" gap={0}> + {driftedPluginSlugs!.map((slug) => ( + <PMText + key={slug} + variant="small" + fontFamily="mono" + data-testid={`marketplace-state-badge-drift-slug-${slug}`} + > + {slug} + </PMText> + ))} + </PMVStack> + </PMVStack> + ) : ( + baseTooltipText + ); + + return ( + <PMHStack gap={1} align="center" justify="center"> + <PMTooltip label={tooltipLabel}> + <PMBadge + size="sm" + colorPalette={presentation.colorPalette} + data-testid={`marketplace-state-badge-${state}`} + > + {label} + </PMBadge> + </PMTooltip> + {isOutdated && ( + <PMTooltip + label={`Outdated — ${outdatedPluginSlugs!.length} plugin(s) changed upstream since last publish`} + > + <PMBadge + size="sm" + colorPalette="yellow" + data-testid="marketplace-outdated-badge" + > + Outdated + </PMBadge> + </PMTooltip> + )} + </PMHStack> + ); +}; diff --git a/apps/frontend/src/domain/marketplaces/components/MarketplaceStateDot.tsx b/apps/frontend/src/domain/marketplaces/components/MarketplaceStateDot.tsx new file mode 100644 index 000000000..fb7526e42 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/MarketplaceStateDot.tsx @@ -0,0 +1,86 @@ +import { PMBox, PMText, PMTooltip, PMVStack } from '@packmind/ui'; +import type { MarketplaceErrorKind, MarketplaceState } from '@packmind/types'; + +export interface MarketplaceStateDotProps { + state: MarketplaceState; + errorKind?: MarketplaceErrorKind | null; + errorDetail?: string | null; +} + +type DotPresentation = { label: string; tooltip: string }; + +const STATE_PRESENTATION: Partial<Record<MarketplaceState, DotPresentation>> = { + unreachable: { + label: 'Repository unreachable', + tooltip: + 'The marketplace repository could not be reached on the last check.', + }, + bad_format: { + label: 'Bad descriptor format', + tooltip: + 'The marketplace descriptor is missing or unparseable. Fix marketplace.json to publish again.', + }, +}; + +const ERROR_KIND_PRESENTATION: Record<MarketplaceErrorKind, DotPresentation> = { + auth_failed: { + label: 'Authentication failed', + tooltip: + 'The marketplace credentials are invalid or expired. Reconnect the Git provider.', + }, + repo_not_found: { + label: 'Repository not found', + tooltip: + 'The marketplace repository could not be found. It may have been deleted or renamed.', + }, + network_transient: { + label: 'Repository unreachable', + tooltip: 'The marketplace repository is temporarily unreachable.', + }, +}; + +export const MarketplaceStateDot = ({ + state, + errorKind, + errorDetail, +}: Readonly<MarketplaceStateDotProps>) => { + const presentation = + state === 'unreachable' && errorKind + ? ERROR_KIND_PRESENTATION[errorKind] + : STATE_PRESENTATION[state]; + + if (!presentation) { + return null; + } + + const tooltipText = + state === 'unreachable' && errorKind && errorDetail + ? errorDetail + : presentation.tooltip; + + const tooltipLabel = ( + <PMVStack align="start" gap={0.5}> + <PMText variant="small" fontWeight="medium"> + {presentation.label} + </PMText> + <PMText variant="small">{tooltipText}</PMText> + </PMVStack> + ); + + return ( + <PMTooltip label={tooltipLabel} showArrow openDelay={150}> + <PMBox + as="span" + display="inline-flex" + width="8px" + height="8px" + borderRadius="full" + bg="orange.500" + cursor="help" + role="status" + aria-label={presentation.label} + data-testid={`marketplace-state-dot-${state}`} + /> + </PMTooltip> + ); +}; diff --git a/apps/frontend/src/domain/marketplaces/components/MarketplacesIndex.spec.tsx b/apps/frontend/src/domain/marketplaces/components/MarketplacesIndex.spec.tsx new file mode 100644 index 000000000..ce665d47f --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/MarketplacesIndex.spec.tsx @@ -0,0 +1,130 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { UIProvider } from '@packmind/ui'; +import { + createGitProviderId, + type MarketplaceId, + type MarketplaceListItem, +} from '@packmind/types'; +import { MarketplacesIndex } from './MarketplacesIndex'; + +const buildMarketplace = ( + overrides: Partial<MarketplaceListItem> = {}, +): MarketplaceListItem => ({ + id: 'mkt-1' as MarketplaceId, + organizationId: 'org-1' as MarketplaceListItem['organizationId'], + gitRepoId: 'repo-1' as MarketplaceListItem['gitRepoId'], + name: 'Acme Playbook', + vendor: 'anthropic', + addedBy: 'user-1' as MarketplaceListItem['addedBy'], + linkedAt: new Date('2026-04-01T10:00:00.000Z'), + state: 'healthy', + lastValidatedAt: new Date(Date.now() - 30 * 60 * 1000), + descriptor: { + vendor: 'anthropic', + name: 'Acme Playbook', + plugins: [], + raw: {}, + }, + pluginCount: 7, + errorKind: null, + errorDetail: null, + pendingPrUrl: null, + outdatedPluginSlugs: null, + createdAt: new Date('2026-04-01T10:00:00.000Z'), + updatedAt: new Date('2026-04-01T10:00:00.000Z'), + deletedAt: null, + addedByUserName: 'Jane Admin', + repository: { + gitProviderId: createGitProviderId('provider-1'), + owner: 'acme', + repo: 'plugins', + branch: 'main', + providerSource: 'github', + url: 'https://github.com/acme/plugins', + }, + ...overrides, +}); + +describe('MarketplacesIndex', () => { + const renderIndex = (props: { + marketplaces?: MarketplaceListItem[]; + isLoading?: boolean; + refreshingIds?: ReadonlySet<MarketplaceId>; + }) => + render( + <UIProvider> + <MarketplacesIndex + marketplaces={props.marketplaces ?? []} + isLoading={props.isLoading ?? false} + refreshingIds={props.refreshingIds} + onUnlink={jest.fn()} + /> + </UIProvider>, + ); + + it('renders the loading state when isLoading is true', () => { + renderIndex({ isLoading: true }); + + expect(screen.getByText('Loading marketplaces')).toBeInTheDocument(); + }); + + it('renders the empty state when there are no marketplaces', () => { + renderIndex({ marketplaces: [] }); + + expect(screen.getByText('Link your first marketplace')).toBeInTheDocument(); + }); + + it('renders the marketplaces table with a row per marketplace', () => { + renderIndex({ + marketplaces: [ + buildMarketplace({ + id: 'mkt-1' as MarketplaceId, + name: 'Acme Playbook', + descriptor: { + vendor: 'anthropic', + name: 'Acme Playbook', + plugins: [], + raw: {}, + }, + }), + buildMarketplace({ + id: 'mkt-2' as MarketplaceId, + name: 'Globex Recipes', + descriptor: { + vendor: 'anthropic', + name: 'Globex Recipes', + plugins: [], + raw: {}, + }, + }), + ], + }); + + expect(screen.getByText('Acme Playbook')).toBeInTheDocument(); + expect(screen.getByText('Globex Recipes')).toBeInTheDocument(); + expect(screen.getByText('2 marketplaces linked')).toBeInTheDocument(); + }); + + it('uses the singular noun when only one marketplace is linked', () => { + renderIndex({ + marketplaces: [buildMarketplace()], + }); + + expect(screen.getByText('1 marketplace linked')).toBeInTheDocument(); + }); + + describe('when a row is refreshing on open', () => { + it('renders the checking indicator only for the refreshing row', () => { + renderIndex({ + marketplaces: [ + buildMarketplace({ id: 'mkt-1' as MarketplaceId }), + buildMarketplace({ id: 'mkt-2' as MarketplaceId }), + ], + refreshingIds: new Set(['mkt-1' as MarketplaceId]), + }); + + expect(screen.getAllByLabelText('Checking marketplace')).toHaveLength(1); + }); + }); +}); diff --git a/apps/frontend/src/domain/marketplaces/components/MarketplacesIndex.tsx b/apps/frontend/src/domain/marketplaces/components/MarketplacesIndex.tsx new file mode 100644 index 000000000..0847217e4 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/MarketplacesIndex.tsx @@ -0,0 +1,122 @@ +import { + PMBox, + PMEmptyState, + PMHStack, + PMSpinner, + PMText, + PMVStack, +} from '@packmind/ui'; +import { LuPlug } from 'react-icons/lu'; +import type { MarketplaceId, MarketplaceListItem } from '@packmind/types'; +import { MarketplaceRow } from './MarketplaceRow'; + +export interface MarketplacesIndexProps { + marketplaces: MarketplaceListItem[]; + isLoading: boolean; + unlinkingMarketplaceId?: MarketplaceId | null; + refreshingIds?: ReadonlySet<MarketplaceId>; + onUnlink: (marketplaceId: MarketplaceId) => void; + /** + * Unused, kept for parity with the route call site so we don't have to + * touch the route in this change. + */ + organizationId?: string; + orgSlug?: string; +} + +/** + * Lists linked marketplaces for the current organization. The route owns + * data + mutations; we just render the empty / loading / populated states. + */ +export const MarketplacesIndex = ({ + marketplaces, + isLoading, + unlinkingMarketplaceId, + refreshingIds, + onUnlink, + orgSlug, +}: Readonly<MarketplacesIndexProps>) => { + if (isLoading) { + return ( + <PMEmptyState + icon={<PMSpinner />} + title="Loading marketplaces" + description="Hang tight while we fetch the marketplaces linked to your organization." + /> + ); + } + + if (marketplaces.length === 0) { + return ( + <PMEmptyState + icon={<LuPlug />} + title="Link your first marketplace" + description="Marketplaces are Git repositories Packmind reads to discover plugins your team can install. Link one to make its plugins available to your organization." + /> + ); + } + + return ( + <PMVStack align="stretch" gap={3} width="full"> + <PMHStack justify="space-between" align="center"> + <PMText variant="small" color="secondary"> + {marketplaces.length}{' '} + {marketplaces.length === 1 ? 'marketplace' : 'marketplaces'} linked + </PMText> + </PMHStack> + <PMBox + bg="background.primary" + borderWidth="1px" + borderColor="border.tertiary" + borderRadius="md" + overflow="hidden" + > + <ListHeader /> + {marketplaces.map((marketplace, index) => { + const isLast = index === marketplaces.length - 1; + return ( + <PMBox + key={marketplace.id} + {...(isLast ? { '& > *': { borderBottom: 'none' } } : {})} + > + <MarketplaceRow + marketplace={marketplace} + onUnlink={onUnlink} + isUnlinking={unlinkingMarketplaceId === marketplace.id} + isRefreshing={refreshingIds?.has(marketplace.id) ?? false} + orgSlug={orgSlug} + /> + </PMBox> + ); + })} + </PMBox> + </PMVStack> + ); +}; + +const ListHeader = () => ( + <PMHStack + gap={5} + paddingX={4} + paddingY={2} + bg="background.secondary" + borderBottom="1px solid" + borderColor="border.tertiary" + fontSize="10px" + color="text.faded" + textTransform="uppercase" + letterSpacing="wider" + fontWeight="semibold" + > + <PMBox flex={1} minW={0}> + Marketplace + </PMBox> + <PMBox width="280px" flexShrink={0}> + Contents + </PMBox> + <PMBox width="180px" flexShrink={0} textAlign="right"> + Coverage + </PMBox> + <PMBox width="64px" flexShrink={0} /> + </PMHStack> +); diff --git a/apps/frontend/src/domain/marketplaces/components/PluginAdoptionTab.spec.tsx b/apps/frontend/src/domain/marketplaces/components/PluginAdoptionTab.spec.tsx new file mode 100644 index 000000000..4d8b2ca48 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/PluginAdoptionTab.spec.tsx @@ -0,0 +1,259 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { UIProvider } from '@packmind/ui'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { + MarketplaceId, + OrganizationId, + PluginInstallationId, + PluginInstallationListItem, +} from '@packmind/types'; +import { PluginAdoptionTab } from './PluginAdoptionTab'; + +const orgId = 'org-1' as OrganizationId; +const marketplaceId = 'mkt-1' as MarketplaceId; +const pluginSlug = 'my-plugin'; + +const usePluginInstallsMock = jest.fn(); + +jest.mock('../api/queries', () => ({ + useMarketplacePluginInstalls: (...args: unknown[]) => + usePluginInstallsMock(...args), +})); + +function makeInstall( + overrides: Partial<PluginInstallationListItem> = {}, +): PluginInstallationListItem { + const base: PluginInstallationListItem = { + id: 'install-1' as PluginInstallationId, + organizationId: orgId, + marketplaceId, + pluginSlug, + packageId: null, + installedVersion: '0.1.0', + scope: 'user', + userId: null, + anonymousIdHash: null, + anonymousEmailMasked: null, + identityKey: '', + repoRemoteUrl: null, + repoKey: '', + createdAt: new Date('2026-06-01T00:00:00Z'), + updatedAt: new Date('2026-06-10T00:00:00Z'), + deletedAt: null, + userDisplayName: null, + }; + return { ...base, ...overrides }; +} + +function renderTab( + items: PluginInstallationListItem[], + { + slug = pluginSlug, + publishedVersion = '0.1.0', + isLoading = false, + }: { + slug?: string; + publishedVersion?: string | null; + isLoading?: boolean; + } = {}, +) { + usePluginInstallsMock.mockReturnValue({ data: items, isLoading }); + + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + <UIProvider> + <QueryClientProvider client={client}> + <PluginAdoptionTab + organizationId={orgId} + marketplaceId={marketplaceId} + pluginSlug={slug} + publishedVersion={publishedVersion} + active + /> + </QueryClientProvider> + </UIProvider>, + ); +} + +describe('PluginAdoptionTab', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('defers the install fetch to the active flag', () => { + renderTab([]); + expect(usePluginInstallsMock).toHaveBeenCalledWith( + orgId, + marketplaceId, + true, + ); + }); + + describe('when loading', () => { + it('renders the loading indicator', () => { + renderTab([], { isLoading: true }); + expect(screen.getByTestId('plugin-adoption-loading')).toBeInTheDocument(); + }); + }); + + describe('when there are no installs', () => { + it('renders the empty-state message', () => { + renderTab([]); + expect(screen.getByTestId('plugin-adoption-empty')).toBeInTheDocument(); + }); + }); + + describe('by repo axis', () => { + it('lists repo-bound installs by repoKey', () => { + renderTab([ + makeInstall({ + id: 'i1' as PluginInstallationId, + scope: 'project', + repoKey: 'acme/frontend', + identityKey: 'user-marc', + userId: 'user-marc' as PluginInstallationListItem['userId'], + userDisplayName: 'Marc Lee', + }), + ]); + expect(screen.getByText('acme/frontend')).toBeInTheDocument(); + }); + + it('renders the installed version badge', () => { + renderTab([ + makeInstall({ + scope: 'project', + repoKey: 'acme/frontend', + installedVersion: '0.1.0', + identityKey: 'user-marc', + userId: 'user-marc' as PluginInstallationListItem['userId'], + }), + ]); + expect(screen.getByText('0.1.0')).toBeInTheDocument(); + }); + + it('shows a behind-arrow when the installed version trails the published one', () => { + renderTab( + [ + makeInstall({ + scope: 'project', + repoKey: 'acme/frontend', + installedVersion: '0.1.0', + identityKey: 'user-marc', + userId: 'user-marc' as PluginInstallationListItem['userId'], + }), + ], + { publishedVersion: '0.2.0' }, + ); + expect(screen.getByText('0.1.0')).toBeInTheDocument(); + expect(screen.getByText('0.2.0')).toBeInTheDocument(); + }); + + it('renders "Unidentified repository" for repo-bound installs with empty repoKey', () => { + renderTab([ + makeInstall({ + scope: 'project', + repoKey: '', + identityKey: 'user-bob', + userId: 'user-bob' as PluginInstallationListItem['userId'], + }), + ]); + expect(screen.getByText('Unidentified repository')).toBeInTheDocument(); + }); + + it('prompts switching axes when only user-scope installs exist', () => { + renderTab([ + makeInstall({ + scope: 'user', + userDisplayName: 'Bob Smith', + identityKey: 'user-bob', + userId: 'user-bob' as PluginInstallationListItem['userId'], + }), + ]); + expect( + screen.getByText(/No repository installs yet/i), + ).toBeInTheDocument(); + }); + }); + + describe('by person axis', () => { + it('lists installers by display name after switching axis', () => { + renderTab([ + makeInstall({ + scope: 'user', + userDisplayName: 'Bob Smith', + identityKey: 'user-bob', + userId: 'user-bob' as PluginInstallationListItem['userId'], + }), + ]); + + fireEvent.click(screen.getByText('By person')); + + expect(screen.getByText('Bob Smith')).toBeInTheDocument(); + }); + + it('renders the masked email for anonymous installers', () => { + renderTab([ + makeInstall({ + scope: 'user', + anonymousIdHash: 'abc123', + anonymousEmailMasked: 'b**.s***@acme.com', + identityKey: 'abc123', + }), + ]); + + fireEvent.click(screen.getByText('By person')); + + expect(screen.getByText('b**.s***@acme.com')).toBeInTheDocument(); + }); + }); + + describe('unknown installed version', () => { + it('renders an "unknown" badge when no version was reported', () => { + renderTab([ + makeInstall({ + scope: 'project', + repoKey: 'acme/frontend', + installedVersion: null, + identityKey: 'user-marc', + userId: 'user-marc' as PluginInstallationListItem['userId'], + }), + ]); + expect(screen.getByText('unknown')).toBeInTheDocument(); + }); + }); + + describe('plugin filtering', () => { + it('filters installs by pluginSlug so unrelated plugins are not shown', () => { + renderTab( + [ + makeInstall({ + scope: 'project', + repoKey: 'acme/frontend', + pluginSlug: 'my-plugin', + identityKey: 'user-bob', + userId: 'user-bob' as PluginInstallationListItem['userId'], + }), + makeInstall({ + id: 'install-other' as PluginInstallationId, + scope: 'project', + repoKey: 'acme/other', + pluginSlug: 'other-plugin', + identityKey: 'user-alice', + userId: 'user-alice' as PluginInstallationListItem['userId'], + }), + ], + { slug: 'my-plugin' }, + ); + + expect(screen.getByText('acme/frontend')).toBeInTheDocument(); + expect(screen.queryByText('acme/other')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/frontend/src/domain/marketplaces/components/PluginAdoptionTab.tsx b/apps/frontend/src/domain/marketplaces/components/PluginAdoptionTab.tsx new file mode 100644 index 000000000..22c517aab --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/PluginAdoptionTab.tsx @@ -0,0 +1,385 @@ +import { useMemo, useState } from 'react'; +import { + PMBadge, + PMBox, + PMHStack, + PMSpinner, + PMText, + PMVStack, + PMTable, + type PMTableColumn, + type PMTableRow, +} from '@packmind/ui'; +import type { + MarketplaceId, + OrganizationId, + PluginInstallationListItem, +} from '@packmind/types'; +import { useMarketplacePluginInstalls } from '../api/queries'; + +// --------------------------------------------------------------------------- +// Adoption tab — ports the playground prototype's version-gap table +// (apps/playground/src/prototypes/marketplace-detail) onto real install +// tracking data. Two axes: "By repo" and "By person", each a two-column table +// of Name | Version (installed → published). +// +// The prototype's auto-update / outdated-filter banners and the collapsed +// "+ N more" tail are intentionally omitted — they depend on data the backend +// does not expose (an auto-update flag and a "repos on latest" count). +// --------------------------------------------------------------------------- + +type AdoptionAxis = 'repo' | 'person'; + +type AdoptionEntry = { + /** Stable React key. */ + key: string; + /** Display label (repo path or person name). */ + name: string; + /** Render the label in a monospace font (repo paths). */ + mono: boolean; + /** Oldest installed version across the grouped installs; null when unknown. */ + installedVersion: string | null; + isOutdated: boolean; +}; + +// ── Version helpers (ported verbatim from the prototype) ──────────────────── + +function parseVersion(v: string): [number, number, number] { + const parts = v.split('.').map((p) => parseInt(p, 10)); + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; +} + +function compareVersions(a: string, b: string): number { + const [a1, a2, a3] = parseVersion(a); + const [b1, b2, b3] = parseVersion(b); + if (a1 !== b1) return a1 - b1; + if (a2 !== b2) return a2 - b2; + return a3 - b3; +} + +function getVersionGapPalette( + installed: string, + latest: string, +): 'blue' | 'orange' | 'red' { + const [im, in_] = parseVersion(installed); + const [lm, ln] = parseVersion(latest); + if (lm > im) return 'red'; + if (ln > in_) return 'orange'; + return 'blue'; +} + +// ── Identity / grouping helpers ───────────────────────────────────────────── + +function resolveDisplayName(item: PluginInstallationListItem): string { + if (item.userDisplayName) return item.userDisplayName; + if (item.anonymousEmailMasked) return item.anonymousEmailMasked; + if (item.identityKey === '') return 'Unknown installer'; + return item.identityKey; +} + +/** + * Oldest installed version among a group of installs. Installs that did not + * report a version are ignored; returns null when none reported one. + */ +function oldestInstalledVersion( + items: PluginInstallationListItem[], +): string | null { + return items.reduce<string | null>((oldest, item) => { + const v = item.installedVersion; + if (!v) return oldest; + if (oldest === null) return v; + return compareVersions(v, oldest) < 0 ? v : oldest; + }, null); +} + +function isBehind(installed: string | null, published: string | null): boolean { + if (!installed || !published) return false; + return compareVersions(installed, published) < 0; +} + +function sortEntries(a: AdoptionEntry, b: AdoptionEntry): number { + if (a.isOutdated !== b.isOutdated) return a.isOutdated ? -1 : 1; + return a.name.localeCompare(b.name); +} + +function groupBy( + items: PluginInstallationListItem[], + keyOf: (item: PluginInstallationListItem) => string, +): Map<string, PluginInstallationListItem[]> { + const map = new Map<string, PluginInstallationListItem[]>(); + for (const item of items) { + const key = keyOf(item); + const bucket = map.get(key); + if (bucket) bucket.push(item); + else map.set(key, [item]); + } + return map; +} + +function aggregateByRepo( + items: PluginInstallationListItem[], + published: string | null, +): AdoptionEntry[] { + // Repo-bound installs only — user-scope installs are global and surface + // under the "By person" axis instead. + const repoItems = items.filter((i) => i.scope !== 'user'); + return Array.from(groupBy(repoItems, (i) => i.repoKey).entries()) + .map(([repoKey, group]) => { + const installedVersion = oldestInstalledVersion(group); + return { + key: repoKey === '' ? '__unidentified__' : repoKey, + name: repoKey === '' ? 'Unidentified repository' : repoKey, + mono: repoKey !== '', + installedVersion, + isOutdated: isBehind(installedVersion, published), + }; + }) + .sort(sortEntries); +} + +function aggregateByPerson( + items: PluginInstallationListItem[], + published: string | null, +): AdoptionEntry[] { + return Array.from(groupBy(items, (i) => i.identityKey).entries()) + .map(([identityKey, group]) => { + const installedVersion = oldestInstalledVersion(group); + return { + key: identityKey === '' ? '__unknown__' : identityKey, + name: resolveDisplayName(group[0]), + mono: false, + installedVersion, + isOutdated: isBehind(installedVersion, published), + }; + }) + .sort(sortEntries); +} + +// ── Rendering ─────────────────────────────────────────────────────────────── + +const ADOPTION_COLUMNS: PMTableColumn[] = [ + { key: 'name', header: 'Name', grow: true }, + { + key: 'version', + header: 'Version (installed → published)', + align: 'center', + grow: true, + }, +]; + +function renderVersionCell( + installed: string | null, + published: string | null, + isOutdated: boolean, +) { + if (!installed) { + return ( + <PMBadge colorPalette="gray" size="sm" variant="outline"> + unknown + </PMBadge> + ); + } + if (!isOutdated || !published) { + return ( + <PMBadge colorPalette="gray" size="sm"> + {installed} + </PMBadge> + ); + } + const palette = getVersionGapPalette(installed, published); + return ( + <PMHStack gap={2} justify="center" align="center"> + <PMBadge colorPalette="gray" size="sm"> + {installed} + </PMBadge> + <PMText fontSize="xs" color="faded" aria-hidden> + → + </PMText> + <PMBadge colorPalette={palette} size="sm"> + {published} + </PMBadge> + </PMHStack> + ); +} + +function toRow(entry: AdoptionEntry, published: string | null): PMTableRow { + return { + name: ( + <PMText + fontSize="sm" + fontFamily={entry.mono ? 'mono' : undefined} + color="primary" + truncate + > + {entry.name} + </PMText> + ), + version: renderVersionCell( + entry.installedVersion, + published, + entry.isOutdated, + ), + }; +} + +interface AxisTabsProps { + active: AdoptionAxis; + onChange: (next: AdoptionAxis) => void; +} + +function AxisTabs({ active, onChange }: Readonly<AxisTabsProps>) { + return ( + <PMHStack + gap={5} + align="center" + borderBottom="1px solid" + borderColor="border.tertiary" + > + <AxisTabButton + active={active === 'repo'} + onClick={() => onChange('repo')} + > + By repo + </AxisTabButton> + <AxisTabButton + active={active === 'person'} + onClick={() => onChange('person')} + > + By person + </AxisTabButton> + </PMHStack> + ); +} + +interface AxisTabButtonProps { + active: boolean; + onClick: () => void; + children: React.ReactNode; +} + +function AxisTabButton({ + active, + onClick, + children, +}: Readonly<AxisTabButtonProps>) { + return ( + <PMBox + as="button" + onClick={onClick} + bg="transparent" + border="none" + cursor="pointer" + paddingY={2} + paddingX={0} + fontSize="sm" + fontWeight="medium" + color={active ? 'text.primary' : 'text.faded'} + borderBottom="2px solid" + borderColor={active ? 'branding.primary' : 'transparent'} + marginBottom="-1px" + transition="color 150ms ease-out" + _hover={active ? undefined : { color: 'text.primary' }} + aria-pressed={active} + > + {children} + </PMBox> + ); +} + +export interface PluginAdoptionTabProps { + organizationId: OrganizationId | string; + marketplaceId: MarketplaceId | string; + pluginSlug: string; + /** Currently-published version of this plugin, used as the comparison tier. */ + publishedVersion: string | null; + /** Defers the install fetch until the Adoption tab is active. */ + active: boolean; +} + +/** + * Adoption tab for the plugin detail pane. Shows who/where the plugin is + * installed and which version they are on, compared against the published + * version. Fetches all installs for the marketplace once (shared TanStack + * Query cache) and filters down to `pluginSlug`. + */ +export function PluginAdoptionTab({ + organizationId, + marketplaceId, + pluginSlug, + publishedVersion, + active, +}: Readonly<PluginAdoptionTabProps>) { + const [axis, setAxis] = useState<AdoptionAxis>('repo'); + const { data, isLoading } = useMarketplacePluginInstalls( + organizationId, + marketplaceId, + active, + ); + + const pluginItems = useMemo( + () => (data ?? []).filter((i) => i.pluginSlug === pluginSlug), + [data, pluginSlug], + ); + + const repoEntries = useMemo( + () => aggregateByRepo(pluginItems, publishedVersion), + [pluginItems, publishedVersion], + ); + const personEntries = useMemo( + () => aggregateByPerson(pluginItems, publishedVersion), + [pluginItems, publishedVersion], + ); + + if (isLoading) { + return ( + <PMHStack gap={2} align="center" data-testid="plugin-adoption-loading"> + <PMSpinner size="sm" /> + <PMText fontSize="sm" color="secondary"> + Loading adoption… + </PMText> + </PMHStack> + ); + } + + if (pluginItems.length === 0) { + return ( + <PMBox + bg="background.secondary" + borderRadius="sm" + padding={3} + data-testid="plugin-adoption-empty" + > + <PMText fontSize="sm" color="faded"> + No consumers yet. Installs appear after someone starts a Claude Code + session with this plugin enabled. + </PMText> + </PMBox> + ); + } + + const entries = axis === 'repo' ? repoEntries : personEntries; + + return ( + <PMVStack + gap={3} + align="stretch" + data-testid={`plugin-adoption-tab-${pluginSlug}`} + > + <AxisTabs active={axis} onChange={setAxis} /> + {entries.length > 0 ? ( + <PMTable + columns={ADOPTION_COLUMNS} + data={entries.map((entry) => toRow(entry, publishedVersion))} + size="sm" + striped={false} + hoverable={false} + /> + ) : ( + <PMText fontSize="sm" color="faded" paddingY={1}> + No repository installs yet. Switch to “By person” to see user-scope + installs. + </PMText> + )} + </PMVStack> + ); +} diff --git a/apps/frontend/src/domain/marketplaces/components/PluginOverviewTab.tsx b/apps/frontend/src/domain/marketplaces/components/PluginOverviewTab.tsx new file mode 100644 index 000000000..bc7c302db --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/PluginOverviewTab.tsx @@ -0,0 +1,506 @@ +import { useMemo, useState } from 'react'; +import { + PMBox, + PMEmptyState, + PMHStack, + PMIcon, + PMInput, + PMSpinner, + PMText, + PMVStack, +} from '@packmind/ui'; +import type { + GetPackageSummaryResponse, + OrganizationId, +} from '@packmind/types'; +import type { IconType } from 'react-icons'; +import { LuSearch, LuShield, LuTerminal, LuWandSparkles } from 'react-icons/lu'; +import { useGetPackageSummaryQuery } from '../../deployments/api/queries/DeploymentsQueries'; + +interface PluginOverviewTabProps { + organizationId: OrganizationId | string; + packageSlug: string; +} + +/** + * Overview tab of the marketplace plugin detail pane. Loads the package + * summary on demand (description + recipes/standards/skills with their + * names and summaries) via the existing /organizations/:orgId/packages/:slug + * endpoint, and renders the prototype's ArtifactsBlock pattern — search + * input, facet chips per kind, grouped listings. + */ +export function PluginOverviewTab({ + organizationId, + packageSlug, +}: Readonly<PluginOverviewTabProps>) { + const { data, isLoading, isError } = useGetPackageSummaryQuery( + organizationId, + packageSlug, + ); + + if (isLoading) { + return ( + <PMBox paddingY={6}> + <PMEmptyState + icon={<PMSpinner />} + title="Loading package details" + description="Fetching description and bundled artifacts." + /> + </PMBox> + ); + } + + if (isError || !data) { + return ( + <PMText fontSize="sm" color="faded"> + Could not load the package summary. + </PMText> + ); + } + + const grouped = groupArtifacts(data); + + return ( + <PMVStack gap={6} align="stretch"> + <DescriptionBlock description={data.description} /> + <ArtifactsBlock grouped={grouped} /> + </PMVStack> + ); +} + +function DescriptionBlock({ description }: Readonly<{ description: string }>) { + if (!description.trim()) { + return ( + <PMText fontSize="sm" color="faded"> + No description provided for this package. + </PMText> + ); + } + return ( + <PMText fontSize="md" color="secondary" lineHeight={1.55} maxW="70ch"> + {description} + </PMText> + ); +} + +// Backend models them as "recipes", but the user-facing concept is a +// slash-invoked Command — match the prototype's terminology in the UI and +// keep the wire-shape translation contained to `groupArtifacts`. +type ArtifactKind = 'command' | 'standard' | 'skill'; + +type Artifact = { + kind: ArtifactKind; + name: string; + summary: string; +}; + +const KIND_ORDER: ArtifactKind[] = ['command', 'standard', 'skill']; + +const KIND_ICON: Record<ArtifactKind, IconType> = { + command: LuTerminal, + standard: LuShield, + skill: LuWandSparkles, +}; + +const KIND_LABEL: Record<ArtifactKind, string> = { + command: 'Commands', + standard: 'Standards', + skill: 'Skills', +}; + +function groupArtifacts( + summary: GetPackageSummaryResponse, +): Record<ArtifactKind, Artifact[]> { + const byName = (a: Artifact, b: Artifact) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + return { + command: summary.recipes + .map((r) => ({ + kind: 'command' as const, + name: r.name, + summary: r.summary ?? '', + })) + .sort(byName), + standard: summary.standards + .map((s) => ({ + kind: 'standard' as const, + name: s.name, + summary: s.summary ?? '', + })) + .sort(byName), + skill: summary.skills + .map((s) => ({ + kind: 'skill' as const, + name: s.name, + summary: s.summary ?? '', + })) + .sort(byName), + }; +} + +type ArtifactFilter = ArtifactKind | 'all'; + +interface ArtifactsBlockProps { + grouped: Record<ArtifactKind, Artifact[]>; +} + +function ArtifactsBlock({ grouped }: Readonly<ArtifactsBlockProps>) { + const total = useMemo( + () => KIND_ORDER.reduce((sum, k) => sum + grouped[k].length, 0), + [grouped], + ); + const [query, setQuery] = useState(''); + const [activeFilter, setActiveFilter] = useState<ArtifactFilter>('all'); + + const normalizedQuery = query.trim().toLowerCase(); + const matchesQuery = (a: Artifact) => + !normalizedQuery || + a.name.toLowerCase().includes(normalizedQuery) || + a.summary.toLowerCase().includes(normalizedQuery); + + const visibleKinds: ArtifactKind[] = + activeFilter === 'all' ? KIND_ORDER : [activeFilter]; + + const filteredGroups = visibleKinds.map((kind) => ({ + kind, + items: grouped[kind].filter(matchesQuery), + })); + + const visibleCount = filteredGroups.reduce((s, g) => s + g.items.length, 0); + const isFiltering = normalizedQuery.length > 0 || activeFilter !== 'all'; + + const handleReset = () => { + setQuery(''); + setActiveFilter('all'); + }; + + return ( + <PMVStack gap={4} align="stretch"> + <PMHStack gap={3} align="baseline" justify="space-between"> + <SectionLabel>Bundled artifacts</SectionLabel> + <PMText fontSize="xs" color="faded" fontVariantNumeric="tabular-nums"> + {isFiltering + ? `${visibleCount} of ${total}` + : `${total} ${total === 1 ? 'item' : 'items'}`} + </PMText> + </PMHStack> + + {total === 0 ? ( + <PMText fontSize="sm" color="faded"> + This package bundles no artifacts yet. + </PMText> + ) : ( + <> + <ArtifactToolbar + query={query} + onQueryChange={setQuery} + total={total} + grouped={grouped} + active={activeFilter} + onFilterChange={setActiveFilter} + /> + + {visibleCount === 0 ? ( + <ArtifactsEmpty query={normalizedQuery} onReset={handleReset} /> + ) : ( + <PMVStack gap={5} align="stretch"> + {filteredGroups.map(({ kind, items }) => + items.length === 0 ? null : ( + <ArtifactGroup + key={kind} + kind={kind} + items={items} + totalInKind={grouped[kind].length} + showHeader={activeFilter === 'all'} + /> + ), + )} + </PMVStack> + )} + </> + )} + </PMVStack> + ); +} + +interface ArtifactToolbarProps { + query: string; + onQueryChange: (next: string) => void; + total: number; + grouped: Record<ArtifactKind, Artifact[]>; + active: ArtifactFilter; + onFilterChange: (next: ArtifactFilter) => void; +} + +function ArtifactToolbar({ + query, + onQueryChange, + total, + grouped, + active, + onFilterChange, +}: Readonly<ArtifactToolbarProps>) { + return ( + <PMVStack gap={2.5} align="stretch"> + <PMBox position="relative"> + <PMBox + position="absolute" + left="10px" + top="50%" + transform="translateY(-50%)" + color="text.faded" + pointerEvents="none" + display="flex" + alignItems="center" + zIndex={1} + aria-hidden + > + <PMIcon fontSize="sm"> + <LuSearch /> + </PMIcon> + </PMBox> + <PMInput + placeholder="Filter artifacts by name or description" + value={query} + onChange={(e) => onQueryChange(e.target.value)} + size="sm" + paddingLeft="32px" + aria-label="Filter artifacts" + /> + </PMBox> + <PMHStack gap={1.5} wrap="wrap" align="center"> + <FacetChip + active={active === 'all'} + onClick={() => onFilterChange('all')} + count={total} + > + All + </FacetChip> + {KIND_ORDER.map((kind) => { + const count = grouped[kind].length; + if (count === 0) return null; + const Icon = KIND_ICON[kind]; + return ( + <FacetChip + key={kind} + active={active === kind} + onClick={() => onFilterChange(kind)} + count={count} + icon={Icon} + > + {KIND_LABEL[kind]} + </FacetChip> + ); + })} + </PMHStack> + </PMVStack> + ); +} + +interface FacetChipProps { + active: boolean; + onClick: () => void; + count: number; + icon?: IconType; + children: React.ReactNode; +} + +function FacetChip({ + active, + onClick, + count, + icon: Icon, + children, +}: Readonly<FacetChipProps>) { + return ( + <PMBox + as="button" + onClick={onClick} + display="inline-flex" + alignItems="center" + gap={1.5} + paddingX="10px" + paddingY="3px" + borderRadius="sm" + bg={active ? 'branding.primary' : 'background.tertiary'} + color={active ? 'beige.1000' : 'text.secondary'} + fontSize="xs" + fontWeight="medium" + cursor="pointer" + border="none" + transition="background-color 120ms ease-out, color 120ms ease-out" + _hover={ + active + ? { bg: 'branding.primary' } + : { bg: 'background.secondary', color: 'text.primary' } + } + aria-pressed={active} + > + {Icon && ( + <PMIcon fontSize="11px" color="inherit"> + <Icon /> + </PMIcon> + )} + <PMBox as="span" color="inherit"> + {children} + </PMBox> + <PMBox + as="span" + color="inherit" + opacity={active ? 0.7 : 0.65} + fontVariantNumeric="tabular-nums" + > + {count} + </PMBox> + </PMBox> + ); +} + +interface ArtifactGroupProps { + kind: ArtifactKind; + items: Artifact[]; + totalInKind: number; + showHeader: boolean; +} + +function ArtifactGroup({ + kind, + items, + totalInKind, + showHeader, +}: Readonly<ArtifactGroupProps>) { + const Icon = KIND_ICON[kind]; + return ( + <PMVStack gap={0} align="stretch"> + {showHeader && ( + <PMHStack + gap={2} + align="center" + paddingY={1.5} + paddingX={2} + marginX={-2} + borderBottom="1px solid" + borderColor="border.tertiary" + position="sticky" + top={0} + bg="background.primary" + zIndex={1} + > + <PMIcon fontSize="sm" color="text.faded"> + <Icon /> + </PMIcon> + <PMText + fontSize="xs" + color="secondary" + textTransform="uppercase" + letterSpacing="wider" + fontWeight="semibold" + > + {KIND_LABEL[kind]} + </PMText> + <PMText fontSize="xs" color="faded" fontVariantNumeric="tabular-nums"> + {items.length === totalInKind + ? items.length + : `${items.length} / ${totalInKind}`} + </PMText> + </PMHStack> + )} + <PMVStack gap={0} align="stretch"> + {items.map((a, idx) => ( + <ArtifactRow key={`${kind}-${a.name}-${idx}`} artifact={a} /> + ))} + </PMVStack> + </PMVStack> + ); +} + +function ArtifactRow({ artifact }: Readonly<{ artifact: Artifact }>) { + const Icon = KIND_ICON[artifact.kind]; + return ( + <PMBox + paddingY="6px" + paddingX={2} + borderRadius="sm" + display="grid" + gridTemplateColumns="14px minmax(0, 32ch) minmax(0, 1fr)" + alignItems="center" + columnGap={3} + transition="background-color 120ms ease-out" + _hover={{ bg: 'background.secondary' }} + role="group" + > + <PMBox + as="span" + display="inline-flex" + alignItems="center" + justifyContent="center" + color="text.faded" + aria-hidden + > + <PMIcon fontSize="sm"> + <Icon /> + </PMIcon> + </PMBox> + <PMText fontSize="sm" fontWeight="medium" color="primary" truncate> + {artifact.name} + </PMText> + <PMText fontSize="xs" color="secondary" lineHeight={1.4} truncate> + {artifact.summary} + </PMText> + </PMBox> + ); +} + +interface ArtifactsEmptyProps { + query: string; + onReset: () => void; +} + +function ArtifactsEmpty({ query, onReset }: Readonly<ArtifactsEmptyProps>) { + return ( + <PMVStack + gap={2} + align="start" + paddingY={6} + paddingX={3} + bg="background.secondary" + borderRadius="sm" + > + <PMText fontSize="sm" color="secondary"> + {query + ? `Nothing in this bundle matches "${query}".` + : 'No artifacts in the selected kind.'} + </PMText> + <PMBox + as="button" + fontSize="xs" + color="branding.primary" + bg="transparent" + border="none" + cursor="pointer" + padding={0} + onClick={onReset} + > + Clear filters + </PMBox> + </PMVStack> + ); +} + +interface SectionLabelProps { + children: React.ReactNode; +} + +function SectionLabel({ children }: Readonly<SectionLabelProps>) { + return ( + <PMText + fontSize="xs" + color="secondary" + textTransform="uppercase" + letterSpacing="wider" + fontWeight="semibold" + > + {children} + </PMText> + ); +} diff --git a/apps/frontend/src/domain/marketplaces/components/PrivateLinkForm.spec.tsx b/apps/frontend/src/domain/marketplaces/components/PrivateLinkForm.spec.tsx new file mode 100644 index 000000000..d73e53206 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/PrivateLinkForm.spec.tsx @@ -0,0 +1,281 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UIProvider } from '@packmind/ui'; +import { PrivateLinkForm } from './PrivateLinkForm'; + +const useGetGitProvidersQueryMock = jest.fn(); +const useGetAvailableRepositoriesQueryMock = jest.fn(); +const useLinkMarketplaceMock = jest.fn(); + +jest.mock('../../git/api/queries', () => ({ + useGetGitProvidersQuery: () => useGetGitProvidersQueryMock(), + useGetAvailableRepositoriesQuery: (...args: unknown[]) => + useGetAvailableRepositoriesQueryMock(...args), +})); + +jest.mock('../api/queries', () => ({ + useLinkMarketplace: (...args: unknown[]) => useLinkMarketplaceMock(...args), +})); + +function renderForm(overrides: { onLinked?: () => void } = {}) { + const onLinked = overrides.onLinked ?? jest.fn(); + const onCancel = jest.fn(); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + render( + <UIProvider> + <QueryClientProvider client={queryClient}> + <MemoryRouter> + <PrivateLinkForm + organizationId="org-1" + orgSlug="acme" + onLinked={onLinked} + onCancel={onCancel} + /> + </MemoryRouter> + </QueryClientProvider> + </UIProvider>, + ); + + return { onLinked, onCancel }; +} + +describe('PrivateLinkForm', () => { + beforeEach(() => { + useGetGitProvidersQueryMock.mockReturnValue({ + data: { + providers: [ + { + id: 'gp-1', + source: 'github', + url: 'https://github.com', + organizationId: 'org-1', + hasAuth: true, + authMethod: 'token', + displayName: '', + }, + ], + }, + isLoading: false, + }); + + useGetAvailableRepositoriesQueryMock.mockReturnValue({ + data: [ + { + owner: 'acme-eng', + name: 'marketplace', + fullName: 'acme-eng/marketplace', + private: true, + defaultBranch: 'main', + stars: 0, + }, + { + owner: 'group1/subgroup2', + name: 'project3', + fullName: 'group1/subgroup2/project3', + private: true, + defaultBranch: 'develop', + stars: 0, + }, + ], + isLoading: false, + isError: false, + }); + + useLinkMarketplaceMock.mockReturnValue({ + mutateAsync: jest.fn().mockResolvedValue({ name: 'Acme Playbook' }), + isPending: false, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the loading state while providers load', () => { + useGetGitProvidersQueryMock.mockReturnValue({ + data: undefined, + isLoading: true, + }); + + renderForm(); + expect( + screen.getByText('Loading your Git connections…'), + ).toBeInTheDocument(); + }); + + it('shows the GitNotConnectedNotice when no providers are connected', () => { + useGetGitProvidersQueryMock.mockReturnValue({ + data: { providers: [] }, + isLoading: false, + }); + + renderForm(); + expect(screen.getByTestId('git-not-connected-notice')).toBeInTheDocument(); + }); + + it('excludes CLI-managed providers (no credentials) from the dropdown', () => { + useGetGitProvidersQueryMock.mockReturnValue({ + data: { + providers: [ + { + id: 'gp-1', + source: 'github', + url: 'https://github.com', + organizationId: 'org-1', + hasAuth: true, + authMethod: 'token', + displayName: '', + }, + { + id: 'gp-cli', + source: 'gitlab', + url: 'https://gitlab.com', + organizationId: 'org-1', + hasAuth: false, + authMethod: 'token', + displayName: '', + }, + ], + }, + isLoading: false, + }); + + renderForm(); + const options = screen + .getAllByRole('option') + .map((option) => (option as HTMLOptionElement).value); + expect(options).toContain('gp-1'); + expect(options).not.toContain('gp-cli'); + }); + + it('shows the GitNotConnectedNotice when only CLI-managed providers exist', () => { + useGetGitProvidersQueryMock.mockReturnValue({ + data: { + providers: [ + { + id: 'gp-cli', + source: 'gitlab', + url: 'https://gitlab.com', + organizationId: 'org-1', + hasAuth: false, + authMethod: 'token', + displayName: '', + }, + ], + }, + isLoading: false, + }); + + renderForm(); + expect(screen.getByTestId('git-not-connected-notice')).toBeInTheDocument(); + }); + + it('renders the form when at least one provider is connected', () => { + renderForm(); + expect( + screen.getByRole('form', { name: 'Link a private marketplace' }), + ).toBeInTheDocument(); + expect(screen.getByText('Git connection')).toBeInTheDocument(); + }); + + it('labels a connection with its display name when one is set', () => { + useGetGitProvidersQueryMock.mockReturnValue({ + data: { + providers: [ + { + id: 'gp-1', + source: 'github', + url: 'https://github.com', + organizationId: 'org-1', + hasAuth: true, + authMethod: 'token', + displayName: 'Acme Engineering', + }, + ], + }, + isLoading: false, + }); + + renderForm(); + expect( + screen.getByRole('option', { name: 'Acme Engineering' }), + ).toBeInTheDocument(); + }); + + it('falls back to vendor and URL when the display name is empty', () => { + renderForm(); + expect( + screen.getByRole('option', { name: 'GITHUB — https://github.com' }), + ).toBeInTheDocument(); + }); + + it('disables the submit button until every field is populated', () => { + renderForm(); + const submit = screen.getByRole('button', { name: 'Link marketplace' }); + expect(submit).toBeDisabled(); + }); + + it('does not render a separate display-name field', () => { + renderForm(); + expect(screen.queryByLabelText('Display name')).not.toBeInTheDocument(); + }); + + it('lists repositories from the selected provider instead of free-text inputs', () => { + renderForm(); + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'gp-1' }, + }); + + expect(screen.queryByLabelText('Repository owner')).not.toBeInTheDocument(); + expect(screen.getByText('acme-eng/marketplace')).toBeInTheDocument(); + expect(screen.getByText('group1/subgroup2/project3')).toBeInTheDocument(); + }); + + it('submits the selected repo coordinates resolved by the provider', async () => { + const mutateAsync = jest.fn().mockResolvedValue({ name: 'project3' }); + useLinkMarketplaceMock.mockReturnValue({ mutateAsync, isPending: false }); + + const onLinked = jest.fn(); + renderForm({ onLinked }); + + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'gp-1' }, + }); + // A GitLab-style repo whose owner is a group/subgroup path — exactly the + // case the manual form got wrong. + fireEvent.click( + screen.getByTestId('marketplace-repo-option-group1/subgroup2/project3'), + ); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Link marketplace' })); + }); + + expect(mutateAsync).toHaveBeenCalledWith({ + gitProviderId: 'gp-1', + owner: 'group1/subgroup2', + repo: 'project3', + branch: 'develop', + name: 'project3', + }); + expect(onLinked).toHaveBeenCalled(); + }); + + it('keeps the submit button disabled until a repository is selected', () => { + renderForm(); + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'gp-1' }, + }); + expect( + screen.getByRole('button', { name: 'Link marketplace' }), + ).toBeDisabled(); + }); +}); diff --git a/apps/frontend/src/domain/marketplaces/components/PrivateLinkForm.tsx b/apps/frontend/src/domain/marketplaces/components/PrivateLinkForm.tsx new file mode 100644 index 000000000..cd15fcae6 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/PrivateLinkForm.tsx @@ -0,0 +1,307 @@ +import { + ChangeEvent, + FormEvent, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { + PMBox, + PMButton, + PMEmptyState, + PMHStack, + PMInput, + PMNativeSelect, + PMSpinner, + PMText, + PMVStack, +} from '@packmind/ui'; +import { GitProviderId, OrganizationId } from '@packmind/types'; +import { + useGetGitProvidersQuery, + useGetAvailableRepositoriesQuery, +} from '../../git/api/queries'; +import type { AvailableRepository } from '../../git/types/GitProviderTypes'; +import { useLinkMarketplace } from '../api/queries'; +import { LinkMarketplaceRequestBody } from '../api/gateways'; +import { AgentsFieldset } from './AgentsFieldset'; +import { GitNotConnectedNotice } from './GitNotConnectedNotice'; +import { SubmitErrorBanner } from './SubmitErrorBanner'; +import { getSubmitErrorMessage } from './errorMapping'; + +export interface PrivateLinkFormProps { + organizationId: OrganizationId | string; + orgSlug: string; + onLinked: () => void; + onCancel: () => void; +} + +/** + * Private path: pick a connected `GitProvider`, then pick one of its + * repositories from the list the provider exposes. Empty providers branch + * deep-links to settings via `<GitNotConnectedNotice/>`. + * + * The admin no longer types owner/repo/branch by hand — the provider resolves + * those coordinates for us, which is what makes the flow correct for GitLab + * subgroups (`group/subgroup` owners) and removes a class of typo errors. The + * marketplace display name is the repository name. Deeper validation (the repo + * exposes a `marketplace.json`) is the backend's responsibility and surfaces + * through `SubmitErrorBanner`. + */ +export const PrivateLinkForm = ({ + organizationId, + orgSlug, + onLinked, + onCancel, +}: Readonly<PrivateLinkFormProps>) => { + const providersQuery = useGetGitProvidersQuery(); + const linkMutation = useLinkMarketplace(organizationId); + + const [gitProviderId, setGitProviderId] = useState(''); + const [selectedRepo, setSelectedRepo] = useState<AvailableRepository | null>( + null, + ); + const [searchTerm, setSearchTerm] = useState(''); + const [errorMessage, setErrorMessage] = useState<string | null>(null); + + const reposQuery = useGetAvailableRepositoriesQuery( + gitProviderId as GitProviderId, + ); + + // Only user-configured providers (PAT token or active GitHub App + // installation) can list repositories. CLI-created providers are + // URL-tracking-only and hold no credentials, so they are filtered out. + const providers = (providersQuery.data?.providers ?? []).filter( + (provider) => provider.hasAuth, + ); + + const filteredRepos = useMemo(() => { + const repos = reposQuery.data ?? []; + const term = searchTerm.trim().toLowerCase(); + if (!term) return repos; + return repos.filter( + (repo) => + repo.name.toLowerCase().includes(term) || + repo.owner.toLowerCase().includes(term) || + repo.fullName.toLowerCase().includes(term), + ); + }, [reposQuery.data, searchTerm]); + + const fieldsValid = Boolean(gitProviderId.trim() && selectedRepo); + + if (providersQuery.isLoading) { + return <PMText color="secondary">Loading your Git connections…</PMText>; + } + + if (providers.length === 0) { + return <GitNotConnectedNotice orgSlug={orgSlug} />; + } + + const handleProviderChange = ( + event: ChangeEvent<HTMLInputElement | HTMLSelectElement>, + ) => { + setGitProviderId(event.target.value); + setSelectedRepo(null); + setSearchTerm(''); + setErrorMessage(null); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!fieldsValid || !selectedRepo) return; + + const body: LinkMarketplaceRequestBody = { + gitProviderId: gitProviderId as GitProviderId, + owner: selectedRepo.owner, + repo: selectedRepo.name, + branch: selectedRepo.defaultBranch, + // The marketplace display name is the repository name — there is no + // separate name field. + name: selectedRepo.name, + }; + + try { + await linkMutation.mutateAsync(body); + setGitProviderId(''); + setSelectedRepo(null); + setSearchTerm(''); + onLinked(); + } catch (error) { + setErrorMessage(getSubmitErrorMessage(error)); + } + }; + + return ( + <form onSubmit={handleSubmit} aria-label="Link a private marketplace"> + <PMVStack align="stretch" gap={4}> + {errorMessage && <SubmitErrorBanner message={errorMessage} />} + + <Field label="Git connection"> + <PMNativeSelect + name="gitProviderId" + value={gitProviderId} + onChange={handleProviderChange} + items={[ + { value: '', label: 'Select a connection' }, + ...providers.map((provider) => ({ + value: provider.id, + label: providerLabel(provider), + })), + ]} + data-testid="private-link-form-provider" + /> + </Field> + + {gitProviderId && ( + <Field label="Repository"> + <RepositoryPicker + isLoading={reposQuery.isLoading} + isError={reposQuery.isError} + repos={filteredRepos} + searchTerm={searchTerm} + onSearch={(value) => { + setSearchTerm(value); + setErrorMessage(null); + }} + selectedRepo={selectedRepo} + onSelect={(repo) => { + setSelectedRepo(repo); + setErrorMessage(null); + }} + /> + </Field> + )} + + <AgentsFieldset vendor="anthropic" /> + + <PMHStack gap={2} justify="end"> + <PMButton variant="tertiary" size="sm" onClick={onCancel}> + Cancel + </PMButton> + <PMButton + variant="primary" + size="sm" + type="submit" + loading={linkMutation.isPending} + disabled={!fieldsValid || linkMutation.isPending} + > + Link marketplace + </PMButton> + </PMHStack> + </PMVStack> + </form> + ); +}; + +interface RepositoryPickerProps { + isLoading: boolean; + isError: boolean; + repos: AvailableRepository[]; + searchTerm: string; + onSearch: (value: string) => void; + selectedRepo: AvailableRepository | null; + onSelect: (repo: AvailableRepository) => void; +} + +const RepositoryPicker = ({ + isLoading, + isError, + repos, + searchTerm, + onSearch, + selectedRepo, + onSelect, +}: Readonly<RepositoryPickerProps>) => { + if (isLoading) { + return ( + <PMHStack gap={2} color="secondary"> + <PMSpinner size="sm" /> + <PMText color="secondary">Loading repositories…</PMText> + </PMHStack> + ); + } + + if (isError) { + return ( + <PMText color="error"> + Failed to load repositories. Check the provider connection and try + again. + </PMText> + ); + } + + return ( + <PMVStack align="stretch" gap={2}> + <PMInput + placeholder="Search repositories…" + value={searchTerm} + onChange={(event: ChangeEvent<HTMLInputElement>) => + onSearch(event.target.value) + } + aria-label="Search repositories" + size="sm" + /> + <PMBox maxHeight="240px" overflowY="auto"> + {repos.length === 0 ? ( + <PMEmptyState + title={ + searchTerm + ? 'No repositories match your search.' + : 'No repositories available from this provider.' + } + /> + ) : ( + <PMVStack align="stretch" gap={2}> + {repos.map((repo) => { + const isSelected = selectedRepo?.fullName === repo.fullName; + return ( + <PMBox + key={repo.fullName} + p={2} + borderWidth="1px" + borderColor={isSelected ? 'blue.500' : 'border.primary'} + borderRadius="md" + cursor="pointer" + _hover={{ borderColor: 'blue.300' }} + onClick={() => onSelect(repo)} + data-testid={`marketplace-repo-option-${repo.fullName}`} + > + <PMVStack align="start" gap={0}> + <PMText variant="small-important">{repo.name}</PMText> + <PMText variant="small" color="secondary"> + {repo.fullName} + </PMText> + </PMVStack> + </PMBox> + ); + })} + </PMVStack> + )} + </PMBox> + </PMVStack> + ); +}; + +const Field = ({ + label, + children, +}: Readonly<{ label: string; children: ReactNode }>) => ( + <PMVStack align="stretch" gap={1}> + <PMText variant="small-important" color="secondary"> + {label} + </PMText> + {children} + </PMVStack> +); + +function providerLabel(provider: { + source: string; + url: string | null; + displayName: string; +}): string { + const displayName = provider.displayName.trim(); + if (displayName) return displayName; + // Unnamed connections fall back to the vendor + URL coordinates. + const vendor = provider.source.toUpperCase(); + return provider.url ? `${vendor} — ${provider.url}` : vendor; +} diff --git a/apps/frontend/src/domain/marketplaces/components/PublicLinkForm.spec.tsx b/apps/frontend/src/domain/marketplaces/components/PublicLinkForm.spec.tsx new file mode 100644 index 000000000..06ec5e995 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/PublicLinkForm.spec.tsx @@ -0,0 +1,202 @@ +import { + render, + screen, + fireEvent, + act, + waitFor, +} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UIProvider } from '@packmind/ui'; +import { PublicLinkForm } from './PublicLinkForm'; + +const useValidateMarketplaceUrlMock = jest.fn(); +const useLinkMarketplaceMock = jest.fn(); + +jest.mock('../api/queries', () => ({ + useValidateMarketplaceUrl: (...args: unknown[]) => + useValidateMarketplaceUrlMock(...args), + useLinkMarketplace: (...args: unknown[]) => useLinkMarketplaceMock(...args), +})); + +function renderForm() { + const onLinked = jest.fn(); + const onCancel = jest.fn(); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + render( + <UIProvider> + <QueryClientProvider client={queryClient}> + <PublicLinkForm + organizationId="org-1" + onLinked={onLinked} + onCancel={onCancel} + /> + </QueryClientProvider> + </UIProvider>, + ); + + return { onLinked, onCancel }; +} + +describe('PublicLinkForm', () => { + beforeEach(() => { + jest.useFakeTimers(); + useValidateMarketplaceUrlMock.mockReturnValue({ + data: undefined, + isLoading: false, + isFetching: false, + isError: false, + error: undefined, + }); + useLinkMarketplaceMock.mockReturnValue({ + mutateAsync: jest.fn().mockResolvedValue({ name: 'OSS Playbook' }), + isPending: false, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('shows the idle hint copy by default', () => { + renderForm(); + expect(screen.getByText(/Paste an HTTPS or SSH URL/i)).toBeInTheDocument(); + }); + + it('shows the checking spinner while validation is loading', () => { + useValidateMarketplaceUrlMock.mockReturnValue({ + data: undefined, + isLoading: false, + isFetching: true, + isError: false, + error: undefined, + }); + + renderForm(); + act(() => { + fireEvent.change(screen.getByLabelText('Repository URL'), { + target: { value: 'https://github.com/acme/playbook' }, + }); + }); + act(() => { + jest.advanceTimersByTime(400); + }); + + expect(screen.getByText('Checking access…')).toBeInTheDocument(); + }); + + it('shows the verified state when validation succeeds', () => { + useValidateMarketplaceUrlMock.mockReturnValue({ + data: { + kind: 'verified', + repoPath: 'acme/playbook', + defaultBranch: 'main', + pluginCount: 3, + }, + isLoading: false, + isFetching: false, + isError: false, + error: undefined, + }); + + renderForm(); + act(() => { + fireEvent.change(screen.getByLabelText('Repository URL'), { + target: { value: 'https://github.com/acme/playbook' }, + }); + }); + act(() => { + jest.advanceTimersByTime(400); + }); + + expect(screen.getByText('Repository verified')).toBeInTheDocument(); + expect(screen.getByText(/3 plugins/)).toBeInTheDocument(); + }); + + it('disables submit until name is filled in after verification', () => { + useValidateMarketplaceUrlMock.mockReturnValue({ + data: { + kind: 'verified', + repoPath: 'acme/playbook', + defaultBranch: 'main', + pluginCount: 2, + }, + isLoading: false, + isFetching: false, + isError: false, + error: undefined, + }); + + renderForm(); + act(() => { + fireEvent.change(screen.getByLabelText('Repository URL'), { + target: { value: 'https://github.com/acme/playbook' }, + }); + }); + act(() => { + jest.advanceTimersByTime(400); + }); + + expect( + screen.getByRole('button', { name: 'Link marketplace' }), + ).toBeDisabled(); + }); + + it('submits with parsed coordinates once the form is verified and named', async () => { + const mutateAsync = jest.fn().mockResolvedValue({ name: 'OSS Playbook' }); + useLinkMarketplaceMock.mockReturnValue({ + mutateAsync, + isPending: false, + }); + useValidateMarketplaceUrlMock.mockReturnValue({ + data: { + kind: 'verified', + repoPath: 'acme/playbook', + defaultBranch: 'main', + pluginCount: 4, + }, + isLoading: false, + isFetching: false, + isError: false, + error: undefined, + }); + + renderForm(); + act(() => { + fireEvent.change(screen.getByLabelText('Repository URL'), { + target: { value: 'https://github.com/acme/playbook' }, + }); + }); + act(() => { + jest.advanceTimersByTime(400); + }); + + act(() => { + fireEvent.change(screen.getByLabelText('Display name'), { + target: { value: 'OSS Playbook' }, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Link marketplace' })); + }); + + await waitFor(() => + expect(mutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'acme', + repo: 'playbook', + branch: 'main', + name: 'OSS Playbook', + }), + ), + ); + }); +}); diff --git a/apps/frontend/src/domain/marketplaces/components/PublicLinkForm.tsx b/apps/frontend/src/domain/marketplaces/components/PublicLinkForm.tsx new file mode 100644 index 000000000..3b6004c1b --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/PublicLinkForm.tsx @@ -0,0 +1,236 @@ +import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from 'react'; +import { + PMAlert, + PMButton, + PMHStack, + PMInput, + PMSpinner, + PMText, + PMVStack, +} from '@packmind/ui'; +import { OrganizationId } from '@packmind/types'; +import { useLinkMarketplace, useValidateMarketplaceUrl } from '../api/queries'; +import { LinkMarketplaceRequestBody } from '../api/gateways'; +import { AgentsFieldset } from './AgentsFieldset'; +import { SubmitErrorBanner } from './SubmitErrorBanner'; +import { getSubmitErrorMessage } from './errorMapping'; + +export interface PublicLinkFormProps { + organizationId: OrganizationId | string; + onLinked: () => void; + onCancel: () => void; +} + +const VALIDATE_DEBOUNCE_MS = 400; + +/** + * Public path: paste a tokenless repository URL, validate it through the + * backend, then submit. The validation step short-circuits via TanStack + * Query's `enabled` flag while the URL is still being typed. + */ +export const PublicLinkForm = ({ + organizationId, + onLinked, + onCancel, +}: Readonly<PublicLinkFormProps>) => { + const [url, setUrl] = useState(''); + const [debouncedUrl, setDebouncedUrl] = useState(''); + const [name, setName] = useState(''); + const [errorMessage, setErrorMessage] = useState<string | null>(null); + + // Debounce — keeps the validate-url request quiet while the user is typing. + useEffect(() => { + const handle = setTimeout(() => { + setDebouncedUrl(url.trim()); + }, VALIDATE_DEBOUNCE_MS); + return () => clearTimeout(handle); + }, [url]); + + const validation = useValidateMarketplaceUrl(organizationId, debouncedUrl); + const linkMutation = useLinkMarketplace(organizationId); + + const validationKind = useMemo< + 'idle' | 'checking' | 'verified' | 'error' + >(() => { + if (!debouncedUrl) return 'idle'; + if (validation.isLoading || validation.isFetching) return 'checking'; + if (validation.isError) return 'error'; + if (validation.data) return 'verified'; + return 'idle'; + }, [ + debouncedUrl, + validation.isLoading, + validation.isFetching, + validation.isError, + validation.data, + ]); + + const validatedCoords = useMemo(() => { + if (validationKind !== 'verified' || !validation.data) return null; + const segments = validation.data.repoPath.split('/'); + if (segments.length < 2) return null; + return { + owner: segments[0], + repo: segments.slice(1).join('/'), + defaultBranch: validation.data.defaultBranch, + }; + }, [validation.data, validationKind]); + + const validationErrorMessage: string | null = + validationKind === 'error' ? getSubmitErrorMessage(validation.error) : null; + + const canSubmit = + validationKind === 'verified' && + !!name.trim() && + !!validatedCoords && + !linkMutation.isPending; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit || !validatedCoords) return; + + // The public path links via a tokenless GitProvider resolved by the + // backend from the host. The frontend does not need to surface the + // provider id, but the link contract still requires it — the backend + // accepts a sentinel and looks it up server-side. We pass the validated + // repo path verbatim. + // + // NB: if the backend ever rejects a missing gitProviderId for the public + // path, this is the line to revisit. The validate-url endpoint already + // verified reachability via the tokenless provider. + const body: LinkMarketplaceRequestBody = { + gitProviderId: '', + owner: validatedCoords.owner, + repo: validatedCoords.repo, + branch: validatedCoords.defaultBranch, + name: name.trim(), + }; + + try { + await linkMutation.mutateAsync(body); + setUrl(''); + setDebouncedUrl(''); + setName(''); + onLinked(); + } catch (error) { + setErrorMessage(getSubmitErrorMessage(error)); + } + }; + + return ( + <form onSubmit={handleSubmit} aria-label="Link a public marketplace"> + <PMVStack align="stretch" gap={4}> + {errorMessage && <SubmitErrorBanner message={errorMessage} />} + + {validationErrorMessage && ( + <SubmitErrorBanner message={validationErrorMessage} /> + )} + + <PMVStack align="stretch" gap={1}> + <PMText variant="small-important" color="secondary"> + Repository URL + </PMText> + <PMInput + placeholder="https://github.com/org/repo" + value={url} + onChange={(event: ChangeEvent<HTMLInputElement>) => { + setUrl(event.target.value); + setErrorMessage(null); + }} + aria-label="Repository URL" + /> + <ValidationHint + validationKind={validationKind} + repoPath={validation.data?.repoPath} + defaultBranch={validation.data?.defaultBranch} + pluginCount={validation.data?.pluginCount} + /> + </PMVStack> + + {validationKind === 'verified' && ( + <PMVStack align="stretch" gap={1}> + <PMText variant="small-important" color="secondary"> + Display name + </PMText> + <PMInput + placeholder="e.g. Community OSS playbook" + value={name} + onChange={(event: ChangeEvent<HTMLInputElement>) => + setName(event.target.value) + } + aria-label="Display name" + /> + </PMVStack> + )} + + <AgentsFieldset vendor="anthropic" /> + + <PMHStack gap={2} justify="end"> + <PMButton variant="tertiary" size="sm" onClick={onCancel}> + Cancel + </PMButton> + <PMButton + variant="primary" + size="sm" + type="submit" + loading={linkMutation.isPending} + disabled={!canSubmit} + > + Link marketplace + </PMButton> + </PMHStack> + </PMVStack> + </form> + ); +}; + +interface ValidationHintProps { + validationKind: 'idle' | 'checking' | 'verified' | 'error'; + repoPath?: string; + defaultBranch?: string; + pluginCount?: number; +} + +const ValidationHint = ({ + validationKind, + repoPath, + defaultBranch, + pluginCount, +}: Readonly<ValidationHintProps>) => { + if (validationKind === 'idle') { + return ( + <PMText variant="small" color="faded"> + Paste an HTTPS or SSH URL. We check the repository is publicly readable. + </PMText> + ); + } + + if (validationKind === 'checking') { + return ( + <PMHStack gap={2} align="center"> + <PMSpinner size="sm" /> + <PMText variant="small" color="faded"> + Checking access… + </PMText> + </PMHStack> + ); + } + + if (validationKind === 'verified' && repoPath) { + return ( + <PMAlert.Root status="success"> + <PMAlert.Indicator /> + <PMAlert.Title>Repository verified</PMAlert.Title> + <PMAlert.Description> + {repoPath} + {defaultBranch ? ` · ${defaultBranch}` : ''} + {typeof pluginCount === 'number' + ? ` · ${pluginCount} ${pluginCount === 1 ? 'plugin' : 'plugins'}` + : ''} + </PMAlert.Description> + </PMAlert.Root> + ); + } + + return null; +}; diff --git a/apps/frontend/src/domain/marketplaces/components/RemovePluginButton.spec.tsx b/apps/frontend/src/domain/marketplaces/components/RemovePluginButton.spec.tsx new file mode 100644 index 000000000..fd80fa748 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/RemovePluginButton.spec.tsx @@ -0,0 +1,75 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { UIProvider } from '@packmind/ui'; +import { RemovePluginButton } from './RemovePluginButton'; + +const baseProps = { + pluginSlug: 'my-plugin', + marketplaceName: 'Acme Playbook', + packageName: 'My Plugin', + isMarking: false, +}; + +describe('RemovePluginButton', () => { + const renderButton = (overrides: Partial<typeof baseProps> = {}) => { + const onMark = jest.fn(); + render( + <UIProvider> + <RemovePluginButton {...baseProps} {...overrides} onMark={onMark} /> + </UIProvider>, + ); + return { onMark }; + }; + + it('exposes an accessible remove button labelled with the package and marketplace', () => { + renderButton(); + expect( + screen.getByRole('button', { + name: 'Remove My Plugin from Acme Playbook', + }), + ).toBeInTheDocument(); + }); + + it('opens the confirmation modal on click and explains the deletion PR is opened automatically', async () => { + renderButton(); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { + name: 'Remove My Plugin from Acme Playbook', + }), + ); + }); + + expect( + await screen.findByText(/Packmind opens the deletion PR/), + ).toBeInTheDocument(); + }); + + it('invokes onMark when the user confirms the modal', async () => { + const { onMark } = renderButton(); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { + name: 'Remove My Plugin from Acme Playbook', + }), + ); + }); + + const confirmButton = await screen.findByRole('button', { + name: 'Mark for removal', + }); + await act(async () => { + fireEvent.click(confirmButton); + }); + + expect(onMark).toHaveBeenCalledTimes(1); + }); + + it('shows the button in a loading state while isMarking is true', () => { + renderButton({ isMarking: true }); + const button = screen.getByRole('button', { + name: 'Remove My Plugin from Acme Playbook', + }); + expect(button).toBeDisabled(); + }); +}); diff --git a/apps/frontend/src/domain/marketplaces/components/RemovePluginButton.tsx b/apps/frontend/src/domain/marketplaces/components/RemovePluginButton.tsx new file mode 100644 index 000000000..749669f4e --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/RemovePluginButton.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import { PMButton, PMConfirmationModal, PMText, PMVStack } from '@packmind/ui'; + +export interface RemovePluginButtonProps { + pluginSlug: string; + marketplaceName?: string; + packageName?: string; + onMark: () => void; + isMarking: boolean; +} + +/** + * Action button + confirmation modal to mark a marketplace plugin distribution + * as `to_be_removed`. The modal body explains that marking for removal + * automatically opens (or amends) the Packmind sync deletion PR on the + * marketplace repo — the user's remaining step is to review and merge it. + * + * Mirrors the unlink-marketplace affordance in `MarketplaceRow.tsx`. + */ +export const RemovePluginButton = ({ + pluginSlug, + marketplaceName, + packageName, + onMark, + isMarking, +}: Readonly<RemovePluginButtonProps>) => { + const [confirmOpen, setConfirmOpen] = useState(false); + + const handleConfirm = () => { + onMark(); + setConfirmOpen(false); + }; + + const subjectLabel = packageName ?? pluginSlug; + const marketplaceLabel = marketplaceName ? ` from "${marketplaceName}"` : ''; + + return ( + <PMConfirmationModal + trigger={ + <PMButton + variant="danger" + size="sm" + loading={isMarking} + aria-label={`Remove ${subjectLabel}${ + marketplaceName ? ` from ${marketplaceName}` : '' + }`} + > + Remove + </PMButton> + } + title="Remove plugin from marketplace" + message={ + <PMVStack align="stretch" gap={2}> + <PMText> + {`Mark "${subjectLabel}"${marketplaceLabel} for removal? Existing installs will stay in place until the deletion PR is merged.`} + </PMText> + <PMText variant="small" color="secondary"> + {`Packmind opens the deletion PR on the marketplace repository automatically. Next step: review and merge that pull request to complete the removal.`} + </PMText> + </PMVStack> + } + confirmText="Mark for removal" + confirmColorScheme="red" + open={confirmOpen} + onOpenChange={({ open }) => setConfirmOpen(open)} + onConfirm={handleConfirm} + isLoading={isMarking} + /> + ); +}; diff --git a/apps/frontend/src/domain/marketplaces/components/SubmitErrorBanner.spec.tsx b/apps/frontend/src/domain/marketplaces/components/SubmitErrorBanner.spec.tsx new file mode 100644 index 000000000..7e0354815 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/SubmitErrorBanner.spec.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { UIProvider } from '@packmind/ui'; +import { SubmitErrorBanner } from './SubmitErrorBanner'; + +describe('SubmitErrorBanner', () => { + const renderBanner = (message: string) => + render( + <UIProvider> + <SubmitErrorBanner message={message} /> + </UIProvider>, + ); + + it('renders the banner with a fixed title', () => { + renderBanner('Something went wrong'); + + expect(screen.getByTestId('submit-error-banner')).toBeInTheDocument(); + expect(screen.getByText('Unable to link marketplace')).toBeInTheDocument(); + }); + + it('displays the provided message verbatim', () => { + const message = + 'Repository acme/foo is already linked as a standard Git repository in this organization'; + + renderBanner(message); + + expect(screen.getByText(message)).toBeInTheDocument(); + }); + + it('has the alert role for assistive technology', () => { + renderBanner('Boom'); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/domain/marketplaces/components/SubmitErrorBanner.tsx b/apps/frontend/src/domain/marketplaces/components/SubmitErrorBanner.tsx new file mode 100644 index 000000000..566902ac8 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/SubmitErrorBanner.tsx @@ -0,0 +1,24 @@ +import { PMAlert } from '@packmind/ui'; + +export interface SubmitErrorBannerProps { + /** + * Human-readable error message, typically surfaced verbatim from the API via + * `getSubmitErrorMessage`. + */ + message: string; +} + +/** + * Error banner for the link-marketplace flow. Displays the message produced by + * `getSubmitErrorMessage` — usually the backend's own error detail — under a + * fixed title, so the user always sees why the link failed. + */ +export const SubmitErrorBanner = ({ + message, +}: Readonly<SubmitErrorBannerProps>) => ( + <PMAlert.Root status="error" role="alert" data-testid="submit-error-banner"> + <PMAlert.Indicator /> + <PMAlert.Title>Unable to link marketplace</PMAlert.Title> + <PMAlert.Description>{message}</PMAlert.Description> + </PMAlert.Root> +); diff --git a/apps/frontend/src/domain/marketplaces/components/errorMapping.spec.ts b/apps/frontend/src/domain/marketplaces/components/errorMapping.spec.ts new file mode 100644 index 000000000..4cc2be04c --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/errorMapping.spec.ts @@ -0,0 +1,48 @@ +import { PackmindError } from '../../../services/api/errors/PackmindError'; +import { PackmindConflictError } from '../../../services/api/errors/PackmindConflictError'; +import { NETWORK_ERROR_MESSAGE, getSubmitErrorMessage } from './errorMapping'; + +describe('getSubmitErrorMessage', () => { + it('surfaces the backend message from a PackmindConflictError (409 already-linked-as-standard)', () => { + const message = + 'Repository acme/foo is already linked as a standard Git repository in this organization'; + + const error = new PackmindConflictError({ + status: 409, + statusText: 'Conflict', + data: { message }, + }); + + expect(getSubmitErrorMessage(error)).toBe(message); + }); + + it('surfaces the backend message from a generic PackmindError', () => { + const message = 'No marketplace.json descriptor was found in acme/foo'; + + const error = new PackmindError({ + status: 400, + statusText: 'Bad Request', + data: { message }, + }); + + expect(getSubmitErrorMessage(error)).toBe(message); + }); + + it('falls back to the connectivity message for a plain network error', () => { + expect( + getSubmitErrorMessage( + new Error('Network Error: No response from server'), + ), + ).toBe(NETWORK_ERROR_MESSAGE); + }); + + describe('when the error is a non-object value', () => { + it('falls back to the connectivity message for a string error', () => { + expect(getSubmitErrorMessage('boom')).toBe(NETWORK_ERROR_MESSAGE); + }); + + it('falls back to the connectivity message for a null error', () => { + expect(getSubmitErrorMessage(null)).toBe(NETWORK_ERROR_MESSAGE); + }); + }); +}); diff --git a/apps/frontend/src/domain/marketplaces/components/errorMapping.ts b/apps/frontend/src/domain/marketplaces/components/errorMapping.ts new file mode 100644 index 000000000..12de172af --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/errorMapping.ts @@ -0,0 +1,26 @@ +import { isPackmindError } from '../../../services/api/errors/PackmindError'; + +/** Shown when the request never reached the server (no usable server message). */ +export const NETWORK_ERROR_MESSAGE = + 'The server could not be reached. Check your connection and try again.'; + +/** + * Extracts a human-readable message from a thrown link / validate-url error. + * + * The shared `ApiService` wraps HTTP errors in a `PackmindError` whose + * `serverError.data.message` carries the backend's detail — the typed domain + * errors mapped in `marketplaces.controller.ts` (e.g. "Repository acme/foo is + * already linked as a standard Git repository in this organization") surface + * here verbatim. Anything else (a genuine connectivity failure, an unexpected + * shape) falls back to a generic connectivity message. + */ +export function getSubmitErrorMessage(error: unknown): string { + if (isPackmindError(error)) { + const message = error.serverError.data.message; + if (typeof message === 'string' && message.trim()) { + return message; + } + } + + return NETWORK_ERROR_MESSAGE; +} diff --git a/apps/frontend/src/domain/marketplaces/components/index.ts b/apps/frontend/src/domain/marketplaces/components/index.ts new file mode 100644 index 000000000..efd262165 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/components/index.ts @@ -0,0 +1,19 @@ +// Barrel for the marketplaces components. Components ship in dependency +// order: presentational pieces first, then the form composites, then the +// drawer + list page that compose them. +export * from './MarketplaceStateBadge'; +export * from './MarketplaceStateDot'; +export * from './SubmitErrorBanner'; +export * from './AgentsFieldset'; +export * from './GitNotConnectedNotice'; +export * from './MarketplaceRow'; +export * from './MarketplacesIndex'; +export * from './PrivateLinkForm'; +export * from './PublicLinkForm'; +export * from './LinkMarketplacePanel'; +export * from './errorMapping'; +export * from './DistributionStatusBadge'; +export * from './RemovePluginButton'; +export * from './PluginAdoptionTab'; +export * from './MarketplaceDetailAlerts'; +export * from './MarketplaceDetailLayout'; diff --git a/apps/frontend/src/domain/marketplaces/hooks/index.ts b/apps/frontend/src/domain/marketplaces/hooks/index.ts new file mode 100644 index 000000000..d22852531 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/hooks/index.ts @@ -0,0 +1 @@ +export * from './useRefreshMarketplacesOnOpen'; diff --git a/apps/frontend/src/domain/marketplaces/hooks/useRefreshMarketplacesOnOpen.spec.ts b/apps/frontend/src/domain/marketplaces/hooks/useRefreshMarketplacesOnOpen.spec.ts new file mode 100644 index 000000000..2ff663aa0 --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/hooks/useRefreshMarketplacesOnOpen.spec.ts @@ -0,0 +1,317 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { createElement, type ReactNode } from 'react'; +import type { + MarketplaceId, + MarketplaceListItem, + OrganizationId, + SyncMarketplaceNowResponse, +} from '@packmind/types'; +import { marketplaceGateway } from '../api/gateways'; +import { marketplaceQueryKeys } from '../api/queries/MarketplaceQueries'; +import { useRefreshMarketplacesOnOpen } from './useRefreshMarketplacesOnOpen'; + +jest.mock('../api/gateways', () => ({ + marketplaceGateway: { + syncMarketplaceNow: jest.fn(), + }, +})); + +const mockGateway = marketplaceGateway as unknown as { + syncMarketplaceNow: jest.Mock; +}; + +const orgId = 'org-1' as OrganizationId; + +const makeRow = ( + id: string, + lastValidatedAt: Date | null, +): MarketplaceListItem => + ({ + id: id as MarketplaceId, + state: 'healthy', + lastValidatedAt, + errorKind: null, + errorDetail: null, + pendingPrUrl: null, + outdatedPluginSlugs: null, + }) as unknown as MarketplaceListItem; + +const okResponse = ( + state: SyncMarketplaceNowResponse['state'], +): SyncMarketplaceNowResponse => ({ + state, + lastValidatedAt: new Date('2026-06-09T12:00:00Z'), + errorKind: null, + errorDetail: null, + pendingPrUrl: null, + outdatedPluginSlugs: null, +}); + +function createWrapper(client: QueryClient) { + return ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client }, children); +} + +function createClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +describe('useRefreshMarketplacesOnOpen', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when rows are stale', () => { + it('fires syncMarketplaceNow once per stale row', async () => { + mockGateway.syncMarketplaceNow.mockResolvedValue(okResponse('healthy')); + const rows = [makeRow('m1', null), makeRow('m2', null)]; + const client = createClient(); + + renderHook(() => useRefreshMarketplacesOnOpen(orgId, rows), { + wrapper: createWrapper(client), + }); + + await waitFor(() => { + expect(mockGateway.syncMarketplaceNow).toHaveBeenCalledTimes(2); + }); + }); + + it('patches the cached row with the reconcile result', async () => { + mockGateway.syncMarketplaceNow.mockResolvedValue( + okResponse('unreachable'), + ); + const rows = [makeRow('m1', null)]; + const client = createClient(); + client.setQueryData(marketplaceQueryKeys.list(orgId), rows); + + renderHook(() => useRefreshMarketplacesOnOpen(orgId, rows), { + wrapper: createWrapper(client), + }); + + await waitFor(() => { + const cached = client.getQueryData<MarketplaceListItem[]>( + marketplaceQueryKeys.list(orgId), + ); + expect(cached?.[0].state).toBe('unreachable'); + }); + }); + + it('clears refreshingIds once every row resolves', async () => { + mockGateway.syncMarketplaceNow.mockResolvedValue(okResponse('healthy')); + const rows = [makeRow('m1', null), makeRow('m2', null)]; + const client = createClient(); + + const { result } = renderHook( + () => useRefreshMarketplacesOnOpen(orgId, rows), + { wrapper: createWrapper(client) }, + ); + + await waitFor(() => { + expect(result.current.refreshingIds.size).toBe(0); + }); + }); + }); + + describe('when a row was validated within the freshness window', () => { + it('does not refresh it', async () => { + mockGateway.syncMarketplaceNow.mockResolvedValue(okResponse('healthy')); + const fresh = makeRow('fresh', new Date()); + const stale = makeRow('stale', null); + const client = createClient(); + + renderHook(() => useRefreshMarketplacesOnOpen(orgId, [fresh, stale]), { + wrapper: createWrapper(client), + }); + + await waitFor(() => { + expect(mockGateway.syncMarketplaceNow).toHaveBeenCalledTimes(1); + }); + expect(mockGateway.syncMarketplaceNow).toHaveBeenCalledWith( + orgId, + 'stale', + ); + }); + }); + + describe('when a row fails to refresh', () => { + it('still clears refreshingIds for every row', async () => { + mockGateway.syncMarketplaceNow.mockImplementation( + (_org: OrganizationId, id: MarketplaceId) => + id === ('m1' as MarketplaceId) + ? Promise.reject(new Error('boom')) + : Promise.resolve(okResponse('healthy')), + ); + const rows = [makeRow('m1', null), makeRow('m2', null)]; + const client = createClient(); + + const { result } = renderHook( + () => useRefreshMarketplacesOnOpen(orgId, rows), + { wrapper: createWrapper(client) }, + ); + + await waitFor(() => { + expect(result.current.refreshingIds.size).toBe(0); + }); + }); + + it('keeps draining the queue past the failing row', async () => { + mockGateway.syncMarketplaceNow.mockImplementation( + (_org: OrganizationId, id: MarketplaceId) => + id === ('m1' as MarketplaceId) + ? Promise.reject(new Error('boom')) + : Promise.resolve(okResponse('healthy')), + ); + const rows = Array.from({ length: 6 }, (_, i) => makeRow(`m${i}`, null)); + const client = createClient(); + + renderHook(() => useRefreshMarketplacesOnOpen(orgId, rows), { + wrapper: createWrapper(client), + }); + + await waitFor(() => { + expect(mockGateway.syncMarketplaceNow).toHaveBeenCalledTimes(6); + }); + }); + }); + + describe('when the page unmounts mid-refresh', () => { + it('does not fire reconcile requests for rows still in the queue', async () => { + const resolvers: Array<() => void> = []; + mockGateway.syncMarketplaceNow.mockImplementation( + () => + new Promise<SyncMarketplaceNowResponse>((resolve) => { + resolvers.push(() => resolve(okResponse('healthy'))); + }), + ); + const rows = Array.from({ length: 10 }, (_, i) => makeRow(`m${i}`, null)); + const client = createClient(); + + const { unmount } = renderHook( + () => useRefreshMarketplacesOnOpen(orgId, rows), + { wrapper: createWrapper(client) }, + ); + + await waitFor(() => { + expect(mockGateway.syncMarketplaceNow).toHaveBeenCalledTimes(4); + }); + + unmount(); + resolvers.forEach((resolve) => resolve()); + // Give any (incorrect) queued drains a chance to fire before asserting. + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(mockGateway.syncMarketplaceNow).toHaveBeenCalledTimes(4); + }); + }); + + describe('when a refresh reports drift', () => { + it('invalidates the list so drifted plugin slugs resync', async () => { + mockGateway.syncMarketplaceNow.mockResolvedValue(okResponse('drift')); + const rows = [makeRow('m1', null)]; + const client = createClient(); + const invalidateSpy = jest.spyOn(client, 'invalidateQueries'); + + renderHook(() => useRefreshMarketplacesOnOpen(orgId, rows), { + wrapper: createWrapper(client), + }); + + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: marketplaceQueryKeys.list(orgId), + }); + }); + }); + + it('does not invalidate the list for a healthy result', async () => { + mockGateway.syncMarketplaceNow.mockResolvedValue(okResponse('healthy')); + const rows = [makeRow('m1', null)]; + const client = createClient(); + const invalidateSpy = jest.spyOn(client, 'invalidateQueries'); + + const { result } = renderHook( + () => useRefreshMarketplacesOnOpen(orgId, rows), + { wrapper: createWrapper(client) }, + ); + + await waitFor(() => { + expect(result.current.refreshingIds.size).toBe(0); + }); + expect(invalidateSpy).not.toHaveBeenCalledWith({ + queryKey: marketplaceQueryKeys.list(orgId), + }); + }); + }); + + describe('after a successful refresh', () => { + it('invalidates the per-marketplace distributions list', async () => { + mockGateway.syncMarketplaceNow.mockResolvedValue(okResponse('healthy')); + const rows = [makeRow('m1', null)]; + const client = createClient(); + const invalidateSpy = jest.spyOn(client, 'invalidateQueries'); + + renderHook(() => useRefreshMarketplacesOnOpen(orgId, rows), { + wrapper: createWrapper(client), + }); + + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: marketplaceQueryKeys.distributionList( + orgId, + 'm1' as MarketplaceId, + ), + }); + }); + }); + + it('invalidates the per-marketplace distribution-changes cache', async () => { + mockGateway.syncMarketplaceNow.mockResolvedValue(okResponse('healthy')); + const rows = [makeRow('m1', null)]; + const client = createClient(); + const invalidateSpy = jest.spyOn(client, 'invalidateQueries'); + + renderHook(() => useRefreshMarketplacesOnOpen(orgId, rows), { + wrapper: createWrapper(client), + }); + + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: marketplaceQueryKeys.distributionChangesForMarketplace( + orgId, + 'm1' as MarketplaceId, + ), + }); + }); + }); + }); + + describe('when more rows than the concurrency cap need refreshing', () => { + it('never exceeds the max in-flight requests', async () => { + let inFlight = 0; + let maxInFlight = 0; + mockGateway.syncMarketplaceNow.mockImplementation(() => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + return new Promise<SyncMarketplaceNowResponse>((resolve) => { + setTimeout(() => { + inFlight -= 1; + resolve(okResponse('healthy')); + }, 5); + }); + }); + const rows = Array.from({ length: 10 }, (_, i) => makeRow(`m${i}`, null)); + const client = createClient(); + + renderHook(() => useRefreshMarketplacesOnOpen(orgId, rows), { + wrapper: createWrapper(client), + }); + + await waitFor(() => { + expect(mockGateway.syncMarketplaceNow).toHaveBeenCalledTimes(10); + }); + expect(maxInFlight).toBeLessThanOrEqual(4); + }); + }); +}); diff --git a/apps/frontend/src/domain/marketplaces/hooks/useRefreshMarketplacesOnOpen.ts b/apps/frontend/src/domain/marketplaces/hooks/useRefreshMarketplacesOnOpen.ts new file mode 100644 index 000000000..e82c4e47c --- /dev/null +++ b/apps/frontend/src/domain/marketplaces/hooks/useRefreshMarketplacesOnOpen.ts @@ -0,0 +1,125 @@ +import { useEffect, useRef, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import type { + MarketplaceId, + MarketplaceListItem, + OrganizationId, +} from '@packmind/types'; +import { marketplaceGateway } from '../api/gateways'; +import { + marketplaceQueryKeys, + patchMarketplaceInCache, +} from '../api/queries/MarketplaceQueries'; + +const MAX_CONCURRENCY = 4; +/** Client-side echo of the server freshness window; avoids firing for rows validated seconds ago. */ +const CLIENT_FRESHNESS_WINDOW_MS = 10_000; + +/** + * On page open, refreshes each marketplace row's live state by firing + * `syncMarketplaceNow` per row (bounded concurrency) and patching the cached + * list row as each result lands. Rows validated within the freshness window + * are skipped. Runs once per mount; `startedRef` guards re-entry. + */ +export function useRefreshMarketplacesOnOpen( + organizationId: OrganizationId | string, + marketplaces: MarketplaceListItem[], +): { refreshingIds: ReadonlySet<MarketplaceId> } { + const queryClient = useQueryClient(); + const [refreshingIds, setRefreshingIds] = useState<Set<MarketplaceId>>( + new Set(), + ); + const startedRef = useRef(false); + + useEffect(() => { + if (startedRef.current || !organizationId || marketplaces.length === 0) { + return; + } + startedRef.current = true; + + const now = Date.now(); + const targets = marketplaces.filter((m) => { + if (!m.lastValidatedAt) return true; + return ( + now - new Date(m.lastValidatedAt).getTime() >= + CLIENT_FRESHNESS_WINDOW_MS + ); + }); + if (targets.length === 0) return; + + setRefreshingIds(new Set(targets.map((m) => m.id))); + + let cancelled = false; + const queue = [...targets]; + const runOne = async (): Promise<void> => { + // Stop draining the queue once the page is gone — queued rows must not + // fire server-side reconciliations the user will never see. + if (cancelled) return; + const next = queue.shift(); + if (!next) return; + try { + const result = await marketplaceGateway.syncMarketplaceNow( + organizationId as OrganizationId, + next.id, + ); + if (!cancelled) { + patchMarketplaceInCache(queryClient, organizationId, next.id, result); + // A reconcile can flip `pending_merge → success`, prune + // `to_be_removed → removed`, and shift the diff exposed by the + // changes tab. Invalidate per-marketplace so any open detail page + // refetches with the freshly reconciled state. + void queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.distributionList( + organizationId, + next.id, + ), + }); + void queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.distributionChangesForMarketplace( + organizationId, + next.id, + ), + }); + if (result.state === 'drift') { + // Drift details (descriptor.driftedPluginSlugs) are not part of + // the sync response, so re-fetch the list to pick up the slug + // list the reconcile just persisted — mirrors the manual + // "Sync now" path which invalidates on every result. + void queryClient.invalidateQueries({ + queryKey: marketplaceQueryKeys.list(organizationId), + }); + } + } + } catch (error) { + console.error('Failed to refresh marketplace on open', next.id, error); + } finally { + if (!cancelled) { + setRefreshingIds((prev) => { + const nextSet = new Set(prev); + nextSet.delete(next.id); + return nextSet; + }); + } + await runOne(); + } + }; + + void Promise.all( + Array.from({ length: Math.min(MAX_CONCURRENCY, queue.length) }, () => + runOne(), + ), + ); + + return () => { + cancelled = true; + // Allow a clean re-run on remount (e.g. React StrictMode's dev + // double-mount) — otherwise the second mount would skip the refresh + // and leave the spinners populated by the first, now-cancelled run. + startedRef.current = false; + }; + // Intentionally run once per mount; `startedRef` guards re-entry. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [organizationId, marketplaces.length]); + + return { refreshingIds }; +} diff --git a/apps/frontend/src/domain/organizations/components/SidebarNavigation.tsx b/apps/frontend/src/domain/organizations/components/SidebarNavigation.tsx index c6f44fc8c..e366538f6 100644 --- a/apps/frontend/src/domain/organizations/components/SidebarNavigation.tsx +++ b/apps/frontend/src/domain/organizations/components/SidebarNavigation.tsx @@ -19,6 +19,7 @@ import { DEFAULT_FEATURE_DOMAIN_MAP, GOVERNANCE_FEATURE_KEY, isFeatureFlagEnabled, + MARKETPLACES_FEATURE_KEY, } from '@packmind/ui'; import { NavLink, useLocation, useNavigate, useParams } from 'react-router'; import { @@ -35,7 +36,9 @@ import { LuLogOut, LuPanelLeftClose, LuPanelLeftOpen, + LuSearch, LuSettings, + LuStore, LuWrench, } from 'react-icons/lu'; import { Analytics } from '@packmind/proprietary/frontend/domain/amplitude/providers/analytics'; @@ -51,6 +54,7 @@ import { useSidebarCollapse } from './SidebarCollapseContext'; import { SpaceNavBlock } from './sidebar/SpaceNavBlock'; import { SpaceNavPanel } from './sidebar/SpaceNavPanel'; import { BrowseSpaces } from '@packmind/proprietary/frontend/domain/spaces-management/components/BrowseSpaces'; +import { BrowseSpacesTab } from '@packmind/proprietary/frontend/domain/spaces-management/components/BrowseSpacesDrawer'; import { CustomSpacesNavBlock } from '@packmind/proprietary/frontend/domain/spaces-management/components/CustomSpacesNavBlock'; const SIDEBAR_WIDTH_EXPANDED = '220px'; @@ -168,6 +172,16 @@ export const SidebarNavigation: React.FunctionComponent< const { user } = useAuthContext(); const [activeSpacePanel, setActiveSpacePanel] = useState<string | null>(null); const [lastPanelSpace, setLastPanelSpace] = useState<Space | null>(null); + const [browseDrawerOpen, setBrowseDrawerOpen] = useState(false); + const [browseDrawerTab, setBrowseDrawerTab] = useState<BrowseSpacesTab>( + BrowseSpacesTab.MY_SPACES, + ); + + const openBrowseDrawer = (tab: BrowseSpacesTab) => { + setBrowseDrawerTab(tab); + setBrowseDrawerOpen(true); + }; + const location = useLocation(); const navigate = useNavigate(); const signOutMutation = useSignOutMutation(); @@ -238,6 +252,17 @@ export const SidebarNavigation: React.FunctionComponent< const orgSlug = organization.slug; + // Marketplaces are administered at the org level and still behind a feature + // flag, so the main-sidebar entry is shown only to admins whose email opts + // into the flag — matching the page's own admin guard. + const canSeeMarketplaces = + organization.role === 'admin' && + isFeatureFlagEnabled({ + featureKeys: [MARKETPLACES_FEATURE_KEY], + featureDomainMap: DEFAULT_FEATURE_DOMAIN_MAP, + userEmail: user?.email, + }); + const defaultSpace = spaces.find((space) => space.isDefaultSpace); if (!defaultSpace) { @@ -439,6 +464,21 @@ export const SidebarNavigation: React.FunctionComponent< /> </PMBox> )} + {canSeeMarketplaces && ( + <PMBox paddingBottom={2}> + <PMVerticalNavSection + navEntries={[ + <SidebarNavigationLink + key="marketplaces" + url={routes.org.toMarketplaces(orgSlug)} + label="Marketplaces" + icon={<LuStore />} + aria-label="Marketplaces" + />, + ]} + /> + </PMBox> + )} {/* Spaces -- scrollable */} <PMBox @@ -466,12 +506,27 @@ export const SidebarNavigation: React.FunctionComponent< > Spaces </PMText> - <BrowseSpaces /> + <PMBox + as="button" + color="text.faded" + cursor="pointer" + _hover={{ color: 'text.primary' }} + transition="color 0.15s" + onClick={() => openBrowseDrawer(BrowseSpacesTab.ALL_SPACES)} + data-testid="browse-spaces-trigger" + display="flex" + alignItems="center" + > + <PMIcon fontSize="xs"> + <LuSearch /> + </PMIcon> + </PMBox> </PMBox> )} <PMVStack alignItems="stretch" + gap={isCollapsed ? 2 : 0} scrollbarColor="{colors.background.tertiary} transparent" minHeight={0} overflowY="auto" @@ -482,11 +537,7 @@ export const SidebarNavigation: React.FunctionComponent< orgSlug={orgSlug} isActive={defaultSpace.slug === currentSpaceSlug} isSelected={activeSpacePanel === defaultSpace.id} - onSpaceClick={() => { - if (defaultSpace.slug !== currentSpaceSlug) { - setActiveSpacePanel(defaultSpace.id); - } - }} + onSpaceClick={() => setActiveSpacePanel(defaultSpace.id)} dataTestId={SidebarNavigationDataTestId.DefaultSpaceRow} /> @@ -496,11 +547,10 @@ export const SidebarNavigation: React.FunctionComponent< orgSlug={orgSlug} currentSpaceSlug={currentSpaceSlug} selectedSpaceId={activeSpacePanel} - onSpaceClick={(space) => { - if (space.slug !== currentSpaceSlug) { - setActiveSpacePanel(space.id); - } - }} + onSpaceClick={(space) => setActiveSpacePanel(space.id)} + onBrowseMySpaces={() => + openBrowseDrawer(BrowseSpacesTab.MY_SPACES) + } /> )} </PMVStack> @@ -508,6 +558,13 @@ export const SidebarNavigation: React.FunctionComponent< </PMBox> </PMVerticalNav> + <BrowseSpaces + open={browseDrawerOpen} + onClose={() => setBrowseDrawerOpen(false)} + initialTab={browseDrawerTab} + containerRef={contentAreaRef} + /> + {/* SpaceNavPanel drawer */} {lastPanelSpace && ( <SpaceNavPanel diff --git a/apps/frontend/src/domain/organizations/components/sidebar/SpaceNavBlock.tsx b/apps/frontend/src/domain/organizations/components/sidebar/SpaceNavBlock.tsx index 0e1723a24..de9cd9c5d 100644 --- a/apps/frontend/src/domain/organizations/components/sidebar/SpaceNavBlock.tsx +++ b/apps/frontend/src/domain/organizations/components/sidebar/SpaceNavBlock.tsx @@ -14,9 +14,14 @@ import { LuHouse, LuPackage, LuSlidersHorizontal, + LuStar, LuTerminal, LuWandSparkles, } from 'react-icons/lu'; +import { + usePinSpaceMutation, + useUnpinSpaceMutation, +} from '../../../spaces-management/api/queries/SpacesManagementQueries'; import { SpaceVisibilityIcon } from './SpaceVisibilityIcon'; import { useNavigate } from 'react-router'; import type { UserSpaceWithRole } from '@packmind/types'; @@ -154,6 +159,8 @@ function ExpandedSpaceNavBlock({ dataTestId, }: Readonly<SpaceNavBlockProps>): React.ReactElement { const navigate = useNavigate(); + const pinMutation = usePinSpaceMutation(); + const unpinMutation = useUnpinSpaceMutation(); return ( <PMBox> @@ -194,11 +201,7 @@ function ExpandedSpaceNavBlock({ whiteSpace="nowrap" minW={0} > - <PMStatus.Root - colorPalette={getSpaceColorPalette(space.name)} - as="span" - mr={1.5} - > + <PMStatus.Root colorPalette={space.color} as="span" mr={1.5}> <PMStatus.Indicator /> </PMStatus.Root> {space.name} @@ -212,10 +215,37 @@ function ExpandedSpaceNavBlock({ onClick={() => navigate(routes.space.toSettings(orgSlug, space.slug)) } + mr={1} data-testid={SidebarNavigationDataTestId.SpaceSettingsLink} > <LuSlidersHorizontal /> </PMIconButton> + {!space.isDefaultSpace && ( + <PMBox + as="button" + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + if (space.pinned) { + unpinMutation.mutate({ spaceId: space.id }); + } else { + pinMutation.mutate({ spaceId: space.id }); + } + }} + title={space.pinned ? 'Unpin space' : 'Pin space'} + flexShrink={0} + cursor="pointer" + color={space.pinned ? 'yellow.400' : 'text.faded'} + _hover={{ color: 'yellow.400' }} + display="flex" + alignItems="center" + data-testid={`space-pin-toggle-${space.id}`} + > + <LuStar + size={14} + fill={space.pinned ? 'currentColor' : 'none'} + /> + </PMBox> + )} </PMBox> <SpaceNavSections orgSlug={orgSlug} spaceSlug={space.slug} /> </PMBox> @@ -232,7 +262,6 @@ function CollapsedSpaceNavBlock({ dataTestId, }: Readonly<Omit<SpaceNavBlockProps, 'isSelected'>>): React.ReactElement { const initials = getSpaceInitials(space.name); - const navigate = useNavigate(); return ( <PMBox @@ -244,62 +273,31 @@ function CollapsedSpaceNavBlock({ borderRadius="md" py={isActive ? 1.5 : 0} > - <PMBox - display="flex" - alignItems="center" - gap={0.5} - {...(!isActive && { - css: { - '& .space-settings-btn': { - opacity: 0, - transition: 'opacity 0.15s', - }, - '&:hover .space-settings-btn': { opacity: 1 }, - }, - })} - > - <PMTooltip label={space.name}> - <PMBox - as="button" - onClick={onSpaceClick} - cursor="pointer" - display="flex" - alignItems="center" - justifyContent="center" - data-testid={dataTestId} - > - <PMAvatar.Root - size="xs" - borderRadius="sm" - backgroundColor={`${getSpaceColorPalette(space.name)}.solid`} - color="text.primary" - {...(isActive && { - outline: '2px solid', - outlineColor: 'border.primary', - outlineOffset: '2px', - })} - > - <PMAvatar.Fallback>{initials}</PMAvatar.Fallback> - </PMAvatar.Root> - </PMBox> - </PMTooltip> - - {!isActive && ( - <PMIconButton - className="space-settings-btn" - aria-label="Space settings" - size="2xs" - variant="ghost" - onClick={(e: React.MouseEvent) => { - e.stopPropagation(); - navigate(routes.space.toSettings(orgSlug, space.slug)); - }} - data-testid={SidebarNavigationDataTestId.SpaceSettingsLink} + <PMTooltip label={space.name}> + <PMBox + as="button" + onClick={onSpaceClick} + cursor="pointer" + display="flex" + alignItems="center" + justifyContent="center" + data-testid={dataTestId} + > + <PMAvatar.Root + size="xs" + borderRadius="sm" + backgroundColor={`${space.color}.solid`} + color="text.primary" + {...(isActive && { + outline: '2px solid', + outlineColor: 'border.primary', + outlineOffset: '2px', + })} > - <LuSlidersHorizontal /> - </PMIconButton> - )} - </PMBox> + <PMAvatar.Fallback>{initials}</PMAvatar.Fallback> + </PMAvatar.Root> + </PMBox> + </PMTooltip> {isActive && ( <PMBox @@ -332,6 +330,8 @@ function SpaceNameRow({ dataTestId?: string; }>): React.ReactElement { const navigate = useNavigate(); + const pinMutation = usePinSpaceMutation(); + const unpinMutation = useUnpinSpaceMutation(); return ( <PMBox @@ -366,11 +366,7 @@ function SpaceNameRow({ whiteSpace="nowrap" minW={0} > - <PMStatus.Root - colorPalette={getSpaceColorPalette(space.name)} - as="span" - mr={1.5} - > + <PMStatus.Root colorPalette={space.color} as="span" mr={1.5}> <PMStatus.Indicator /> </PMStatus.Root> {space.name} @@ -390,6 +386,31 @@ function SpaceNameRow({ > <LuSlidersHorizontal /> </PMIconButton> + {!space.isDefaultSpace && ( + <PMBox + as="button" + {...(!space.pinned && { className: 'space-settings-btn' })} + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + if (space.pinned) { + unpinMutation.mutate({ spaceId: space.id }); + } else { + pinMutation.mutate({ spaceId: space.id }); + } + }} + title={space.pinned ? 'Unpin space' : 'Pin space'} + flexShrink={0} + cursor="pointer" + transition="opacity 0.15s" + color={space.pinned ? 'yellow.400' : 'text.faded'} + _hover={{ color: 'yellow.400' }} + display="flex" + alignItems="center" + data-testid={`space-pin-toggle-${space.id}`} + > + <LuStar size={14} fill={space.pinned ? 'currentColor' : 'none'} /> + </PMBox> + )} </PMBox> ); } diff --git a/apps/frontend/src/domain/organizations/components/sidebar/SpaceNavPanel.tsx b/apps/frontend/src/domain/organizations/components/sidebar/SpaceNavPanel.tsx index eb49643ee..a40700820 100644 --- a/apps/frontend/src/domain/organizations/components/sidebar/SpaceNavPanel.tsx +++ b/apps/frontend/src/domain/organizations/components/sidebar/SpaceNavPanel.tsx @@ -1,6 +1,16 @@ import type { RefObject } from 'react'; -import { PMBox, PMCloseButton, PMDrawer, PMPortal } from '@packmind/ui'; +import { + PMBox, + PMCloseButton, + PMDrawer, + PMIconButton, + PMPortal, +} from '@packmind/ui'; +import { LuSlidersHorizontal } from 'react-icons/lu'; +import { useNavigate } from 'react-router'; import type { Space } from '@packmind/types'; +import { SidebarNavigationDataTestId } from '@packmind/frontend'; +import { routes } from '../../../../shared/utils/routes'; import { SpaceNavSections } from './SpaceNavSections'; interface SpaceNavPanelProps { @@ -18,6 +28,8 @@ export function SpaceNavPanel({ onClose, containerRef, }: Readonly<SpaceNavPanelProps>) { + const navigate = useNavigate(); + return ( <PMDrawer.Root open={open} @@ -28,11 +40,24 @@ export function SpaceNavPanel({ size="xs" > <PMPortal container={containerRef}> - <PMDrawer.Backdrop position="absolute" /> - <PMDrawer.Positioner position="absolute"> + <PMDrawer.Backdrop position="absolute" boxSize="full" /> + <PMDrawer.Positioner position="absolute" boxSize="full"> <PMDrawer.Content> <PMDrawer.Header> - <PMDrawer.Title fontSize="sm">{space.name}</PMDrawer.Title> + <PMBox display="flex" alignItems="center" gap={1}> + <PMDrawer.Title fontSize="sm">{space.name}</PMDrawer.Title> + <PMIconButton + aria-label="Space settings" + size="2xs" + variant="ghost" + onClick={() => + navigate(routes.space.toSettings(orgSlug, space.slug)) + } + data-testid={SidebarNavigationDataTestId.SpaceSettingsLink} + > + <LuSlidersHorizontal /> + </PMIconButton> + </PMBox> </PMDrawer.Header> <PMDrawer.Body paddingX={1} paddingY={2}> <SpaceNavSections orgSlug={orgSlug} spaceSlug={space.slug} /> diff --git a/apps/frontend/src/domain/organizations/components/sidebar/SpaceNavSections.tsx b/apps/frontend/src/domain/organizations/components/sidebar/SpaceNavSections.tsx index 303784d17..8c5c9b105 100644 --- a/apps/frontend/src/domain/organizations/components/sidebar/SpaceNavSections.tsx +++ b/apps/frontend/src/domain/organizations/components/sidebar/SpaceNavSections.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { PMBox } from '@packmind/ui'; import { LuBookCheck, + LuGitPullRequestArrow, LuHouse, LuPackage, LuTerminal, @@ -9,6 +10,8 @@ import { } from 'react-icons/lu'; import { SidebarNavigationDataTestId } from '@packmind/frontend'; import { routes } from '../../../../shared/utils/routes'; +import { useGetSpaceBySlugQuery } from '../../../spaces/api/queries/SpacesQueries'; +import { useGetGroupedChangeProposalsQuery } from '../../../change-proposals/api/queries/ChangeProposalsQueries'; import { SpaceNavItemLink } from './SpaceNavItemLink'; interface SpaceNavSectionsProps { @@ -46,6 +49,8 @@ export function SpaceNavSections({ icon={<LuWandSparkles />} data-testid={SidebarNavigationDataTestId.SkillsLink} /> + <ReviewChangesNavLink orgSlug={orgSlug} spaceSlug={spaceSlug} /> + <SectionHeading title="Distribution" /> <SpaceNavItemLink url={routes.space.toPackages(orgSlug, spaceSlug)} @@ -77,3 +82,33 @@ function SectionHeading({ </PMBox> ); } + +function ReviewChangesNavLink({ + orgSlug, + spaceSlug, +}: Readonly<{ orgSlug: string; spaceSlug: string }>): React.ReactElement { + const { data: space } = useGetSpaceBySlugQuery(spaceSlug); + const { data: groupedProposals } = useGetGroupedChangeProposalsQuery( + space?.id, + ); + + const totalArtefacts = groupedProposals + ? groupedProposals.standards.length + + groupedProposals.commands.length + + groupedProposals.skills.length + + groupedProposals.creations.length + : 0; + + return ( + <SpaceNavItemLink + url={routes.space.toReviewChanges(orgSlug, spaceSlug)} + label="Review changes" + icon={<LuGitPullRequestArrow />} + badge={ + totalArtefacts > 0 + ? { text: String(totalArtefacts), colorScheme: 'blue' } + : undefined + } + /> + ); +} diff --git a/apps/frontend/src/domain/recipes/api/queries/RecipesQueries.ts b/apps/frontend/src/domain/recipes/api/queries/RecipesQueries.ts index ea3ab0ce6..a8bdc4b2f 100644 --- a/apps/frontend/src/domain/recipes/api/queries/RecipesQueries.ts +++ b/apps/frontend/src/domain/recipes/api/queries/RecipesQueries.ts @@ -18,7 +18,7 @@ import { useCurrentSpace } from '../../../spaces/hooks/useCurrentSpace'; import { CHANGE_PROPOSALS_QUERY_SCOPE, GET_GROUPED_CHANGE_PROPOSALS_KEY, -} from '@packmind/proprietary/frontend/domain/change-proposals/api/queryKeys'; +} from '../../../change-proposals/api/queryKeys'; export const getRecipesBySpaceQueryOptions = ( organizationId: OrganizationId | undefined, diff --git a/apps/frontend/src/domain/recipes/components/ProposeChangeModal.test.tsx b/apps/frontend/src/domain/recipes/components/ProposeChangeModal.test.tsx index 006c28dc5..9444d52c2 100644 --- a/apps/frontend/src/domain/recipes/components/ProposeChangeModal.test.tsx +++ b/apps/frontend/src/domain/recipes/components/ProposeChangeModal.test.tsx @@ -17,14 +17,11 @@ import { ChangeProposalCaptureMode, } from '@packmind/types'; import { ProposeChangeModal } from './ProposeChangeModal'; -import { useCreateChangeProposalMutation } from '@packmind/proprietary/frontend/domain/change-proposals/api/queries/ChangeProposalsQueries'; - -jest.mock( - '@packmind/proprietary/frontend/domain/change-proposals/api/queries/ChangeProposalsQueries', - () => ({ - useCreateChangeProposalMutation: jest.fn(), - }), -); +import { useCreateChangeProposalMutation } from '../../change-proposals/api/queries/ChangeProposalsQueries'; + +jest.mock('../../change-proposals/api/queries/ChangeProposalsQueries', () => ({ + useCreateChangeProposalMutation: jest.fn(), +})); const mockUseCreateChangeProposalMutation = useCreateChangeProposalMutation as jest.MockedFunction< diff --git a/apps/frontend/src/domain/recipes/components/ProposeChangeModal.tsx b/apps/frontend/src/domain/recipes/components/ProposeChangeModal.tsx index 2824fd81d..3dc9ec0e0 100644 --- a/apps/frontend/src/domain/recipes/components/ProposeChangeModal.tsx +++ b/apps/frontend/src/domain/recipes/components/ProposeChangeModal.tsx @@ -19,7 +19,7 @@ import { RecipeId, SpaceId, } from '@packmind/types'; -import { useCreateChangeProposalMutation } from '@packmind/proprietary/frontend/domain/change-proposals/api/queries/ChangeProposalsQueries'; +import { useCreateChangeProposalMutation } from '../../change-proposals/api/queries/ChangeProposalsQueries'; interface ProposeChangeModalProps { recipeName: string; diff --git a/apps/frontend/src/domain/recipes/components/ProposeDescriptionChangeModal.test.tsx b/apps/frontend/src/domain/recipes/components/ProposeDescriptionChangeModal.test.tsx index 3a589540d..439656e7a 100644 --- a/apps/frontend/src/domain/recipes/components/ProposeDescriptionChangeModal.test.tsx +++ b/apps/frontend/src/domain/recipes/components/ProposeDescriptionChangeModal.test.tsx @@ -17,7 +17,7 @@ import { ChangeProposalCaptureMode, } from '@packmind/types'; import { ProposeDescriptionChangeModal } from './ProposeDescriptionChangeModal'; -import { useCreateChangeProposalMutation } from '@packmind/proprietary/frontend/domain/change-proposals/api/queries/ChangeProposalsQueries'; +import { useCreateChangeProposalMutation } from '../../change-proposals/api/queries/ChangeProposalsQueries'; let mockOnMarkdownChange: ((value: string) => void) | undefined; @@ -43,12 +43,9 @@ jest.mock('../../../shared/components/editor/MarkdownEditor', () => ({ ), })); -jest.mock( - '@packmind/proprietary/frontend/domain/change-proposals/api/queries/ChangeProposalsQueries', - () => ({ - useCreateChangeProposalMutation: jest.fn(), - }), -); +jest.mock('../../change-proposals/api/queries/ChangeProposalsQueries', () => ({ + useCreateChangeProposalMutation: jest.fn(), +})); const mockUseCreateChangeProposalMutation = useCreateChangeProposalMutation as jest.MockedFunction< diff --git a/apps/frontend/src/domain/recipes/components/ProposeDescriptionChangeModal.tsx b/apps/frontend/src/domain/recipes/components/ProposeDescriptionChangeModal.tsx index 77b580c7d..5cf7b739e 100644 --- a/apps/frontend/src/domain/recipes/components/ProposeDescriptionChangeModal.tsx +++ b/apps/frontend/src/domain/recipes/components/ProposeDescriptionChangeModal.tsx @@ -17,7 +17,7 @@ import { RecipeId, SpaceId, } from '@packmind/types'; -import { useCreateChangeProposalMutation } from '@packmind/proprietary/frontend/domain/change-proposals/api/queries/ChangeProposalsQueries'; +import { useCreateChangeProposalMutation } from '../../change-proposals/api/queries/ChangeProposalsQueries'; import { MarkdownEditor, MarkdownEditorProvider, diff --git a/apps/frontend/src/domain/recipes/components/RecipeDetails.tsx b/apps/frontend/src/domain/recipes/components/RecipeDetails.tsx index cdeef37b5..da773c387 100644 --- a/apps/frontend/src/domain/recipes/components/RecipeDetails.tsx +++ b/apps/frontend/src/domain/recipes/components/RecipeDetails.tsx @@ -38,7 +38,7 @@ import { useNavigation } from '../../../shared/hooks/useNavigation'; import { ProposeChangeModal } from './ProposeChangeModal'; import { ProposeDescriptionChangeModal } from './ProposeDescriptionChangeModal'; import { RecipeVersionHistoryHeader } from './RecipeVersionHistoryHeader'; -import { useListChangeProposalsByRecipeQuery } from '@packmind/proprietary/frontend/domain/change-proposals/api/queries/ChangeProposalsQueries'; +import { useListChangeProposalsByRecipeQuery } from '../../change-proposals/api/queries/ChangeProposalsQueries'; import { ArtifactResultFilePreview } from '../../artifacts/components/ArtifactResultFilePreview'; interface RecipeDetailsProps { @@ -68,7 +68,7 @@ export const RecipeDetails = ({ id, orgSlug }: RecipeDetailsProps) => { const hasDistributions = distributions && distributions.length > 0; const pendingCount = changeProposals?.changeProposals?.filter( - (p: { status: string }) => p.status === ChangeProposalStatus.pending, + (p) => p.status === ChangeProposalStatus.pending, ).length ?? 0; const defaultPath = `.packmind/recipes/${recipe?.slug}.md`; diff --git a/apps/frontend/src/domain/skills/api/queries/SkillsQueries.ts b/apps/frontend/src/domain/skills/api/queries/SkillsQueries.ts index 007224543..2c4759d28 100644 --- a/apps/frontend/src/domain/skills/api/queries/SkillsQueries.ts +++ b/apps/frontend/src/domain/skills/api/queries/SkillsQueries.ts @@ -16,7 +16,7 @@ import { import { CHANGE_PROPOSALS_QUERY_SCOPE, GET_GROUPED_CHANGE_PROPOSALS_KEY, -} from '@packmind/proprietary/frontend/domain/change-proposals/api/queryKeys'; +} from '../../../change-proposals/api/queryKeys'; import { ORGANIZATION_QUERY_SCOPE } from '../../../organizations/api/queryKeys'; export const getSkillsBySpaceQueryOptions = ( diff --git a/apps/frontend/src/domain/spaces-management/api/gateways/ISpacesManagementGateway.ts b/apps/frontend/src/domain/spaces-management/api/gateways/ISpacesManagementGateway.ts new file mode 100644 index 000000000..4407d88f5 --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/api/gateways/ISpacesManagementGateway.ts @@ -0,0 +1,36 @@ +import { + ArtifactReference, + BrowseSpacesResponse, + Space, + SpaceColor, + SpaceId, + SpaceType, +} from '@packmind/types'; + +export type MoveArtifactsToSpaceParams = { + sourceSpaceId: SpaceId; + destinationSpaceId: SpaceId; + artifacts: ArtifactReference[]; +}; + +export type MoveArtifactsToSpaceResponse = { movedCount: number }; + +export interface ISpacesManagementGateway { + createSpace(orgId: string, name: string, type: SpaceType): Promise<Space>; + moveArtifactsToSpace( + orgId: string, + params: MoveArtifactsToSpaceParams, + ): Promise<MoveArtifactsToSpaceResponse>; + browseSpaces(orgId: string): Promise<BrowseSpacesResponse>; + joinSpace(orgId: string, spaceId: SpaceId): Promise<void>; + joinSpaceBySlug(orgId: string, spaceSlug: string): Promise<void>; + leaveSpace(orgId: string, spaceId: SpaceId): Promise<void>; + updateSpace( + orgId: string, + spaceId: SpaceId, + fields: { name?: string; type?: SpaceType; color?: SpaceColor }, + ): Promise<Space>; + deleteSpace(orgId: string, spaceId: string): Promise<void>; + pinSpace(orgId: string, spaceId: SpaceId): Promise<void>; + unpinSpace(orgId: string, spaceId: SpaceId): Promise<void>; +} diff --git a/apps/frontend/src/domain/spaces-management/api/gateways/SpacesManagementGatewayApi.ts b/apps/frontend/src/domain/spaces-management/api/gateways/SpacesManagementGatewayApi.ts new file mode 100644 index 000000000..cb94027e2 --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/api/gateways/SpacesManagementGatewayApi.ts @@ -0,0 +1,133 @@ +import { + BrowseSpacesResponse, + Space, + SpaceColor, + SpaceId, + SpaceType, +} from '@packmind/types'; +import { PackmindGateway } from '../../../../shared/PackmindGateway'; +import { + ISpacesManagementGateway, + MoveArtifactsToSpaceParams, + MoveArtifactsToSpaceResponse, +} from './ISpacesManagementGateway'; + +export class SpacesManagementGatewayApi + extends PackmindGateway + implements ISpacesManagementGateway +{ + constructor() { + super('/organizations'); + } + + async createSpace( + orgId: string, + name: string, + type: SpaceType, + ): Promise<Space> { + if (!orgId) { + throw new Error('Organization ID is required to create a space'); + } + return this._api.post<Space>( + `${this._endpoint}/${orgId}/spaces-management`, + { name, type }, + ); + } + + async moveArtifactsToSpace( + orgId: string, + params: MoveArtifactsToSpaceParams, + ): Promise<MoveArtifactsToSpaceResponse> { + if (!orgId) { + throw new Error( + 'Organization ID is required to move artifacts to a space', + ); + } + return this._api.post<MoveArtifactsToSpaceResponse>( + `${this._endpoint}/${orgId}/spaces-management/move`, + params, + ); + } + + async browseSpaces(orgId: string): Promise<BrowseSpacesResponse> { + if (!orgId) { + throw new Error('Organization ID is required to browse spaces'); + } + return this._api.get<BrowseSpacesResponse>( + `${this._endpoint}/${orgId}/spaces-management/browse`, + ); + } + + async joinSpace(orgId: string, spaceId: SpaceId): Promise<void> { + if (!orgId) { + throw new Error('Organization ID is required to join a space'); + } + return this._api.post( + `${this._endpoint}/${orgId}/spaces-management/${spaceId}/join`, + {}, + ); + } + + async joinSpaceBySlug(orgId: string, spaceSlug: string): Promise<void> { + if (!orgId) { + throw new Error('Organization ID is required to join a space'); + } + return this._api.post( + `${this._endpoint}/${orgId}/spaces-management/by-slug/${spaceSlug}/join`, + {}, + ); + } + + async leaveSpace(orgId: string, spaceId: SpaceId): Promise<void> { + if (!orgId) { + throw new Error('Organization ID is required to leave a space'); + } + return this._api.post( + `${this._endpoint}/${orgId}/spaces-management/${spaceId}/leave`, + {}, + ); + } + + async updateSpace( + orgId: string, + spaceId: SpaceId, + fields: { name?: string; type?: SpaceType; color?: SpaceColor }, + ): Promise<Space> { + if (!orgId) { + throw new Error('Organization ID is required to update a space'); + } + return this._api.patch<Space>( + `${this._endpoint}/${orgId}/spaces-management/${spaceId}`, + fields, + ); + } + + async deleteSpace(orgId: string, spaceId: string): Promise<void> { + if (!orgId) { + throw new Error('Organization ID is required to delete a space'); + } + return this._api.delete( + `${this._endpoint}/${orgId}/spaces-management/${spaceId}`, + ); + } + + async pinSpace(orgId: string, spaceId: SpaceId): Promise<void> { + if (!orgId) { + throw new Error('Organization ID is required to pin a space'); + } + return this._api.post( + `${this._endpoint}/${orgId}/spaces-management/${spaceId}/pin`, + {}, + ); + } + + async unpinSpace(orgId: string, spaceId: SpaceId): Promise<void> { + if (!orgId) { + throw new Error('Organization ID is required to unpin a space'); + } + return this._api.post( + `${this._endpoint}/${orgId}/spaces-management/${spaceId}/unpin`, + {}, + ); + } +} diff --git a/apps/frontend/src/domain/spaces-management/api/gateways/index.ts b/apps/frontend/src/domain/spaces-management/api/gateways/index.ts new file mode 100644 index 000000000..172462451 --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/api/gateways/index.ts @@ -0,0 +1,8 @@ +import { ISpacesManagementGateway } from './ISpacesManagementGateway'; +import { SpacesManagementGatewayApi } from './SpacesManagementGatewayApi'; + +export const spacesManagementGateway: ISpacesManagementGateway = + new SpacesManagementGatewayApi(); + +export * from './ISpacesManagementGateway'; +export * from './SpacesManagementGatewayApi'; diff --git a/apps/frontend/src/domain/spaces-management/api/index.ts b/apps/frontend/src/domain/spaces-management/api/index.ts new file mode 100644 index 000000000..434918d2c --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/api/index.ts @@ -0,0 +1,3 @@ +export * from './gateways'; +export * from './queries'; +export * from './queryKeys'; diff --git a/apps/frontend/src/domain/spaces-management/api/queries/SpacesManagementQueries.ts b/apps/frontend/src/domain/spaces-management/api/queries/SpacesManagementQueries.ts new file mode 100644 index 000000000..2eb7e12c2 --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/api/queries/SpacesManagementQueries.ts @@ -0,0 +1,349 @@ +import { + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import { + ArtifactReference, + BrowseSpacesResponse, + SpaceColor, + SpaceId, + SpaceType, +} from '@packmind/types'; +import { pmToaster } from '@packmind/ui'; +import { spacesManagementGateway } from '../gateways'; +import { spacesManagementQueryKeys } from '../queryKeys'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; +import { useCurrentSpace } from '../../../spaces/hooks/useCurrentSpace'; +import { spacesQueryKeys } from '../../../spaces/api/queryKeys'; +import { getSkillsBySpaceKey } from '../../../skills/api/queryKeys'; +import { getStandardsBySpaceKey } from '../../../standards/api/queryKeys'; +import { getRecipesBySpaceKey } from '../../../recipes/api/queryKeys'; +import { LIST_PACKAGES_BY_SPACE_KEY } from '../../../deployments/api/queryKeys'; +import { CHANGE_PROPOSALS_QUERY_SCOPE } from '../../../change-proposals/api/queryKeys'; +import { ORGANIZATION_QUERY_SCOPE } from '../../../organizations/api/queryKeys'; +export const getBrowseSpacesQueryOptions = (orgId: string) => + queryOptions({ + queryKey: spacesManagementQueryKeys.browse(orgId), + queryFn: async (): Promise<BrowseSpacesResponse> => { + if (!orgId) { + throw new Error('Organization ID is required to browse spaces'); + } + return spacesManagementGateway.browseSpaces(orgId); + }, + enabled: !!orgId, + staleTime: 1000 * 60 * 5, + }); + +export const useBrowseSpacesQuery = () => { + const { organization } = useAuthContext(); + const orgId = organization?.id; + return useQuery(getBrowseSpacesQueryOptions(orgId || '')); +}; + +const CREATE_SPACE_MUTATION_KEY = 'createSpace'; + +export const useCreateSpaceMutation = () => { + const queryClient = useQueryClient(); + const { organization } = useAuthContext(); + + return useMutation({ + mutationKey: [CREATE_SPACE_MUTATION_KEY], + mutationFn: async ({ name, type }: { name: string; type: SpaceType }) => { + if (!organization?.id) { + throw new Error('Organization context required'); + } + return spacesManagementGateway.createSpace(organization.id, name, type); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: spacesQueryKeys.all, + refetchType: 'all', + }), + queryClient.invalidateQueries({ + queryKey: spacesManagementQueryKeys.all, + refetchType: 'all', + }), + ]); + }, + }); +}; + +type MoveArtifactsToSpaceMutationParams = { + destinationSpaceId: SpaceId; + artifacts: ArtifactReference[]; +}; + +const MOVE_ARTIFACTS_TO_SPACE_MUTATION_KEY = 'moveArtifactsToSpace'; + +export const useMoveArtifactsToSpaceMutation = () => { + const queryClient = useQueryClient(); + const { organization } = useAuthContext(); + const { spaceId } = useCurrentSpace(); + + return useMutation({ + mutationKey: [MOVE_ARTIFACTS_TO_SPACE_MUTATION_KEY], + mutationFn: async (params: MoveArtifactsToSpaceMutationParams) => { + if (!organization?.id || !spaceId) { + throw new Error('Organization and space context required'); + } + return spacesManagementGateway.moveArtifactsToSpace(organization.id, { + sourceSpaceId: spaceId, + ...params, + }); + }, + onSuccess: async (_data, variables) => { + const { destinationSpaceId } = variables; + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: getSkillsBySpaceKey(spaceId), + }), + queryClient.invalidateQueries({ + queryKey: getStandardsBySpaceKey(spaceId), + }), + queryClient.invalidateQueries({ + queryKey: getRecipesBySpaceKey(spaceId), + }), + queryClient.invalidateQueries({ + queryKey: getSkillsBySpaceKey(destinationSpaceId), + }), + queryClient.invalidateQueries({ + queryKey: getStandardsBySpaceKey(destinationSpaceId), + }), + queryClient.invalidateQueries({ + queryKey: getRecipesBySpaceKey(destinationSpaceId), + }), + queryClient.invalidateQueries({ + queryKey: LIST_PACKAGES_BY_SPACE_KEY, + }), + queryClient.invalidateQueries({ + queryKey: [ORGANIZATION_QUERY_SCOPE, CHANGE_PROPOSALS_QUERY_SCOPE], + }), + ]); + }, + }); +}; + +const JOIN_SPACE_MUTATION_KEY = 'joinSpace'; + +export const useJoinSpaceMutation = () => { + const queryClient = useQueryClient(); + const { organization } = useAuthContext(); + + return useMutation({ + mutationKey: [JOIN_SPACE_MUTATION_KEY], + mutationFn: async ({ spaceId }: { spaceId: SpaceId }) => { + if (!organization?.id) { + throw new Error('Organization context required'); + } + return spacesManagementGateway.joinSpace(organization.id, spaceId); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: spacesQueryKeys.all, + refetchType: 'all', + }), + queryClient.invalidateQueries({ + queryKey: spacesManagementQueryKeys.all, + refetchType: 'all', + }), + ]); + }, + }); +}; + +const JOIN_SPACE_BY_SLUG_MUTATION_KEY = 'joinSpaceBySlug'; + +export const useJoinSpaceBySlugMutation = () => { + const queryClient = useQueryClient(); + const { organization } = useAuthContext(); + + return useMutation({ + mutationKey: [JOIN_SPACE_BY_SLUG_MUTATION_KEY], + mutationFn: async ({ spaceSlug }: { spaceSlug: string }) => { + if (!organization?.id) { + throw new Error('Organization context required'); + } + return spacesManagementGateway.joinSpaceBySlug( + organization.id, + spaceSlug, + ); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: spacesQueryKeys.all, + refetchType: 'all', + }), + queryClient.invalidateQueries({ + queryKey: spacesManagementQueryKeys.all, + refetchType: 'all', + }), + ]); + }, + }); +}; + +const LEAVE_SPACE_MUTATION_KEY = 'leaveSpace'; + +export const useLeaveSpaceMutation = () => { + const queryClient = useQueryClient(); + const { organization } = useAuthContext(); + + return useMutation({ + mutationKey: [LEAVE_SPACE_MUTATION_KEY], + mutationFn: async ({ spaceId }: { spaceId: SpaceId }) => { + if (!organization?.id) { + throw new Error('Organization context required'); + } + return spacesManagementGateway.leaveSpace(organization.id, spaceId); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: spacesQueryKeys.all, + refetchType: 'all', + }), + queryClient.invalidateQueries({ + queryKey: spacesManagementQueryKeys.all, + refetchType: 'all', + }), + ]); + }, + }); +}; + +const DELETE_SPACE_MUTATION_KEY = 'deleteSpace'; + +export const useDeleteSpaceMutation = () => { + const queryClient = useQueryClient(); + const { organization } = useAuthContext(); + + return useMutation({ + mutationKey: [DELETE_SPACE_MUTATION_KEY], + mutationFn: async ({ spaceId }: { spaceId: string }) => { + if (!organization?.id) { + throw new Error('Organization context required'); + } + return spacesManagementGateway.deleteSpace(organization.id, spaceId); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [...spacesManagementQueryKeys.all], + }), + queryClient.invalidateQueries({ + queryKey: [...spacesQueryKeys.all], + }), + ]); + }, + }); +}; + +const UPDATE_SPACE_MUTATION_KEY = 'updateSpace'; + +export const useUpdateSpaceMutation = () => { + const queryClient = useQueryClient(); + const { organization } = useAuthContext(); + + return useMutation({ + mutationKey: [UPDATE_SPACE_MUTATION_KEY], + mutationFn: async ({ + spaceId, + fields, + }: { + spaceId: SpaceId; + fields: { name?: string; type?: SpaceType; color?: SpaceColor }; + }) => { + if (!organization?.id) { + throw new Error('Organization context required'); + } + return spacesManagementGateway.updateSpace( + organization.id, + spaceId, + fields, + ); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [...spacesManagementQueryKeys.all], + }), + queryClient.invalidateQueries({ + queryKey: [...spacesQueryKeys.all], + }), + ]); + }, + }); +}; + +const PIN_SPACE_MUTATION_KEY = 'pinSpace'; + +export const usePinSpaceMutation = () => { + const queryClient = useQueryClient(); + const { organization } = useAuthContext(); + + return useMutation({ + mutationKey: [PIN_SPACE_MUTATION_KEY], + mutationFn: async ({ spaceId }: { spaceId: SpaceId }) => { + if (!organization?.id) { + throw new Error('Organization context required'); + } + return spacesManagementGateway.pinSpace(organization.id, spaceId); + }, + onSuccess: async () => { + pmToaster.success({ title: 'Added to favorites' }); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [...spacesManagementQueryKeys.all], + }), + queryClient.invalidateQueries({ + queryKey: [...spacesQueryKeys.all], + }), + ]); + }, + onError: () => { + pmToaster.error({ + title: 'Unable to add to favorites', + description: 'Something went wrong. Please try again.', + }); + }, + }); +}; + +const UNPIN_SPACE_MUTATION_KEY = 'unpinSpace'; + +export const useUnpinSpaceMutation = () => { + const queryClient = useQueryClient(); + const { organization } = useAuthContext(); + + return useMutation({ + mutationKey: [UNPIN_SPACE_MUTATION_KEY], + mutationFn: async ({ spaceId }: { spaceId: SpaceId }) => { + if (!organization?.id) { + throw new Error('Organization context required'); + } + return spacesManagementGateway.unpinSpace(organization.id, spaceId); + }, + onSuccess: async () => { + pmToaster.success({ title: 'Removed from favorites' }); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [...spacesManagementQueryKeys.all], + }), + queryClient.invalidateQueries({ + queryKey: [...spacesQueryKeys.all], + }), + ]); + }, + onError: () => { + pmToaster.error({ + title: 'Unable to remove from favorites', + description: 'Something went wrong. Please try again.', + }); + }, + }); +}; diff --git a/apps/frontend/src/domain/spaces-management/api/queries/index.ts b/apps/frontend/src/domain/spaces-management/api/queries/index.ts new file mode 100644 index 000000000..3048e6b27 --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/api/queries/index.ts @@ -0,0 +1 @@ +export * from './SpacesManagementQueries'; diff --git a/apps/frontend/src/domain/spaces-management/api/queryKeys.ts b/apps/frontend/src/domain/spaces-management/api/queryKeys.ts new file mode 100644 index 000000000..9cd09c51e --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/api/queryKeys.ts @@ -0,0 +1,19 @@ +import { ORGANIZATION_QUERY_SCOPE } from '../../organizations/api/queryKeys'; + +export const SPACES_MANAGEMENT_SCOPE = 'spaces-management' as const; + +export enum SpacesManagementQueryKey { + CREATE = 'create', + BROWSE = 'browse', +} + +export const spacesManagementQueryKeys = { + all: [ORGANIZATION_QUERY_SCOPE, SPACES_MANAGEMENT_SCOPE] as const, + browse: (orgId: string) => + [ + ORGANIZATION_QUERY_SCOPE, + SPACES_MANAGEMENT_SCOPE, + SpacesManagementQueryKey.BROWSE, + orgId, + ] as const, +}; diff --git a/apps/frontend/src/domain/spaces-management/components/BrowseSpaces.tsx b/apps/frontend/src/domain/spaces-management/components/BrowseSpaces.tsx new file mode 100644 index 000000000..f96da890b --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/components/BrowseSpaces.tsx @@ -0,0 +1,94 @@ +import React, { useState } from 'react'; +import type { RefObject } from 'react'; +import { pmToaster } from '@packmind/ui'; +import { useNavigate } from 'react-router'; +import { SpaceId, UserSpaceWithRole } from '@packmind/types'; +import { useAuthContext } from '../../accounts/hooks/useAuthContext'; +import { BrowseSpacesDrawer, BrowseSpacesTab } from './BrowseSpacesDrawer'; +import { CreateSpaceDialog } from './CreateSpaceDialog'; +import { + useBrowseSpacesQuery, + useJoinSpaceMutation, + usePinSpaceMutation, + useUnpinSpaceMutation, +} from '../api/queries/SpacesManagementQueries'; +import { useGetSpacesQuery } from '../../spaces/api/queries/SpacesQueries'; +import { routes } from '../../../shared/utils/routes'; + +interface BrowseSpacesProps { + open: boolean; + onClose: () => void; + initialTab?: BrowseSpacesTab; + containerRef?: RefObject<HTMLElement | null>; +} + +export function BrowseSpaces({ + open, + onClose, + initialTab, + containerRef, +}: Readonly<BrowseSpacesProps>): React.ReactElement { + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const navigate = useNavigate(); + const { organization } = useAuthContext(); + const { data: browseData, isLoading, isError } = useBrowseSpacesQuery(); + const { data: mySpaces } = useGetSpacesQuery(); + const joinMutation = useJoinSpaceMutation(); + const pinMutation = usePinSpaceMutation(); + const unpinMutation = useUnpinSpaceMutation(); + + const handleSpaceClick = (space: UserSpaceWithRole) => { + if (organization?.slug) { + navigate(routes.space.toDashboard(organization.slug, space.slug)); + } + onClose(); + }; + + const handleJoinSpace = (spaceId: SpaceId, spaceName: string) => { + joinMutation.mutate( + { spaceId }, + { + onSuccess: () => { + pmToaster.success({ + title: 'Joined!', + description: `You've joined ${spaceName}.`, + }); + }, + onError: () => { + pmToaster.error({ + title: 'Failed to join', + description: 'Something went wrong. Please try again.', + }); + }, + }, + ); + }; + + return ( + <> + <BrowseSpacesDrawer + mySpaces={mySpaces ?? []} + allSpaces={browseData?.allSpaces ?? []} + open={open} + onClose={onClose} + onSpaceClick={handleSpaceClick} + onJoinSpace={handleJoinSpace} + onPinSpace={(spaceId) => pinMutation.mutate({ spaceId })} + onUnpinSpace={(spaceId) => unpinMutation.mutate({ spaceId })} + isLoading={isLoading} + isError={isError} + isJoining={joinMutation.isPending} + onCreateSpace={() => { + onClose(); + setIsCreateDialogOpen(true); + }} + containerRef={containerRef} + initialTab={initialTab} + /> + <CreateSpaceDialog + open={isCreateDialogOpen} + setOpen={setIsCreateDialogOpen} + /> + </> + ); +} diff --git a/apps/frontend/src/domain/spaces-management/components/BrowseSpacesDrawer.test.tsx b/apps/frontend/src/domain/spaces-management/components/BrowseSpacesDrawer.test.tsx new file mode 100644 index 000000000..aa447623b --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/components/BrowseSpacesDrawer.test.tsx @@ -0,0 +1,261 @@ +// apps/frontend/src/domain/spaces-management/components/BrowseSpacesDrawer.test.tsx +import React, { useState } from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UIProvider } from '@packmind/ui'; +import { + SpaceType, + createSpaceId, + createOrganizationId, +} from '@packmind/types'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { BrowseSpacesDrawer } from './BrowseSpacesDrawer'; + +const renderWithProviders = (component: React.ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + <MemoryRouter> + <UIProvider> + <QueryClientProvider client={queryClient}> + {component} + </QueryClientProvider> + </UIProvider> + </MemoryRouter>, + ); +}; + +describe('BrowseSpacesDrawer', () => { + const organizationId = createOrganizationId('org-1'); + const mySpaces = [ + spaceFactory({ + id: createSpaceId('space-1'), + name: 'My Team', + slug: 'my-team', + type: SpaceType.open, + organizationId, + }), + spaceFactory({ + id: createSpaceId('space-2'), + name: 'Default', + slug: 'default', + type: SpaceType.open, + organizationId, + isDefaultSpace: true, + }), + ]; + + const allSpaces = [ + { + id: createSpaceId('space-3'), + name: 'Open Space', + slug: 'open-space', + type: SpaceType.open, + }, + { + id: createSpaceId('space-4'), + name: 'Restricted Space', + slug: 'restricted-space', + type: SpaceType.restricted, + }, + ]; + + const defaultProps = { + mySpaces, + allSpaces, + open: true, + onClose: jest.fn(), + onSpaceClick: jest.fn(), + onJoinSpace: jest.fn(), + onCreateSpace: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when the drawer is open on My spaces tab', () => { + it('renders My Team space', () => { + renderWithProviders(<BrowseSpacesDrawer {...defaultProps} />); + + expect(screen.getByText('My Team')).toBeInTheDocument(); + }); + + it('renders Default space', () => { + renderWithProviders(<BrowseSpacesDrawer {...defaultProps} />); + + expect(screen.getByText('Default')).toBeInTheDocument(); + }); + + it('navigates to space on click', () => { + renderWithProviders(<BrowseSpacesDrawer {...defaultProps} />); + + fireEvent.click(screen.getByTestId('browse-spaces-my-space-1')); + + expect(defaultProps.onSpaceClick).toHaveBeenCalledWith(mySpaces[0]); + }); + }); + + describe('when switching to All spaces tab', () => { + describe('when rendering open type space', () => { + beforeEach(() => { + renderWithProviders(<BrowseSpacesDrawer {...defaultProps} />); + fireEvent.click(screen.getByTestId('browse-spaces-tab-all-spaces')); + }); + + it('renders Open Space name', () => { + expect(screen.getByText('Open Space')).toBeInTheDocument(); + }); + + it('renders join button for open space', () => { + expect( + screen.getByTestId('browse-spaces-join-space-3'), + ).toBeInTheDocument(); + }); + }); + + describe('when rendering restricted type space', () => { + beforeEach(() => { + renderWithProviders(<BrowseSpacesDrawer {...defaultProps} />); + fireEvent.click(screen.getByTestId('browse-spaces-tab-all-spaces')); + }); + + it('renders join button for restricted space', () => { + expect( + screen.getByTestId('browse-spaces-join-space-4'), + ).toBeInTheDocument(); + }); + }); + + it('calls onJoinSpace when clicking join', () => { + renderWithProviders(<BrowseSpacesDrawer {...defaultProps} />); + + fireEvent.click(screen.getByTestId('browse-spaces-tab-all-spaces')); + fireEvent.click(screen.getByTestId('browse-spaces-join-space-3')); + + expect(defaultProps.onJoinSpace).toHaveBeenCalledWith( + createSpaceId('space-3'), + 'Open Space', + ); + }); + }); + + describe('when searching', () => { + describe('when filtering my spaces by name', () => { + beforeEach(() => { + renderWithProviders(<BrowseSpacesDrawer {...defaultProps} />); + const searchInput = screen.getByTestId('browse-spaces-search'); + fireEvent.change(searchInput, { target: { value: 'Team' } }); + }); + + it('shows matching space', () => { + expect(screen.getByText('My Team')).toBeInTheDocument(); + }); + + it('hides non-matching space', () => { + expect(screen.queryByText('Default')).not.toBeInTheDocument(); + }); + }); + + it('shows empty state when no match', () => { + renderWithProviders(<BrowseSpacesDrawer {...defaultProps} />); + + const searchInput = screen.getByTestId('browse-spaces-search'); + fireEvent.change(searchInput, { target: { value: 'zzzzz' } }); + + expect(screen.getByText(/No spaces matching/)).toBeInTheDocument(); + }); + }); + + describe('when clicking New button', () => { + it('calls onCreateSpace', () => { + renderWithProviders(<BrowseSpacesDrawer {...defaultProps} />); + + fireEvent.click(screen.getByTestId('browse-spaces-new-button')); + + expect(defaultProps.onCreateSpace).toHaveBeenCalled(); + }); + }); + + describe('when mySpaces contains a default space', () => { + const defaultSpace = spaceFactory({ + id: createSpaceId('space-default'), + name: 'Zulu Default', + slug: 'zulu-default', + type: SpaceType.open, + organizationId, + isDefaultSpace: true, + }); + const alphaSpace = spaceFactory({ + id: createSpaceId('space-alpha'), + name: 'Alpha', + slug: 'alpha', + type: SpaceType.open, + organizationId, + }); + const middleSpace = spaceFactory({ + id: createSpaceId('space-middle'), + name: 'Middle', + slug: 'middle', + type: SpaceType.open, + organizationId, + }); + + it('renders the default space first and the rest sorted alphabetically', () => { + renderWithProviders( + <BrowseSpacesDrawer + {...defaultProps} + mySpaces={[middleSpace, defaultSpace, alphaSpace]} + />, + ); + + const items = screen.getAllByTestId(/^browse-spaces-my-/); + + expect(items.map((el) => el.getAttribute('data-testid'))).toEqual([ + 'browse-spaces-my-space-default', + 'browse-spaces-my-space-alpha', + 'browse-spaces-my-space-middle', + ]); + }); + }); + + describe('when the drawer reopens after closing', () => { + function ControlledDrawer() { + const [open, setOpen] = useState(true); + return ( + <> + <button + type="button" + data-testid="ctrl-toggle" + onClick={() => setOpen((prev) => !prev)} + > + toggle + </button> + <BrowseSpacesDrawer + {...defaultProps} + open={open} + onClose={() => setOpen(false)} + /> + </> + ); + } + + it('clears the previously typed search query', () => { + renderWithProviders(<ControlledDrawer />); + fireEvent.change(screen.getByTestId('browse-spaces-search'), { + target: { value: 'Team' }, + }); + fireEvent.click(screen.getByTestId('ctrl-toggle')); + fireEvent.click(screen.getByTestId('ctrl-toggle')); + + expect(screen.getByTestId('browse-spaces-search')).toHaveValue(''); + }); + }); +}); diff --git a/apps/frontend/src/domain/spaces-management/components/BrowseSpacesDrawer.tsx b/apps/frontend/src/domain/spaces-management/components/BrowseSpacesDrawer.tsx new file mode 100644 index 000000000..9505a6d02 --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/components/BrowseSpacesDrawer.tsx @@ -0,0 +1,413 @@ +import { useEffect, useState } from 'react'; +import type { RefObject } from 'react'; +import { + PMBox, + PMButton, + PMCloseButton, + PMDrawer, + PMHStack, + PMIcon, + PMInput, + PMPortal, + PMSpinner, + PMStatus, + PMText, +} from '@packmind/ui'; +import { LuPlus, LuStar, LuUserPlus } from 'react-icons/lu'; +import type { + SpaceId, + BrowsableSpace, + UserSpaceWithRole, +} from '@packmind/types'; +import { SpaceVisibilityIcon } from '../../organizations/components/sidebar/SpaceVisibilityIcon'; +import { sortSpacesByName } from '../utils/sortSpacesByName'; + +export enum BrowseSpacesTab { + MY_SPACES = 'my', + ALL_SPACES = 'all', +} + +interface BrowseSpacesDrawerProps { + mySpaces: UserSpaceWithRole[]; + allSpaces: BrowsableSpace[]; + open: boolean; + onClose: () => void; + onSpaceClick: (space: UserSpaceWithRole) => void; + onJoinSpace: (spaceId: SpaceId, spaceName: string) => void; + onPinSpace?: (spaceId: SpaceId) => void; + onUnpinSpace?: (spaceId: SpaceId) => void; + onCreateSpace?: () => void; + containerRef?: RefObject<HTMLElement | null>; + isLoading?: boolean; + isError?: boolean; + isJoining?: boolean; + initialTab?: BrowseSpacesTab; +} + +export function BrowseSpacesDrawer({ + mySpaces, + allSpaces, + open, + onClose, + onSpaceClick, + onJoinSpace, + onPinSpace, + onUnpinSpace, + onCreateSpace, + containerRef, + isLoading, + isError, + isJoining, + initialTab = BrowseSpacesTab.MY_SPACES, +}: Readonly<BrowseSpacesDrawerProps>) { + const [activeTab, setActiveTab] = useState<BrowseSpacesTab>(initialTab); + const [searchQuery, setSearchQuery] = useState(''); + + useEffect(() => { + if (!open) { + setSearchQuery(''); + } + }, [open]); + + useEffect(() => { + if (open) { + setActiveTab(initialTab); + } + }, [open, initialTab]); + + const isSearchDisabled = + (activeTab === BrowseSpacesTab.MY_SPACES && mySpaces.length === 0) || + (activeTab === BrowseSpacesTab.ALL_SPACES && allSpaces.length === 0); + + const searchedMySpaces = searchQuery.trim() + ? mySpaces.filter((s) => + s.name.toLowerCase().includes(searchQuery.toLowerCase()), + ) + : mySpaces; + const filteredMySpaces = [ + ...searchedMySpaces.filter((s) => s.isDefaultSpace), + ...sortSpacesByName( + searchedMySpaces.filter((s) => !s.isDefaultSpace && s.pinned), + ), + ...sortSpacesByName( + searchedMySpaces.filter((s) => !s.isDefaultSpace && !s.pinned), + ), + ]; + + const filteredAllSpaces = sortSpacesByName( + searchQuery.trim() + ? allSpaces.filter((s) => + s.name.toLowerCase().includes(searchQuery.toLowerCase()), + ) + : allSpaces, + ); + + return ( + <PMDrawer.Root + open={open} + onOpenChange={(e) => { + if (!e.open) onClose(); + }} + placement="start" + size="sm" + > + <PMPortal container={containerRef}> + <PMDrawer.Backdrop position="absolute" boxSize="full" /> + <PMDrawer.Positioner position="absolute" boxSize="full"> + <PMDrawer.Content> + <PMDrawer.Header paddingBottom={0} borderBottomWidth="0"> + <PMDrawer.CloseTrigger asChild pos="initial"> + <PMCloseButton size="sm" /> + </PMDrawer.CloseTrigger> + <PMDrawer.Title fontSize="sm" flex={1}> + Spaces + </PMDrawer.Title> + {onCreateSpace && ( + <PMButton + size="xs" + variant="secondary" + onClick={onCreateSpace} + data-testid="browse-spaces-new-button" + > + <PMIcon fontSize="xs"> + <LuPlus /> + </PMIcon> + New + </PMButton> + )} + </PMDrawer.Header> + + {/* Tabs */} + <PMBox + paddingX={4} + borderBottomWidth="1px" + borderColor="border.tertiary" + > + <PMHStack gap={0}> + <TabButton + label="My spaces" + isActive={activeTab === BrowseSpacesTab.MY_SPACES} + onClick={() => setActiveTab(BrowseSpacesTab.MY_SPACES)} + data-testid="browse-spaces-tab-my-spaces" + /> + <TabButton + label="All spaces" + isActive={activeTab === BrowseSpacesTab.ALL_SPACES} + onClick={() => setActiveTab(BrowseSpacesTab.ALL_SPACES)} + data-testid="browse-spaces-tab-all-spaces" + /> + </PMHStack> + </PMBox> + + {/* Search */} + <PMBox paddingX={3} paddingTop={3} paddingBottom={1} flexShrink={0}> + <PMInput + placeholder="Search spaces…" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + size="sm" + disabled={isSearchDisabled} + data-testid="browse-spaces-search" + /> + </PMBox> + + <PMDrawer.Body paddingX={1} paddingY={2}> + {isLoading ? ( + <PMBox + display="flex" + justifyContent="center" + alignItems="center" + paddingY={8} + > + <PMSpinner size="sm" /> + </PMBox> + ) : isError ? ( + <PMBox paddingX={3} paddingY={8} textAlign="center"> + <PMText color="faded" fontSize="xs"> + Failed to load spaces + </PMText> + </PMBox> + ) : ( + <> + {activeTab === BrowseSpacesTab.MY_SPACES && ( + <MySpacesTab + spaces={filteredMySpaces} + searchQuery={searchQuery} + onSpaceClick={onSpaceClick} + onPinSpace={onPinSpace} + onUnpinSpace={onUnpinSpace} + /> + )} + + {activeTab === BrowseSpacesTab.ALL_SPACES && ( + <AllSpacesTab + spaces={filteredAllSpaces} + searchQuery={searchQuery} + onJoinSpace={onJoinSpace} + isJoining={isJoining} + /> + )} + </> + )} + </PMDrawer.Body> + </PMDrawer.Content> + </PMDrawer.Positioner> + </PMPortal> + </PMDrawer.Root> + ); +} + +// ── Sub-components ─────────────────────────────────────────────────────────── + +function TabButton({ + label, + isActive, + onClick, + 'data-testid': dataTestId, +}: Readonly<{ + label: string; + isActive: boolean; + onClick: () => void; + 'data-testid'?: string; +}>) { + return ( + <PMBox + as="button" + onClick={onClick} + paddingX={3} + paddingY={1.5} + fontSize="xs" + fontWeight="medium" + borderBottomWidth="2px" + borderColor={isActive ? 'text.primary' : 'transparent'} + color={isActive ? 'text.primary' : 'text.faded'} + cursor="pointer" + _hover={isActive ? undefined : { color: 'text.secondary' }} + data-testid={dataTestId} + > + {label} + </PMBox> + ); +} + +function MySpacesTab({ + spaces, + searchQuery, + onSpaceClick, + onPinSpace, + onUnpinSpace, +}: Readonly<{ + spaces: UserSpaceWithRole[]; + searchQuery: string; + onSpaceClick: (space: UserSpaceWithRole) => void; + onPinSpace?: (spaceId: SpaceId) => void; + onUnpinSpace?: (spaceId: SpaceId) => void; +}>) { + if (spaces.length === 0) { + return ( + <PMBox paddingX={3} paddingY={8} textAlign="center"> + <PMText color="faded" fontSize="xs"> + {searchQuery.trim() ? ( + <>No spaces matching “{searchQuery}”</> + ) : ( + 'No spaces yet' + )} + </PMText> + </PMBox> + ); + } + + return ( + <> + {spaces.map((space) => ( + <PMBox + key={space.id} + as="button" + onClick={() => onSpaceClick(space)} + display="flex" + alignItems="center" + gap={2} + paddingX={3} + paddingY={2} + borderRadius="sm" + _hover={{ backgroundColor: 'blue.900' }} + cursor="pointer" + width="full" + textAlign="left" + data-testid={`browse-spaces-my-${space.id}`} + > + <PMStatus.Root colorPalette={space.color} as="span" flexShrink={0}> + <PMStatus.Indicator /> + </PMStatus.Root> + <PMBox display="flex" alignItems="center" flex={1} minW={0}> + <PMText + fontSize="sm" + color="secondary" + overflow="hidden" + textOverflow="ellipsis" + whiteSpace="nowrap" + minW={0} + > + {space.name} + </PMText> + <SpaceVisibilityIcon type={space.type} /> + </PMBox> + {!space.isDefaultSpace && onPinSpace && onUnpinSpace && ( + <PMBox + as="button" + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + if (space.pinned) { + onUnpinSpace(space.id); + } else { + onPinSpace(space.id); + } + }} + title={ + space.pinned ? 'Remove from favorites' : 'Add to favorites' + } + flexShrink={0} + cursor="pointer" + color={space.pinned ? 'yellow.400' : 'text.faded'} + _hover={{ color: 'yellow.400' }} + display="flex" + alignItems="center" + data-testid={`browse-spaces-pin-${space.id}`} + > + <LuStar size={14} fill={space.pinned ? 'currentColor' : 'none'} /> + </PMBox> + )} + </PMBox> + ))} + </> + ); +} + +function AllSpacesTab({ + spaces, + searchQuery, + onJoinSpace, + isJoining, +}: Readonly<{ + spaces: BrowsableSpace[]; + searchQuery: string; + onJoinSpace: (spaceId: SpaceId, spaceName: string) => void; + isJoining?: boolean; +}>) { + if (spaces.length === 0) { + return ( + <PMBox paddingX={3} paddingY={8} textAlign="center"> + <PMText color="faded" fontSize="xs"> + {searchQuery.trim() ? ( + <>No spaces matching “{searchQuery}”</> + ) : ( + 'No other spaces to discover' + )} + </PMText> + </PMBox> + ); + } + + return ( + <> + {spaces.map((space) => ( + <PMHStack + key={space.id} + gap={2} + paddingX={3} + paddingY={2} + borderRadius="sm" + _hover={{ backgroundColor: 'blue.900' }} + data-testid={`browse-spaces-all-${space.id}`} + > + <PMStatus.Root colorPalette={space.color} as="span" flexShrink={0}> + <PMStatus.Indicator /> + </PMStatus.Root> + <PMText + flex={1} + minW={0} + fontSize="sm" + color="secondary" + overflow="hidden" + textOverflow="ellipsis" + whiteSpace="nowrap" + > + {space.name} + </PMText> + <PMButton + size="xs" + variant="secondary" + onClick={() => onJoinSpace(space.id, space.name)} + disabled={isJoining} + data-testid={`browse-spaces-join-${space.id}`} + > + <PMIcon fontSize="xs"> + <LuUserPlus /> + </PMIcon> + Join + </PMButton> + </PMHStack> + ))} + </> + ); +} diff --git a/apps/frontend/src/domain/spaces-management/components/CreateSpaceDialog.test.tsx b/apps/frontend/src/domain/spaces-management/components/CreateSpaceDialog.test.tsx new file mode 100644 index 000000000..89c0e5ea2 --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/components/CreateSpaceDialog.test.tsx @@ -0,0 +1,194 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UIProvider } from '@packmind/ui'; +import { SpaceType } from '@packmind/types'; +import { CreateSpaceDialog } from './CreateSpaceDialog'; +import { useCreateSpaceMutation } from '../api/queries/SpacesManagementQueries'; + +const mockUseAuthContext = jest.fn(); +jest.mock('../../accounts/hooks/useAuthContext', () => ({ + useAuthContext: () => mockUseAuthContext(), +})); + +const authContextAsAdmin = () => ({ + organization: { + id: 'org-1', + name: 'Test Organization', + slug: 'test-org', + role: 'admin', + }, +}); + +const authContextAsMember = () => ({ + organization: { + id: 'org-1', + name: 'Test Organization', + slug: 'test-org', + role: 'member', + }, +}); + +jest.mock('../api/queries/SpacesManagementQueries', () => ({ + ...jest.requireActual('../api/queries/SpacesManagementQueries'), + useCreateSpaceMutation: jest.fn(), +})); + +const mockUseCreateSpaceMutation = + useCreateSpaceMutation as jest.MockedFunction<typeof useCreateSpaceMutation>; + +const createMockMutation = (overrides = {}) => + ({ + mutate: jest.fn(), + mutateAsync: jest + .fn() + .mockResolvedValue({ name: 'My Space', slug: 'my-space' }), + isPending: false, + isSuccess: false, + isError: false, + ...overrides, + }) as unknown as ReturnType<typeof useCreateSpaceMutation>; + +const renderWithProviders = (component: React.ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + <MemoryRouter> + <UIProvider> + <QueryClientProvider client={queryClient}> + {component} + </QueryClientProvider> + </UIProvider> + </MemoryRouter>, + ); +}; + +describe('CreateSpaceDialog', () => { + const defaultProps = { + open: true, + setOpen: jest.fn(), + }; + + beforeEach(() => { + mockUseAuthContext.mockReturnValue(authContextAsAdmin()); + mockUseCreateSpaceMutation.mockReturnValue(createMockMutation()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when the dialog is open', () => { + it('renders available access status options', () => { + renderWithProviders(<CreateSpaceDialog {...defaultProps} />); + + const wrapper = screen.getByTestId('create-space-type-select'); + const select = wrapper.querySelector('select')!; + const options = select.querySelectorAll('option'); + const optionTexts = Array.from(options).map((o) => o.textContent); + + expect(optionTexts).toEqual([ + 'Open — anyone in the organization can join', + 'Private — accessible only to invited members', + ]); + }); + + it('selects private as default access status', () => { + renderWithProviders(<CreateSpaceDialog {...defaultProps} />); + + const wrapper = screen.getByTestId('create-space-type-select'); + const select = wrapper.querySelector('select')!; + expect(select).toHaveValue(SpaceType.private); + }); + }); + + describe('when submitting with the default type', () => { + it('sends private type to the mutation', async () => { + const mockMutateAsync = jest + .fn() + .mockResolvedValue({ name: 'My Space', slug: 'my-space' }); + mockUseCreateSpaceMutation.mockReturnValue( + createMockMutation({ mutateAsync: mockMutateAsync }), + ); + + renderWithProviders(<CreateSpaceDialog {...defaultProps} />); + + const input = screen.getByTestId('create-space-name-input'); + fireEvent.change(input, { target: { value: 'My Space' } }); + + const submitButton = screen.getByTestId('create-space-submit'); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + name: 'My Space', + type: SpaceType.private, + }); + }); + }); + }); + + describe('when the user is not an admin', () => { + beforeEach(() => { + mockUseAuthContext.mockReturnValue(authContextAsMember()); + }); + + it('disables the access status selector', () => { + renderWithProviders(<CreateSpaceDialog {...defaultProps} />); + + const wrapper = screen.getByTestId('create-space-type-select'); + const select = wrapper.querySelector('select')!; + expect(select).toBeDisabled(); + }); + + it('shows a helper text explaining the restriction', () => { + renderWithProviders(<CreateSpaceDialog {...defaultProps} />); + + expect( + screen.getByText( + 'Only organization administrators can change space visibility', + ), + ).toBeInTheDocument(); + }); + }); + + // TODO: Re-enable when restricted type is available in the UI + // describe('when selecting restricted type before submitting', () => { ... }); + + describe('when selecting private type before submitting', () => { + it('sends private type to the mutation', async () => { + const mockMutateAsync = jest + .fn() + .mockResolvedValue({ name: 'My Space', slug: 'my-space' }); + mockUseCreateSpaceMutation.mockReturnValue( + createMockMutation({ mutateAsync: mockMutateAsync }), + ); + + renderWithProviders(<CreateSpaceDialog {...defaultProps} />); + + const input = screen.getByTestId('create-space-name-input'); + fireEvent.change(input, { target: { value: 'My Space' } }); + + const wrapper = screen.getByTestId('create-space-type-select'); + const select = wrapper.querySelector('select')!; + fireEvent.change(select, { target: { value: SpaceType.private } }); + + const submitButton = screen.getByTestId('create-space-submit'); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + name: 'My Space', + type: SpaceType.private, + }); + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/spaces-management/components/CreateSpaceDialog.tsx b/apps/frontend/src/domain/spaces-management/components/CreateSpaceDialog.tsx new file mode 100644 index 000000000..04765e281 --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/components/CreateSpaceDialog.tsx @@ -0,0 +1,200 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router'; +import { + PMField, + PMDialog, + PMButton, + PMCloseButton, + PMInput, + PMText, + PMNativeSelect, + PMVStack, + pmToaster, +} from '@packmind/ui'; +import { SpaceType } from '@packmind/types'; +import { useCreateSpaceMutation } from '../api/queries/SpacesManagementQueries'; +import { isPackmindConflictError } from '../../../services/api/errors/PackmindConflictError'; +import { useAuthContext } from '../../accounts/hooks/useAuthContext'; +import { routes } from '../../../shared/utils/routes'; + +const SPACE_TYPE_OPTIONS = [ + { + value: SpaceType.open, + label: 'Open — anyone in the organization can join', + }, + // TODO: Re-enable when approval workflow is implemented + // { + // value: SpaceType.restricted, + // label: 'Restricted — visible to everyone, approval required to join', + // }, + { + value: SpaceType.private, + label: 'Private — accessible only to invited members', + }, +]; + +interface CreateSpaceDialogProps { + open: boolean; + setOpen: (open: boolean) => void; + redirectAfterCreate?: boolean; + onCreated?: () => void | Promise<void>; +} + +const SPACE_NAME_MAX_LENGTH = 64; + +export const CreateSpaceDialog: React.FC<CreateSpaceDialogProps> = ({ + open, + setOpen, + redirectAfterCreate = true, + onCreated, +}) => { + const navigate = useNavigate(); + const { organization } = useAuthContext(); + const isAdmin = organization?.role === 'admin'; + const [spaceName, setSpaceName] = useState(''); + const [spaceType, setSpaceType] = useState<SpaceType>(SpaceType.private); + const [spaceNameError, setSpaceNameError] = useState<string | undefined>(); + const createSpaceMutation = useCreateSpaceMutation(); + + const handleSpaceCreation = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!spaceName.trim()) { + setSpaceNameError('Space name is required'); + return; + } + + try { + const space = await createSpaceMutation.mutateAsync({ + name: spaceName.trim(), + type: spaceType, + }); + + pmToaster.create({ + title: 'Success', + description: `Space "${space.name}" created successfully`, + type: 'success', + }); + + setSpaceName(''); + setSpaceType(SpaceType.private); + setSpaceNameError(undefined); + + if (onCreated) { + await onCreated(); + return; + } + + setOpen(false); + + if (redirectAfterCreate && organization) { + navigate(routes.space.toDashboard(organization.slug, space.slug)); + } + } catch (error) { + if (isPackmindConflictError(error)) { + setSpaceNameError('A space with this name already exists'); + } else { + pmToaster.create({ + title: 'Error', + description: 'Failed to create space', + type: 'error', + }); + } + } + }; + + return ( + <PMDialog.Root + open={open} + onOpenChange={(details: { open: boolean }) => { + if (!createSpaceMutation.isPending) { + setOpen(details.open); + if (!details.open) { + setSpaceName(''); + setSpaceType(SpaceType.private); + setSpaceNameError(undefined); + } + } + }} + size={'lg'} + scrollBehavior={'inside'} + closeOnInteractOutside={false} + > + <PMDialog.Backdrop /> + <PMDialog.Positioner> + <PMDialog.Content> + <form onSubmit={handleSpaceCreation}> + <PMDialog.Header> + <PMDialog.Title>Create new space</PMDialog.Title> + <PMDialog.CloseTrigger asChild> + <PMCloseButton /> + </PMDialog.CloseTrigger> + </PMDialog.Header> + <PMDialog.Body> + <PMVStack gap={4}> + <PMField.Root required invalid={!!spaceNameError}> + <PMField.Label> + Space Name{' '} + <PMText as="span" variant="small" color="secondary"> + ({spaceName.length} / {SPACE_NAME_MAX_LENGTH} max) + </PMText> + <PMField.RequiredIndicator /> + </PMField.Label> + <PMInput + value={spaceName} + onChange={(e) => { + setSpaceName(e.target.value); + setSpaceNameError(undefined); + }} + maxLength={SPACE_NAME_MAX_LENGTH} + placeholder="e.g. 'frontend team', 'security'..." + required + disabled={createSpaceMutation.isPending} + data-testid="create-space-name-input" + /> + <PMField.ErrorText>{spaceNameError}</PMField.ErrorText> + </PMField.Root> + <PMField.Root + disabled={!isAdmin || createSpaceMutation.isPending} + > + <PMField.Label>Access status</PMField.Label> + <PMNativeSelect + items={SPACE_TYPE_OPTIONS} + value={spaceType} + onChange={(e) => setSpaceType(e.target.value as SpaceType)} + data-testid="create-space-type-select" + size="sm" + /> + {!isAdmin && ( + <PMField.HelperText> + Only organization administrators can change space + visibility + </PMField.HelperText> + )} + </PMField.Root> + </PMVStack> + </PMDialog.Body> + <PMDialog.Footer> + <PMDialog.Trigger asChild> + <PMButton + variant="tertiary" + disabled={createSpaceMutation.isPending} + > + Close + </PMButton> + </PMDialog.Trigger> + <PMButton + variant="primary" + type="submit" + loading={createSpaceMutation.isPending} + data-testid="create-space-submit" + > + {createSpaceMutation.isPending ? 'Creating...' : 'Create space'} + </PMButton> + </PMDialog.Footer> + </form> + </PMDialog.Content> + </PMDialog.Positioner> + </PMDialog.Root> + ); +}; diff --git a/apps/frontend/src/domain/spaces-management/components/CustomSpacesNavBlock.tsx b/apps/frontend/src/domain/spaces-management/components/CustomSpacesNavBlock.tsx new file mode 100644 index 000000000..aa1a95143 --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/components/CustomSpacesNavBlock.tsx @@ -0,0 +1,139 @@ +import type { UserSpaceWithRole } from '@packmind/types'; +import { PMBox, PMText, PMTooltip } from '@packmind/ui'; +import { SpaceNavBlock } from '../../organizations/components/sidebar/SpaceNavBlock'; +import { useSidebarCollapse } from '../../organizations/components/SidebarCollapseContext'; +import { sortSpacesByName } from '../utils/sortSpacesByName'; + +const MAX_UNPINNED_SIDEBAR_SPACES = 3; + +interface CustomSpacesNavBlockProps { + spaces: UserSpaceWithRole[]; + orgSlug: string; + currentSpaceSlug: string | undefined; + selectedSpaceId: string | null; + onSpaceClick: (space: UserSpaceWithRole) => void; + onBrowseMySpaces?: () => void; +} + +export function CustomSpacesNavBlock({ + spaces, + orgSlug, + currentSpaceSlug, + selectedSpaceId, + onSpaceClick, + onBrowseMySpaces, +}: Readonly<CustomSpacesNavBlockProps>): React.ReactElement { + const { isCollapsed } = useSidebarCollapse(); + const customSpaces = spaces.filter((space) => !space.isDefaultSpace); + const pinnedSpaces = sortSpacesByName( + customSpaces.filter((space) => space.pinned), + ); + const sortedUnpinned = sortSpacesByName( + customSpaces.filter((space) => !space.pinned), + ); + const visibleUnpinned = sortedUnpinned.slice(0, MAX_UNPINNED_SIDEBAR_SPACES); + + // Ensure the currently active space is always visible in the sidebar + const activeSpaceHidden = + currentSpaceSlug && + !pinnedSpaces.some((s) => s.slug === currentSpaceSlug) && + !visibleUnpinned.some((s) => s.slug === currentSpaceSlug); + const hiddenActiveSpace = activeSpaceHidden + ? sortedUnpinned.find((s) => s.slug === currentSpaceSlug) + : undefined; + + const expandedUnpinned = hiddenActiveSpace + ? [...visibleUnpinned, hiddenActiveSpace] + : visibleUnpinned; + + // Collapsed sidebar shows the default space (rendered separately), pinned + // spaces, and the current space (kept visible even when not pinned). Other + // joined spaces are hidden to keep the rail compact. + const unpinnedSpaces = isCollapsed + ? expandedUnpinned.filter((space) => space.slug === currentSpaceSlug) + : expandedUnpinned; + + const hasNoCustomSpaces = customSpaces.length === 0; + + return ( + <> + {pinnedSpaces.length > 0 && ( + <> + {!isCollapsed && ( + <PMBox px={3} pt={3} pb={1}> + <PMText fontSize="2xs" fontWeight="semibold" color="faded"> + Favorites + </PMText> + </PMBox> + )} + {pinnedSpaces.map((space) => ( + <SpaceNavBlock + key={space.id} + space={space} + orgSlug={orgSlug} + isActive={space.slug === currentSpaceSlug} + isSelected={space.id === selectedSpaceId} + onSpaceClick={() => onSpaceClick(space)} + /> + ))} + </> + )} + {!isCollapsed && ( + <PMBox + px={3} + pt={3} + pb={1} + display="flex" + justifyContent="space-between" + alignItems="center" + > + <PMText fontSize="2xs" fontWeight="semibold" color="faded"> + My spaces + </PMText> + {onBrowseMySpaces && ( + <PMBox + as="button" + fontSize="10px" + color="text.faded" + cursor="pointer" + _hover={{ color: 'text.primary' }} + transition="color 0.15s" + onClick={onBrowseMySpaces} + data-testid="browse-my-spaces-trigger" + > + Browse + </PMBox> + )} + </PMBox> + )} + {unpinnedSpaces.map((space) => ( + <SpaceNavBlock + key={space.id} + space={space} + orgSlug={orgSlug} + isActive={space.slug === currentSpaceSlug} + isSelected={space.id === selectedSpaceId} + onSpaceClick={() => onSpaceClick(space)} + /> + ))} + {!isCollapsed && hasNoCustomSpaces && ( + <PMBox px={3} pt={1} pb={2}> + <PMText fontSize="xs" color="tertiary"> + Browse to discover{' '} + <PMTooltip label='Organize your playbook by team, project, or language. E.g. "Backend", "Frontend", "Security".'> + <PMBox + as="span" + textDecoration="underline" + textDecorationStyle="dotted" + cursor="help" + > + spaces + </PMBox> + </PMTooltip>{' '} + or create your own. + </PMText> + </PMBox> + )} + </> + ); +} diff --git a/apps/frontend/src/domain/spaces-management/components/MoveToSpaceDialog.tsx b/apps/frontend/src/domain/spaces-management/components/MoveToSpaceDialog.tsx new file mode 100644 index 000000000..deb812d95 --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/components/MoveToSpaceDialog.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { + PMDialog, + PMButton, + PMCloseButton, + PMSelect, + PMSelectTrigger, + pmCreateListCollection, + PMText, + pmToaster, +} from '@packmind/ui'; +import { ArtifactReference, ArtifactType, SpaceId } from '@packmind/types'; +import { useGetSpacesQuery } from '../../spaces/api/queries/SpacesQueries'; +import { useCurrentSpace } from '../../spaces/hooks/useCurrentSpace'; +import { isPackmindConflictError } from '../../../services/api/errors/PackmindConflictError'; +import { useMoveArtifactsToSpaceMutation } from '../api/queries/SpacesManagementQueries'; + +interface MoveToSpaceDialogProps { + open: boolean; + setOpen: (open: boolean) => void; + artifactType: ArtifactType; + selectedIds: string[]; + onSuccess: () => void; +} + +const ARTIFACT_TYPE_LABELS: Record<ArtifactType, string> = { + standard: 'standard', + skill: 'skill', + command: 'command', +}; + +export const MoveToSpaceDialog: React.FC<MoveToSpaceDialogProps> = ({ + open, + setOpen, + artifactType, + selectedIds, + onSuccess, +}) => { + const { spaceId: currentSpaceId } = useCurrentSpace(); + const { data: spaces } = useGetSpacesQuery(); + const moveArtifactsMutation = useMoveArtifactsToSpaceMutation(); + const [destinationSpaceId, setDestinationSpaceId] = React.useState< + SpaceId | undefined + >(); + + const availableSpaces = React.useMemo( + () => (spaces ?? []).filter((space) => space.id !== currentSpaceId), + [spaces, currentSpaceId], + ); + + const spaceCollection = React.useMemo( + () => + pmCreateListCollection({ + items: availableSpaces.map((space) => ({ + value: space.id, + label: space.name, + })), + }), + [availableSpaces], + ); + + const label = ARTIFACT_TYPE_LABELS[artifactType]; + const count = selectedIds.length; + const pluralLabel = count > 1 ? `${label}s` : label; + + const handleMove = async () => { + if (!destinationSpaceId) return; + + const artifacts: ArtifactReference[] = selectedIds.map((id) => ({ + id, + type: artifactType, + })) as ArtifactReference[]; + + try { + const result = await moveArtifactsMutation.mutateAsync({ + destinationSpaceId, + artifacts, + }); + + const movedLabel = result.movedCount > 1 ? `${label}s` : label; + pmToaster.create({ + title: 'Moved successfully', + description: `${result.movedCount} ${movedLabel} moved to the selected space`, + type: 'success', + }); + + setOpen(false); + onSuccess(); + } catch (error) { + pmToaster.create({ + title: 'Error', + description: isPackmindConflictError(error) + ? error.serverError.data.message + : `Failed to move ${label}s`, + type: 'error', + }); + } + }; + + return ( + <PMDialog.Root + open={open} + onOpenChange={(details: { open: boolean }) => { + if (!moveArtifactsMutation.isPending) { + setOpen(details.open); + if (!details.open) { + setDestinationSpaceId(undefined); + } + } + }} + size="lg" + scrollBehavior="inside" + closeOnInteractOutside={false} + > + <PMDialog.Backdrop /> + <PMDialog.Positioner> + <PMDialog.Content> + <PMDialog.Header> + <PMDialog.Title> + Move {count} {pluralLabel} to another space + </PMDialog.Title> + <PMDialog.CloseTrigger asChild> + <PMCloseButton /> + </PMDialog.CloseTrigger> + </PMDialog.Header> + <PMDialog.Body> + <PMText mb={4}> + Select the destination space for the selected {pluralLabel}. They + will be removed from the current space. + </PMText> + <PMSelect.Root + collection={spaceCollection} + value={destinationSpaceId ? [destinationSpaceId] : []} + onValueChange={(e) => { + setDestinationSpaceId(e.value[0] as SpaceId); + }} + > + <PMSelectTrigger + mt={4} + placeholder="Select a destination space" + borderColor="border.tertiary" + borderWidth="1px" + /> + <PMSelect.Positioner> + <PMSelect.Content zIndex={1500}> + {spaceCollection.items.map((item) => ( + <PMSelect.Item item={item} key={item.value}> + {item.label} + </PMSelect.Item> + ))} + </PMSelect.Content> + </PMSelect.Positioner> + </PMSelect.Root> + </PMDialog.Body> + <PMDialog.Footer> + <PMDialog.Trigger asChild> + <PMButton + variant="tertiary" + disabled={moveArtifactsMutation.isPending} + > + Cancel + </PMButton> + </PMDialog.Trigger> + <PMButton + variant="primary" + onClick={handleMove} + loading={moveArtifactsMutation.isPending} + disabled={!destinationSpaceId} + > + Move + </PMButton> + </PMDialog.Footer> + </PMDialog.Content> + </PMDialog.Positioner> + </PMDialog.Root> + ); +}; diff --git a/apps/frontend/src/domain/spaces-management/components/SpacesManagementActions.tsx b/apps/frontend/src/domain/spaces-management/components/SpacesManagementActions.tsx new file mode 100644 index 000000000..71ec0e241 --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/components/SpacesManagementActions.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { + PMButton, + PMFeatureFlag, + DEFAULT_FEATURE_DOMAIN_MAP, +} from '@packmind/ui'; +import { ArtifactType } from '@packmind/types'; +import { useAuthContext } from '../../accounts/hooks/useAuthContext'; +import { MoveToSpaceDialog } from './MoveToSpaceDialog'; + +interface SpacesManagementActionsProps { + artifactType: ArtifactType; + selectedIds: string[]; + isSomeSelected: boolean; + onSuccess: () => void; +} + +export function SpacesManagementActions({ + artifactType, + selectedIds, + isSomeSelected, + onSuccess, +}: SpacesManagementActionsProps): React.ReactElement { + const [moveDialogOpen, setMoveDialogOpen] = React.useState(false); + + return ( + <> + <PMButton + variant="secondary" + onClick={() => setMoveDialogOpen(true)} + size="sm" + disabled={!isSomeSelected} + data-testid="move-to-space-button" + > + {`Move to space (${selectedIds.length})`} + </PMButton> + <MoveToSpaceDialog + open={moveDialogOpen} + setOpen={setMoveDialogOpen} + artifactType={artifactType} + selectedIds={selectedIds} + onSuccess={onSuccess} + /> + </> + ); +} diff --git a/apps/frontend/src/domain/spaces-management/index.ts b/apps/frontend/src/domain/spaces-management/index.ts new file mode 100644 index 000000000..c1825dd30 --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/index.ts @@ -0,0 +1,2 @@ +export { BrowseSpaces } from './components/BrowseSpaces'; +export { CustomSpacesNavBlock } from './components/CustomSpacesNavBlock'; diff --git a/apps/frontend/src/domain/spaces-management/utils/sortSpacesByName.spec.ts b/apps/frontend/src/domain/spaces-management/utils/sortSpacesByName.spec.ts new file mode 100644 index 000000000..78a601856 --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/utils/sortSpacesByName.spec.ts @@ -0,0 +1,90 @@ +import { sortSpacesByName } from './sortSpacesByName'; + +describe('sortSpacesByName', () => { + describe('with a reverse-sorted array', () => { + it('returns items sorted ascending by name', () => { + const input = [{ name: 'Zebra' }, { name: 'Monkey' }, { name: 'Apple' }]; + + expect(sortSpacesByName(input)).toEqual([ + { name: 'Apple' }, + { name: 'Monkey' }, + { name: 'Zebra' }, + ]); + }); + }); + + describe('with an already-sorted array', () => { + it('preserves the existing order', () => { + const input = [{ name: 'Alpha' }, { name: 'Beta' }, { name: 'Gamma' }]; + + expect(sortSpacesByName(input)).toEqual([ + { name: 'Alpha' }, + { name: 'Beta' }, + { name: 'Gamma' }, + ]); + }); + }); + + describe('with an empty array', () => { + it('returns an empty array', () => { + expect(sortSpacesByName([])).toEqual([]); + }); + }); + + describe('with a single-item array', () => { + it('returns the item unchanged', () => { + expect(sortSpacesByName([{ name: 'Alpha' }])).toEqual([ + { name: 'Alpha' }, + ]); + }); + }); + + describe('with mixed-case names', () => { + it('sorts case-insensitively using base sensitivity', () => { + const input = [{ name: 'banana' }, { name: 'Apple' }, { name: 'cherry' }]; + + expect(sortSpacesByName(input)).toEqual([ + { name: 'Apple' }, + { name: 'banana' }, + { name: 'cherry' }, + ]); + }); + }); + + describe('with accented names', () => { + it('orders them using locale-aware comparison', () => { + const input = [{ name: 'Zebra' }, { name: 'Éclair' }, { name: 'Apple' }]; + + expect(sortSpacesByName(input).map((s) => s.name)).toEqual([ + 'Apple', + 'Éclair', + 'Zebra', + ]); + }); + }); + + describe('with an input array', () => { + it('does not mutate the input', () => { + const input = [{ name: 'Zebra' }, { name: 'Apple' }]; + const snapshot = [...input]; + + sortSpacesByName(input); + + expect(input).toEqual(snapshot); + }); + }); + + describe('with items that carry extra properties', () => { + it('preserves the extra properties on sorted items', () => { + const input = [ + { name: 'Beta', id: '2' as const }, + { name: 'Alpha', id: '1' as const }, + ]; + + expect(sortSpacesByName(input)).toEqual([ + { name: 'Alpha', id: '1' }, + { name: 'Beta', id: '2' }, + ]); + }); + }); +}); diff --git a/apps/frontend/src/domain/spaces-management/utils/sortSpacesByName.ts b/apps/frontend/src/domain/spaces-management/utils/sortSpacesByName.ts new file mode 100644 index 000000000..31548c74f --- /dev/null +++ b/apps/frontend/src/domain/spaces-management/utils/sortSpacesByName.ts @@ -0,0 +1,7 @@ +export function sortSpacesByName<T extends { name: string }>( + spaces: readonly T[], +): T[] { + return [...spaces].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }), + ); +} diff --git a/apps/frontend/src/domain/spaces/api/gateways/ISpacesGateway.ts b/apps/frontend/src/domain/spaces/api/gateways/ISpacesGateway.ts index 94872f4ec..f279f64e1 100644 --- a/apps/frontend/src/domain/spaces/api/gateways/ISpacesGateway.ts +++ b/apps/frontend/src/domain/spaces/api/gateways/ISpacesGateway.ts @@ -1,4 +1,8 @@ -import { ListUserSpacesResponse, Space } from '@packmind/types'; +import { + ListOrganizationSpacesForManagementResponse, + ListUserSpacesResponse, + Space, +} from '@packmind/types'; import { SpaceMemberEntry, SpaceMemberRole, @@ -10,6 +14,10 @@ import { export interface ISpacesGateway { getUserSpaces(orgId: string): Promise<ListUserSpacesResponse>; + listOrganizationSpacesForManagement( + orgId: string, + page: number, + ): Promise<ListOrganizationSpacesForManagementResponse>; getSpaceBySlug(slug: string, orgId: string): Promise<Space>; listSpaceMembers( orgId: string, diff --git a/apps/frontend/src/domain/spaces/api/gateways/SpacesGatewayApi.ts b/apps/frontend/src/domain/spaces/api/gateways/SpacesGatewayApi.ts index 941308398..ee69fd218 100644 --- a/apps/frontend/src/domain/spaces/api/gateways/SpacesGatewayApi.ts +++ b/apps/frontend/src/domain/spaces/api/gateways/SpacesGatewayApi.ts @@ -1,4 +1,8 @@ -import { ListUserSpacesResponse, Space } from '@packmind/types'; +import { + ListOrganizationSpacesForManagementResponse, + ListUserSpacesResponse, + Space, +} from '@packmind/types'; import { PackmindGateway } from '../../../../shared/PackmindGateway'; import { ISpacesGateway } from './ISpacesGateway'; import { @@ -27,6 +31,20 @@ export class SpacesGatewayApi ); } + async listOrganizationSpacesForManagement( + orgId: string, + page: number, + ): Promise<ListOrganizationSpacesForManagementResponse> { + if (!orgId) { + throw new Error( + 'Organization ID is required to fetch spaces for management', + ); + } + return this._api.get<ListOrganizationSpacesForManagementResponse>( + `${this._endpoint}/${orgId}/spaces-management/listing?page=${encodeURIComponent(String(page))}`, + ); + } + async getSpaceBySlug(slug: string, orgId: string): Promise<Space> { if (!orgId) { throw new Error('Organization ID is required to fetch space'); diff --git a/apps/frontend/src/domain/spaces/api/queries/SpacesQueries.ts b/apps/frontend/src/domain/spaces/api/queries/SpacesQueries.ts index af1ab9533..91dab1968 100644 --- a/apps/frontend/src/domain/spaces/api/queries/SpacesQueries.ts +++ b/apps/frontend/src/domain/spaces/api/queries/SpacesQueries.ts @@ -1,4 +1,5 @@ import { + keepPreviousData, queryOptions, useMutation, useQuery, @@ -30,6 +31,32 @@ export const useGetSpacesQuery = () => { return useQuery(getSpacesQueryOptions(orgId || '')); }; +export const getOrganizationSpacesForManagementQueryOptions = ( + organizationId: string, + page: number, +) => + queryOptions({ + queryKey: [ + 'organizations', + organizationId, + 'spaces', + 'management', + page, + ] as const, + queryFn: () => + spacesGateway.listOrganizationSpacesForManagement(organizationId, page), + placeholderData: keepPreviousData, + staleTime: 30_000, + }); + +export const useGetOrganizationSpacesForManagementQuery = ( + organizationId: string, + page: number, +) => + useQuery( + getOrganizationSpacesForManagementQueryOptions(organizationId, page), + ); + export const getSpaceBySlugQueryOptions = (slug: string, orgId: string) => queryOptions({ queryKey: spacesQueryKeys.detail(orgId, slug), diff --git a/apps/frontend/src/domain/spaces/components/AddSpaceMembersDialog.test.tsx b/apps/frontend/src/domain/spaces/components/AddSpaceMembersDialog.test.tsx index 5c0e6e60d..c9d7a8e31 100644 --- a/apps/frontend/src/domain/spaces/components/AddSpaceMembersDialog.test.tsx +++ b/apps/frontend/src/domain/spaces/components/AddSpaceMembersDialog.test.tsx @@ -253,6 +253,57 @@ describe('AddSpaceMembersDialog', () => { expect(mockSetOpen).toHaveBeenCalledWith(false); }); }); + + it('calls onSuccess when provided after the mutation resolves', async () => { + const onSuccess = jest.fn(); + const mockMutateAsync = jest + .fn() + .mockResolvedValue({ memberships: [] }); + mockUseAddMembersToSpaceMutation.mockReturnValue( + createMockMutation({ mutateAsync: mockMutateAsync }), + ); + + renderWithProviders( + <AddSpaceMembersDialog {...defaultProps} onSuccess={onSuccess} />, + ); + + selectUser('charlie.brown'); + await screen.findByText('charlie.brown'); + + const addButton = screen.getByRole('button', { + name: /add 1 member$/i, + }); + fireEvent.click(addButton); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + it('does not call onSuccess when the mutation rejects', async () => { + const onSuccess = jest.fn(); + const mockMutateAsync = jest.fn().mockRejectedValue(new Error('boom')); + mockUseAddMembersToSpaceMutation.mockReturnValue( + createMockMutation({ mutateAsync: mockMutateAsync }), + ); + + renderWithProviders( + <AddSpaceMembersDialog {...defaultProps} onSuccess={onSuccess} />, + ); + + selectUser('charlie.brown'); + await screen.findByText('charlie.brown'); + + const addButton = screen.getByRole('button', { + name: /add 1 member$/i, + }); + fireEvent.click(addButton); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalled(); + }); + expect(onSuccess).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/frontend/src/domain/spaces/components/AddSpaceMembersDialog.tsx b/apps/frontend/src/domain/spaces/components/AddSpaceMembersDialog.tsx index 9ff7269e9..0b5adfa0e 100644 --- a/apps/frontend/src/domain/spaces/components/AddSpaceMembersDialog.tsx +++ b/apps/frontend/src/domain/spaces/components/AddSpaceMembersDialog.tsx @@ -25,6 +25,7 @@ import { useGetUsersInMyOrganizationQuery } from '../../accounts/api/queries/Use import { useAddMembersToSpaceMutation } from '../api/queries/SpacesQueries'; import { SpaceMemberEntry, SpaceMemberRole } from '../types'; import { SpaceMember } from './SpaceMembersTable'; +import { isPackmindError } from '../../../services/api/errors/PackmindError'; interface UserSearchComboboxProps { items: { label: string; value: string }[]; @@ -96,6 +97,7 @@ interface AddSpaceMembersDialogProps { setOpen: (open: boolean) => void; spaceId: string; existingMembers: SpaceMember[]; + onSuccess?: () => void; } export const AddSpaceMembersDialog: React.FC<AddSpaceMembersDialogProps> = ({ @@ -103,6 +105,7 @@ export const AddSpaceMembersDialog: React.FC<AddSpaceMembersDialogProps> = ({ setOpen, spaceId, existingMembers, + onSuccess, }) => { const [selectedMembers, setSelectedMembers] = useState<SpaceMemberEntry[]>( [], @@ -158,17 +161,36 @@ export const AddSpaceMembersDialog: React.FC<AddSpaceMembersDialogProps> = ({ title: 'Members added', description: `${selectedMembers.length} member(s) added to the space.`, }); + onSuccess?.(); } catch (error) { + const status = isPackmindError(error) + ? error.serverError.status + : undefined; + const titleByStatus: Record<number, string> = { + 400: 'Invalid member selection.', + 403: "You don't have permission to add members to this space.", + 404: 'This space no longer exists.', + 409: 'One or more selected users are already members.', + }; + const title = + (status !== undefined && titleByStatus[status]) || + 'Failed to add members'; + const description = + status === undefined + ? (error as Error)?.message || 'An unexpected error occurred.' + : undefined; pmToaster.create({ type: 'error', - title: 'Failed to add members', - description: - (error as Error)?.message || 'An unexpected error occurred.', + title, + description, }); } }; const handleOpenChange = (details: { open: boolean }) => { + if (isPending && !details.open) { + return; + } setOpen(details.open); if (!details.open) { setSelectedMembers([]); @@ -183,6 +205,101 @@ export const AddSpaceMembersDialog: React.FC<AddSpaceMembersDialogProps> = ({ placement="end" size="md" > +<<<<<<< HEAD + <PMDialog.Backdrop /> + <PMDialog.Positioner> + <PMDialog.Content> + <PMDialog.Header> + <PMDialog.Title>Add members to space</PMDialog.Title> + <PMDialog.CloseTrigger asChild> + <PMCloseButton disabled={isPending} /> + </PMDialog.CloseTrigger> + </PMDialog.Header> + <PMDialog.Body> + <PMVStack gap={4} alignItems="stretch"> + <UserSearchCombobox + items={comboboxItems} + onSelect={handleUserSelect} + disabled={isPending} + /> + + {selectedMembers.length > 0 && ( + <PMVStack gap={2} alignItems="stretch"> + {selectedMembers.map((member) => { + const displayName = getDisplayName(member.userId); + return ( + <PMHStack + key={member.userId} + gap={3} + padding={2} + borderRadius="md" + border="1px solid" + borderColor="border.secondary" + > + <UserAvatarWithInitials + displayName={displayName} + size="sm" + /> + <PMText flex={1}>{displayName}</PMText> + <PMNativeSelect + size="sm" + value={member.role} + disabled={isPending} + onChange={(e) => + setUserRole( + member.userId, + e.target.value as SpaceMemberRole, + ) + } + items={[ + { value: 'admin', label: 'Admin' }, + { value: 'member', label: 'Member' }, + ]} + width="120px" + /> + <PMButton + size="xs" + variant="ghost" + colorPalette="red" + disabled={isPending} + onClick={() => removeUser(member.userId)} + > + <PMIcon> + <LuX /> + </PMIcon> + </PMButton> + </PMHStack> + ); + })} + </PMVStack> + )} + </PMVStack> + </PMDialog.Body> + <PMDialog.Footer> + <PMButtonGroup size="sm"> + <PMDialog.Trigger asChild> + <PMButton variant="tertiary" disabled={isPending}> + Cancel + </PMButton> + </PMDialog.Trigger> + <PMButton + variant="primary" + onClick={handleSubmit} + disabled={isPending || selectedMembers.length === 0} + loading={isPending} + > + <PMIcon> + <LuPlus /> + </PMIcon> + Add {selectedMembers.length > 0 ? selectedMembers.length : ''}{' '} + member{selectedMembers.length !== 1 ? 's' : ''} + </PMButton> + </PMButtonGroup> + </PMDialog.Footer> + </PMDialog.Content> + </PMDialog.Positioner> + </PMDialog.Root> +======= <PMPortal> <PMDrawer.Backdrop /> <PMDrawer.Positioner> @@ -289,5 +406,6 @@ export const AddSpaceMembersDialog: React.FC<AddSpaceMembersDialogProps> = ({ </PMDrawer.Positioner> </PMPortal> </PMDrawer.Root> +>>>>>>> origin/main ); }; diff --git a/apps/frontend/src/domain/spaces/components/SpaceDangerZoneSection.test.tsx b/apps/frontend/src/domain/spaces/components/SpaceDangerZoneSection.test.tsx index 0f3c6a988..1f615a79d 100644 --- a/apps/frontend/src/domain/spaces/components/SpaceDangerZoneSection.test.tsx +++ b/apps/frontend/src/domain/spaces/components/SpaceDangerZoneSection.test.tsx @@ -1,27 +1,21 @@ -import { - render, - screen, - fireEvent, - waitFor, - act, - within, -} from '@testing-library/react'; +import { render, screen, fireEvent, act, within } from '@testing-library/react'; import '@testing-library/jest-dom'; import { UIProvider, pmToaster } from '@packmind/ui'; -import { SpaceType, createPackageId, createSpaceId } from '@packmind/types'; +import { + Space, + SpaceType, + createOrganizationId, + createPackageId, + createSpaceId, +} from '@packmind/types'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; -import * as UseCurrentSpaceModule from '../hooks/useCurrentSpace'; import * as SpacesManagementQueriesModule from '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries'; import * as DeploymentsQueriesModule from '../../deployments/api/queries/DeploymentsQueries'; import * as UseNavigationModule from '../../../shared/hooks/useNavigation'; import * as UseAuthContextModule from '../../accounts/hooks/useAuthContext'; import { SpaceDangerZoneSection } from './SpaceDangerZoneSection'; -jest.mock('../hooks/useCurrentSpace', () => ({ - ...jest.requireActual('../hooks/useCurrentSpace'), - useCurrentSpace: jest.fn(), -})); - jest.mock( '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries', () => ({ @@ -56,31 +50,10 @@ jest.mock('@packmind/ui', () => ({ error: jest.fn(), }, })); -const mockUseCurrentSpace = ( - overrides: Partial<ReturnType<typeof UseCurrentSpaceModule.useCurrentSpace>>, -) => { - jest.spyOn(UseCurrentSpaceModule, 'useCurrentSpace').mockReturnValue({ - spaceId: 'space-1', - spaceSlug: 'test-space', - spaceName: 'Test Space', - space: { - id: 'space-1', - name: 'Test Space', - slug: 'test-space', - type: SpaceType.open, - organizationId: 'org-1', - isDefaultSpace: false, - }, - isLoading: false, - error: null, - isReady: true, - ...overrides, - } as unknown as ReturnType<typeof UseCurrentSpaceModule.useCurrentSpace>); -}; const mockMutate = jest.fn(); - const mockLeaveMutate = jest.fn(); +const mockToDashboard = jest.fn(); const mockLeaveSpaceMutation = () => { jest @@ -116,8 +89,6 @@ const mockListPackagesBySpaceQuery = ( >); }; -const mockToDashboard = jest.fn(); - const mockNavigation = () => { jest.spyOn(UseNavigationModule, 'useNavigation').mockReturnValue({ org: { toDashboard: mockToDashboard }, @@ -126,7 +97,7 @@ const mockNavigation = () => { const mockAuth = () => { jest.spyOn(UseAuthContextModule, 'useAuthContext').mockReturnValue({ - organization: { id: 'org-1', slug: 'org-slug' }, + organization: { id: createOrganizationId('org-1'), slug: 'org-slug' }, } as unknown as ReturnType<typeof UseAuthContextModule.useAuthContext>); }; @@ -134,14 +105,26 @@ const renderWithProviders = (component: React.ReactElement) => { return render(<UIProvider>{component}</UIProvider>); }; +const buildSpace = (overrides: Partial<Space> = {}): Space => + spaceFactory({ + id: createSpaceId('space-1'), + name: 'Test Space', + slug: 'test-space', + type: SpaceType.open, + organizationId: createOrganizationId('org-1'), + isDefaultSpace: false, + ...overrides, + }); + describe('SpaceDangerZoneSection', () => { afterEach(() => { jest.clearAllMocks(); mockMutate.mockReset(); + mockLeaveMutate.mockReset(); + mockToDashboard.mockReset(); }); beforeEach(() => { - mockUseCurrentSpace({}); mockLeaveSpaceMutation(); mockDeleteSpaceMutation(); mockListPackagesBySpaceQuery(); @@ -150,44 +133,38 @@ describe('SpaceDangerZoneSection', () => { }); describe('when the space is the default space', () => { - beforeEach(() => { - mockUseCurrentSpace({ - space: { - id: 'space-1', - name: 'Default Space', - slug: 'default-space', - type: SpaceType.open, - organizationId: 'org-1', - isDefaultSpace: true, - }, - }); - }); - it('does not render the leave button', () => { - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={true} />); + const space = buildSpace({ isDefaultSpace: true }); + + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={true} />, + ); expect( screen.queryByRole('button', { name: /leave this space/i }), ).not.toBeInTheDocument(); }); - }); - describe('when the space is open', () => { - beforeEach(() => { - mockUseCurrentSpace({ - space: { - id: 'space-1', - name: 'Test Space', - slug: 'test-space', - type: SpaceType.open, - organizationId: 'org-1', - isDefaultSpace: false, - }, - }); + it('does not render the delete button', () => { + const space = buildSpace({ isDefaultSpace: true }); + + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={true} />, + ); + + expect( + screen.queryByRole('button', { name: /delete this space/i }), + ).not.toBeInTheDocument(); }); + }); + describe('when the space is open', () => { it('shows the rejoin-anytime message in the danger zone description', () => { - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={true} />); + const space = buildSpace({ type: SpaceType.open }); + + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={true} />, + ); expect( screen.getByText(/You can rejoin whenever you want\./), @@ -195,7 +172,11 @@ describe('SpaceDangerZoneSection', () => { }); it('shows the rejoin-anytime message in the leave confirmation dialog', async () => { - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={true} />); + const space = buildSpace({ type: SpaceType.open }); + + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={true} />, + ); const trigger = screen.getByRole('button', { name: /leave this space/i, }); @@ -211,21 +192,12 @@ describe('SpaceDangerZoneSection', () => { }); describe('when the space is restricted', () => { - beforeEach(() => { - mockUseCurrentSpace({ - space: { - id: 'space-1', - name: 'Test Space', - slug: 'test-space', - type: SpaceType.restricted, - organizationId: 'org-1', - isDefaultSpace: false, - }, - }); - }); - it('shows the ask-administrator message in the danger zone description', () => { - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={true} />); + const space = buildSpace({ type: SpaceType.restricted }); + + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={true} />, + ); expect( screen.getByText(/You'll have to ask an administrator to rejoin\./), @@ -233,7 +205,11 @@ describe('SpaceDangerZoneSection', () => { }); it('shows the ask-administrator message in the leave confirmation dialog', async () => { - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={true} />); + const space = buildSpace({ type: SpaceType.restricted }); + + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={true} />, + ); const trigger = screen.getByRole('button', { name: /leave this space/i, }); @@ -251,21 +227,12 @@ describe('SpaceDangerZoneSection', () => { }); describe('when the space is private', () => { - beforeEach(() => { - mockUseCurrentSpace({ - space: { - id: 'space-1', - name: 'Test Space', - slug: 'test-space', - type: SpaceType.private, - organizationId: 'org-1', - isDefaultSpace: false, - }, - }); - }); - it('shows the ask-administrator message in the danger zone description', () => { - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={true} />); + const space = buildSpace({ type: SpaceType.private }); + + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={true} />, + ); expect( screen.getByText(/You'll have to ask an administrator to rejoin\./), @@ -275,7 +242,10 @@ describe('SpaceDangerZoneSection', () => { describe('when the leave dialog is opened', () => { const openLeaveDialog = async () => { - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={true} />); + const space = buildSpace(); + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={true} />, + ); const trigger = screen.getByRole('button', { name: /leave this space/i, }); @@ -331,7 +301,7 @@ describe('SpaceDangerZoneSection', () => { fireEvent.click(leaveButton); expect(mockLeaveMutate).toHaveBeenCalledWith( - { spaceId: 'space-1' }, + { spaceId: createSpaceId('space-1') }, expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function), @@ -354,7 +324,7 @@ describe('SpaceDangerZoneSection', () => { fireEvent.submit(form); expect(mockLeaveMutate).toHaveBeenCalledWith( - { spaceId: 'space-1' }, + { spaceId: createSpaceId('space-1') }, expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function), @@ -399,7 +369,10 @@ describe('SpaceDangerZoneSection', () => { // Simulate a slow mutation: do not invoke any callbacks }); - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={true} />); + const space = buildSpace(); + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={true} />, + ); const trigger = screen.getByRole('button', { name: /leave this space/i, }); @@ -447,7 +420,10 @@ describe('SpaceDangerZoneSection', () => { }, ); - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={true} />); + const space = buildSpace(); + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={true} />, + ); const trigger = screen.getByRole('button', { name: /leave this space/i, }); @@ -482,7 +458,10 @@ describe('SpaceDangerZoneSection', () => { }, ); - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={true} />); + const space = buildSpace(); + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={true} />, + ); const trigger = screen.getByRole('button', { name: /leave this space/i, }); @@ -506,9 +485,52 @@ describe('SpaceDangerZoneSection', () => { }); }); + describe('when canDelete is true', () => { + it('renders the delete button enabled', () => { + const space = buildSpace(); + + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={true} />, + ); + + expect( + screen.getByRole('button', { name: /delete this space/i }), + ).toBeEnabled(); + }); + }); + + describe('when canDelete is false', () => { + it('disables the delete button', () => { + const space = buildSpace(); + + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={false} />, + ); + + expect( + screen.getByRole('button', { name: /delete this space/i }), + ).toBeDisabled(); + }); + + it('still renders the leave button', () => { + const space = buildSpace(); + + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={false} />, + ); + + expect( + screen.getByRole('button', { name: /leave this space/i }), + ).toBeInTheDocument(); + }); + }); + describe('when the delete dialog is opened', () => { const openDeleteDialog = async () => { - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={true} />); + const space = buildSpace(); + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={true} />, + ); const trigger = screen.getByRole('button', { name: /delete this space/i, }); @@ -576,7 +598,7 @@ describe('SpaceDangerZoneSection', () => { fireEvent.click(deleteButton); expect(mockMutate).toHaveBeenCalledWith( - { spaceId: 'space-1' }, + { spaceId: createSpaceId('space-1') }, expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function), @@ -599,7 +621,7 @@ describe('SpaceDangerZoneSection', () => { fireEvent.submit(form); expect(mockMutate).toHaveBeenCalledWith( - { spaceId: 'space-1' }, + { spaceId: createSpaceId('space-1') }, expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function), @@ -638,7 +660,10 @@ describe('SpaceDangerZoneSection', () => { name: '@test/other-package', }, ]); - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={true} />); + const space = buildSpace(); + renderWithProviders( + <SpaceDangerZoneSection space={space} canDelete={true} />, + ); const trigger = screen.getByRole('button', { name: /delete this space/i, }); @@ -653,67 +678,59 @@ describe('SpaceDangerZoneSection', () => { }); }); - describe('when the space is the default space', () => { - beforeEach(() => { - mockUseCurrentSpace({ - space: { - id: createSpaceId('space-1'), - name: 'Default Space', - slug: 'default-space', - type: SpaceType.open, - organizationId: 'org-1', - isDefaultSpace: true, + describe('when the delete mutation succeeds', () => { + const triggerDeleteSuccess = async ( + onDeleted?: () => void, + ): Promise<void> => { + mockMutate.mockImplementation( + ( + _params: unknown, + options: { onSuccess?: () => void; onError?: () => void }, + ) => { + options.onSuccess?.(); }, - }); - }); - - it('does not render the delete button', () => { - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={true} />); - - expect( - screen.queryByRole('button', { name: /delete this space/i }), - ).not.toBeInTheDocument(); - }); - }); + ); - describe('when the space is not the default space', () => { - beforeEach(() => { - mockUseCurrentSpace({ - space: { - id: createSpaceId('space-1'), - name: 'Test Space', - slug: 'test-space', - type: SpaceType.open, - organizationId: 'org-1', - isDefaultSpace: false, - }, + const space = buildSpace(); + renderWithProviders( + <SpaceDangerZoneSection + space={space} + canDelete={true} + onDeleted={onDeleted} + />, + ); + const trigger = screen.getByRole('button', { + name: /delete this space/i, }); - }); + await act(async () => { + fireEvent.click(trigger); + }); + await screen.findByPlaceholderText('Enter space name'); + const input = screen.getByPlaceholderText('Enter space name'); + fireEvent.change(input, { target: { value: 'Test Space' } }); + const deleteButton = screen.getByRole('button', { name: 'Delete' }); + await act(async () => { + fireEvent.click(deleteButton); + }); + }; - it('renders the delete button', () => { - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={true} />); + describe('when no onDeleted callback is provided', () => { + it('redirects to the organization dashboard', async () => { + await triggerDeleteSuccess(); - expect( - screen.getByRole('button', { name: /delete this space/i }), - ).toBeInTheDocument(); + expect(mockToDashboard).toHaveBeenCalled(); + }); }); - }); - - describe('when canDeleteSpace is false', () => { - it('does not render the delete button', () => { - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={false} />); - expect( - screen.queryByRole('button', { name: /delete this space/i }), - ).not.toBeInTheDocument(); - }); + describe('when an onDeleted callback is provided', () => { + it('calls onDeleted instead of redirecting', async () => { + const onDeleted = jest.fn(); - it('renders the leave button', () => { - renderWithProviders(<SpaceDangerZoneSection canDeleteSpace={false} />); + await triggerDeleteSuccess(onDeleted); - expect( - screen.getByRole('button', { name: /leave this space/i }), - ).toBeInTheDocument(); + expect(onDeleted).toHaveBeenCalled(); + expect(mockToDashboard).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/apps/frontend/src/domain/spaces/components/SpaceDangerZoneSection.tsx b/apps/frontend/src/domain/spaces/components/SpaceDangerZoneSection.tsx index 604efcdec..204f44ab0 100644 --- a/apps/frontend/src/domain/spaces/components/SpaceDangerZoneSection.tsx +++ b/apps/frontend/src/domain/spaces/components/SpaceDangerZoneSection.tsx @@ -1,4 +1,3 @@ -import { Dialog, Portal } from '@chakra-ui/react'; import { useState } from 'react'; import { LuLogOut, LuTrash2 } from 'react-icons/lu'; import { @@ -6,23 +5,22 @@ import { PMHeading, PMHStack, PMIcon, - PMInput, PMPageSection, PMSeparator, PMText, PMVStack, pmToaster, } from '@packmind/ui'; -import { Package, SpaceType } from '@packmind/types'; +import { Package, Space, SpaceType } from '@packmind/types'; import { useLeaveSpaceMutation, useDeleteSpaceMutation, } from '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries'; -import { useCurrentSpace } from '../hooks/useCurrentSpace'; import { useNavigation } from '../../../shared/hooks/useNavigation'; import { useListPackagesBySpaceQuery } from '../../deployments/api/queries/DeploymentsQueries'; import { useAuthContext } from '../../accounts/hooks/useAuthContext'; +import { TypeToConfirmDialog } from '../../../shared/components/TypeToConfirmDialog'; function LeaveSpaceConfirmationDialog({ spaceName, @@ -36,17 +34,9 @@ function LeaveSpaceConfirmationDialog({ onConfirm: (onSettled: () => void) => void; }>) { const [open, setOpen] = useState(false); - const [confirmationInput, setConfirmationInput] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const isBusy = isPending || isSubmitting; - const isConfirmEnabled = confirmationInput === spaceName && !isBusy; - - const handleOpenChange = (details: { open: boolean }) => { - if (!isBusy) { - setOpen(details.open); - } - }; const handleConfirm = () => { setIsSubmitting(true); @@ -56,85 +46,33 @@ function LeaveSpaceConfirmationDialog({ }); }; - const handleSubmit = (event: React.FormEvent) => { - event.preventDefault(); - if (!isConfirmEnabled) { - return; - } - handleConfirm(); - }; - return ( - <Dialog.Root - open={open} - onOpenChange={handleOpenChange} - placement="center" - onExitComplete={() => setConfirmationInput('')} - > - <Dialog.Trigger asChild> - <PMButton variant="secondary" minW="180px"> - <PMIcon> - <LuLogOut /> - </PMIcon> - Leave this space - </PMButton> - </Dialog.Trigger> - - <Portal> - <Dialog.Backdrop /> - <Dialog.Positioner> - <Dialog.Content> - <form onSubmit={handleSubmit}> - <Dialog.Header> - <Dialog.Title>Leave space</Dialog.Title> - </Dialog.Header> - - <Dialog.Body> - <PMVStack gap={4} align="stretch"> - <PMText> - You are about to leave <strong>{spaceName}</strong>. You - will lose access to its standards, commands, skills, and - other content.{' '} - {spaceType === SpaceType.open - ? 'You can rejoin whenever you want.' - : "You'll have to ask an administrator to rejoin."} - </PMText> - <PMVStack gap={2} align="stretch"> - <PMText variant="body" fontSize="sm"> - Type <strong>{spaceName}</strong> to confirm: - </PMText> - <PMInput - placeholder="Enter space name" - value={confirmationInput} - onChange={(e) => setConfirmationInput(e.target.value)} - /> - </PMVStack> - </PMVStack> - </Dialog.Body> - - <Dialog.Footer> - <Dialog.ActionTrigger asChild> - <PMButton type="button" variant="tertiary" disabled={isBusy}> - Cancel - </PMButton> - </Dialog.ActionTrigger> - <PMButton - type="submit" - colorScheme="red" - disabled={!isConfirmEnabled} - loading={isBusy} - ml={3} - > - Leave - </PMButton> - </Dialog.Footer> - </form> - - <Dialog.CloseTrigger /> - </Dialog.Content> - </Dialog.Positioner> - </Portal> - </Dialog.Root> + <> + <PMButton variant="secondary" minW="180px" onClick={() => setOpen(true)}> + <PMIcon> + <LuLogOut /> + </PMIcon> + Leave this space + </PMButton> + <TypeToConfirmDialog + open={open} + onOpenChange={setOpen} + title="Leave space" + expectedValue={spaceName} + inputPlaceholder="Enter space name" + confirmLabel="Leave" + isPending={isBusy} + onConfirm={handleConfirm} + > + <PMText> + You are about to leave <strong>{spaceName}</strong>. You will lose + access to its standards, commands, skills, and other content.{' '} + {spaceType === SpaceType.open + ? 'You can rejoin whenever you want.' + : "You'll have to ask an administrator to rejoin."} + </PMText> + </TypeToConfirmDialog> + </> ); } @@ -142,143 +80,95 @@ function DeleteSpaceConfirmationDialog({ spaceName, packages, isPending, + isDisabled, onConfirm, }: Readonly<{ spaceName: string; packages: Package[]; isPending: boolean; + isDisabled: boolean; onConfirm: (onSettled: () => void) => void; }>) { const [open, setOpen] = useState(false); - const [confirmationInput, setConfirmationInput] = useState(''); - - const isConfirmEnabled = confirmationInput === spaceName && !isPending; - - const handleOpenChange = (details: { open: boolean }) => { - if (!isPending) { - setOpen(details.open); - } - }; const handleConfirm = () => { onConfirm(() => setOpen(false)); }; - const handleSubmit = (event: React.FormEvent) => { - event.preventDefault(); - if (!isConfirmEnabled) { - return; - } - handleConfirm(); - }; - return ( - <Dialog.Root - open={open} - onOpenChange={handleOpenChange} - placement="center" - onExitComplete={() => setConfirmationInput('')} - > - <Dialog.Trigger asChild> - <PMButton variant="danger" minW="180px"> - <PMIcon> - <LuTrash2 /> - </PMIcon> - Delete this space - </PMButton> - </Dialog.Trigger> - - <Portal> - <Dialog.Backdrop /> - <Dialog.Positioner> - <Dialog.Content> - <form onSubmit={handleSubmit}> - <Dialog.Header> - <Dialog.Title>Delete space</Dialog.Title> - </Dialog.Header> - - <Dialog.Body> - <PMVStack gap={4} align="stretch"> - <PMText> - This will permanently delete <strong>{spaceName}</strong>{' '} - and all its standards, commands, and skills. This action - cannot be undone. - </PMText> - {packages.length > 0 && ( - <PMVStack gap={2} align="stretch"> - <PMText variant="body" fontSize="sm"> - The following packages will be affected: - </PMText> - <PMVStack gap={1} align="stretch" pl={4}> - {packages.map((pkg) => ( - <PMText key={pkg.id} variant="body" fontSize="sm"> - - {pkg.name} - </PMText> - ))} - </PMVStack> - <PMText variant="body" fontSize="sm"> - All deployments using these packages will stop receiving - updates. - </PMText> - </PMVStack> - )} - <PMVStack gap={2} align="stretch"> - <PMText variant="body" fontSize="sm"> - Type <strong>{spaceName}</strong> to confirm: - </PMText> - <PMInput - placeholder="Enter space name" - value={confirmationInput} - onChange={(e) => setConfirmationInput(e.target.value)} - /> - </PMVStack> - </PMVStack> - </Dialog.Body> - - <Dialog.Footer> - <Dialog.ActionTrigger asChild> - <PMButton - type="button" - variant="tertiary" - disabled={isPending} - > - Cancel - </PMButton> - </Dialog.ActionTrigger> - <PMButton - type="submit" - colorScheme="red" - disabled={!isConfirmEnabled} - loading={isPending} - ml={3} - > - Delete - </PMButton> - </Dialog.Footer> - </form> - - <Dialog.CloseTrigger /> - </Dialog.Content> - </Dialog.Positioner> - </Portal> - </Dialog.Root> + <> + <PMButton + variant="danger" + minW="180px" + onClick={() => setOpen(true)} + disabled={isDisabled} + > + <PMIcon> + <LuTrash2 /> + </PMIcon> + Delete this space + </PMButton> + <TypeToConfirmDialog + open={open} + onOpenChange={setOpen} + title="Delete space" + expectedValue={spaceName} + inputPlaceholder="Enter space name" + confirmLabel="Delete" + isPending={isPending} + onConfirm={handleConfirm} + > + <PMText> + This will permanently delete <strong>{spaceName}</strong> and all its + standards, commands, and skills. This action cannot be undone. + </PMText> + {packages.length > 0 && ( + <PMVStack gap={2} align="stretch"> + <PMText variant="body" fontSize="sm"> + The following packages will be affected: + </PMText> + <PMVStack gap={1} align="stretch" pl={4}> + {packages.map((pkg) => ( + <PMText key={pkg.id} variant="body" fontSize="sm"> + - {pkg.name} + </PMText> + ))} + </PMVStack> + <PMText variant="body" fontSize="sm"> + All deployments using these packages will stop receiving updates. + </PMText> + </PMVStack> + )} + </TypeToConfirmDialog> + </> ); } +interface SpaceDangerZoneSectionProps { + space: Space; + canDelete: boolean; + isMember?: boolean; + onDeleted?: () => void; + onLeft?: () => void; +} + export function SpaceDangerZoneSection({ - canDeleteSpace, -}: Readonly<{ canDeleteSpace: boolean }>) { - const { space } = useCurrentSpace(); + space, + canDelete, + isMember = true, + onDeleted, + onLeft, +}: Readonly<SpaceDangerZoneSectionProps>) { const leaveSpaceMutation = useLeaveSpaceMutation(); const { organization } = useAuthContext(); const deleteSpaceMutation = useDeleteSpaceMutation(); const nav = useNavigation(); const { data: packagesData } = useListPackagesBySpaceQuery( - space?.id, + space.id, organization?.id, ); - if (!space || space.isDefaultSpace) { + if (space.isDefaultSpace) { return null; } @@ -292,80 +182,48 @@ export function SpaceDangerZoneSection({ </PMHeading> } > - <PMVStack align="stretch" gap={5} pt={4} w="lg"> - <PMHStack justify="space-between" align="center"> - <PMVStack gap={1} align="flex-start"> - <PMText fontWeight="medium">Leave this space</PMText> - <PMText variant="body" color="secondary" fontSize="sm"> - You will lose access to all standards, commands, skills, and other - content.{' '} - {space.type === SpaceType.open - ? 'You can rejoin whenever you want.' - : "You'll have to ask an administrator to rejoin."} - </PMText> - </PMVStack> - <LeaveSpaceConfirmationDialog - spaceName={space.name} - spaceType={space.type} - isPending={leaveSpaceMutation.isPending} - onConfirm={(onSettled) => { - leaveSpaceMutation.mutate( - { spaceId: space.id }, - { - onSuccess: () => { - onSettled(); - pmToaster.create({ - type: 'success', - title: 'Left space', - description: `You've left ${space.name}.`, - }); - }, - onError: () => { - onSettled(); - pmToaster.create({ - type: 'error', - title: 'Failed to leave space', - description: - 'An error occurred while leaving the space. Please try again.', - }); - }, - }, - ); - }} - /> - </PMHStack> - - {canDeleteSpace && ( + <PMVStack align="stretch" gap={5} pt={4}> + {isMember && ( <> - <PMSeparator /> - <PMHStack justify="space-between" align="center"> <PMVStack gap={1} align="flex-start"> - <PMText fontWeight="medium">Delete this space</PMText> + <PMText fontWeight="medium">Leave this space</PMText> <PMText variant="body" color="secondary" fontSize="sm"> - Permanently remove this space and all its content. This action - cannot be undone. + You will lose access to all standards, commands, skills, and + other content.{' '} + {space.type === SpaceType.open + ? 'You can rejoin whenever you want.' + : "You'll have to ask an administrator to rejoin."} </PMText> </PMVStack> - <DeleteSpaceConfirmationDialog + <LeaveSpaceConfirmationDialog spaceName={space.name} - packages={packagesData?.packages ?? []} - isPending={deleteSpaceMutation.isPending} + spaceType={space.type} + isPending={leaveSpaceMutation.isPending} onConfirm={(onSettled) => { - deleteSpaceMutation.mutate( + leaveSpaceMutation.mutate( { spaceId: space.id }, { onSuccess: () => { onSettled(); - nav.org.toDashboard(); + pmToaster.create({ + type: 'success', + title: 'Left space', + description: `You've left ${space.name}.`, + }); + if (onLeft) { + onLeft(); + } else { + nav.org.toDashboard(); + } }, onError: () => { onSettled(); pmToaster.create({ type: 'error', - title: 'Failed to delete space', + title: 'Failed to leave space', description: - 'An error occurred while deleting the space. Please try again.', + 'An error occurred while leaving the space. Please try again.', }); }, }, @@ -373,8 +231,50 @@ export function SpaceDangerZoneSection({ }} /> </PMHStack> + + <PMSeparator /> </> )} + + <PMHStack justify="space-between" align="center"> + <PMVStack gap={1} align="flex-start"> + <PMText fontWeight="medium">Delete this space</PMText> + <PMText variant="body" color="secondary" fontSize="sm"> + Permanently remove this space and all its content. This action + cannot be undone. + </PMText> + </PMVStack> + <DeleteSpaceConfirmationDialog + spaceName={space.name} + packages={packagesData?.packages ?? []} + isPending={deleteSpaceMutation.isPending} + isDisabled={!canDelete} + onConfirm={(onSettled) => { + deleteSpaceMutation.mutate( + { spaceId: space.id }, + { + onSuccess: () => { + onSettled(); + if (onDeleted) { + onDeleted(); + } else { + nav.org.toDashboard(); + } + }, + onError: () => { + onSettled(); + pmToaster.create({ + type: 'error', + title: 'Failed to delete space', + description: + 'An error occurred while deleting the space. Please try again.', + }); + }, + }, + ); + }} + /> + </PMHStack> </PMVStack> </PMPageSection> ); diff --git a/apps/frontend/src/domain/spaces/components/SpaceGeneralSettings.tsx b/apps/frontend/src/domain/spaces/components/SpaceGeneralSettings.tsx index 0b65a624d..8b591f91b 100644 --- a/apps/frontend/src/domain/spaces/components/SpaceGeneralSettings.tsx +++ b/apps/frontend/src/domain/spaces/components/SpaceGeneralSettings.tsx @@ -15,14 +15,21 @@ export function SpaceGeneralSettings() { const currentUserMember = data?.members?.find((m) => m.userId === user?.id); const isSpaceAdmin = currentUserMember?.role === 'admin'; const isOrgAdmin = organization?.role === 'admin'; + const canEditIdentity = isSpaceAdmin || isOrgAdmin; const canDeleteSpace = isSpaceAdmin || isOrgAdmin; return ( <PMVStack align="stretch" gap={6} pt={4}> - {isSpaceAdmin && <SpaceIdentitySection />} + {space && ( + <SpaceIdentitySection + key={space.id} + space={space} + canEdit={canEditIdentity} + /> + )} {isSpaceAdmin && !space?.isDefaultSpace && <SpaceAccessSection />} - {!space?.isDefaultSpace && ( - <SpaceDangerZoneSection canDeleteSpace={canDeleteSpace} /> + {!space?.isDefaultSpace && space && ( + <SpaceDangerZoneSection space={space} canDelete={canDeleteSpace} /> )} </PMVStack> ); diff --git a/apps/frontend/src/domain/spaces/components/SpaceIdentitySection.test.tsx b/apps/frontend/src/domain/spaces/components/SpaceIdentitySection.test.tsx new file mode 100644 index 000000000..f4c38b129 --- /dev/null +++ b/apps/frontend/src/domain/spaces/components/SpaceIdentitySection.test.tsx @@ -0,0 +1,225 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UIProvider, pmToaster } from '@packmind/ui'; +import { + createOrganizationId, + createSpaceId, + createUserId, + Space, + SpaceType, + type UserOrganizationRole, +} from '@packmind/types'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; + +import * as SpacesManagementQueriesModule from '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries'; +import * as UseAuthContextModule from '../../accounts/hooks/useAuthContext'; +import { SpaceIdentitySection } from './SpaceIdentitySection'; + +jest.mock( + '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries', + () => ({ + ...jest.requireActual( + '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries', + ), + useUpdateSpaceMutation: jest.fn(), + }), +); + +jest.mock('../../accounts/hooks/useAuthContext', () => ({ + ...jest.requireActual('../../accounts/hooks/useAuthContext'), + useAuthContext: jest.fn(), +})); + +jest.mock('@packmind/ui', () => ({ + ...jest.requireActual('@packmind/ui'), + pmToaster: { + create: jest.fn(), + success: jest.fn(), + error: jest.fn(), + }, +})); + +const mockMutateAsync = jest.fn(); + +const mockUpdateSpaceMutation = ( + overrides: Partial<{ isPending: boolean }> = {}, +) => { + jest + .spyOn(SpacesManagementQueriesModule, 'useUpdateSpaceMutation') + .mockReturnValue({ + mutateAsync: mockMutateAsync, + isPending: false, + ...overrides, + } as unknown as ReturnType< + typeof SpacesManagementQueriesModule.useUpdateSpaceMutation + >); +}; + +const organizationId = createOrganizationId('org-1'); + +const mockAuth = ( + overrides: Partial<{ + organization: { id: ReturnType<typeof createOrganizationId> } | undefined; + }> = {}, +) => { + jest.spyOn(UseAuthContextModule, 'useAuthContext').mockReturnValue({ + organization: + 'organization' in overrides + ? overrides.organization + : { + id: organizationId, + name: 'Org 1', + slug: 'org-1', + role: 'admin' as UserOrganizationRole, + }, + user: { + id: createUserId('user-1'), + email: 'user@test.com', + displayName: null, + memberships: [], + }, + isAuthenticated: true, + isLoading: false, + getMe: jest.fn(), + getUserOrganizations: jest.fn(), + validateAndSwitchIfNeeded: jest.fn(), + } as unknown as ReturnType<typeof UseAuthContextModule.useAuthContext>); +}; + +const buildSpace = (overrides: Partial<Space> = {}): Space => + spaceFactory({ + id: createSpaceId('space-1'), + name: 'Test Space', + slug: 'test-space', + type: SpaceType.open, + organizationId, + isDefaultSpace: false, + ...overrides, + }); + +const renderWithProviders = (component: React.ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + const utils = render( + <UIProvider> + <QueryClientProvider client={queryClient}> + {component} + </QueryClientProvider> + </UIProvider>, + ); + return { ...utils, queryClient, invalidateSpy }; +}; + +describe('SpaceIdentitySection', () => { + beforeEach(() => { + mockUpdateSpaceMutation(); + mockAuth(); + }); + + afterEach(() => { + jest.clearAllMocks(); + mockMutateAsync.mockReset(); + }); + + describe('when the user saves a name change', () => { + it('invalidates the spaces management listing key on success', async () => { + mockMutateAsync.mockResolvedValue(undefined); + const space = buildSpace({ name: 'Original Name' }); + + const { invalidateSpy } = renderWithProviders( + <SpaceIdentitySection space={space} canEdit={true} />, + ); + + const nameInput = screen.getByLabelText('Name'); + fireEvent.change(nameInput, { target: { value: 'New Name' } }); + + const saveButton = screen.getByRole('button', { name: /save changes/i }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + spaceId: space.id, + fields: { name: 'New Name' }, + }); + }); + + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['organizations', organizationId, 'spaces', 'management'], + }); + }); + }); + + it('shows a success toaster when the update succeeds', async () => { + mockMutateAsync.mockResolvedValue(undefined); + const space = buildSpace({ name: 'Original Name' }); + + renderWithProviders( + <SpaceIdentitySection space={space} canEdit={true} />, + ); + + const nameInput = screen.getByLabelText('Name'); + fireEvent.change(nameInput, { target: { value: 'New Name' } }); + fireEvent.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => { + expect(pmToaster.create).toHaveBeenCalledWith( + expect.objectContaining({ type: 'success' }), + ); + }); + }); + }); + + describe('when the update fails', () => { + it('does not invalidate the management listing key', async () => { + mockMutateAsync.mockRejectedValue(new Error('boom')); + const space = buildSpace({ name: 'Original Name' }); + + const { invalidateSpy } = renderWithProviders( + <SpaceIdentitySection space={space} canEdit={true} />, + ); + + const nameInput = screen.getByLabelText('Name'); + fireEvent.change(nameInput, { target: { value: 'New Name' } }); + fireEvent.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => { + expect(pmToaster.create).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ); + }); + + expect(invalidateSpy).not.toHaveBeenCalledWith({ + queryKey: ['organizations', organizationId, 'spaces', 'management'], + }); + }); + }); + + describe('when there is no organization in context', () => { + it('does not invalidate the management listing key', async () => { + mockMutateAsync.mockResolvedValue(undefined); + mockAuth({ organization: undefined }); + const space = buildSpace({ name: 'Original Name' }); + + const { invalidateSpy } = renderWithProviders( + <SpaceIdentitySection space={space} canEdit={true} />, + ); + + const nameInput = screen.getByLabelText('Name'); + fireEvent.change(nameInput, { target: { value: 'New Name' } }); + fireEvent.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalled(); + }); + + expect(invalidateSpy).not.toHaveBeenCalledWith({ + queryKey: expect.arrayContaining(['management']), + }); + }); + }); +}); diff --git a/apps/frontend/src/domain/spaces/components/SpaceIdentitySection.tsx b/apps/frontend/src/domain/spaces/components/SpaceIdentitySection.tsx index 1bfcf12e2..9c4dcf1ae 100644 --- a/apps/frontend/src/domain/spaces/components/SpaceIdentitySection.tsx +++ b/apps/frontend/src/domain/spaces/components/SpaceIdentitySection.tsx @@ -1,5 +1,8 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Space, SPACE_COLOR_PALETTES, SpaceColor } from '@packmind/types'; import { + PMAlert, PMButton, PMColorSwatch, PMField, @@ -8,32 +11,87 @@ import { PMInput, PMPageSection, PMVStack, + pmToaster, } from '@packmind/ui'; +import { useUpdateSpaceMutation } from '../../spaces-management/api/queries/SpacesManagementQueries'; +import { useAuthContext } from '../../accounts/hooks/useAuthContext'; +import { isPackmindError } from '../../../services/api/errors/PackmindError'; -const SPACE_COLOR_PALETTES = [ - 'red', - 'orange', - 'yellow', - 'green', - 'teal', - 'blue', - 'cyan', - 'purple', - 'pink', -] as const; +interface SpaceIdentitySectionProps { + space: Space; + canEdit: boolean; + onDirtyChange?: (dirty: boolean) => void; +} -type SpaceColor = (typeof SPACE_COLOR_PALETTES)[number]; +export function SpaceIdentitySection({ + space, + canEdit, + onDirtyChange, +}: Readonly<SpaceIdentitySectionProps>) { + const [name, setName] = useState(space.name); + const [nameConflictError, setNameConflictError] = useState(false); + const [selectedColor, setSelectedColor] = useState<SpaceColor>(space.color); + const updateSpaceMutation = useUpdateSpaceMutation(); + const queryClient = useQueryClient(); + const { organization } = useAuthContext(); -const MOCK_SPACE = { - name: 'My Space', - color: 'blue' as SpaceColor, -}; + const isDefaultSpace = space.isDefaultSpace; + const nameDisabled = !canEdit || isDefaultSpace; + const colorDisabled = !canEdit; -export function SpaceIdentitySection() { - const [name, setName] = useState(MOCK_SPACE.name); - const [selectedColor, setSelectedColor] = useState<SpaceColor>( - MOCK_SPACE.color, - ); + const trimmedName = name.trim(); + const isNameValid = trimmedName.length > 0; + const isNameDirty = trimmedName !== space.name && !isDefaultSpace; + const isColorDirty = selectedColor !== space.color; + const hasChanges = isNameDirty || isColorDirty; + const showNameRequiredError = !nameDisabled && !isNameValid; + + useEffect(() => { + onDirtyChange?.(hasChanges); + }, [hasChanges, onDirtyChange]); + + useEffect(() => { + return () => { + onDirtyChange?.(false); + }; + }, [onDirtyChange]); + + const handleSave = async () => { + const fields: { name?: string; color?: SpaceColor } = {}; + if (isNameDirty) fields.name = trimmedName; + if (isColorDirty) fields.color = selectedColor; + if (Object.keys(fields).length === 0) return; + + try { + await updateSpaceMutation.mutateAsync({ spaceId: space.id, fields }); + pmToaster.create({ + type: 'success', + title: 'Space updated', + }); + if (organization?.id) { + queryClient.invalidateQueries({ + queryKey: ['organizations', organization.id, 'spaces', 'management'], + }); + } + } catch (err) { + const status = isPackmindError(err) ? err.serverError.status : undefined; + if (status === 409) { + setNameConflictError(true); + return; + } + const messageByStatus: Record<number, string> = { + 400: 'Invalid color selected.', + 403: "You don't have permission to update this space.", + 422: 'The default space cannot be renamed.', + }; + pmToaster.create({ + type: 'error', + title: + (status !== undefined && messageByStatus[status]) || + 'Failed to update the space.', + }); + } + }; return ( <PMPageSection @@ -44,33 +102,66 @@ export function SpaceIdentitySection() { </PMHeading> } > - <PMVStack align="stretch" gap={5} pt={4} w="lg"> - <PMField.Root> + <PMVStack align="stretch" gap={5} pt={4} width="full"> + {!canEdit && ( + <PMAlert.Root status="info" size="sm"> + <PMAlert.Indicator /> + <PMAlert.Title>Read-only access</PMAlert.Title> + <PMAlert.Description> + Only space admins and organization admins can edit these settings. + </PMAlert.Description> + </PMAlert.Root> + )} + <PMField.Root + disabled={nameDisabled} + invalid={showNameRequiredError || nameConflictError} + > <PMField.Label>Name</PMField.Label> <PMInput value={name} - onChange={(e) => setName(e.target.value)} + onChange={(e) => { + setName(e.target.value); + setNameConflictError(false); + }} placeholder="Space name" + disabled={nameDisabled} + aria-label="Name" /> + {showNameRequiredError ? ( + <PMField.ErrorText>Name is required.</PMField.ErrorText> + ) : nameConflictError ? ( + <PMField.ErrorText> + Another space with a similar name already exists. + </PMField.ErrorText> + ) : isDefaultSpace ? ( + <PMField.HelperText> + The default space cannot be renamed. + </PMField.HelperText> + ) : ( + <PMField.HelperText> + Renaming doesn't change the space URL — links and bookmarks keep + working. + </PMField.HelperText> + )} </PMField.Root> - <PMField.Root> + <PMField.Root disabled={colorDisabled}> <PMField.Label>Color</PMField.Label> <PMHStack gap={3} flexWrap="wrap"> {SPACE_COLOR_PALETTES.map((color) => ( <PMColorSwatch key={color} value={`{colors.${color}.solid}`} - cursor="pointer" + cursor={colorDisabled ? 'not-allowed' : 'pointer'} outline={selectedColor === color ? '2px solid' : 'none'} outlineColor={ selectedColor === color ? `${color}.solid` : undefined } outlineOffset="2px" - transition="outline 0.15s" - _hover={{ opacity: 0.8 }} + _hover={colorDisabled ? undefined : { opacity: 0.8 }} aria-label={`Select ${color} color`} - onClick={() => setSelectedColor(color)} + aria-disabled={colorDisabled} + onClick={() => !colorDisabled && setSelectedColor(color)} /> ))} </PMHStack> @@ -82,9 +173,14 @@ export function SpaceIdentitySection() { <PMHStack justify="flex-end"> <PMButton variant="secondary" - onClick={() => { - /* TODO: wire to API */ - }} + onClick={handleSave} + disabled={ + !canEdit || + !hasChanges || + !isNameValid || + updateSpaceMutation.isPending + } + loading={updateSpaceMutation.isPending} > Save changes </PMButton> diff --git a/apps/frontend/src/domain/spaces/components/SpaceMembersList.test.tsx b/apps/frontend/src/domain/spaces/components/SpaceMembersList.test.tsx index 12fc5e53a..72f526753 100644 --- a/apps/frontend/src/domain/spaces/components/SpaceMembersList.test.tsx +++ b/apps/frontend/src/domain/spaces/components/SpaceMembersList.test.tsx @@ -1,29 +1,45 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import '@testing-library/jest-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { UIProvider } from '@packmind/ui'; import type { UserId, UserOrganizationRole } from '@packmind/types'; +import { createSpaceId } from '@packmind/types'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; import * as AuthContextModule from '../../accounts/hooks/useAuthContext'; -import { useGetSpaceMembersQuery } from '../api/queries/SpacesQueries'; -import * as UseCurrentSpaceModule from '../hooks/useCurrentSpace'; +import { + useGetSpaceMembersQuery, + useRemoveMemberFromSpaceMutation, + useUpdateMemberRoleMutation, +} from '../api/queries/SpacesQueries'; import { SpaceMembersList } from './SpaceMembersList'; jest.mock('../api/queries/SpacesQueries', () => ({ ...jest.requireActual('../api/queries/SpacesQueries'), useGetSpaceMembersQuery: jest.fn(), -})); - -jest.mock('../hooks/useCurrentSpace', () => ({ - ...jest.requireActual('../hooks/useCurrentSpace'), - useCurrentSpace: jest.fn(), + useRemoveMemberFromSpaceMutation: jest.fn(), + useUpdateMemberRoleMutation: jest.fn(), })); const mockUseGetSpaceMembersQuery = useGetSpaceMembersQuery as jest.MockedFunction< typeof useGetSpaceMembersQuery >; +const mockUseRemoveMemberFromSpaceMutation = + useRemoveMemberFromSpaceMutation as jest.MockedFunction< + typeof useRemoveMemberFromSpaceMutation + >; +const mockUseUpdateMemberRoleMutation = + useUpdateMemberRoleMutation as jest.MockedFunction< + typeof useUpdateMemberRoleMutation + >; jest.mock('@packmind/ui', () => { const actual = jest.requireActual('@packmind/ui'); @@ -65,13 +81,15 @@ const renderWithProviders = (component: React.ReactElement) => { }, }); - return render( + const utils = render( <UIProvider> <QueryClientProvider client={queryClient}> {component} </QueryClientProvider> </UIProvider>, ); + + return { ...utils, queryClient }; }; describe('SpaceMembersList', () => { @@ -88,17 +106,15 @@ describe('SpaceMembersList', () => { role: 'admin' as UserOrganizationRole, }; - beforeEach(() => { - jest.spyOn(UseCurrentSpaceModule, 'useCurrentSpace').mockReturnValue({ - spaceId: 'space-1', - spaceSlug: 'test-space', - spaceName: 'Test Space', - space: undefined, - isLoading: false, - error: null, - isReady: true, - } as unknown as ReturnType<typeof UseCurrentSpaceModule.useCurrentSpace>); + const space = spaceFactory({ + id: createSpaceId('space-1'), + isDefaultSpace: false, + }); + let removeMutateMock: jest.Mock; + let updateRoleMutateMock: jest.Mock; + + beforeEach(() => { jest.spyOn(AuthContextModule, 'useAuthContext').mockReturnValue({ user: mockUser, organization: mockOrganization, @@ -109,6 +125,30 @@ describe('SpaceMembersList', () => { validateAndSwitchIfNeeded: jest.fn(), } as unknown as ReturnType<typeof AuthContextModule.useAuthContext>); + removeMutateMock = jest.fn( + ( + _vars: unknown, + opts?: { onSuccess?: () => void; onSettled?: () => void }, + ) => { + opts?.onSuccess?.(); + opts?.onSettled?.(); + }, + ); + updateRoleMutateMock = jest.fn( + (_vars: unknown, opts?: { onSuccess?: () => void }) => { + opts?.onSuccess?.(); + }, + ); + + mockUseRemoveMemberFromSpaceMutation.mockReturnValue({ + mutate: removeMutateMock, + isPending: false, + } as unknown as ReturnType<typeof useRemoveMemberFromSpaceMutation>); + mockUseUpdateMemberRoleMutation.mockReturnValue({ + mutate: updateRoleMutateMock, + isPending: false, + } as unknown as ReturnType<typeof useUpdateMemberRoleMutation>); + mockUseGetSpaceMembersQuery.mockReturnValue({ data: { members: [ @@ -147,13 +187,13 @@ describe('SpaceMembersList', () => { }); it('renders the current user in the members list', () => { - renderWithProviders(<SpaceMembersList />); + renderWithProviders(<SpaceMembersList space={space} isSpaceAdmin={true} />); expect(screen.getByText('current.user@test.com')).toBeInTheDocument(); }); it('renders mock members alongside the current user', () => { - renderWithProviders(<SpaceMembersList />); + renderWithProviders(<SpaceMembersList space={space} isSpaceAdmin={true} />); expect(screen.getByText('john.doe')).toBeInTheDocument(); expect(screen.getByText('jane.smith')).toBeInTheDocument(); @@ -161,8 +201,82 @@ describe('SpaceMembersList', () => { }); it('marks the current user with "You" badge', () => { - renderWithProviders(<SpaceMembersList />); + renderWithProviders(<SpaceMembersList space={space} isSpaceAdmin={true} />); expect(screen.getByText('You')).toBeInTheDocument(); }); + + it('when isSpaceAdmin is false, hides the Add button and disables remove/role controls', () => { + renderWithProviders( + <SpaceMembersList space={space} isSpaceAdmin={false} />, + ); + + expect(screen.queryByRole('button', { name: /add members/i })).toBeNull(); + + const roleSelects = screen.getAllByRole('combobox'); + roleSelects.forEach((select) => { + expect(select).toBeDisabled(); + }); + + expect(screen.queryByRole('button', { name: /remove/i })).toBeNull(); + }); + + it('invalidates the management listing key when a member role is updated', async () => { + const { queryClient } = renderWithProviders( + <SpaceMembersList space={space} isSpaceAdmin={true} />, + ); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + + const roleSelects = screen.getAllByRole('combobox') as HTMLSelectElement[]; + const targetSelect = roleSelects.find((s) => !s.disabled); + expect(targetSelect).toBeDefined(); + + await act(async () => { + fireEvent.change(targetSelect as HTMLSelectElement, { + target: { value: 'member' }, + }); + }); + + expect(updateRoleMutateMock).toHaveBeenCalled(); + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['organizations', 'org-id', 'spaces', 'management'], + }); + }); + }); + + it('invalidates the management listing key when a member is removed', async () => { + const { queryClient } = renderWithProviders( + <SpaceMembersList space={space} isSpaceAdmin={true} />, + ); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + + const rowRemoveButtons = Array.from( + document.body.querySelectorAll<HTMLButtonElement>('table button'), + ); + expect(rowRemoveButtons.length).toBeGreaterThan(0); + + await act(async () => { + fireEvent.click(rowRemoveButtons[0]); + }); + + const confirmButton = await waitFor(() => { + const buttons = Array.from( + document.body.querySelectorAll<HTMLButtonElement>('button'), + ).filter((btn) => btn.textContent?.trim() === 'Remove'); + expect(buttons.length).toBeGreaterThan(0); + return buttons[buttons.length - 1]; + }); + + await act(async () => { + fireEvent.click(confirmButton); + }); + + expect(removeMutateMock).toHaveBeenCalled(); + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['organizations', 'org-id', 'spaces', 'management'], + }); + }); + }); }); diff --git a/apps/frontend/src/domain/spaces/components/SpaceMembersList.tsx b/apps/frontend/src/domain/spaces/components/SpaceMembersList.tsx index bdeaea0b8..7025b5398 100644 --- a/apps/frontend/src/domain/spaces/components/SpaceMembersList.tsx +++ b/apps/frontend/src/domain/spaces/components/SpaceMembersList.tsx @@ -1,16 +1,20 @@ import { useMemo, useState } from 'react'; import { LuPlus } from 'react-icons/lu'; +import { useQueryClient } from '@tanstack/react-query'; import { + PMAlert, PMButton, PMConfirmationModal, PMHeading, PMIcon, PMPageSection, PMSpinner, + PMText, PMVStack, pmToaster, } from '@packmind/ui'; +import { Space } from '@packmind/types'; import { useAuthContext } from '../../accounts/hooks/useAuthContext'; import { @@ -19,38 +23,56 @@ import { useUpdateMemberRoleMutation, } from '../api/queries/SpacesQueries'; import { SpaceMemberRole } from '../types'; -import { useCurrentSpace } from '../hooks/useCurrentSpace'; import { SpaceMember, SpaceMembersTable } from './SpaceMembersTable'; import { AddSpaceMembersDialog } from './AddSpaceMembersDialog'; -export function SpaceMembersList() { - const { user } = useAuthContext(); - const { spaceId, space } = useCurrentSpace(); +interface SpaceMembersListProps { + space: Space; + isSpaceAdmin: boolean; +} + +export function SpaceMembersList({ + space, + isSpaceAdmin, +}: Readonly<SpaceMembersListProps>) { + const { user, organization } = useAuthContext(); const currentUserId = user?.id; + const queryClient = useQueryClient(); const [addDialogOpen, setAddDialogOpen] = useState(false); const [memberToRemove, setMemberToRemove] = useState<SpaceMember | null>( null, ); - const { data, isLoading } = useGetSpaceMembersQuery(spaceId ?? ''); - const removeMutation = useRemoveMemberFromSpaceMutation(spaceId ?? ''); - const updateRoleMutation = useUpdateMemberRoleMutation(spaceId ?? ''); + const { data, isLoading, isError, refetch } = useGetSpaceMembersQuery( + space.id, + ); + const removeMutation = useRemoveMemberFromSpaceMutation(space.id); + const updateRoleMutation = useUpdateMemberRoleMutation(space.id); + + const invalidateManagementListing = () => { + if (organization?.id) { + queryClient.invalidateQueries({ + queryKey: ['organizations', organization.id, 'spaces', 'management'], + }); + } + }; const members = useMemo<SpaceMember[]>( () => - (data?.members ?? []).map((m) => ({ - id: m.userId, - displayName: m.displayName, - role: m.role, - })), + (data?.members ?? []) + .map((m) => ({ + id: m.userId, + displayName: m.displayName, + role: m.role, + })) + .sort((a, b) => + a.displayName.localeCompare(b.displayName, undefined, { + sensitivity: 'base', + }), + ), [data], ); - const currentUserMember = data?.members?.find( - (m) => m.userId === currentUserId, - ); - const isSpaceAdmin = currentUserMember?.role === 'admin'; - const handleUpdateMemberRole = (memberId: string, role: SpaceMemberRole) => { updateRoleMutation.mutate( { targetUserId: memberId, role }, @@ -61,6 +83,7 @@ export function SpaceMembersList() { description: 'Member role has been updated.', type: 'success', }); + invalidateManagementListing(); }, onError: () => { pmToaster.create({ @@ -89,6 +112,7 @@ export function SpaceMembersList() { description: `${memberToRemove.displayName} has been removed from the space.`, type: 'success', }); + invalidateManagementListing(); }, onError: () => { pmToaster.create({ @@ -131,19 +155,58 @@ export function SpaceMembersList() { } > <PMVStack align="stretch" pt={4} w="full"> - <SpaceMembersTable - members={members} - currentUserId={currentUserId} - isDefaultSpace={space?.isDefaultSpace} - isSpaceAdmin={isSpaceAdmin} - onRemoveMember={handleRemoveMember} - onUpdateMemberRole={handleUpdateMemberRole} - /> + {isError ? ( + <PMAlert.Root status="error" size="sm"> + <PMAlert.Indicator /> + <PMVStack align="stretch" gap={2} flex={1}> + <PMAlert.Title>Failed to load members</PMAlert.Title> + <PMAlert.Description> + Something went wrong while loading the member list. + </PMAlert.Description> + <PMButton + size="xs" + variant="secondary" + alignSelf="flex-start" + onClick={() => refetch()} + > + Retry + </PMButton> + </PMVStack> + </PMAlert.Root> + ) : members.length === 0 ? ( + <PMVStack + align="center" + justify="center" + py={8} + gap={1} + border="solid 1px {colors.border.tertiary}" + borderRadius="md" + > + <PMText fontWeight="medium">No members yet</PMText> + <PMText variant="body" color="secondary" fontSize="sm"> + {isSpaceAdmin + ? 'Add members to give them access to this space.' + : 'No one has been added to this space.'} + </PMText> + </PMVStack> + ) : ( + <SpaceMembersTable + members={members} + currentUserId={currentUserId} + isDefaultSpace={space.isDefaultSpace} + isSpaceAdmin={isSpaceAdmin} + isUpdatingRole={updateRoleMutation.isPending} + isRemovingMember={removeMutation.isPending} + onRemoveMember={handleRemoveMember} + onUpdateMemberRole={handleUpdateMemberRole} + /> + )} <AddSpaceMembersDialog open={addDialogOpen} setOpen={setAddDialogOpen} - spaceId={spaceId ?? ''} + spaceId={space.id} existingMembers={members} + onSuccess={invalidateManagementListing} /> <PMConfirmationModal trigger={<span />} diff --git a/apps/frontend/src/domain/spaces/components/SpaceMembersTable.tsx b/apps/frontend/src/domain/spaces/components/SpaceMembersTable.tsx index 168300607..0d42adeea 100644 --- a/apps/frontend/src/domain/spaces/components/SpaceMembersTable.tsx +++ b/apps/frontend/src/domain/spaces/components/SpaceMembersTable.tsx @@ -1,8 +1,9 @@ import { useMemo } from 'react'; -import { LuX } from 'react-icons/lu'; +import { LuChevronDown, LuX } from 'react-icons/lu'; import { PMBadge, + PMBox, PMButton, PMHStack, PMIcon, @@ -11,6 +12,7 @@ import { PMTableColumn, PMTableRow, PMText, + PMTooltip, } from '@packmind/ui'; import { UserAvatarWithInitials } from '../../accounts/components/UserAvatarWithInitials'; @@ -33,22 +35,74 @@ interface SpaceMembersTableProps { currentUserId?: string; isDefaultSpace?: boolean; isSpaceAdmin?: boolean; + isUpdatingRole?: boolean; + isRemovingMember?: boolean; onRemoveMember?: (memberId: string) => void; onUpdateMemberRole?: (memberId: string, role: SpaceMemberRole) => void; } +const LAST_ADMIN_REASON = 'This is the last admin of the space.'; +const SELF_REASON = "You can't change your own role."; + export function SpaceMembersTable({ members, currentUserId, isDefaultSpace, isSpaceAdmin, + isUpdatingRole = false, + isRemovingMember = false, onRemoveMember, onUpdateMemberRole, }: Readonly<SpaceMembersTableProps>) { + const adminCount = useMemo( + () => members.filter((m) => m.role === 'admin').length, + [members], + ); + const data = useMemo<PMTableRow[]>( () => members.map((member) => { const isCurrentUser = member.id === currentUserId; + const isLastAdmin = member.role === 'admin' && adminCount === 1; + + const roleDisabledReason = isCurrentUser + ? SELF_REASON + : isLastAdmin + ? LAST_ADMIN_REASON + : undefined; + const roleDisabled = + !isSpaceAdmin || + isCurrentUser || + isLastAdmin || + isUpdatingRole || + isRemovingMember; + + const roleSelect = ( + <PMNativeSelect + size="sm" + minWidth="120px" + value={member.role} + disabled={roleDisabled} + onChange={(e) => + onUpdateMemberRole?.( + member.id, + e.currentTarget.value as SpaceMemberRole, + ) + } + items={[ + { value: 'admin', label: 'Admin' }, + { value: 'member', label: 'Member' }, + ]} + icon={<LuChevronDown />} + /> + ); + + const removeHidden = isCurrentUser || isDefaultSpace || !isSpaceAdmin; + const removeDisabledReason = isLastAdmin + ? LAST_ADMIN_REASON + : undefined; + const removeDisabled = + isLastAdmin || isUpdatingRole || isRemovingMember; return { id: member.id, @@ -68,43 +122,55 @@ export function SpaceMembersTable({ )} </PMHStack> ), - role: ( - <PMNativeSelect - size="sm" - value={member.role} - disabled={isCurrentUser || !isSpaceAdmin} - onChange={(e) => - onUpdateMemberRole?.( - member.id, - e.currentTarget.value as SpaceMemberRole, - ) - } - items={[ - { value: 'admin', label: 'Admin' }, - { value: 'member', label: 'Member' }, - ]} - /> + role: roleDisabledReason ? ( + <PMTooltip label={roleDisabledReason}> + <PMBox display="inline-block" width="full"> + {roleSelect} + </PMBox> + </PMTooltip> + ) : ( + roleSelect + ), + actions: removeHidden ? null : removeDisabledReason ? ( + <PMTooltip label={removeDisabledReason}> + <PMBox display="inline-block"> + <PMButton + size="xs" + variant="ghost" + colorPalette="red" + disabled + aria-label="Remove member" + > + <PMIcon> + <LuX /> + </PMIcon> + </PMButton> + </PMBox> + </PMTooltip> + ) : ( + <PMButton + size="xs" + variant="ghost" + colorPalette="red" + disabled={removeDisabled} + aria-label="Remove member" + onClick={() => onRemoveMember?.(member.id)} + > + <PMIcon> + <LuX /> + </PMIcon> + </PMButton> ), - actions: - isCurrentUser || isDefaultSpace || !isSpaceAdmin ? null : ( - <PMButton - size="xs" - variant="ghost" - colorPalette="red" - onClick={() => onRemoveMember?.(member.id)} - > - <PMIcon> - <LuX /> - </PMIcon> - </PMButton> - ), }; }), [ members, + adminCount, currentUserId, isDefaultSpace, isSpaceAdmin, + isUpdatingRole, + isRemovingMember, onRemoveMember, onUpdateMemberRole, ], diff --git a/apps/frontend/src/domain/spaces/components/SpaceSettingsPage.tsx b/apps/frontend/src/domain/spaces/components/SpaceSettingsPage.tsx index f45919ac5..833fc0901 100644 --- a/apps/frontend/src/domain/spaces/components/SpaceSettingsPage.tsx +++ b/apps/frontend/src/domain/spaces/components/SpaceSettingsPage.tsx @@ -1,30 +1,33 @@ import { PMPage, PMTabs, PMVStack } from '@packmind/ui'; +import { useAuthContext } from '../../accounts/hooks/useAuthContext'; import { useCurrentSpace } from '../hooks/useCurrentSpace'; +import { useGetSpaceMembersQuery } from '../api/queries/SpacesQueries'; import { SpaceGeneralSettings } from './SpaceGeneralSettings'; import { SpaceMembersList } from './SpaceMembersList'; export function SpaceSettingsPage() { - const { space } = useCurrentSpace(); + const { space, spaceId } = useCurrentSpace(); + const { user } = useAuthContext(); + const { data: membersData } = useGetSpaceMembersQuery(spaceId ?? ''); - const showGeneralTab = !space?.isDefaultSpace; + const isSpaceAdmin = + membersData?.members?.find((m) => m.userId === user?.id)?.role === 'admin'; const tabs = [ - ...(showGeneralTab - ? [ - { - value: 'general', - triggerLabel: 'General', - content: <SpaceGeneralSettings />, - }, - ] - : []), + { + value: 'general', + triggerLabel: 'General', + content: <SpaceGeneralSettings />, + }, { value: 'members', triggerLabel: 'Members', content: ( <PMVStack align="stretch" pt={4}> - <SpaceMembersList /> + {space && ( + <SpaceMembersList space={space} isSpaceAdmin={isSpaceAdmin} /> + )} </PMVStack> ), }, diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/DeleteSpaceConfirmDialog.test.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/DeleteSpaceConfirmDialog.test.tsx new file mode 100644 index 000000000..05f969eff --- /dev/null +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/DeleteSpaceConfirmDialog.test.tsx @@ -0,0 +1,262 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UIProvider, pmToaster } from '@packmind/ui'; +import { createSpaceId } from '@packmind/types'; + +import * as SpacesManagementQueriesModule from '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries'; +import * as UseAuthContextModule from '../../../accounts/hooks/useAuthContext'; +import { DeleteSpaceConfirmDialog } from './DeleteSpaceConfirmDialog'; + +jest.mock( + '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries', + () => ({ + ...jest.requireActual( + '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries', + ), + useDeleteSpaceMutation: jest.fn(), + }), +); + +jest.mock('../../../accounts/hooks/useAuthContext', () => ({ + ...jest.requireActual('../../../accounts/hooks/useAuthContext'), + useAuthContext: jest.fn(), +})); + +jest.mock('@packmind/ui', () => ({ + ...jest.requireActual('@packmind/ui'), + pmToaster: { + create: jest.fn(), + }, +})); + +const mockMutate = jest.fn(); + +const mockDeleteMutation = (overrides: Record<string, unknown> = {}) => { + jest + .spyOn(SpacesManagementQueriesModule, 'useDeleteSpaceMutation') + .mockReturnValue({ + mutate: mockMutate, + isPending: false, + ...overrides, + } as unknown as ReturnType< + typeof SpacesManagementQueriesModule.useDeleteSpaceMutation + >); +}; + +const mockAuth = (organizationId: string | null = 'org-1') => { + jest.spyOn(UseAuthContextModule, 'useAuthContext').mockReturnValue({ + organization: organizationId ? { id: organizationId } : null, + } as unknown as ReturnType<typeof UseAuthContextModule.useAuthContext>); +}; + +const renderWithProviders = (ui: React.ReactElement) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + <UIProvider> + <QueryClientProvider client={client}>{ui}</QueryClientProvider> + </UIProvider>, + ); +}; + +const baseSpace = { + id: createSpaceId('space-1'), + name: 'Engineering', +}; + +describe('DeleteSpaceConfirmDialog', () => { + beforeEach(() => { + mockDeleteMutation(); + mockAuth(); + }); + + afterEach(() => { + jest.clearAllMocks(); + mockMutate.mockReset(); + }); + + describe('when the dialog is open', () => { + it('renders the confirmation copy with the space name', async () => { + renderWithProviders( + <DeleteSpaceConfirmDialog + isOpen + onClose={jest.fn()} + space={baseSpace} + />, + ); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent(/Delete space/i); + expect(dialog).toHaveTextContent(/Engineering/); + expect(dialog).toHaveTextContent(/This action is irreversible\./); + }); + + it('renders the type-to-confirm input', async () => { + renderWithProviders( + <DeleteSpaceConfirmDialog + isOpen + onClose={jest.fn()} + space={baseSpace} + />, + ); + + expect( + await screen.findByPlaceholderText('Enter space name'), + ).toBeInTheDocument(); + }); + + it('renders the Delete button as disabled by default', async () => { + renderWithProviders( + <DeleteSpaceConfirmDialog + isOpen + onClose={jest.fn()} + space={baseSpace} + />, + ); + + const deleteButton = await screen.findByRole('button', { + name: 'Delete', + }); + expect(deleteButton).toBeDisabled(); + }); + }); + + describe('when the user types a non-matching space name', () => { + it('keeps the Delete button disabled', async () => { + renderWithProviders( + <DeleteSpaceConfirmDialog + isOpen + onClose={jest.fn()} + space={baseSpace} + />, + ); + + const input = await screen.findByPlaceholderText('Enter space name'); + fireEvent.change(input, { target: { value: 'Wrong Name' } }); + + expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled(); + }); + }); + + describe('when the user types the matching space name', () => { + it('enables the Delete button', async () => { + renderWithProviders( + <DeleteSpaceConfirmDialog + isOpen + onClose={jest.fn()} + space={baseSpace} + />, + ); + + const input = await screen.findByPlaceholderText('Enter space name'); + fireEvent.change(input, { target: { value: 'Engineering' } }); + + expect(screen.getByRole('button', { name: 'Delete' })).not.toBeDisabled(); + }); + }); + + describe('when the user clicks Cancel', () => { + it('calls onClose without invoking the mutation', async () => { + const onClose = jest.fn(); + + renderWithProviders( + <DeleteSpaceConfirmDialog isOpen onClose={onClose} space={baseSpace} />, + ); + + const cancelButton = await screen.findByRole('button', { + name: /cancel/i, + }); + await act(async () => { + fireEvent.click(cancelButton); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(mockMutate).not.toHaveBeenCalled(); + }); + }); + + describe('when the user confirms deletion and the mutation succeeds', () => { + it('shows a success toast and closes the dialog', async () => { + mockMutate.mockImplementation( + ( + _params: unknown, + options: { + onSuccess?: () => void | Promise<void>; + onError?: () => void; + }, + ) => { + options.onSuccess?.(); + }, + ); + const onClose = jest.fn(); + + renderWithProviders( + <DeleteSpaceConfirmDialog isOpen onClose={onClose} space={baseSpace} />, + ); + + const input = await screen.findByPlaceholderText('Enter space name'); + fireEvent.change(input, { target: { value: 'Engineering' } }); + + const deleteButton = await screen.findByRole('button', { + name: 'Delete', + }); + await act(async () => { + fireEvent.click(deleteButton); + }); + + expect(mockMutate).toHaveBeenCalledWith( + { spaceId: baseSpace.id }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + expect(pmToaster.create).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + title: `Space 'Engineering' deleted`, + }), + ); + expect(onClose).toHaveBeenCalled(); + }); + }); + + describe('when the user confirms deletion and the mutation fails', () => { + it('shows an error toast and keeps the dialog open', async () => { + mockMutate.mockImplementation( + ( + _params: unknown, + options: { onSuccess?: () => void; onError?: () => void }, + ) => { + options.onError?.(); + }, + ); + const onClose = jest.fn(); + + renderWithProviders( + <DeleteSpaceConfirmDialog isOpen onClose={onClose} space={baseSpace} />, + ); + + const input = await screen.findByPlaceholderText('Enter space name'); + fireEvent.change(input, { target: { value: 'Engineering' } }); + + const deleteButton = await screen.findByRole('button', { + name: 'Delete', + }); + await act(async () => { + fireEvent.click(deleteButton); + }); + + expect(pmToaster.create).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + title: 'Failed to delete space', + }), + ); + expect(onClose).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/DeleteSpaceConfirmDialog.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/DeleteSpaceConfirmDialog.tsx new file mode 100644 index 000000000..d0128c050 --- /dev/null +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/DeleteSpaceConfirmDialog.tsx @@ -0,0 +1,78 @@ +import { PMText, pmToaster } from '@packmind/ui'; +import type { Space } from '@packmind/types'; +import { useQueryClient } from '@tanstack/react-query'; + +import { useDeleteSpaceMutation } from '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; +import { TypeToConfirmDialog } from '../../../../shared/components/TypeToConfirmDialog'; + +type DeleteSpaceConfirmDialogProps = { + isOpen: boolean; + onClose: () => void; + space: Pick<Space, 'id' | 'name'>; +}; + +export function DeleteSpaceConfirmDialog({ + isOpen, + onClose, + space, +}: Readonly<DeleteSpaceConfirmDialogProps>) { + const { organization } = useAuthContext(); + const queryClient = useQueryClient(); + const { mutate, isPending } = useDeleteSpaceMutation(); + + const handleConfirm = () => { + mutate( + { spaceId: space.id }, + { + onSuccess: async () => { + pmToaster.create({ + type: 'success', + title: `Space '${space.name}' deleted`, + }); + if (organization?.id) { + await queryClient.invalidateQueries({ + queryKey: [ + 'organizations', + organization.id, + 'spaces', + 'management', + ], + }); + } + onClose(); + }, + onError: () => { + pmToaster.create({ + type: 'error', + title: 'Failed to delete space', + description: + 'An error occurred while deleting the space. Please try again.', + }); + }, + }, + ); + }; + + return ( + <TypeToConfirmDialog + open={isOpen} + onOpenChange={(next) => { + if (!next) { + onClose(); + } + }} + title="Delete space" + expectedValue={space.name} + inputPlaceholder="Enter space name" + confirmLabel="Delete" + isPending={isPending} + onConfirm={handleConfirm} + > + <PMText> + This will permanently delete <strong>{space.name}</strong>. This action + is irreversible. + </PMText> + </TypeToConfirmDialog> + ); +} diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceAdminsCell.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceAdminsCell.tsx index e92f1fe74..d6a3e4ad8 100644 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceAdminsCell.tsx +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceAdminsCell.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { PMHStack, PMText } from '@packmind/ui'; -import { UserAvatarWithInitials } from '../../../accounts/components/UserAvatarWithInitials'; +import { PMText } from '@packmind/ui'; import { SpaceAdminAvatar } from './types'; +import { UserAvatarStack } from '@packmind/proprietary/frontend/domain/accounts/components/UserAvatarStack'; interface SpaceAdminsCellProps { admins: SpaceAdminAvatar[]; @@ -14,22 +14,11 @@ export const SpaceAdminsCell: React.FC<SpaceAdminsCellProps> = ({ admins }) => { return <PMText color="faded">—</PMText>; } - const visible = admins.slice(0, MAX_VISIBLE_AVATARS); - const isSingle = admins.length === 1; - const label = isSingle ? admins[0].displayName : `${admins.length} admins`; - return ( - <PMHStack gap={2} align="center"> - <PMHStack gap={-2}> - {visible.map((admin) => ( - <UserAvatarWithInitials - key={admin.id} - displayName={admin.displayName} - size="sm" - /> - ))} - </PMHStack> - <PMText color="secondary">{label}</PMText> - </PMHStack> + <UserAvatarStack + users={admins} + maxVisibleAvatars={MAX_VISIBLE_AVATARS} + size={'xs'} + /> ); }; diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceManagementDrawer.test.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceManagementDrawer.test.tsx new file mode 100644 index 000000000..53723d91a --- /dev/null +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceManagementDrawer.test.tsx @@ -0,0 +1,327 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router'; +import { UIProvider } from '@packmind/ui'; +import { + SpaceType, + createOrganizationId, + createSpaceId, + type SpaceManagementListItem, + type UserId, + type UserOrganizationRole, +} from '@packmind/types'; + +import * as AuthContextModule from '../../../accounts/hooks/useAuthContext'; +import * as SpacesQueriesModule from '../../api/queries/SpacesQueries'; +import * as SpacesManagementQueriesModule from '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries'; +import * as DeploymentsQueriesModule from '../../../deployments/api/queries/DeploymentsQueries'; +import * as UseNavigationModule from '../../../../shared/hooks/useNavigation'; +import * as UserQueriesModule from '../../../accounts/api/queries/UserQueries'; +import { SpaceManagementDrawer } from './SpaceManagementDrawer'; + +jest.mock('@packmind/ui', () => { + const actual = jest.requireActual('@packmind/ui'); + return { + ...actual, + PMTable: ({ + columns, + data, + }: { + columns: { key: string; header: React.ReactNode }[]; + data: Record<string, React.ReactNode>[]; + }) => ( + <table> + <thead> + <tr> + {columns.map((col) => ( + <th key={col.key}>{col.header}</th> + ))} + </tr> + </thead> + <tbody> + {data.map((row, index) => ( + <tr key={index} role="row"> + {columns.map((col) => ( + <td key={col.key}>{row[col.key]}</td> + ))} + </tr> + ))} + </tbody> + </table> + ), + }; +}); + +const mockUser = { + id: 'current-user-id' as UserId, + email: 'current.user@test.com', + displayName: 'Current User', + memberships: [], +}; + +const buildOrganization = ( + role: UserOrganizationRole = 'admin' as UserOrganizationRole, +) => ({ + id: createOrganizationId('org-1'), + name: 'Test Org', + slug: 'test-org', + role, +}); + +const buildSpace = ( + overrides: Partial<SpaceManagementListItem> = {}, +): SpaceManagementListItem => ({ + id: createSpaceId('s-1'), + name: 'Frontend', + slug: 'frontend', + color: 'orange', + type: SpaceType.open, + organizationId: createOrganizationId('org-1'), + isDefaultSpace: false, + admins: [], + membersCount: 3, + artifactsCount: 7, + ...overrides, +}); + +const mockAuthContext = ( + role: UserOrganizationRole = 'admin' as UserOrganizationRole, +) => { + jest.spyOn(AuthContextModule, 'useAuthContext').mockReturnValue({ + user: mockUser, + organization: buildOrganization(role), + isAuthenticated: true, + isLoading: false, + getMe: jest.fn(), + getUserOrganizations: jest.fn(), + validateAndSwitchIfNeeded: jest.fn(), + } as unknown as ReturnType<typeof AuthContextModule.useAuthContext>); +}; + +const mockGetSpaceMembers = ( + members: Array<{ + userId: string; + spaceId: string; + displayName: string; + role: 'admin' | 'member'; + }> = [], +) => { + jest.spyOn(SpacesQueriesModule, 'useGetSpaceMembersQuery').mockReturnValue({ + data: { members }, + isLoading: false, + } as unknown as ReturnType< + typeof SpacesQueriesModule.useGetSpaceMembersQuery + >); +}; + +const mockMemberMutations = () => { + jest + .spyOn(SpacesQueriesModule, 'useRemoveMemberFromSpaceMutation') + .mockReturnValue({ + mutate: jest.fn(), + isPending: false, + } as unknown as ReturnType< + typeof SpacesQueriesModule.useRemoveMemberFromSpaceMutation + >); + + jest + .spyOn(SpacesQueriesModule, 'useUpdateMemberRoleMutation') + .mockReturnValue({ + mutate: jest.fn(), + isPending: false, + } as unknown as ReturnType< + typeof SpacesQueriesModule.useUpdateMemberRoleMutation + >); +}; + +const mockSpaceManagementMutations = () => { + jest + .spyOn(SpacesManagementQueriesModule, 'useUpdateSpaceMutation') + .mockReturnValue({ + mutate: jest.fn(), + mutateAsync: jest.fn(), + isPending: false, + } as unknown as ReturnType< + typeof SpacesManagementQueriesModule.useUpdateSpaceMutation + >); + + jest + .spyOn(SpacesManagementQueriesModule, 'useLeaveSpaceMutation') + .mockReturnValue({ + mutate: jest.fn(), + isPending: false, + } as unknown as ReturnType< + typeof SpacesManagementQueriesModule.useLeaveSpaceMutation + >); + + jest + .spyOn(SpacesManagementQueriesModule, 'useDeleteSpaceMutation') + .mockReturnValue({ + mutate: jest.fn(), + isPending: false, + } as unknown as ReturnType< + typeof SpacesManagementQueriesModule.useDeleteSpaceMutation + >); +}; + +const mockListPackages = () => { + jest + .spyOn(DeploymentsQueriesModule, 'useListPackagesBySpaceQuery') + .mockReturnValue({ + data: { packages: [] }, + } as unknown as ReturnType< + typeof DeploymentsQueriesModule.useListPackagesBySpaceQuery + >); +}; + +const mockNavigation = () => { + jest.spyOn(UseNavigationModule, 'useNavigation').mockReturnValue({ + org: { toDashboard: jest.fn() }, + } as unknown as ReturnType<typeof UseNavigationModule.useNavigation>); +}; + +const mockUsersInOrg = () => { + jest + .spyOn(UserQueriesModule, 'useGetUsersInMyOrganizationQuery') + .mockReturnValue({ + data: { users: [] }, + isLoading: false, + } as unknown as ReturnType< + typeof UserQueriesModule.useGetUsersInMyOrganizationQuery + >); +}; + +const renderWithProviders = (component: React.ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + <UIProvider> + <QueryClientProvider client={queryClient}> + <MemoryRouter>{component}</MemoryRouter> + </QueryClientProvider> + </UIProvider>, + ); +}; + +describe('SpaceManagementDrawer', () => { + beforeEach(() => { + mockAuthContext('admin' as UserOrganizationRole); + mockGetSpaceMembers(); + mockMemberMutations(); + mockSpaceManagementMutations(); + mockListPackages(); + mockNavigation(); + mockUsersInOrg(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not render the drawer dialog when space is null', () => { + renderWithProviders( + <SpaceManagementDrawer space={null} onClose={jest.fn()} />, + ); + + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('renders three tabs for a non-default space', () => { + renderWithProviders( + <SpaceManagementDrawer space={buildSpace()} onClose={jest.fn()} />, + ); + + expect(screen.getByRole('tab', { name: /general/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /members/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /danger/i })).toBeInTheDocument(); + }); + + it('omits the Danger tab for the default space', () => { + renderWithProviders( + <SpaceManagementDrawer + space={buildSpace({ isDefaultSpace: true })} + onClose={jest.fn()} + />, + ); + + expect(screen.getByRole('tab', { name: /general/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /members/i })).toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: /danger/i })).toBeNull(); + }); + + it('renders the space name in the header', () => { + renderWithProviders( + <SpaceManagementDrawer + space={buildSpace({ name: 'Engineering' })} + onClose={jest.fn()} + />, + ); + + expect( + screen.getByRole('heading', { name: 'Engineering' }), + ).toBeInTheDocument(); + }); + + it('renders the header subtitle with member and artifact counts', () => { + renderWithProviders( + <SpaceManagementDrawer + space={buildSpace({ membersCount: 5, artifactsCount: 12 })} + onClose={jest.fn()} + />, + ); + + expect(screen.getByText(/5 members/)).toBeInTheDocument(); + expect(screen.getByText(/12 artifacts/)).toBeInTheDocument(); + }); + + it('uses singular form when counts equal 1', () => { + renderWithProviders( + <SpaceManagementDrawer + space={buildSpace({ membersCount: 1, artifactsCount: 1 })} + onClose={jest.fn()} + />, + ); + + expect(screen.getByText(/1 member(?!s)/)).toBeInTheDocument(); + expect(screen.getByText(/1 artifact(?!s)/)).toBeInTheDocument(); + }); + + it('grants edit when current user is org admin', () => { + renderWithProviders( + <SpaceManagementDrawer space={buildSpace()} onClose={jest.fn()} />, + ); + + expect(screen.queryByText(/Read-only access/i)).toBeNull(); + }); + + it('denies edit when user is org member and not space admin', () => { + mockAuthContext('member' as UserOrganizationRole); + + renderWithProviders( + <SpaceManagementDrawer space={buildSpace()} onClose={jest.fn()} />, + ); + + expect(screen.getByText(/Read-only access/i)).toBeInTheDocument(); + }); + + it('grants edit when user is space admin even if org member', () => { + mockAuthContext('member' as UserOrganizationRole); + mockGetSpaceMembers([ + { + userId: 'current-user-id', + spaceId: 's-1', + displayName: 'current.user@test.com', + role: 'admin', + }, + ]); + + renderWithProviders( + <SpaceManagementDrawer space={buildSpace()} onClose={jest.fn()} />, + ); + + expect(screen.queryByText(/Read-only access/i)).toBeNull(); + }); +}); diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceManagementDrawer.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceManagementDrawer.tsx new file mode 100644 index 000000000..85ea8f88c --- /dev/null +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceManagementDrawer.tsx @@ -0,0 +1,224 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + PMCloseButton, + PMConfirmationModal, + PMDrawer, + PMHeading, + PMHStack, + PMPortal, + PMStatus, + PMTabs, + PMText, + PMVStack, +} from '@packmind/ui'; +import { useQueryClient } from '@tanstack/react-query'; +import type { SpaceManagementListItem } from '@packmind/types'; + +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; +import { useGetSpaceMembersQuery } from '../../api/queries/SpacesQueries'; +import { SpaceIdentitySection } from '../SpaceIdentitySection'; +import { SpaceMembersList } from '../SpaceMembersList'; +import { SpaceDangerZoneSection } from '../SpaceDangerZoneSection'; + +type DrawerTab = 'general' | 'members' | 'danger'; + +interface SpaceManagementDrawerProps { + space: SpaceManagementListItem | null; + onClose: () => void; +} + +export function SpaceManagementDrawer({ + space, + onClose, +}: Readonly<SpaceManagementDrawerProps>) { + const queryClient = useQueryClient(); + const { user, organization } = useAuthContext(); + const [activeTab, setActiveTab] = useState<DrawerTab>('general'); + const [isIdentityDirty, setIsIdentityDirty] = useState(false); + const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false); + const bypassDirtyCheckRef = useRef(false); + + const { data: membersData } = useGetSpaceMembersQuery(space?.id ?? ''); + + const currentUserMember = membersData?.members?.find( + (m) => m.userId === user?.id, + ); + const isMember = !!currentUserMember; + const isSpaceAdmin = currentUserMember?.role === 'admin'; + const isOrgAdmin = organization?.role === 'admin'; + const canEdit = isSpaceAdmin || isOrgAdmin; + const canDelete = isSpaceAdmin || isOrgAdmin; + + const spaceId = space?.id; + useEffect(() => { + if (spaceId) { + setActiveTab('general'); + setIsIdentityDirty(false); + bypassDirtyCheckRef.current = false; + } + }, [spaceId]); + + const handleIdentityDirtyChange = useCallback((dirty: boolean) => { + setIsIdentityDirty(dirty); + }, []); + + const handleDeleted = () => { + if (organization?.id) { + queryClient.invalidateQueries({ + queryKey: ['organizations', organization.id, 'spaces', 'management'], + }); + } + bypassDirtyCheckRef.current = true; + setIsIdentityDirty(false); + onClose(); + }; + + const handleLeft = () => { + if (organization?.id) { + queryClient.invalidateQueries({ + queryKey: ['organizations', organization.id, 'spaces', 'management'], + }); + } + bypassDirtyCheckRef.current = true; + setIsIdentityDirty(false); + onClose(); + }; + + const requestClose = () => { + if (bypassDirtyCheckRef.current) { + bypassDirtyCheckRef.current = false; + onClose(); + return; + } + if (isIdentityDirty) { + setDiscardConfirmOpen(true); + return; + } + onClose(); + }; + + const handleConfirmDiscard = () => { + bypassDirtyCheckRef.current = true; + setDiscardConfirmOpen(false); + setIsIdentityDirty(false); + onClose(); + }; + + const handleKeepEditing = () => { + setDiscardConfirmOpen(false); + }; + + const tabs: { + value: DrawerTab; + triggerLabel: string; + content: React.ReactNode; + }[] = space + ? [ + { + value: 'general', + triggerLabel: 'General', + content: ( + <SpaceIdentitySection + space={space} + canEdit={canEdit} + onDirtyChange={handleIdentityDirtyChange} + /> + ), + }, + { + value: 'members', + triggerLabel: 'Members', + content: <SpaceMembersList space={space} isSpaceAdmin={canEdit} />, + }, + ...(space.isDefaultSpace + ? [] + : [ + { + value: 'danger' as const, + triggerLabel: 'Danger', + content: ( + <SpaceDangerZoneSection + space={space} + canDelete={canDelete} + isMember={isMember} + onDeleted={handleDeleted} + onLeft={handleLeft} + /> + ), + }, + ]), + ] + : []; + + return ( + <> + <PMDrawer.Root + open={!!space} + onOpenChange={(e) => { + if (!e.open) { + requestClose(); + } + }} + placement="end" + size="lg" + > + <PMPortal> + <PMDrawer.Backdrop /> + <PMDrawer.Positioner> + <PMDrawer.Content> + {space && ( + <> + <PMDrawer.Header> + <PMHStack gap={3} align="center" flex={1}> + <PMVStack gap={0.5} align="start" flex={1} minW={0}> + <PMHStack gap={2} align="center"> + <PMStatus.Root colorPalette={space.color} as="span"> + <PMStatus.Indicator /> + </PMStatus.Root> + <PMHeading size="md">{space.name}</PMHeading> + </PMHStack> + <PMText fontSize="xs" color="faded"> + {space.membersCount} member + {space.membersCount === 1 ? '' : 's'} ·{' '} + {space.artifactsCount} artifact + {space.artifactsCount === 1 ? '' : 's'} + </PMText> + </PMVStack> + </PMHStack> + </PMDrawer.Header> + <PMDrawer.Body padding={5}> + <PMTabs + defaultValue={activeTab} + value={activeTab} + onValueChange={(details: { value: string }) => + setActiveTab(details.value as DrawerTab) + } + tabs={tabs} + /> + </PMDrawer.Body> + <PMDrawer.CloseTrigger asChild> + <PMCloseButton size="sm" /> + </PMDrawer.CloseTrigger> + </> + )} + </PMDrawer.Content> + </PMDrawer.Positioner> + </PMPortal> + </PMDrawer.Root> + <PMConfirmationModal + trigger={<span />} + open={discardConfirmOpen} + onOpenChange={({ open }) => { + if (!open) { + handleKeepEditing(); + } + }} + title="Discard changes?" + message="Your unsaved identity changes will be lost." + confirmText="Discard" + cancelText="Keep editing" + onConfirm={handleConfirmDiscard} + /> + </> + ); +} diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceNameCell.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceNameCell.tsx index ddccab976..61fc779ee 100644 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceNameCell.tsx +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceNameCell.tsx @@ -1,29 +1,25 @@ import React from 'react'; -import { Box } from '@chakra-ui/react'; -import { PMBadge, PMHStack, PMText } from '@packmind/ui'; -import { SpaceColorToken } from './types'; +import { PMBadge, PMHStack, PMStatus, PMText } from '@packmind/ui'; +import type { SpaceColor } from '@packmind/types'; interface SpaceNameCellProps { name: string; - colorToken: SpaceColorToken; - isOrgWide: boolean; + color: SpaceColor; + isDefaultSpace: boolean; } export const SpaceNameCell: React.FC<SpaceNameCellProps> = ({ name, - colorToken, - isOrgWide, + color, + isDefaultSpace, }) => { return ( <PMHStack gap={2} align="center"> - <Box - boxSize="2" - borderRadius="full" - bg={`${colorToken}.500`} - flexShrink={0} - /> + <PMStatus.Root colorPalette={color} as="span"> + <PMStatus.Indicator /> + </PMStatus.Root> <PMText fontWeight="semibold">{name}</PMText> - {isOrgWide && ( + {isDefaultSpace && ( <PMBadge size="xs" colorPalette="purple"> org-wide </PMBadge> diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceRowActions.test.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceRowActions.test.tsx new file mode 100644 index 000000000..63290ece3 --- /dev/null +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceRowActions.test.tsx @@ -0,0 +1,244 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UIProvider } from '@packmind/ui'; +import { + createOrganizationId, + createSpaceId, + SpaceType, +} from '@packmind/types'; + +import { SpaceRowActions } from './SpaceRowActions'; + +const mockNavigate = jest.fn(); + +jest.mock('react-router', () => { + const actual = jest.requireActual('react-router'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +jest.mock( + '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries', + () => ({ + ...jest.requireActual( + '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries', + ), + useDeleteSpaceMutation: jest.fn(() => ({ + mutate: jest.fn(), + isPending: false, + })), + useJoinSpaceMutation: jest.fn(() => ({ + mutate: jest.fn(), + isPending: false, + })), + }), +); + +jest.mock('../../../accounts/hooks/useAuthContext', () => ({ + ...jest.requireActual('../../../accounts/hooks/useAuthContext'), + useAuthContext: () => ({ + organization: { id: 'org-1', slug: 'test-org' }, + }), +})); + +const buildSpace = ( + overrides: Partial<{ + id: string; + name: string; + slug: string; + isDefaultSpace: boolean; + }> = {}, +) => ({ + id: createSpaceId('00000000-0000-0000-0000-000000000001'), + name: 'Engineering', + slug: 'engineering', + type: SpaceType.open, + organizationId: createOrganizationId('11111111-1111-1111-1111-111111111111'), + isDefaultSpace: false, + ...overrides, +}); + +const renderWithProviders = ( + ui: React.ReactElement, + { route = '/org/test-org/settings/spaces' }: { route?: string } = {}, +) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + <UIProvider> + <QueryClientProvider client={client}> + <MemoryRouter initialEntries={[route]}> + <Routes> + <Route path="/org/:orgSlug/settings/spaces" element={ui} /> + </Routes> + </MemoryRouter> + </QueryClientProvider> + </UIProvider>, + ); +}; + +const openMenu = async () => { + const trigger = await screen.findByRole('button', { name: /actions/i }); + await act(async () => { + fireEvent.click(trigger); + }); +}; + +describe('SpaceRowActions', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Edit menu item', () => { + it('renders the Edit item in the menu when onEdit is provided', async () => { + const onEdit = jest.fn(); + renderWithProviders( + <SpaceRowActions + space={buildSpace()} + isMember={false} + onEdit={onEdit} + />, + ); + await openMenu(); + expect( + screen.getByRole('menuitem', { name: /edit/i }), + ).toBeInTheDocument(); + }); + + it('calls onEdit when the Edit menu item is clicked', async () => { + const onEdit = jest.fn(); + renderWithProviders( + <SpaceRowActions + space={buildSpace()} + isMember={false} + onEdit={onEdit} + />, + ); + await openMenu(); + await act(async () => { + fireEvent.click(screen.getByRole('menuitem', { name: /edit/i })); + }); + expect(onEdit).toHaveBeenCalledTimes(1); + }); + + it('does not render the Edit item when onEdit is not provided', async () => { + renderWithProviders( + <SpaceRowActions space={buildSpace()} isMember={false} />, + ); + await openMenu(); + expect( + screen.queryByRole('menuitem', { name: /edit/i }), + ).not.toBeInTheDocument(); + }); + }); + + describe('when the space is the default org-wide space', () => { + it('does not render the Delete action', async () => { + renderWithProviders( + <SpaceRowActions + space={buildSpace({ isDefaultSpace: true })} + isMember={true} + />, + ); + + await openMenu(); + + expect( + screen.queryByRole('menuitem', { name: /delete/i }), + ).not.toBeInTheDocument(); + }); + }); + + describe('when the space is not the default space', () => { + it('renders the Delete action and opens the confirmation dialog when clicked', async () => { + renderWithProviders( + <SpaceRowActions + space={buildSpace({ isDefaultSpace: false })} + isMember={false} + />, + ); + + await openMenu(); + + const deleteItem = await screen.findByRole('menuitem', { + name: /delete/i, + }); + await act(async () => { + fireEvent.click(deleteItem); + }); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent(/Delete space/i); + expect(dialog).toHaveTextContent(/Engineering/); + }); + }); + + describe('View vs Join based on membership', () => { + it('renders View when the user is a member and has a slug', async () => { + renderWithProviders( + <SpaceRowActions space={buildSpace()} isMember={true} />, + ); + + await openMenu(); + + expect( + screen.getByRole('menuitem', { name: /view/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('menuitem', { name: /join/i }), + ).not.toBeInTheDocument(); + }); + + it('renders Join when the user is not a member', async () => { + renderWithProviders( + <SpaceRowActions space={buildSpace()} isMember={false} />, + ); + + await openMenu(); + + expect( + screen.getByRole('menuitem', { name: /join/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('menuitem', { name: /view/i }), + ).not.toBeInTheDocument(); + }); + + it('does not render View when the slug is missing even if member', async () => { + renderWithProviders( + <SpaceRowActions space={buildSpace({ slug: '' })} isMember={true} />, + ); + + await openMenu(); + + expect( + screen.queryByRole('menuitem', { name: /view/i }), + ).not.toBeInTheDocument(); + }); + }); + + describe('when the user clicks View', () => { + it('navigates to the space dashboard route', async () => { + renderWithProviders( + <SpaceRowActions space={buildSpace()} isMember={true} />, + ); + + await openMenu(); + + const viewItem = await screen.findByRole('menuitem', { name: /view/i }); + await act(async () => { + fireEvent.click(viewItem); + }); + + expect(mockNavigate).toHaveBeenCalledWith( + '/org/test-org/space/engineering', + ); + }); + }); +}); diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceRowActions.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceRowActions.tsx index 868a1b696..0879b4997 100644 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceRowActions.tsx +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpaceRowActions.tsx @@ -1,60 +1,152 @@ -import React from 'react'; -import { LuEye, LuPencil, LuTrash2 } from 'react-icons/lu'; +import React, { useState } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useQueryClient } from '@tanstack/react-query'; +import { LuEye, LuPencil, LuTrash2, LuUserPlus } from 'react-icons/lu'; import { PMEllipsisMenu, - PMEllipsisMenuProps, + PMEllipsisMenuAction, PMHStack, PMIcon, PMText, + pmToaster, } from '@packmind/ui'; +import type { Space } from '@packmind/types'; + +import { routes } from '../../../../shared/utils/routes'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; +import { useJoinSpaceMutation } from '../../../spaces-management/api/queries/SpacesManagementQueries'; +import { DeleteSpaceConfirmDialog } from './DeleteSpaceConfirmDialog'; interface SpaceRowActionsProps { - spaceId: string; + space: Pick<Space, 'id' | 'name' | 'slug' | 'isDefaultSpace'>; + isMember: boolean; + onEdit?: () => void; } export const SpaceRowActions: React.FC<SpaceRowActionsProps> = ({ - spaceId, + space, + isMember, + onEdit, }) => { - const menu: PMEllipsisMenuProps = { - actions: [ - { - value: `view-${spaceId}`, - content: ( - <PMHStack gap={2}> - <PMIcon> - <LuEye /> - </PMIcon> - <PMText color="secondary">View</PMText> - </PMHStack> - ), - onClick: () => undefined, - }, - { - value: `edit-${spaceId}`, - content: ( - <PMHStack gap={2}> - <PMIcon> - <LuPencil /> - </PMIcon> - <PMText color="secondary">Edit</PMText> - </PMHStack> - ), - onClick: () => undefined, - }, + const [isJoining, setIsJoining] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const navigate = useNavigate(); + const { orgSlug } = useParams<{ orgSlug?: string }>(); + const { organization } = useAuthContext(); + const queryClient = useQueryClient(); + const joinMutation = useJoinSpaceMutation(); + + const handleJoin = () => { + setIsJoining(true); + joinMutation.mutate( + { spaceId: space.id }, { - value: `delete-${spaceId}`, - content: ( - <PMHStack gap={2}> - <PMIcon> - <LuTrash2 /> - </PMIcon> - <PMText color="error">Delete</PMText> - </PMHStack> - ), - onClick: () => undefined, + onSuccess: () => { + if (organization?.id) { + queryClient.invalidateQueries({ + queryKey: [ + 'organizations', + organization.id, + 'spaces', + 'management', + ], + }); + } + pmToaster.create({ + type: 'success', + title: 'Joined space', + description: `You've joined ${space.name}.`, + }); + }, + onError: () => { + pmToaster.create({ + type: 'error', + title: 'Failed to join space', + description: + 'An error occurred while joining the space. Please try again.', + }); + }, + onSettled: () => setIsJoining(false), }, - ], + ); }; - return <PMEllipsisMenu {...menu} />; + const menuActions: PMEllipsisMenuAction[] = []; + + if (onEdit) { + menuActions.push({ + value: `edit-${space.id}`, + content: ( + <PMHStack gap={2}> + <PMIcon> + <LuPencil /> + </PMIcon> + <PMText color="secondary">Edit</PMText> + </PMHStack> + ), + onClick: onEdit, + }); + } + + if (isMember && space.slug && orgSlug) { + menuActions.push({ + value: `view-${space.id}`, + content: ( + <PMHStack gap={2}> + <PMIcon> + <LuEye /> + </PMIcon> + <PMText color="secondary">View</PMText> + </PMHStack> + ), + onClick: () => navigate(routes.space.toDashboard(orgSlug, space.slug!)), + }); + } + + if (!isMember) { + menuActions.push({ + value: `join-${space.id}`, + content: ( + <PMHStack gap={2}> + <PMIcon> + <LuUserPlus /> + </PMIcon> + <PMText color="secondary">Join</PMText> + </PMHStack> + ), + onClick: handleJoin, + }); + } + + if (!space.isDefaultSpace) { + menuActions.push({ + value: `delete-${space.id}`, + content: ( + <PMHStack gap={2}> + <PMIcon> + <LuTrash2 /> + </PMIcon> + <PMText color="error">Delete</PMText> + </PMHStack> + ), + onClick: () => setIsDeleteOpen(true), + }); + } + + return ( + <> + <PMHStack gap={1} justify="flex-end"> + {menuActions.length > 0 && ( + <PMEllipsisMenu actions={menuActions} disabled={isJoining} /> + )} + </PMHStack> + {!space.isDefaultSpace && ( + <DeleteSpaceConfirmDialog + isOpen={isDeleteOpen} + onClose={() => setIsDeleteOpen(false)} + space={{ id: space.id, name: space.name }} + /> + )} + </> + ); }; diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesBulkActionBar.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesBulkActionBar.tsx deleted file mode 100644 index 105e65e01..000000000 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesBulkActionBar.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import { Box } from '@chakra-ui/react'; -import { PMButton, PMHStack, PMText } from '@packmind/ui'; - -interface SpacesBulkActionBarProps { - selectedCount: number; - onClear: () => void; -} - -export const SpacesBulkActionBar: React.FC<SpacesBulkActionBarProps> = ({ - selectedCount, - onClear, -}) => { - if (selectedCount === 0) { - return null; - } - - return ( - <Box - bg="background.secondary" - borderRadius="md" - px={4} - py={3} - data-testid="spaces-bulk-action-bar" - > - <PMHStack justify="space-between" align="center" gap={4}> - <PMHStack gap={3} align="center"> - <PMText fontWeight="semibold">{selectedCount} selected</PMText> - <PMText color="faded">·</PMText> - <PMButton variant="danger" size="sm" onClick={() => undefined}> - Delete - </PMButton> - </PMHStack> - <PMButton variant="tertiary" size="sm" onClick={onClear}> - clear - </PMButton> - </PMHStack> - </Box> - ); -}; diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesManagementPage.test.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesManagementPage.test.tsx index fd245f8a7..447d70024 100644 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesManagementPage.test.tsx +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesManagementPage.test.tsx @@ -1,18 +1,53 @@ import React from 'react'; -import { render, screen, fireEvent, within } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; -import { - createOrganizationId, - createSpaceId, - SpaceType, - UserSpaceWithRole, -} from '@packmind/types'; +import { MemoryRouter } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { UIProvider } from '@packmind/ui'; -import { useGetSpacesQuery } from '../../api/queries'; import { SpacesManagementPage } from './SpacesManagementPage'; +import * as queries from '../../api/queries/SpacesQueries'; +import * as SpacesManagementQueriesModule from '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries'; +import * as DeploymentsQueriesModule from '../../../deployments/api/queries/DeploymentsQueries'; +import * as UseNavigationModule from '../../../../shared/hooks/useNavigation'; +import * as UserQueriesModule from '../../../accounts/api/queries/UserQueries'; -jest.mock('../../api/queries', () => ({ - useGetSpacesQuery: jest.fn(), +jest.mock('./SpacesToolbar', () => ({ + SpacesToolbar: () => null, +})); + +jest.mock( + '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries', + () => ({ + ...jest.requireActual( + '@packmind/proprietary/frontend/domain/spaces-management/api/queries/SpacesManagementQueries', + ), + useDeleteSpaceMutation: jest.fn(() => ({ + mutate: jest.fn(), + isPending: false, + })), + useUpdateSpaceMutation: jest.fn(() => ({ + mutate: jest.fn(), + mutateAsync: jest.fn(), + isPending: false, + })), + useLeaveSpaceMutation: jest.fn(() => ({ + mutate: jest.fn(), + isPending: false, + })), + }), +); + +jest.mock('../../../accounts/hooks/useAuthContext', () => ({ + useAuthContext: () => ({ + user: { id: 'current-user-id' }, + organization: { + id: 'org-1', + name: 'Test Organization', + slug: 'test-org', + role: 'admin', + }, + }), })); jest.mock('@packmind/ui', () => { @@ -22,243 +57,349 @@ jest.mock('@packmind/ui', () => { PMTable: ({ columns, data, - selectable, - getRowId, - selectedRows, - onSelectionChange, }: { columns: { key: string; header: React.ReactNode }[]; data: Record<string, React.ReactNode>[]; - selectable?: boolean; - getRowId?: (row: unknown, index: number) => string; - selectedRows?: Set<string>; - onSelectionChange?: (rows: Set<string>) => void; - }) => { - const handleToggle = (rowId: string) => { - if (!onSelectionChange || !selectedRows) return; - const next = new Set(selectedRows); - if (next.has(rowId)) { - next.delete(rowId); - } else { - next.add(rowId); - } - onSelectionChange(next); - }; - const handleSelectAll = () => { - if (!onSelectionChange || !selectedRows || !getRowId) return; - const allIds = data.map((row, idx) => getRowId(row, idx)); - if (selectedRows.size === allIds.length) { - onSelectionChange(new Set()); - } else { - onSelectionChange(new Set(allIds)); - } - }; - return ( - <table> - <thead> - <tr> - {selectable && ( - <th> - <input - type="checkbox" - aria-label="Select all" - onChange={handleSelectAll} - /> - </th> - )} + }) => ( + <table> + <thead> + <tr> + {columns.map((col) => ( + <th key={col.key}>{col.header}</th> + ))} + </tr> + </thead> + <tbody> + {data.map((row, index) => ( + <tr key={index} role="row"> {columns.map((col) => ( - <th key={col.key}>{col.header}</th> + <td key={col.key}>{row[col.key]}</td> ))} </tr> - </thead> - <tbody> - {data.map((row, index) => { - const rowId = getRowId ? getRowId(row, index) : String(index); - return ( - <tr key={rowId}> - {selectable && ( - <td> - <input - type="checkbox" - aria-label={`Select row ${rowId}`} - checked={selectedRows?.has(rowId) ?? false} - onChange={() => handleToggle(rowId)} - /> - </td> - )} - {columns.map((col) => ( - <td key={col.key}>{row[col.key]}</td> - ))} - </tr> - ); - })} - </tbody> - </table> - ); - }, + ))} + </tbody> + </table> + ), }; }); -const ORG_ID = createOrganizationId('11111111-1111-1111-1111-111111111111'); - -const buildUserSpace = ( - overrides: Partial<UserSpaceWithRole> = {}, -): UserSpaceWithRole => ({ - id: createSpaceId('00000000-0000-0000-0000-000000000001'), - name: 'Frontend', - slug: 'frontend', - type: SpaceType.open, - organizationId: ORG_ID, +const buildItem = (overrides: Record<string, unknown> = {}) => ({ + id: 's1', + name: 'Engineering', + slug: 'engineering', + type: 'open', + color: 'blue', + organizationId: 'org-1', isDefaultSpace: false, - role: 'member', - pinned: false, + admins: [], + memberIds: [], + membersCount: 0, + artifactsCount: 0, + createdAt: '2025-01-12T00:00:00.000Z', ...overrides, }); -const mockedUseGetSpacesQuery = useGetSpacesQuery as jest.MockedFunction< - typeof useGetSpacesQuery ->; +const mockDrawerDependencies = () => { + jest.spyOn(queries, 'useGetSpaceMembersQuery').mockReturnValue({ + data: { members: [] }, + isLoading: false, + } as unknown as ReturnType<typeof queries.useGetSpaceMembersQuery>); -type QueryShape = ReturnType<typeof useGetSpacesQuery>; + jest.spyOn(queries, 'useRemoveMemberFromSpaceMutation').mockReturnValue({ + mutate: jest.fn(), + isPending: false, + } as unknown as ReturnType<typeof queries.useRemoveMemberFromSpaceMutation>); -const setQueryResult = (overrides: Partial<QueryShape>) => { - mockedUseGetSpacesQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - ...overrides, - } as QueryShape); + jest.spyOn(queries, 'useUpdateMemberRoleMutation').mockReturnValue({ + mutate: jest.fn(), + isPending: false, + } as unknown as ReturnType<typeof queries.useUpdateMemberRoleMutation>); + + jest + .mocked(SpacesManagementQueriesModule.useUpdateSpaceMutation) + .mockReturnValue({ + mutate: jest.fn(), + mutateAsync: jest.fn(), + isPending: false, + } as unknown as ReturnType< + typeof SpacesManagementQueriesModule.useUpdateSpaceMutation + >); + + jest + .mocked(SpacesManagementQueriesModule.useLeaveSpaceMutation) + .mockReturnValue({ + mutate: jest.fn(), + isPending: false, + } as unknown as ReturnType< + typeof SpacesManagementQueriesModule.useLeaveSpaceMutation + >); + + jest + .spyOn(DeploymentsQueriesModule, 'useListPackagesBySpaceQuery') + .mockReturnValue({ + data: { packages: [] }, + } as unknown as ReturnType< + typeof DeploymentsQueriesModule.useListPackagesBySpaceQuery + >); + + jest.spyOn(UseNavigationModule, 'useNavigation').mockReturnValue({ + org: { toDashboard: jest.fn() }, + } as unknown as ReturnType<typeof UseNavigationModule.useNavigation>); + + jest + .spyOn(UserQueriesModule, 'useGetUsersInMyOrganizationQuery') + .mockReturnValue({ + data: { users: [] }, + isLoading: false, + } as unknown as ReturnType< + typeof UserQueriesModule.useGetUsersInMyOrganizationQuery + >); }; -const renderPage = () => - render( +const renderWithQuery = (ui: React.ReactNode) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( <UIProvider> - <SpacesManagementPage /> + <QueryClientProvider client={client}> + <MemoryRouter>{ui}</MemoryRouter> + </QueryClientProvider> </UIProvider>, ); +}; describe('SpacesManagementPage', () => { - beforeEach(() => { - mockedUseGetSpacesQuery.mockReset(); + afterEach(() => { + jest.restoreAllMocks(); }); - it('shows a loading indicator while spaces are loading', () => { - setQueryResult({ isLoading: true }); - renderPage(); - expect(screen.getByTestId('spaces-loading')).toBeInTheDocument(); - }); + it('renders the filter bar with search and combobox inputs', () => { + jest + .spyOn(queries, 'useGetOrganizationSpacesForManagementQuery') + .mockReturnValue({ + data: { + items: [buildItem({ name: 'Engineering' })], + totalCount: 1, + page: 1, + pageSize: 8, + }, + isLoading: false, + isError: false, + } as unknown as ReturnType< + typeof queries.useGetOrganizationSpacesForManagementQuery + >); - it('treats data === undefined (e.g. during refetch) as loading rather than empty', () => { - setQueryResult({ isLoading: false, data: undefined }); - renderPage(); - expect(screen.getByTestId('spaces-loading')).toBeInTheDocument(); - expect(screen.queryByTestId('spaces-empty')).not.toBeInTheDocument(); - }); + renderWithQuery(<SpacesManagementPage />); - it('shows an actionable error message when the query fails', () => { - setQueryResult({ isError: true, error: new Error('boom') }); - renderPage(); - expect(screen.getByText(/couldn't load your spaces/i)).toBeInTheDocument(); - expect(screen.getByText(/try again/i)).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('Search by name...'), + ).toBeInTheDocument(); + expect(screen.getByPlaceholderText('All admins')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('All collaborators'), + ).toBeInTheDocument(); }); - it('shows an empty state with a user-visible message when the user belongs to no spaces', () => { - setQueryResult({ data: [] }); - renderPage(); - expect(screen.getByText(/no spaces yet/i)).toBeInTheDocument(); - expect(screen.getByText(/don't belong to any space/i)).toBeInTheDocument(); - }); + it('filters spaces by admin', async () => { + jest + .spyOn(queries, 'useGetOrganizationSpacesForManagementQuery') + .mockReturnValue({ + data: { + items: [ + buildItem({ + id: 's1', + name: 'Engineering', + admins: [{ id: 'admin-1', displayName: 'Alice' }], + memberIds: [], + }), + buildItem({ + id: 's2', + name: 'Frontend', + admins: [{ id: 'admin-2', displayName: 'Bob' }], + memberIds: [], + }), + ], + totalCount: 2, + page: 1, + pageSize: 8, + }, + isLoading: false, + isError: false, + } as unknown as ReturnType< + typeof queries.useGetOrganizationSpacesForManagementQuery + >); - it('renders rows mapped from the real spaces returned by the API', () => { - setQueryResult({ - data: [ - buildUserSpace({ name: 'Global', isDefaultSpace: true }), - buildUserSpace({ - id: createSpaceId('00000000-0000-0000-0000-000000000002'), - name: 'Frontend', - }), - ], - }); - renderPage(); - expect(screen.getByText('Global')).toBeInTheDocument(); - expect(screen.getByText('Frontend')).toBeInTheDocument(); + renderWithQuery(<SpacesManagementPage />); + + const user = userEvent.setup(); + await user.click(screen.getByPlaceholderText('All admins')); + await user.click(screen.getByRole('option', { name: 'Alice' })); + + expect(screen.getByText('Engineering')).toBeInTheDocument(); + expect(screen.queryByText('Frontend')).not.toBeInTheDocument(); }); - it('renders the org-wide badge only on the default space', () => { - setQueryResult({ - data: [ - buildUserSpace({ name: 'Global', isDefaultSpace: true }), - buildUserSpace({ - id: createSpaceId('00000000-0000-0000-0000-000000000002'), - name: 'Frontend', - isDefaultSpace: false, - }), - ], - }); - renderPage(); - expect(screen.getAllByText(/org-wide/i)).toHaveLength(1); + it('filters spaces by member', async () => { + jest + .spyOn(queries, 'useGetOrganizationSpacesForManagementQuery') + .mockReturnValue({ + data: { + items: [ + buildItem({ + id: 's1', + name: 'Engineering', + admins: [], + memberIds: ['member-1'], + }), + buildItem({ + id: 's2', + name: 'Frontend', + admins: [], + memberIds: ['member-2'], + }), + ], + totalCount: 2, + page: 1, + pageSize: 8, + }, + isLoading: false, + isError: false, + } as unknown as ReturnType< + typeof queries.useGetOrganizationSpacesForManagementQuery + >); + + renderWithQuery(<SpacesManagementPage />); + + const user = userEvent.setup(); + await user.click(screen.getByPlaceholderText('All collaborators')); + await user.click(screen.getByRole('option', { name: 'member-1' })); + + expect(screen.getByText('Engineering')).toBeInTheDocument(); + expect(screen.queryByText('Frontend')).not.toBeInTheDocument(); }); - it('renders an em-dash placeholder for fields the API does not return yet', () => { - setQueryResult({ data: [buildUserSpace({ name: 'Frontend' })] }); - renderPage(); - expect(screen.getAllByText('—').length).toBeGreaterThan(0); + it('filters spaces by name when searching', async () => { + jest + .spyOn(queries, 'useGetOrganizationSpacesForManagementQuery') + .mockReturnValue({ + data: { + items: [ + buildItem({ id: 's1', name: 'Engineering' }), + buildItem({ id: 's2', name: 'Frontend' }), + ], + totalCount: 2, + page: 1, + pageSize: 8, + }, + isLoading: false, + isError: false, + } as unknown as ReturnType< + typeof queries.useGetOrganizationSpacesForManagementQuery + >); + + renderWithQuery(<SpacesManagementPage />); + + const user = userEvent.setup(); + await user.type(screen.getByPlaceholderText('Search by name...'), 'Eng'); + + expect(screen.getByText('Engineering')).toBeInTheDocument(); + expect(screen.queryByText('Frontend')).not.toBeInTheDocument(); }); - it('hides the bulk action bar by default', () => { - setQueryResult({ data: [buildUserSpace({ name: 'Frontend' })] }); - renderPage(); - expect( - screen.queryByTestId('spaces-bulk-action-bar'), - ).not.toBeInTheDocument(); + it('renders rows from the new management query', async () => { + jest + .spyOn(queries, 'useGetOrganizationSpacesForManagementQuery') + .mockReturnValue({ + data: { + items: [buildItem({ name: 'Engineering' })], + totalCount: 1, + page: 1, + pageSize: 8, + }, + isLoading: false, + isError: false, + } as unknown as ReturnType< + typeof queries.useGetOrganizationSpacesForManagementQuery + >); + + renderWithQuery(<SpacesManagementPage />); + + expect(await screen.findByText('Engineering')).toBeInTheDocument(); }); - it('reveals the bulk action bar with "1 selected" when a row checkbox is checked', () => { - setQueryResult({ data: [buildUserSpace({ name: 'Frontend' })] }); - renderPage(); - const checkboxes = screen.getAllByRole('checkbox'); - fireEvent.click(checkboxes[1]); + it('does not render bulk action UI', () => { + jest + .spyOn(queries, 'useGetOrganizationSpacesForManagementQuery') + .mockReturnValue({ + data: { items: [], totalCount: 0, page: 1, pageSize: 8 }, + isLoading: false, + isError: false, + } as unknown as ReturnType< + typeof queries.useGetOrganizationSpacesForManagementQuery + >); + + renderWithQuery(<SpacesManagementPage />); - const bar = screen.getByTestId('spaces-bulk-action-bar'); - expect(within(bar).getByText('1 selected')).toBeInTheDocument(); + expect(screen.queryByTestId('spaces-bulk-action-bar')).toBeNull(); + expect(screen.queryByRole('checkbox', { name: /select/i })).toBeNull(); }); - it('hides the bulk action bar after clicking "clear"', () => { - setQueryResult({ data: [buildUserSpace({ name: 'Frontend' })] }); - renderPage(); - const checkboxes = screen.getAllByRole('checkbox'); - fireEvent.click(checkboxes[1]); + it('opens the drawer when a row is clicked', async () => { + mockDrawerDependencies(); + jest + .spyOn(queries, 'useGetOrganizationSpacesForManagementQuery') + .mockReturnValue({ + data: { + items: [buildItem({ name: 'Frontend' })], + totalCount: 1, + page: 1, + pageSize: 8, + }, + isLoading: false, + isError: false, + } as unknown as ReturnType< + typeof queries.useGetOrganizationSpacesForManagementQuery + >); + + renderWithQuery(<SpacesManagementPage />); - const clearButton = screen.getByRole('button', { name: /clear/i }); - fireEvent.click(clearButton); + const user = userEvent.setup(); + await user.click(screen.getByText('Frontend')); expect( - screen.queryByTestId('spaces-bulk-action-bar'), - ).not.toBeInTheDocument(); + await screen.findByRole('heading', { name: 'Frontend' }), + ).toBeInTheDocument(); }); - it('hides the pagination footer when total spaces fit on a single page', () => { - setQueryResult({ data: [buildUserSpace({ name: 'Frontend' })] }); - renderPage(); - expect(screen.queryByText(/showing/i)).not.toBeInTheDocument(); - }); + it('closes the drawer when the close button is clicked', async () => { + mockDrawerDependencies(); + jest + .spyOn(queries, 'useGetOrganizationSpacesForManagementQuery') + .mockReturnValue({ + data: { + items: [buildItem({ name: 'Frontend' })], + totalCount: 1, + page: 1, + pageSize: 8, + }, + isLoading: false, + isError: false, + } as unknown as ReturnType< + typeof queries.useGetOrganizationSpacesForManagementQuery + >); + + renderWithQuery(<SpacesManagementPage />); + + const user = userEvent.setup(); + await user.click(screen.getByText('Frontend')); + + expect( + await screen.findByRole('heading', { name: 'Frontend' }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /close/i })); - it('renders only the first page of spaces when there are more than one page', () => { - const data = Array.from({ length: 12 }, (_, i) => - buildUserSpace({ - id: createSpaceId( - `00000000-0000-0000-0000-${String(i).padStart(12, '0')}`, - ), - name: `Space ${i + 1}`, - slug: `space-${i + 1}`, - }), - ); - setQueryResult({ data }); - renderPage(); - expect(screen.getByText(/showing 1.*8 of 12/i)).toBeInTheDocument(); - expect(screen.getByText('Space 1')).toBeInTheDocument(); - expect(screen.getByText('Space 8')).toBeInTheDocument(); - expect(screen.queryByText('Space 9')).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Frontend' })).toBeNull(); }); }); diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesManagementPage.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesManagementPage.tsx index 3ffa2616d..1c7fcb1da 100644 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesManagementPage.tsx +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesManagementPage.tsx @@ -1,22 +1,268 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { PMAlert, PMBox, + PMCombobox, + pmCreateListCollection, PMEmptyState, + PMPortal, + PMSelect, PMSpinner, + PMTableRow, + pmUseFilter, PMVStack, + SortDirection, } from '@packmind/ui'; -import { useGetSpacesQuery } from '../../api/queries'; -import { SpacesBulkActionBar } from './SpacesBulkActionBar'; -import { SpacesTable } from './SpacesTable'; -import { SpacesPagination } from './SpacesPagination'; +import { + useGetOrganizationSpacesForManagementQuery, + useGetSpacesQuery, +} from '../../api/queries/SpacesQueries'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; +import { useGetUsersInMyOrganizationQuery } from '../../../accounts/api/queries/UserQueries'; import { toSpaceListItem } from './toSpaceListItem'; +import { SpaceManagementDrawer } from './SpaceManagementDrawer'; +import { SpaceNameCell } from './SpaceNameCell'; +import { SpaceAdminsCell } from './SpaceAdminsCell'; +import { SpaceRowActions } from './SpaceRowActions'; +import { formatCount, formatCreatedAt } from './formatters'; +import { SpaceListItem } from './types'; +import { + ItemsListing, + ItemsListingColumn, +} from '../../../../shared/components/ItemsListing'; +import { SpaceType } from '@packmind/types'; + +const SPACE_COLUMNS: ItemsListingColumn[] = [ + { key: 'name', header: 'Name', grow: true, sortKey: 'name' }, + { key: 'visibility', header: 'Visibility', sortKey: 'visibility' }, + { key: 'admins', header: 'Admins', width: '320px' }, + { + key: 'membersCount', + header: 'Collaborators', + width: '130px', + align: 'center', + sortKey: 'membersCount', + }, + { + key: 'artifactsCount', + header: 'Artifacts', + width: '110px', + align: 'center', + sortKey: 'artifactsCount', + }, + { key: 'createdAt', header: 'Created', width: '140px', sortKey: 'createdAt' }, + { key: 'actions', header: '', width: '60px' }, +]; + +const INTERACTIVE_SELECTOR = + 'button, a, input, [role="menu"], [role="menuitem"]'; + +type FilterItem = { label: string; value: string }; -const PAGE_SIZE = 8; +const spaceTypeFilter = pmCreateListCollection({ + items: [ + { label: 'Private', value: SpaceType.private }, + { label: 'Open', value: SpaceType.open }, + ], +}); + +function MultiFilterCombobox({ + items, + value, + onChange, + placeholder, +}: { + items: FilterItem[]; + value: string[]; + onChange: (v: string[]) => void; + placeholder: string; +}) { + const [open, setOpen] = React.useState(false); + const [searchInput, setSearchInput] = React.useState(''); + const { contains } = pmUseFilter({ sensitivity: 'base' }); + + const selectedLabels = useMemo( + () => + items + .filter((i) => value.includes(i.value)) + .map((i) => i.label) + .join(', '), + [items, value], + ); + + const collection = useMemo( + () => + pmCreateListCollection({ + items: + open && searchInput + ? items.filter((i) => contains(i.label, searchInput)) + : items, + }), + [items, open, searchInput, contains], + ); + + return ( + <PMCombobox.Root + collection={collection} + multiple + value={value} + inputValue={open ? searchInput : selectedLabels} + open={open} + onOpenChange={(details: { open: boolean }) => { + setOpen(details.open); + if (!details.open) setSearchInput(''); + }} + onInputValueChange={(e: { inputValue: string }) => { + if (open) setSearchInput(e.inputValue); + }} + onValueChange={(details: { value: string[] }) => onChange(details.value)} + openOnClick + > + <PMCombobox.Control> + <PMVStack gap={0}> + <PMCombobox.Input placeholder={placeholder} /> + <PMCombobox.IndicatorGroup> + <PMCombobox.ClearTrigger /> + <PMCombobox.Trigger /> + </PMCombobox.IndicatorGroup> + </PMVStack> + </PMCombobox.Control> + <PMPortal> + <PMCombobox.Positioner> + <PMCombobox.Content> + <PMCombobox.Empty>No results</PMCombobox.Empty> + {collection.items.map((item) => ( + <PMCombobox.Item item={item} key={item.value}> + <PMCombobox.ItemText>{item.label}</PMCombobox.ItemText> + <PMCombobox.ItemIndicator /> + </PMCombobox.Item> + ))} + </PMCombobox.Content> + </PMCombobox.Positioner> + </PMPortal> + </PMCombobox.Root> + ); +} + +function sortSpaces( + items: SpaceListItem[], + sortKey: string | null, + sortDirection: SortDirection, +): SpaceListItem[] { + const direction = sortDirection === 'asc' ? 1 : -1; + return items.sort((a, b) => { + switch (sortKey) { + case 'name': + return ( + direction * + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + ); + case 'visibility': + return direction * a.type.localeCompare(b.type); + + case 'membersCount': + return direction * ((a.membersCount ?? 0) - (b.membersCount ?? 0)); + case 'artifactsCount': + return direction * ((a.artifactsCount ?? 0) - (b.artifactsCount ?? 0)); + case 'createdAt': + return ( + direction * + (new Date(a.createdAt || 0).getTime() - + new Date(b.createdAt || 0).getTime()) + ); + default: + return 0; + } + }); +} export const SpacesManagementPage: React.FC = () => { - const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set()); - const { data, isLoading, isError } = useGetSpacesQuery(); + const { organization } = useAuthContext(); + const [page] = useState(1); + const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null); + const [selectedAdminIds, setSelectedAdminIds] = useState<string[]>([]); + const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([]); + const [selectedVisibility, setSelectedVisibility] = + useState<SpaceType | null>(null); + const orgId = organization?.id ?? ''; + const { data, isLoading, isError } = + useGetOrganizationSpacesForManagementQuery(orgId, page); + const { data: mySpaces } = useGetSpacesQuery(); + const { data: orgUsers } = useGetUsersInMyOrganizationQuery(); + const memberSpaceIds = useMemo( + () => new Set((mySpaces ?? []).map((s) => s.id)), + [mySpaces], + ); + + const selectedSpace = useMemo( + () => data?.items.find((item) => item.id === selectedSpaceId) ?? null, + [selectedSpaceId, data], + ); + + const userDisplayNameMap = useMemo(() => { + const map = new Map<string, string>(); + for (const user of orgUsers?.users ?? []) { + map.set(user.userId as string, user.displayName); + } + return map; + }, [orgUsers]); + + const items = useMemo(() => (data?.items ?? []).map(toSpaceListItem), [data]); + + const adminItems = useMemo(() => { + const seen = new Set<string>(); + const result: { label: string; value: string }[] = []; + for (const item of items) { + for (const admin of item.admins) { + if (!seen.has(admin.id)) { + seen.add(admin.id); + result.push({ + label: userDisplayNameMap.get(admin.id) ?? admin.displayName, + value: admin.id, + }); + } + } + } + return result; + }, [items, userDisplayNameMap]); + + const memberItems = useMemo(() => { + const seen = new Set<string>(); + const result: { label: string; value: string }[] = []; + for (const item of items) { + for (const memberId of item.memberIds) { + if (!seen.has(memberId)) { + seen.add(memberId); + result.push({ + label: userDisplayNameMap.get(memberId) ?? memberId, + value: memberId, + }); + } + } + } + return result; + }, [items, userDisplayNameMap]); + + const filteredItems = useMemo(() => { + return items.filter((space) => { + if ( + selectedAdminIds.length > 0 && + !space.admins.some((a) => selectedAdminIds.includes(a.id)) + ) + return false; + if ( + selectedMemberIds.length > 0 && + !space.memberIds.some((id) => selectedMemberIds.includes(id)) + ) + return false; + + if (selectedVisibility !== null) { + return space.type === selectedVisibility; + } + + return true; + }); + }, [items, selectedAdminIds, selectedMemberIds, selectedVisibility]); if (isError) { return ( @@ -40,43 +286,127 @@ export const SpacesManagementPage: React.FC = () => { ); } - const spaces = data; + const handleSelectSpace = (space: SpaceListItem) => { + setSelectedSpaceId(space.id); + }; - if (spaces.length === 0) { - return ( - <PMBox data-testid="spaces-empty" py={8}> - <PMEmptyState - title="No spaces yet" - description="You don't belong to any space in this organization." - /> + const makeTableData = (space: SpaceListItem): PMTableRow => { + const handleCellClick = (event: React.MouseEvent<HTMLDivElement>) => { + const target = event.target as HTMLElement | null; + if (target && target.closest(INTERACTIVE_SELECTOR)) return; + handleSelectSpace(space); + }; + + const wrap = (content: React.ReactNode) => ( + <PMBox + as="div" + onClick={handleCellClick} + cursor="pointer" + width="100%" + height="100%" + > + {content} </PMBox> ); - } - const items = spaces.map(toSpaceListItem); - const visibleSpaces = items.slice(0, PAGE_SIZE); - const totalCount = items.length; - const totalPages = Math.ceil(totalCount / PAGE_SIZE); + return { + name: wrap( + <SpaceNameCell + name={space.name} + color={space.color} + isDefaultSpace={space.isDefaultSpace} + />, + ), + visibility: wrap(<>{space.type}</>), + admins: wrap(<SpaceAdminsCell admins={space.admins} />), + membersCount: wrap(formatCount(space.membersCount)), + artifactsCount: wrap(formatCount(space.artifactsCount)), + createdAt: wrap(formatCreatedAt(space.createdAt)), + actions: ( + <SpaceRowActions + space={space} + isMember={memberSpaceIds.has(space.id)} + onEdit={() => handleSelectSpace(space)} + /> + ), + }; + }; + + if (items.length === 0) { + return ( + <PMEmptyState + title="No spaces found" + description="No spaces have been created in your organization yet." + /> + ); + } return ( - <PMVStack alignItems="stretch" gap={4} width="full"> - <SpacesBulkActionBar - selectedCount={selectedRows.size} - onClear={() => setSelectedRows(new Set())} + <PMVStack alignItems="stretch" gap={0} width="full"> + <ItemsListing + items={filteredItems} + columns={SPACE_COLUMNS} + makeTableData={makeTableData} + sortItems={sortSpaces} + filters={[ + <PMSelect.Root + collection={spaceTypeFilter} + value={[`${selectedVisibility}`]} + onValueChange={(e) => { + if (e.value.length === 0) { + setSelectedVisibility(null); + return; + } + + setSelectedVisibility( + e.value[0] === 'private' ? SpaceType.private : SpaceType.open, + ); + }} + key={'visibility-filter'} + > + <PMSelect.HiddenSelect /> + <PMSelect.Control> + <PMSelect.Trigger> + <PMSelect.ValueText placeholder="Select visibility" /> + </PMSelect.Trigger> + <PMSelect.IndicatorGroup> + {selectedVisibility && <PMSelect.ClearTrigger />} + <PMSelect.Indicator /> + </PMSelect.IndicatorGroup> + </PMSelect.Control> + <PMPortal> + <PMSelect.Positioner> + <PMSelect.Content> + {spaceTypeFilter.items.map((visibility) => ( + <PMSelect.Item item={visibility} key={visibility.value}> + {visibility.label} + <PMSelect.ItemIndicator /> + </PMSelect.Item> + ))} + </PMSelect.Content> + </PMSelect.Positioner> + </PMPortal> + </PMSelect.Root>, + <MultiFilterCombobox + items={adminItems} + value={selectedAdminIds} + onChange={setSelectedAdminIds} + placeholder="All admins" + key={'admin-filter'} + />, + <MultiFilterCombobox + items={memberItems} + value={selectedMemberIds} + onChange={setSelectedMemberIds} + placeholder="All collaborators" + key={'member-filter'} + />, + ]} /> - <SpacesTable - spaces={visibleSpaces} - selectedRows={selectedRows} - onSelectionChange={setSelectedRows} + <SpaceManagementDrawer + space={selectedSpace} + onClose={() => setSelectedSpaceId(null)} /> - {totalCount > PAGE_SIZE && ( - <SpacesPagination - totalCount={totalCount} - pageSize={PAGE_SIZE} - currentPage={1} - totalPages={totalPages} - /> - )} </PMVStack> ); }; diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesPagination.test.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesPagination.test.tsx new file mode 100644 index 000000000..e18cf7f59 --- /dev/null +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesPagination.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { UIProvider } from '@packmind/ui'; +import { SpacesPagination } from './SpacesPagination'; + +const renderWithProvider = (ui: React.ReactElement) => + render(<UIProvider>{ui}</UIProvider>); + +describe('SpacesPagination', () => { + it('renders nothing when totalCount <= pageSize', () => { + renderWithProvider( + <SpacesPagination + page={1} + pageSize={8} + totalCount={5} + onPageChange={() => undefined} + />, + ); + expect(screen.queryByRole('navigation')).toBeNull(); + }); + + it('disables prev on page 1 and next on last page', () => { + const { rerender } = renderWithProvider( + <SpacesPagination + page={1} + pageSize={8} + totalCount={32} + onPageChange={() => undefined} + />, + ); + expect(screen.getByRole('button', { name: /prev/i })).toBeDisabled(); + + rerender( + <UIProvider> + <SpacesPagination + page={4} + pageSize={8} + totalCount={32} + onPageChange={() => undefined} + /> + </UIProvider>, + ); + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); + }); + + it('calls onPageChange with the requested page', async () => { + const onPageChange = jest.fn(); + renderWithProvider( + <SpacesPagination + page={1} + pageSize={8} + totalCount={32} + onPageChange={onPageChange} + />, + ); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + expect(onPageChange).toHaveBeenCalledWith(2); + }); +}); diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesPagination.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesPagination.tsx index cb55237b2..805324bd3 100644 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesPagination.tsx +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesPagination.tsx @@ -3,23 +3,36 @@ import { LuChevronLeft, LuChevronRight } from 'react-icons/lu'; import { PMButton, PMHStack, PMIcon, PMText } from '@packmind/ui'; interface SpacesPaginationProps { - totalCount: number; + page: number; pageSize: number; - currentPage: number; - totalPages: number; + totalCount: number; + onPageChange: (page: number) => void; } export const SpacesPagination: React.FC<SpacesPaginationProps> = ({ - totalCount, + page, pageSize, - currentPage, - totalPages, + totalCount, + onPageChange, }) => { - const start = (currentPage - 1) * pageSize + 1; - const end = Math.min(currentPage * pageSize, totalCount); + if (totalCount <= pageSize) { + return null; + } + + const totalPages = Math.ceil(totalCount / pageSize); + const start = (page - 1) * pageSize + 1; + const end = Math.min(page * pageSize, totalCount); + const isFirstPage = page <= 1; + const isLastPage = page >= totalPages; return ( - <PMHStack justify="space-between" align="center" width="full"> + <PMHStack + as="nav" + aria-label="Spaces pagination" + justify="space-between" + align="center" + width="full" + > <PMText color="faded" fontSize="sm"> Showing {start}–{end} of {totalCount} </PMText> @@ -28,29 +41,33 @@ export const SpacesPagination: React.FC<SpacesPaginationProps> = ({ variant="secondary" size="sm" aria-label="Previous page" - onClick={() => undefined} + disabled={isFirstPage} + onClick={() => onPageChange(page - 1)} > <PMIcon> <LuChevronLeft /> </PMIcon> </PMButton> - {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( - <PMButton - key={page} - variant={page === currentPage ? 'primary' : 'secondary'} - size="sm" - onClick={() => undefined} - aria-label={`Page ${page}`} - aria-current={page === currentPage ? 'page' : undefined} - > - {page} - </PMButton> - ))} + {Array.from({ length: totalPages }, (_, i) => i + 1).map( + (pageNumber) => ( + <PMButton + key={pageNumber} + variant={pageNumber === page ? 'primary' : 'secondary'} + size="sm" + onClick={() => onPageChange(pageNumber)} + aria-label={`Page ${pageNumber}`} + aria-current={pageNumber === page ? 'page' : undefined} + > + {pageNumber} + </PMButton> + ), + )} <PMButton variant="secondary" size="sm" aria-label="Next page" - onClick={() => undefined} + disabled={isLastPage} + onClick={() => onPageChange(page + 1)} > <PMIcon> <LuChevronRight /> diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesTable.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesTable.tsx deleted file mode 100644 index f968bbc8a..000000000 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesTable.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { useMemo } from 'react'; -import { PMTable, PMTableColumn, PMTableRow } from '@packmind/ui'; -import { SpaceListItem } from './types'; -import { SpaceNameCell } from './SpaceNameCell'; -import { SpaceAdminsCell } from './SpaceAdminsCell'; -import { SpaceRowActions } from './SpaceRowActions'; -import { formatCount, formatCreatedAt } from './formatters'; - -interface SpacesTableProps { - spaces: SpaceListItem[]; - selectedRows: Set<string>; - onSelectionChange: (rows: Set<string>) => void; -} - -const COLUMNS: PMTableColumn[] = [ - { key: 'name', header: 'Name', grow: true }, - { key: 'admins', header: 'Admins', width: '320px' }, - { - key: 'membersCount', - header: 'Members', - width: '110px', - align: 'right', - }, - { - key: 'artifactsCount', - header: 'Artifacts', - width: '110px', - align: 'right', - }, - { key: 'createdAt', header: 'Created', width: '140px' }, - { key: 'actions', header: '', width: '60px', align: 'right' }, -]; - -export const SpacesTable: React.FC<SpacesTableProps> = ({ - spaces, - selectedRows, - onSelectionChange, -}) => { - const rows = useMemo<PMTableRow[]>( - () => - spaces.map((space) => ({ - id: space.id, - name: ( - <SpaceNameCell - name={space.name} - colorToken={space.colorToken} - isOrgWide={space.isOrgWide} - /> - ), - admins: <SpaceAdminsCell admins={space.admins} />, - membersCount: formatCount(space.membersCount), - artifactsCount: formatCount(space.artifactsCount), - createdAt: formatCreatedAt(space.createdAt), - actions: <SpaceRowActions spaceId={space.id} />, - })), - [spaces], - ); - - return ( - <PMTable - columns={COLUMNS} - data={rows} - selectable - getRowId={(row) => (row as { id: string }).id} - selectedRows={selectedRows} - onSelectionChange={onSelectionChange} - striped={false} - hoverable - /> - ); -}; diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesToolbar.test.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesToolbar.test.tsx new file mode 100644 index 000000000..6e5a01821 --- /dev/null +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesToolbar.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UIProvider } from '@packmind/ui'; +import { SpacesToolbar } from './SpacesToolbar'; + +jest.mock('../../../accounts/hooks/useAuthContext', () => ({ + useAuthContext: () => ({ + organization: { + id: 'org-1', + name: 'Test Organization', + slug: 'test-org', + role: 'admin', + }, + }), +})); + +const capturedDialogProps: { + current: { onCreated?: () => void | Promise<void> } | null; +} = { current: null }; + +jest.mock('../../../spaces-management/components/CreateSpaceDialog', () => ({ + CreateSpaceDialog: (props: { + open: boolean; + setOpen: (open: boolean) => void; + redirectAfterCreate?: boolean; + onCreated?: () => void | Promise<void>; + }) => { + capturedDialogProps.current = props; + return props.open ? ( + <div data-testid="create-space-dialog-stub">dialog open</div> + ) : null; + }, +})); + +const renderWithProviders = (ui: React.ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return { + queryClient, + ...render( + <UIProvider> + <QueryClientProvider client={queryClient}>{ui}</QueryClientProvider> + </UIProvider>, + ), + }; +}; + +describe('SpacesToolbar', () => { + beforeEach(() => { + capturedDialogProps.current = null; + }); + + it('opens the CreateSpaceDialog when clicking the "New space" button', async () => { + renderWithProviders(<SpacesToolbar />); + + expect(screen.queryByTestId('create-space-dialog-stub')).toBeNull(); + + await userEvent.click(screen.getByRole('button', { name: /new space/i })); + + expect(screen.getByTestId('create-space-dialog-stub')).toBeInTheDocument(); + }); + + it('passes redirectAfterCreate=false to CreateSpaceDialog', async () => { + renderWithProviders(<SpacesToolbar />); + + await userEvent.click(screen.getByRole('button', { name: /new space/i })); + + expect(capturedDialogProps.current).not.toBeNull(); + expect( + (capturedDialogProps.current as { redirectAfterCreate?: boolean }) + .redirectAfterCreate, + ).toBe(false); + }); + + it('invalidates the management spaces query when onCreated fires', async () => { + const invalidateSpy = jest.spyOn( + QueryClient.prototype, + 'invalidateQueries', + ); + + renderWithProviders(<SpacesToolbar />); + + await userEvent.click(screen.getByRole('button', { name: /new space/i })); + + const onCreated = capturedDialogProps.current?.onCreated; + expect(typeof onCreated).toBe('function'); + + await act(async () => { + await onCreated?.(); + }); + + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: expect.arrayContaining(['spaces', 'management']), + }), + ); + + invalidateSpy.mockRestore(); + }); + + it('closes the dialog after onCreated resolves', async () => { + renderWithProviders(<SpacesToolbar />); + + await userEvent.click(screen.getByRole('button', { name: /new space/i })); + expect(screen.getByTestId('create-space-dialog-stub')).toBeInTheDocument(); + + await act(async () => { + await capturedDialogProps.current?.onCreated?.(); + }); + + await waitFor(() => { + expect(screen.queryByTestId('create-space-dialog-stub')).toBeNull(); + }); + }); +}); diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesToolbar.tsx b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesToolbar.tsx index a3e815349..6544a5c54 100644 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesToolbar.tsx +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/SpacesToolbar.tsx @@ -1,53 +1,63 @@ -import React from 'react'; +import React, { useState } from 'react'; import { LuPlus } from 'react-icons/lu'; -import { - PMButton, - PMHStack, - PMIcon, - PMInput, - PMNativeSelect, -} from '@packmind/ui'; +import { useQueryClient } from '@tanstack/react-query'; +import { PMButton, PMHStack, PMIcon } from '@packmind/ui'; +import { CreateSpaceDialog } from '../../../spaces-management/components/CreateSpaceDialog'; +import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; -const ADMIN_FILTER_ITEMS = [ - { label: 'Admin: any', value: 'any' }, - { label: 'Org admins', value: 'org-admins' }, - { label: 'Individual users', value: 'individual' }, -]; +export const SpacesToolbar: React.FC = () => { + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const queryClient = useQueryClient(); + const { organization } = useAuthContext(); -const MEMBER_FILTER_ITEMS = [ - { label: 'Member: any', value: 'any' }, - { label: 'Org members', value: 'org-members' }, - { label: 'Individual users', value: 'individual' }, -]; + const handleCreated = async () => { + if (organization?.id) { + await queryClient.invalidateQueries({ + queryKey: ['organizations', organization.id, 'spaces', 'management'], + }); + } + setIsCreateDialogOpen(false); + }; -export const SpacesToolbar: React.FC = () => { return ( - <PMHStack gap={2} align="center" flexWrap="nowrap"> - <PMInput - type="search" - placeholder="Search spaces..." - aria-label="Search spaces" - size="sm" - width="220px" - /> - <PMNativeSelect - items={ADMIN_FILTER_ITEMS} - defaultValue="any" - size="sm" - aria-label="Filter by admin" - /> - <PMNativeSelect - items={MEMBER_FILTER_ITEMS} - defaultValue="any" - size="sm" - aria-label="Filter by member" + <> + <PMHStack gap={2} align="center" flexWrap="nowrap"> + {/* <PMInput + type="search" + placeholder="Search spaces..." + aria-label="Search spaces" + size="sm" + width="220px" + /> + <PMButton variant="secondary" size="sm" aria-label="Filter by admin"> + Admin: any + <PMIcon fontSize="xs"> + <LuChevronDown /> + </PMIcon> + </PMButton> + <PMButton variant="secondary" size="sm" aria-label="Filter by member"> + Member: any + <PMIcon fontSize="xs"> + <LuChevronDown /> + </PMIcon> + </PMButton> */} + <PMButton + variant="primary" + size="xs" + onClick={() => setIsCreateDialogOpen(true)} + > + <PMIcon> + <LuPlus /> + </PMIcon> + New space + </PMButton> + </PMHStack> + <CreateSpaceDialog + open={isCreateDialogOpen} + setOpen={setIsCreateDialogOpen} + redirectAfterCreate={false} + onCreated={handleCreated} /> - <PMButton variant="primary" size="sm" onClick={() => undefined}> - <PMIcon> - <LuPlus /> - </PMIcon> - New space - </PMButton> - </PMHStack> + </> ); }; diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/formatters.test.ts b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/formatters.test.ts index 522d360b0..5d9fee87f 100644 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/formatters.test.ts +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/formatters.test.ts @@ -32,12 +32,12 @@ describe('formatCreatedAt', () => { expect(formatCreatedAt(null)).toBe(EMPTY_PLACEHOLDER); }); - it('formats a valid ISO date in en-GB short form', () => { - expect(formatCreatedAt('2025-02-03')).toBe('03 Feb 2025'); + it('formats a valid ISO date in en-US short form', () => { + expect(formatCreatedAt('2025-02-03')).toBe('Feb 3, 2025'); }); it('formats a full ISO datetime down to the day', () => { - expect(formatCreatedAt('2025-02-03T23:59:59.000Z')).toBe('03 Feb 2025'); + expect(formatCreatedAt('2025-02-03T23:59:59.000Z')).toBe('Feb 3, 2025'); }); it('returns the placeholder for an unparseable string', () => { diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/formatters.ts b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/formatters.ts index c80616570..c5283e878 100644 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/formatters.ts +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/formatters.ts @@ -15,8 +15,8 @@ export const formatCreatedAt = (iso: string | null): string => { if (Number.isNaN(date.getTime())) { return EMPTY_PLACEHOLDER; } - return date.toLocaleDateString('en-GB', { - day: '2-digit', + return date.toLocaleDateString('en-US', { + day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC', diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/spaceColor.test.ts b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/spaceColor.test.ts deleted file mode 100644 index 851e086dd..000000000 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/spaceColor.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { SPACE_COLOR_PALETTE, getColorTokenForSpace } from './spaceColor'; - -const DETERMINISTIC_IDS = [ - 'a', - 'aa', - 'space-1', - 'SPACE-1', - '00000000-0000-0000-0000-000000000000', - 'd9b1c2-aaaa-bbbb', - 'b1d4f7e2-3a8c-4f7d-9e2a-1c5b6d8e9f0a', - 'global', - 'frontend', - 'design-system', - 'platform', - 'mobile', -]; - -describe('getColorTokenForSpace', () => { - it('returns the same color for the same id across calls', () => { - const space = { id: 'abc-123-def', isDefaultSpace: false }; - const first = getColorTokenForSpace(space); - const second = getColorTokenForSpace(space); - const third = getColorTokenForSpace(space); - expect(first).toBe(second); - expect(second).toBe(third); - }); - - it('always returns a token from the palette for a deterministic set of ids', () => { - for (const id of DETERMINISTIC_IDS) { - const result = getColorTokenForSpace({ id, isDefaultSpace: false }); - expect(SPACE_COLOR_PALETTE).toContain(result); - } - }); - - it('returns a palette token for an empty id when not the default space', () => { - const result = getColorTokenForSpace({ id: '', isDefaultSpace: false }); - expect(SPACE_COLOR_PALETTE).toContain(result); - }); - - it('returns a palette token for very long ids', () => { - const result = getColorTokenForSpace({ - id: 'x'.repeat(2048), - isDefaultSpace: false, - }); - expect(SPACE_COLOR_PALETTE).toContain(result); - }); - - it('produces more than one distinct color across distinct ids', () => { - const colors = new Set( - DETERMINISTIC_IDS.map((id) => - getColorTokenForSpace({ id, isDefaultSpace: false }), - ), - ); - expect(colors.size).toBeGreaterThan(1); - }); - - it('always returns blue when the space is the default space', () => { - const ids = ['anything', 'whatever-id', 'd9b1c2-aaaa-bbbb', '']; - for (const id of ids) { - const result = getColorTokenForSpace({ id, isDefaultSpace: true }); - expect(result).toBe('blue'); - } - }); - - it('exposes a palette aligned with the space identity section', () => { - expect(SPACE_COLOR_PALETTE).toEqual([ - 'red', - 'orange', - 'yellow', - 'green', - 'teal', - 'blue', - 'cyan', - 'purple', - 'pink', - ]); - }); -}); diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/spaceColor.ts b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/spaceColor.ts deleted file mode 100644 index 04b32a12a..000000000 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/spaceColor.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { SpaceColorToken } from './types'; - -export const SPACE_COLOR_PALETTE: readonly SpaceColorToken[] = [ - 'red', - 'orange', - 'yellow', - 'green', - 'teal', - 'blue', - 'cyan', - 'purple', - 'pink', -] as const; - -const DEFAULT_SPACE_COLOR: SpaceColorToken = 'blue'; - -export function getColorTokenForSpace(space: { - id: string; - isDefaultSpace: boolean; -}): SpaceColorToken { - if (space.isDefaultSpace) { - return DEFAULT_SPACE_COLOR; - } - - let hash = 0; - for (let i = 0; i < space.id.length; i++) { - hash = (hash * 31 + space.id.charCodeAt(i)) | 0; - } - - return SPACE_COLOR_PALETTE[Math.abs(hash) % SPACE_COLOR_PALETTE.length]; -} diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/toSpaceListItem.test.ts b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/toSpaceListItem.test.ts index 506d09705..55a1d20d2 100644 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/toSpaceListItem.test.ts +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/toSpaceListItem.test.ts @@ -1,72 +1,96 @@ import { createOrganizationId, createSpaceId, - Space, + createUserId, + SpaceManagementListItem, SpaceType, } from '@packmind/types'; -import { SPACE_COLOR_PALETTE } from './spaceColor'; import { toSpaceListItem } from './toSpaceListItem'; -const buildSpace = (overrides: Partial<Space> = {}): Space => ({ - id: createSpaceId('00000000-0000-0000-0000-000000000001'), - name: 'Frontend', - slug: 'frontend', - type: SpaceType.open, - organizationId: createOrganizationId('11111111-1111-1111-1111-111111111111'), - isDefaultSpace: false, - ...overrides, -}); +const buildDto = ( + overrides: Partial<SpaceManagementListItem> = {}, +): SpaceManagementListItem => + ({ + id: createSpaceId('00000000-0000-0000-0000-000000000001'), + name: 'Engineering', + slug: 'engineering', + type: SpaceType.open, + organizationId: createOrganizationId( + '11111111-1111-1111-1111-111111111111', + ), + isDefaultSpace: false, + color: 'green', + admins: [ + { + id: createUserId('22222222-2222-2222-2222-222222222222'), + displayName: 'Alice', + }, + ], + membersCount: 12, + artifactsCount: 9, + createdAt: '2025-01-12T10:00:00.000Z', + ...overrides, + }) as SpaceManagementListItem; describe('toSpaceListItem', () => { - it('marks a default space as org-wide and assigns the blue color token', () => { - const space = buildSpace({ isDefaultSpace: true, name: 'Global' }); - const item = toSpaceListItem(space); - expect(item.isOrgWide).toBe(true); - expect(item.colorToken).toBe('blue'); + it('preserves the backend-provided color', () => { + const dto = buildDto({ color: 'pink' }); + const row = toSpaceListItem(dto); + + expect(row.color).toBe('pink'); + }); + + it('flags non-default spaces correctly and forwards aggregated fields', () => { + const dto = buildDto(); + const row = toSpaceListItem(dto); + + expect(row.isDefaultSpace).toBe(false); + expect(row.admins).toEqual([ + { id: '22222222-2222-2222-2222-222222222222', displayName: 'Alice' }, + ]); + expect(row.membersCount).toBe(12); + expect(row.artifactsCount).toBe(9); + expect(row.createdAt).toBe('2025-01-12T10:00:00.000Z'); }); - it('marks a non-default space as not org-wide and derives a palette color', () => { - const space = buildSpace({ isDefaultSpace: false }); - const item = toSpaceListItem(space); - expect(item.isOrgWide).toBe(false); - expect(SPACE_COLOR_PALETTE).toContain(item.colorToken); + it('marks the default space correctly', () => { + const dto = buildDto({ isDefaultSpace: true, name: 'Global' }); + const row = toSpaceListItem(dto); + + expect(row.isDefaultSpace).toBe(true); }); - it('keeps isOrgWide tied solely to isDefaultSpace, regardless of space type', () => { - const restrictedDefault = buildSpace({ + it('isDefaultSpace is independent of space type', () => { + const restrictedDefault = buildDto({ isDefaultSpace: true, type: SpaceType.restricted, }); - expect(toSpaceListItem(restrictedDefault).isOrgWide).toBe(true); - }); - it('leaves API-unsourced fields empty or null', () => { - const item = toSpaceListItem(buildSpace()); - expect(item.admins).toEqual([]); - expect(item.membersCount).toBeNull(); - expect(item.artifactsCount).toBeNull(); - expect(item.createdAt).toBeNull(); + expect(toSpaceListItem(restrictedDefault).isDefaultSpace).toBe(true); }); - it('preserves every field from the source Space', () => { - const space = buildSpace({ - id: createSpaceId('22222222-2222-2222-2222-222222222222'), + it('preserves every field from the source space management item', () => { + const dto = buildDto({ + id: createSpaceId('33333333-3333-3333-3333-333333333333'), name: 'Mobile', slug: 'mobile', type: SpaceType.restricted, organizationId: createOrganizationId( - '33333333-3333-3333-3333-333333333333', + '44444444-4444-4444-4444-444444444444', ), isDefaultSpace: false, + color: 'cyan', }); - const item = toSpaceListItem(space); - expect(item).toMatchObject({ - id: space.id, - name: space.name, - slug: space.slug, - type: space.type, - organizationId: space.organizationId, - isDefaultSpace: space.isDefaultSpace, + const row = toSpaceListItem(dto); + + expect(row).toMatchObject({ + id: dto.id, + name: dto.name, + slug: dto.slug, + type: dto.type, + organizationId: dto.organizationId, + isDefaultSpace: dto.isDefaultSpace, + color: dto.color, }); }); }); diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/toSpaceListItem.ts b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/toSpaceListItem.ts index 524fba6d6..905728fb1 100644 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/toSpaceListItem.ts +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/toSpaceListItem.ts @@ -1,15 +1,13 @@ -import type { Space } from '@packmind/types'; -import { getColorTokenForSpace } from './spaceColor'; +import type { SpaceManagementListItem } from '@packmind/types'; import type { SpaceListItem } from './types'; -export function toSpaceListItem(space: Space): SpaceListItem { +export function toSpaceListItem(item: SpaceManagementListItem): SpaceListItem { return { - ...space, - colorToken: getColorTokenForSpace(space), - isOrgWide: space.isDefaultSpace, - admins: [], - membersCount: null, - artifactsCount: null, - createdAt: null, - }; + ...item, + admins: item.admins.map((admin) => ({ + id: admin.id as string, + displayName: admin.displayName, + })), + memberIds: item.memberIds as string[], + } as SpaceListItem; } diff --git a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/types.ts b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/types.ts index 6ba240f1c..b4793a8ba 100644 --- a/apps/frontend/src/domain/spaces/components/SpacesManagementPage/types.ts +++ b/apps/frontend/src/domain/spaces/components/SpacesManagementPage/types.ts @@ -1,26 +1,14 @@ import type { Space } from '@packmind/types'; -export type SpaceColorToken = - | 'red' - | 'orange' - | 'yellow' - | 'green' - | 'teal' - | 'blue' - | 'cyan' - | 'purple' - | 'pink'; - export type SpaceAdminAvatar = { id: string; displayName: string; }; export type SpaceListItem = Space & { - colorToken: SpaceColorToken; - isOrgWide: boolean; admins: SpaceAdminAvatar[]; - membersCount: number | null; - artifactsCount: number | null; - createdAt: string | null; + memberIds: string[]; + membersCount: number; + artifactsCount: number; + createdAt: string; }; diff --git a/apps/frontend/src/domain/standards/api/queries/StandardsQueries.ts b/apps/frontend/src/domain/standards/api/queries/StandardsQueries.ts index 27a609c79..2c1ff91cd 100644 --- a/apps/frontend/src/domain/standards/api/queries/StandardsQueries.ts +++ b/apps/frontend/src/domain/standards/api/queries/StandardsQueries.ts @@ -25,7 +25,7 @@ import { useAuthContext } from '../../../accounts/hooks/useAuthContext'; import { CHANGE_PROPOSALS_QUERY_SCOPE, GET_GROUPED_CHANGE_PROPOSALS_KEY, -} from '@packmind/proprietary/frontend/domain/change-proposals/api/queryKeys'; +} from '../../../change-proposals/api/queryKeys'; import { ORGANIZATION_QUERY_SCOPE } from '../../../organizations/api/queryKeys'; import { GET_STANDARD_RULES_DETECTION_STATUS_KEY } from '@packmind/proprietary/frontend/domain/detection/api/queryKeys'; diff --git a/apps/frontend/src/domain/standards/components/RuleSummarySeverity.tsx b/apps/frontend/src/domain/standards/components/RuleSummarySeverity.tsx new file mode 100644 index 000000000..fb4963487 --- /dev/null +++ b/apps/frontend/src/domain/standards/components/RuleSummarySeverity.tsx @@ -0,0 +1,102 @@ +import { useMemo } from 'react'; +import { PMText } from '@packmind/ui'; +import { + RuleLanguageDetectionStatus, + DetectionSeverity, +} from '@packmind/types'; +import type { ActiveDetectionProgramId } from '@packmind/types'; +import { getLanguageDisplayName } from '@packmind/proprietary/frontend/domain/detection/components/DetectionCardUtils'; +import { useGetStandardRulesDetectionStatusQuery } from '@packmind/proprietary/frontend/domain/detection/hooks/useStandardEditionFeatures'; +import { useUpdateActiveDetectionProgramSeverityMutation } from '@packmind/proprietary/frontend/domain/detection/api/queries/DetectionProgramQueries'; +import { SeverityDropdownBadge } from '@packmind/proprietary/frontend/domain/detection/components/SeverityDropdownBadge'; + +interface RuleSummarySeverityProps { + ruleId: string; + standardId: string; +} + +type LanguageSeverityData = { + language: string; + severity: DetectionSeverity; + activeDetectionProgramId: ActiveDetectionProgramId; +}; + +export const RuleSummarySeverity = ({ + ruleId, + standardId, +}: RuleSummarySeverityProps) => { + const { data: statusData, isLoading } = + useGetStandardRulesDetectionStatusQuery(standardId); + + const updateSeverity = useUpdateActiveDetectionProgramSeverityMutation(); + + const languagesWithSeverity = useMemo(() => { + const foundRuleStatus = statusData?.find((r) => r.ruleId === ruleId); + if (!foundRuleStatus) return []; + + return foundRuleStatus.languages + .filter( + ( + ls, + ): ls is typeof ls & { + severity: DetectionSeverity; + activeDetectionProgramId: ActiveDetectionProgramId; + } => + ls.status === RuleLanguageDetectionStatus.OK && + ls.severity !== undefined && + ls.activeDetectionProgramId !== undefined, + ) + .map( + (ls): LanguageSeverityData => ({ + language: ls.language, + severity: ls.severity, + activeDetectionProgramId: ls.activeDetectionProgramId, + }), + ) + .sort((a, b) => + getLanguageDisplayName(a.language).localeCompare( + getLanguageDisplayName(b.language), + ), + ); + }, [statusData, ruleId]); + + if (isLoading || languagesWithSeverity.length === 0) { + return null; + } + + return ( + <PMText + fontSize="sm" + display="inline-flex" + gap={2} + flexWrap="wrap" + alignItems="center" + > + {languagesWithSeverity.map((langData) => ( + <PMText + key={`${ruleId}-severity-${langData.language}`} + as="span" + display="inline-flex" + alignItems="center" + fontSize="sm" + gap={1} + > + {languagesWithSeverity.length > 1 && + getLanguageDisplayName(langData.language)} + <SeverityDropdownBadge + severity={langData.severity} + onSeverityChange={(newSeverity) => { + updateSeverity.mutate({ + standardId, + ruleId, + activeDetectionProgramId: langData.activeDetectionProgramId, + severity: newSeverity, + }); + }} + isDisabled={updateSeverity.isPending} + /> + </PMText> + ))} + </PMText> + ); +}; diff --git a/apps/frontend/src/domain/standards/components/RuleSummaryStatus.tsx b/apps/frontend/src/domain/standards/components/RuleSummaryStatus.tsx index 155702602..c5e249617 100644 --- a/apps/frontend/src/domain/standards/components/RuleSummaryStatus.tsx +++ b/apps/frontend/src/domain/standards/components/RuleSummaryStatus.tsx @@ -36,7 +36,10 @@ export const RuleSummaryStatus = ({ }); } - return { groupedStatuses: groups, ruleStatus: foundRuleStatus }; + return { + groupedStatuses: groups, + ruleStatus: foundRuleStatus, + }; }, [statusData, ruleId]); if (isLoading) { @@ -69,13 +72,13 @@ export const RuleSummaryStatus = ({ }, }; - const orderedStatuses: RuleLanguageDetectionStatus[] = [ - RuleLanguageDetectionStatus.OK, - RuleLanguageDetectionStatus.WIP, - RuleLanguageDetectionStatus.NONE, - ]; - - const statusNodes = orderedStatuses + const statusNodes = ( + [ + RuleLanguageDetectionStatus.OK, + RuleLanguageDetectionStatus.WIP, + RuleLanguageDetectionStatus.NONE, + ] as const + ) .filter((status) => groupedStatuses[status].length > 0) .map((status) => { const languagesForStatus = groupedStatuses[status] @@ -102,7 +105,7 @@ export const RuleSummaryStatus = ({ }); return ( - <PMText fontSize="sm" display="inline-flex" gap={2}> + <PMText fontSize="sm" display="inline-flex" gap={2} flexWrap="wrap"> {statusNodes} </PMText> ); diff --git a/apps/frontend/src/domain/standards/components/RuleSummaryTable.tsx b/apps/frontend/src/domain/standards/components/RuleSummaryTable.tsx index 79e5eb729..a7c4abec8 100644 --- a/apps/frontend/src/domain/standards/components/RuleSummaryTable.tsx +++ b/apps/frontend/src/domain/standards/components/RuleSummaryTable.tsx @@ -9,6 +9,7 @@ import { } from '@packmind/ui'; import type { Rule } from '@packmind/types'; import { RuleSummaryStatus } from './RuleSummaryStatus'; +import { RuleSummarySeverity } from './RuleSummarySeverity'; import { routes } from '../../../shared/utils/routes'; interface RuleSummaryTableProps { @@ -29,11 +30,11 @@ export const RuleSummaryTable = ({ orgSlug?: string; spaceSlug?: string; }>(); - const columns = useMemo<PMTableColumn[]>( () => [ { key: 'name', header: 'Name', grow: true }, - { key: 'linter', header: 'Linter status', grow: true }, + { key: 'linter', header: 'Linter status' }, + { key: 'severity', header: 'Severity' }, ], [], ); @@ -87,6 +88,9 @@ export const RuleSummaryTable = ({ </PMLink> ), linter: <RuleSummaryStatus ruleId={rule.id} standardId={standardId} />, + severity: ( + <RuleSummarySeverity ruleId={rule.id} standardId={standardId} /> + ), }; }); }, [rules, standardId, orgSlug, spaceSlug, navigate]); diff --git a/apps/frontend/src/domain/standards/components/StandardDetails.tsx b/apps/frontend/src/domain/standards/components/StandardDetails.tsx index 6d1d7d6f7..fd5624ecf 100644 --- a/apps/frontend/src/domain/standards/components/StandardDetails.tsx +++ b/apps/frontend/src/domain/standards/components/StandardDetails.tsx @@ -72,7 +72,7 @@ export const StandardDetails = ({ ); const pendingCount = changeProposals?.changeProposals?.filter( - (p: { status: string }) => p.status === ChangeProposalStatus.pending, + (p) => p.status === ChangeProposalStatus.pending, ).length ?? 0; const { ruleLanguages } = useStandardEditionFeatures(standard.id); diff --git a/apps/frontend/src/routes/__tests__/space-protected-loader.spec.ts b/apps/frontend/src/routes/__tests__/space-protected-loader.spec.ts index 1af9ac16d..95a8ab5d7 100644 --- a/apps/frontend/src/routes/__tests__/space-protected-loader.spec.ts +++ b/apps/frontend/src/routes/__tests__/space-protected-loader.spec.ts @@ -65,9 +65,6 @@ jest.mock('react-router', () => { const ensureQueryDataMock = queryClient.ensureQueryData as jest.MockedFunction< typeof queryClient.ensureQueryData >; -const fetchQueryMock = queryClient.fetchQuery as jest.MockedFunction< - typeof queryClient.fetchQuery ->; const prefetchQueryMock = queryClient.prefetchQuery as jest.MockedFunction< typeof queryClient.prefetchQuery >; @@ -101,7 +98,6 @@ async function runLoaderExpectingRedirect(spaceSlug: string) { describe('space-protected loader', () => { beforeEach(() => { ensureQueryDataMock.mockReset(); - fetchQueryMock.mockReset(); prefetchQueryMock.mockReset(); setFlashToastMock.mockReset(); redirectMock.mockClear(); @@ -113,9 +109,7 @@ describe('space-protected loader', () => { beforeEach(() => { ensureQueryDataMock .mockResolvedValueOnce(me) - .mockResolvedValueOnce(space) .mockResolvedValueOnce([space]); - prefetchQueryMock.mockResolvedValueOnce(undefined); }); it('returns the space', async () => { @@ -131,12 +125,7 @@ describe('space-protected loader', () => { }); }); - describe('when the user is not a member of the space', () => { - const targetSpace = { - id: 'space-2', - slug: 'other-space', - name: 'Other Space', - }; + describe('when the space slug does not match any user space', () => { const userSpace = { id: 'space-1', slug: 'my-space', @@ -146,7 +135,6 @@ describe('space-protected loader', () => { beforeEach(async () => { ensureQueryDataMock .mockResolvedValueOnce(me) - .mockResolvedValueOnce(targetSpace) .mockResolvedValueOnce([userSpace]); await runLoaderExpectingRedirect('other-space'); @@ -156,69 +144,53 @@ describe('space-protected loader', () => { expect(redirectMock).toHaveBeenCalledWith('/org/org-slug/space/my-space'); }); - it('sets a permission error flash toast', () => { + it('sets a space not found flash toast without leaking space details', () => { expect(setFlashToastMock).toHaveBeenCalledWith({ type: 'error', - title: 'Access denied', - description: expect.stringContaining( - 'do not have permission to access', - ), + title: 'Space not found', + description: expect.stringContaining('could not be found'), }); }); }); - describe('when the space does not exist', () => { - const userSpace = { - id: 'space-1', - slug: 'my-space', - name: 'My Space', - }; - + describe('when the user has no spaces', () => { beforeEach(async () => { - ensureQueryDataMock.mockResolvedValueOnce(me).mockResolvedValueOnce(null); - fetchQueryMock.mockResolvedValueOnce([userSpace]); + ensureQueryDataMock.mockResolvedValueOnce(me).mockResolvedValueOnce([]); - await runLoaderExpectingRedirect('nonexistent'); + await runLoaderExpectingRedirect('any-space'); }); - it('redirects to the first available user space', () => { - expect(redirectMock).toHaveBeenCalledWith('/org/org-slug/space/my-space'); + it('redirects to the org page', () => { + expect(redirectMock).toHaveBeenCalledWith('/org/org-slug'); }); - it('sets a space not found flash toast', () => { + it('sets a no spaces available flash toast', () => { expect(setFlashToastMock).toHaveBeenCalledWith({ type: 'error', - title: 'Space not found', - description: expect.stringContaining('does not exist'), + title: 'No spaces available', + description: expect.stringContaining('not a member of any space'), }); }); }); - describe('when the space fetch throws an error', () => { - const userSpace = { - id: 'space-1', - slug: 'my-space', - name: 'My Space', - }; - + describe('when the spaces list fetch throws an error', () => { beforeEach(async () => { ensureQueryDataMock .mockResolvedValueOnce(me) .mockRejectedValueOnce(new Error('Network error')); - fetchQueryMock.mockResolvedValueOnce([userSpace]); await runLoaderExpectingRedirect('broken-space'); }); - it('redirects to the first available user space', () => { - expect(redirectMock).toHaveBeenCalledWith('/org/org-slug/space/my-space'); + it('redirects to the org page', () => { + expect(redirectMock).toHaveBeenCalledWith('/org/org-slug'); }); - it('sets an error loading space flash toast', () => { + it('sets an error loading spaces flash toast', () => { expect(setFlashToastMock).toHaveBeenCalledWith({ type: 'error', - title: 'Error loading space', - description: expect.stringContaining('Redirecting'), + title: 'Error loading spaces', + description: expect.stringContaining('Please try again'), }); }); }); diff --git a/apps/frontend/src/shared/components/TypeToConfirmDialog.test.tsx b/apps/frontend/src/shared/components/TypeToConfirmDialog.test.tsx new file mode 100644 index 000000000..01a171d78 --- /dev/null +++ b/apps/frontend/src/shared/components/TypeToConfirmDialog.test.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { UIProvider } from '@packmind/ui'; +import { TypeToConfirmDialog } from './TypeToConfirmDialog'; + +const renderWithProviders = (ui: React.ReactElement) => + render(<UIProvider>{ui}</UIProvider>); + +const defaultProps = { + open: true, + onOpenChange: jest.fn(), + title: 'Delete space', + expectedValue: 'Test Space', + inputPlaceholder: 'Enter space name', + confirmLabel: 'Delete', + isPending: false, + onConfirm: jest.fn(), +}; + +describe('TypeToConfirmDialog', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when the dialog is open', () => { + it('renders the title', async () => { + renderWithProviders(<TypeToConfirmDialog {...defaultProps} />); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent('Delete space'); + }); + + it('renders the body children above the input', async () => { + renderWithProviders( + <TypeToConfirmDialog {...defaultProps}> + <p>Custom description content</p> + </TypeToConfirmDialog>, + ); + + expect( + await screen.findByText('Custom description content'), + ).toBeInTheDocument(); + }); + + it('renders the input with the provided placeholder', async () => { + renderWithProviders(<TypeToConfirmDialog {...defaultProps} />); + + expect( + await screen.findByPlaceholderText('Enter space name'), + ).toBeInTheDocument(); + }); + + it('renders the confirm button as disabled by default', async () => { + renderWithProviders(<TypeToConfirmDialog {...defaultProps} />); + + const confirmButton = await screen.findByRole('button', { + name: 'Delete', + }); + expect(confirmButton).toBeDisabled(); + }); + }); + + describe('when the user types a non-matching value', () => { + it('keeps the confirm button disabled', async () => { + renderWithProviders(<TypeToConfirmDialog {...defaultProps} />); + + const input = await screen.findByPlaceholderText('Enter space name'); + fireEvent.change(input, { target: { value: 'Wrong Name' } }); + + expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled(); + }); + }); + + describe('when the user types a value with different casing', () => { + it('keeps the confirm button disabled (case sensitive)', async () => { + renderWithProviders(<TypeToConfirmDialog {...defaultProps} />); + + const input = await screen.findByPlaceholderText('Enter space name'); + fireEvent.change(input, { target: { value: 'test space' } }); + + expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled(); + }); + }); + + describe('when the user types the matching value', () => { + it('enables the confirm button', async () => { + renderWithProviders(<TypeToConfirmDialog {...defaultProps} />); + + const input = await screen.findByPlaceholderText('Enter space name'); + fireEvent.change(input, { target: { value: 'Test Space' } }); + + expect(screen.getByRole('button', { name: 'Delete' })).not.toBeDisabled(); + }); + + describe('and clicks the confirm button', () => { + it('calls onConfirm', async () => { + const onConfirm = jest.fn(); + renderWithProviders( + <TypeToConfirmDialog {...defaultProps} onConfirm={onConfirm} />, + ); + + const input = await screen.findByPlaceholderText('Enter space name'); + fireEvent.change(input, { target: { value: 'Test Space' } }); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + }); + + describe('and submits with the Enter key', () => { + it('calls onConfirm', async () => { + const onConfirm = jest.fn(); + renderWithProviders( + <TypeToConfirmDialog {...defaultProps} onConfirm={onConfirm} />, + ); + + const input = await screen.findByPlaceholderText('Enter space name'); + fireEvent.change(input, { target: { value: 'Test Space' } }); + const form = input.closest('form'); + if (!form) { + throw new Error('Expected the input to be inside a form'); + } + fireEvent.submit(form); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when the user submits with a non-matching value via Enter', () => { + it('does not call onConfirm', async () => { + const onConfirm = jest.fn(); + renderWithProviders( + <TypeToConfirmDialog {...defaultProps} onConfirm={onConfirm} />, + ); + + const input = await screen.findByPlaceholderText('Enter space name'); + fireEvent.change(input, { target: { value: 'Wrong Name' } }); + const form = input.closest('form'); + if (!form) { + throw new Error('Expected the input to be inside a form'); + } + fireEvent.submit(form); + + expect(onConfirm).not.toHaveBeenCalled(); + }); + }); + + describe('when isPending is true', () => { + it('disables the cancel button', async () => { + renderWithProviders(<TypeToConfirmDialog {...defaultProps} isPending />); + + const cancelButton = await screen.findByRole('button', { + name: 'Cancel', + }); + expect(cancelButton).toBeDisabled(); + }); + + it('disables the confirm button even when the value matches', async () => { + renderWithProviders(<TypeToConfirmDialog {...defaultProps} isPending />); + + const input = await screen.findByPlaceholderText('Enter space name'); + fireEvent.change(input, { target: { value: 'Test Space' } }); + + expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled(); + }); + + it('blocks onOpenChange from being called', async () => { + const onOpenChange = jest.fn(); + renderWithProviders( + <TypeToConfirmDialog + {...defaultProps} + isPending + onOpenChange={onOpenChange} + />, + ); + + const cancelButton = await screen.findByRole('button', { + name: 'Cancel', + }); + await act(async () => { + fireEvent.click(cancelButton); + }); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/frontend/src/shared/components/TypeToConfirmDialog.tsx b/apps/frontend/src/shared/components/TypeToConfirmDialog.tsx new file mode 100644 index 000000000..3584d8b14 --- /dev/null +++ b/apps/frontend/src/shared/components/TypeToConfirmDialog.tsx @@ -0,0 +1,108 @@ +import { Dialog, Portal } from '@chakra-ui/react'; +import { useState } from 'react'; +import { PMButton, PMInput, PMText, PMVStack } from '@packmind/ui'; + +type TypeToConfirmDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + title: React.ReactNode; + children?: React.ReactNode; + expectedValue: string; + inputPlaceholder: string; + confirmLabel: string; + isPending: boolean; + onConfirm: () => void; +}; + +export function TypeToConfirmDialog({ + open, + onOpenChange, + title, + children, + expectedValue, + inputPlaceholder, + confirmLabel, + isPending, + onConfirm, +}: Readonly<TypeToConfirmDialogProps>) { + const [confirmationInput, setConfirmationInput] = useState(''); + + const isConfirmEnabled = confirmationInput === expectedValue && !isPending; + + const handleOpenChange = (details: { open: boolean }) => { + if (isPending) { + return; + } + onOpenChange(details.open); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!isConfirmEnabled) { + return; + } + onConfirm(); + }; + + return ( + <Dialog.Root + open={open} + onOpenChange={handleOpenChange} + placement="center" + onExitComplete={() => setConfirmationInput('')} + > + <Portal> + <Dialog.Backdrop /> + <Dialog.Positioner> + <Dialog.Content> + <form onSubmit={handleSubmit}> + <Dialog.Header> + <Dialog.Title>{title}</Dialog.Title> + </Dialog.Header> + + <Dialog.Body> + <PMVStack gap={4} align="stretch"> + {children} + <PMVStack gap={2} align="stretch"> + <PMText variant="body" fontSize="sm"> + Type <strong>{expectedValue}</strong> to confirm: + </PMText> + <PMInput + placeholder={inputPlaceholder} + value={confirmationInput} + onChange={(e) => setConfirmationInput(e.target.value)} + /> + </PMVStack> + </PMVStack> + </Dialog.Body> + + <Dialog.Footer> + <Dialog.ActionTrigger asChild> + <PMButton + type="button" + variant="tertiary" + disabled={isPending} + > + Cancel + </PMButton> + </Dialog.ActionTrigger> + <PMButton + type="submit" + colorScheme="red" + disabled={!isConfirmEnabled} + loading={isPending} + aria-label={confirmLabel} + ml={3} + > + {confirmLabel} + </PMButton> + </Dialog.Footer> + </form> + + <Dialog.CloseTrigger /> + </Dialog.Content> + </Dialog.Positioner> + </Portal> + </Dialog.Root> + ); +} diff --git a/apps/frontend/src/shared/components/editor/DiffMarkdownEditor.tsx b/apps/frontend/src/shared/components/editor/DiffMarkdownEditor.tsx index cea946038..9641a32a6 100644 --- a/apps/frontend/src/shared/components/editor/DiffMarkdownEditor.tsx +++ b/apps/frontend/src/shared/components/editor/DiffMarkdownEditor.tsx @@ -3,7 +3,7 @@ import { Milkdown, useEditor } from '@milkdown/react'; import '@packmind/assets/milkdown.theme'; import { PMBox } from '@packmind/ui'; import React, { useEffect, useRef, useMemo, useState } from 'react'; -import { buildUnifiedMarkdownDiff } from '@packmind/proprietary/frontend/domain/change-proposals/utils/buildUnifiedMarkdownDiff'; +import { buildUnifiedMarkdownDiff } from '../../../domain/change-proposals/utils/buildUnifiedMarkdownDiff'; interface IDiffMarkdownEditorProps { /** Original markdown content before changes */ diff --git a/apps/frontend/src/shared/utils/browserNavigation.ts b/apps/frontend/src/shared/utils/browserNavigation.ts new file mode 100644 index 000000000..4440a7425 --- /dev/null +++ b/apps/frontend/src/shared/utils/browserNavigation.ts @@ -0,0 +1,3 @@ +export const navigateTo = (url: string): void => { + window.location.assign(url); +}; diff --git a/apps/frontend/src/shared/utils/dateUtils.ts b/apps/frontend/src/shared/utils/dateUtils.ts index a6aa3b898..3649fcfbf 100644 --- a/apps/frontend/src/shared/utils/dateUtils.ts +++ b/apps/frontend/src/shared/utils/dateUtils.ts @@ -1,17 +1,14 @@ -/** - * Formats a date string into a human-readable format - * @param dateString - The date string to format - * @returns Formatted date string - */ -export const formatDate = (dateString: string): string => { - if (!dateString) return ''; +function ensureDate(date: Date | string) { + return date instanceof Date ? date : new Date(date); +} - const date = new Date(dateString); +export const formatDateTime = (date: Date | string): string => { + const realDate = ensureDate(date); // Check if the date is valid - if (isNaN(date.getTime())) return 'Invalid date'; + if (isNaN(realDate.getTime())) return 'Invalid date'; - return date.toLocaleDateString('en-US', { + return realDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', @@ -19,3 +16,33 @@ export const formatDate = (dateString: string): string => { minute: '2-digit', }); }; + +export const formatDate = (date: Date | string): string => { + const realDate = ensureDate(date); + + // Check if the date is valid + if (isNaN(realDate.getTime())) return 'Invalid date'; + + return realDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); +}; + +export const formatDuration = (updatedAt: Date): string => { + const now = new Date(); + const diffInMs = now.getTime() - updatedAt.getTime(); + const diffInMinutes = Math.floor(diffInMs / 60000); + const diffInHours = Math.floor(diffInMinutes / 60); + const diffInDays = Math.floor(diffInHours / 24); + + if (diffInDays > 0) { + return `${diffInDays} day${diffInDays > 1 ? 's' : ''} ago`; + } else if (diffInHours > 0) { + return `${diffInHours} hour${diffInHours > 1 ? 's' : ''} ago`; + } else if (diffInMinutes > 0) { + return `${diffInMinutes} minute${diffInMinutes > 1 ? 's' : ''} ago`; + } else { + return 'Just now'; + } +}; diff --git a/apps/frontend/src/shared/utils/routes.ts b/apps/frontend/src/shared/utils/routes.ts index 4916a85a9..ae2c0d844 100644 --- a/apps/frontend/src/shared/utils/routes.ts +++ b/apps/frontend/src/shared/utils/routes.ts @@ -15,8 +15,10 @@ export const routes = { toDashboard: (orgSlug: string) => `/org/${orgSlug}`, toDeployments: (orgSlug: string) => `/org/${orgSlug}/deployments`, toGovernance: (orgSlug: string) => `/org/${orgSlug}/governance`, + toMarketplaces: (orgSlug: string) => `/org/${orgSlug}/marketplaces`, toSettings: (orgSlug: string) => `/org/${orgSlug}/settings`, toSettingsUsers: (orgSlug: string) => `/org/${orgSlug}/settings/users`, + toSettingsSpaces: (orgSlug: string) => `/org/${orgSlug}/settings/spaces`, toSettingsGit: (orgSlug: string) => `/org/${orgSlug}/settings/git`, toSettingsTargets: (orgSlug: string) => `/org/${orgSlug}/settings/targets`, toSettingsDistribution: (orgSlug: string) => @@ -124,6 +126,8 @@ export const routes = { proposalId: string, ) => `/org/${orgSlug}/space/${spaceSlug}/review-changes/${artefactType}/new/${proposalId}`, + toJoinSpace: (orgSlug: string, spaceSlug: string) => + `/org/${orgSlug}/spaces/${spaceSlug}/join`, }, /** diff --git a/apps/mcp-server/src/PackmindApp.spec.ts b/apps/mcp-server/src/PackmindApp.spec.ts index b4fe37c05..4ae84b6a2 100644 --- a/apps/mcp-server/src/PackmindApp.spec.ts +++ b/apps/mcp-server/src/PackmindApp.spec.ts @@ -1,7 +1,8 @@ import { AccountsHexa } from '@packmind/accounts'; import { CodingAgentHexa } from '@packmind/coding-agent'; import { DeploymentsHexa } from '@packmind/deployments'; -import { AmplitudeHexa, LinterHexa } from '@packmind/editions'; +import { AmplitudeHexa } from '@packmind/amplitude'; +import { LinterHexa } from '@packmind/linter'; import { GitHexa } from '@packmind/git'; import { LlmHexa } from '@packmind/llm'; import { JobsService, PackmindEventEmitterService } from '@packmind/node-utils'; @@ -13,9 +14,7 @@ import { DataSource } from 'typeorm'; import { getPackmindAppDefinition, initializePackmindApp } from './PackmindApp'; describe('PackmindApp MCP Server', () => { - let dataSource: DataSource; - - beforeEach(() => { + const buildMockDataSource = (): DataSource => { const mockRepository = { find: jest.fn(), findOne: jest.fn(), @@ -26,12 +25,12 @@ describe('PackmindApp MCP Server', () => { delete: jest.fn(), }; - dataSource = { + return { manager: {}, isInitialized: true, getRepository: jest.fn().mockReturnValue(mockRepository), } as unknown as DataSource; - }); + }; describe('getPackmindAppDefinition', () => { it('returns all hexas in correct dependency order', () => { @@ -63,19 +62,30 @@ describe('PackmindApp MCP Server', () => { }); describe('initializePackmindApp', () => { - it('initializes HexaRegistry with all hexas', async () => { - const registry = await initializePackmindApp(dataSource); + let registry: Awaited<ReturnType<typeof initializePackmindApp>>; + + // Bootstrapping the app spins up every hexa's BullMQ queues and the Redis + // connections behind them, and the hexa destroy() hooks do not reclaim + // them. All assertions below are read-only, so boot ONCE for the whole + // describe — re-initializing per test accumulated connections until a + // later bootstrap exceeded the 30s hook timeout on slower machines. + beforeAll(async () => { + registry = await initializePackmindApp(buildMockDataSource()); + }); - expect(registry.initialized).toBe(true); + afterAll(() => { + registry.destroyAll(); }); - describe('when registering hexas', () => { - let registry: Awaited<ReturnType<typeof initializePackmindApp>>; + it('initializes HexaRegistry with all hexas', () => { + expect(registry.initialized).toBe(true); + }); - beforeEach(async () => { - registry = await initializePackmindApp(dataSource); - }); + it('registers JobsService', () => { + expect(registry.getService(JobsService)).toBeDefined(); + }); + describe('when registering hexas', () => { it('registers AccountsHexa', () => { expect(registry.get(AccountsHexa)).toBeDefined(); }); @@ -109,19 +119,7 @@ describe('PackmindApp MCP Server', () => { }); }); - it('registers JobsService', async () => { - const registry = await initializePackmindApp(dataSource); - - expect(registry.getService(JobsService)).toBeDefined(); - }); - describe('when accessing hexa adapters', () => { - let registry: Awaited<ReturnType<typeof initializePackmindApp>>; - - beforeEach(async () => { - registry = await initializePackmindApp(dataSource); - }); - it('provides access to AccountsHexa adapter', () => { const accountsHexa = registry.get(AccountsHexa); diff --git a/apps/mcp-server/src/PackmindApp.ts b/apps/mcp-server/src/PackmindApp.ts index 5c040bad8..45f5a2854 100644 --- a/apps/mcp-server/src/PackmindApp.ts +++ b/apps/mcp-server/src/PackmindApp.ts @@ -1,7 +1,9 @@ import { AccountsHexa } from '@packmind/accounts'; import { CodingAgentHexa } from '@packmind/coding-agent'; import { DeploymentsHexa } from '@packmind/deployments'; -import { AmplitudeHexa, LinterHexa, mcpHexaPlugins } from '@packmind/editions'; +import { AmplitudeHexa } from '@packmind/amplitude'; +import { LinterHexa } from '@packmind/linter'; +import { mcpHexaPlugins } from '@packmind/plugins'; import { GitHexa } from '@packmind/git'; import { LlmHexa } from '@packmind/llm'; import { LogLevel, PackmindLogger } from '@packmind/logger'; diff --git a/apps/mcp-server/src/app/mcp-server.ts b/apps/mcp-server/src/app/mcp-server.ts index 0621e3fc5..a7e22e774 100644 --- a/apps/mcp-server/src/app/mcp-server.ts +++ b/apps/mcp-server/src/app/mcp-server.ts @@ -1,5 +1,5 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { EventTrackingAdapter } from '@packmind/editions'; +import { EventTrackingAdapter } from '@packmind/amplitude'; import { LogLevel, PackmindLogger } from '@packmind/logger'; import { IEventTrackingPort } from '@packmind/types'; import { FastifyInstance } from 'fastify'; diff --git a/apps/mcp-server/src/app/routes/root.ts b/apps/mcp-server/src/app/routes/root.ts index 0380d02f9..777505e2e 100644 --- a/apps/mcp-server/src/app/routes/root.ts +++ b/apps/mcp-server/src/app/routes/root.ts @@ -9,7 +9,7 @@ import { createUserId, IEventTrackingPort, } from '@packmind/types'; -import { EventTrackingAdapter } from '@packmind/editions'; +import { EventTrackingAdapter } from '@packmind/amplitude'; interface UserContext { email: string; diff --git a/apps/mcp-server/src/app/tools/tools.integration.spec.ts b/apps/mcp-server/src/app/tools/tools.integration.spec.ts index fe51ccfdc..c38c5b8b1 100644 --- a/apps/mcp-server/src/app/tools/tools.integration.spec.ts +++ b/apps/mcp-server/src/app/tools/tools.integration.spec.ts @@ -26,7 +26,6 @@ describe('tools.integration', () => { deploymentsHexa: () => unknown; recipesHexa: () => unknown; standardsHexa: () => unknown; - analyticsHexa: () => unknown; spacesHexa: () => unknown; accountsHexa: () => unknown; }>; @@ -56,14 +55,12 @@ describe('tools.integration', () => { deploymentsHexa: jest.fn(), recipesHexa: jest.fn(), standardsHexa: jest.fn(), - analyticsHexa: jest.fn(), spacesHexa: jest.fn(), accountsHexa: jest.fn(), } as unknown as jest.Mocked<{ deploymentsHexa: () => unknown; recipesHexa: () => unknown; standardsHexa: () => unknown; - analyticsHexa: () => unknown; spacesHexa: () => unknown; accountsHexa: () => unknown; }>; diff --git a/apps/mcp-server/src/db.ts b/apps/mcp-server/src/db.ts index 8e01eaa18..06e8432bc 100644 --- a/apps/mcp-server/src/db.ts +++ b/apps/mcp-server/src/db.ts @@ -6,7 +6,7 @@ import dbConnection from 'typeorm-fastify-plugin'; import { accountsSchemas } from '@packmind/accounts'; import { deploymentsSchemas } from '@packmind/deployments'; import { gitSchemas } from '@packmind/git'; -import { linterSchemas } from '@packmind/editions'; +import { linterSchemas } from '@packmind/linter'; import { recipesSchemas } from '@packmind/recipes'; import { llmSchemas } from '@packmind/llm'; import { spacesSchemas } from '@packmind/spaces'; diff --git a/audit-skill-context-datadog-analysis.md b/audit-skill-context-datadog-analysis.md new file mode 100644 index 000000000..b90c5b520 --- /dev/null +++ b/audit-skill-context-datadog-analysis.md @@ -0,0 +1,74 @@ +# Skill Context Audit — datadog-analysis + +**Target**: `.claude/skills/datadog-analysis/` +**Date**: 2026-04-28 +**Files analyzed**: 2 +**Context efficiency**: 9/10 — one phase-gated template block dominates the eager footprint +**Always-loaded footprint**: SKILL.md ~2623 tokens (214 lines) · description ~157 tokens · eager refs ~0 tokens · **total ~2780 tokens / trigger** +**Eager / lazy references**: 0 / 1 +**Frequency tier**: periodic (×0.75) — description signals audit/review/report cadence; matches actual usage as a weekly-ish error triage +**Preamble lag**: 3 lines before first imperative +**Inferred script language**: node + +## Corpus inventory + +| File | Lines | ~Tokens | Role | +| ------------------------- | ----- | ------- | ---------------- | +| SKILL.md | 214 | 2623 | entrypoint | +| references/datadog_mcp.md | 159 | 1738 | reference (lazy) | + +## Summary + +**Workflow patterns** (agent-judgment): + +| Priority | P1 | P2 | P3 | P5 | P8 | P10 | P12 | Total | +| -------- | --- | --- | --- | --- | --- | --- | --- | ----- | +| HIGH | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| MEDIUM | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | +| LOW | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | + +**Loading patterns** (script-emitted, agent-validated): + +| Priority | P4 | P6 | P7 | P9 | P11 | P13 | P14 | P15 | Total | +| -------- | --- | --- | --- | --- | --- | --- | --- | --- | ----- | +| HIGH | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| MEDIUM | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | +| LOW | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 1 | + +Pattern legend: P1 deterministic ops · P2 complex workflow · P3 file edits · P4 large inline content · P5 read-then-grep chain · P6 verbose prose · P7 repeated boilerplate · P8 conditionals · P9 description bloat · P10 phase-gated content · P11 verbose tool prose · P12 meta prose · P13 cross-file duplication · P14 eager-load · P15 preamble lag. + +## Findings — MEDIUM + +### [MEDIUM] P10 — Phase-gated content kept inline + +**Location**: `SKILL.md:L98-L192` +**Excerpt**: "Write the report to `datadog_{YYYY_MM_DD}.md` at the project root, where the date is today's date." +**Issue**: ~90 lines of report template, ordering rubric, occurrence labels, and summary-table scaffold load on every trigger but are only consumed at the final output step. +**Fix**: Move the template + ordering + labels + summary block to `references/report-template.md`; replace Phase 4 in SKILL.md with a one-line "read `references/report-template.md` then write the report". +**Est. tokens saved**: ~1100 + +## Findings — LOW + +### [LOW] P9 — Bloated frontmatter description + +**Location**: `SKILL.md:frontmatter` +**Excerpt**: "This skill should be used when investigating production errors … Also triggers when the user mentions Datadog … Also triggers on references to specific Datadog service names…" +**Issue**: 86 words with 4 redundant trigger restatements ("This skill should be used when…", "Also triggers when…", "Also triggers on references…") — each session pays for the duplication. +**Fix**: Collapse to one sentence stating what the skill does plus a single trigger clause naming the user phrases (Datadog, prod errors, service names api/mcp/frontend-proprietary). +**Est. tokens saved**: ~80 + +### [LOW] P12 — Self-referential / meta prose + +**Location**: `SKILL.md:L8` +**Excerpt**: "Analyze production error logs from Packmind Datadog services, group them into patterns, cross-reference stack traces with the codebase, and produce a structured markdown report…" +**Issue**: Lead paragraph restates the description verbatim in different words; the description has already triggered the skill so this re-introduction adds no signal. +**Fix**: Delete line 8 and start directly with the "## Prerequisites" or "## Services" heading. +**Est. tokens saved**: ~55 + +## Top 3 wins + +Tackle in this order for maximum context reduction: + +1. **P10 at `SKILL.md:L98-L192`** — saves ~1100 tokens / trigger by moving the report template, ordering rubric, occurrence labels, and summary-table scaffold to `references/report-template.md`. +2. **P9 at `SKILL.md:frontmatter`** — tighten the 86-word description (4 redundant trigger phrases) to one sentence + one trigger clause; saves ~80 tokens per session. +3. **P12 at `SKILL.md:L8`** — drop the meta lead paragraph that restates the description; saves ~55 tokens per trigger. diff --git a/docker-compose.yml b/docker-compose.yml index 9cc7dd22d..7734427ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,7 +52,7 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: packmind ports: - - '5432:5432' + - '5433:5432' volumes: - dev-postgres-data:/var/lib/postgresql/data healthcheck: @@ -66,7 +66,7 @@ services: image: redis:7.2.4 container_name: ${COMPOSE_PROJECT_NAME:-dev}-${PACKMIND_EDITION:-oss}-redis ports: - - '6379:6379' + - '6380:6379' volumes: - dev-redis-data:/data healthcheck: @@ -86,7 +86,7 @@ services: PGADMIN_DEFAULT_PASSWORD: password PGADMIN_LISTEN_PORT: 80 ports: - - '2345:80' + - '2346:80' volumes: - dev-pgadmin:/var/lib/pgadmin depends_on: @@ -150,7 +150,7 @@ services: container_name: ${COMPOSE_PROJECT_NAME:-dev}-${PACKMIND_EDITION:-oss}-packmind-frontend working_dir: /packmind ports: - - '4200:4200' + - '4201:4200' entrypoint: > sh -lc ' set -eu @@ -333,6 +333,7 @@ services: environment: BASE_URL: http://frontend:4200 CI: 'true' + NODE_OPTIONS: '--max-old-space-size=16384' depends_on: frontend: condition: service_healthy diff --git a/em-plugin-report.md b/em-plugin-report.md new file mode 100644 index 000000000..83a780537 --- /dev/null +++ b/em-plugin-report.md @@ -0,0 +1,106 @@ +# QA Review Report + +**Spec**: em-plugin.md | **Date**: 2026-05-29 | **Branch**: main | **Commit**: 82a829f85 +**Rules**: 4 | **Examples**: 8 | **Tech Rules**: 2 | **Events**: 2 + +## Summary + +| Metric | Count | +| -------------------- | ------------------------------------------- | +| Covered | 11 | +| Partially Covered | 0 | +| Not Covered | 0 | +| Code Findings | 5 (Critical: 0, High: 1, Medium: 3, Low: 1) | +| Standards Violations | 0 | + +All spec rules and examples are functionally covered with strong test coverage (unit + e2e). The code review surfaced one High-severity destructive-delete risk and several edge/spec-deviation findings worth addressing. + +## Functional Coverage + +### Coverage Matrix + +| ID | Rule / Item | Layer | Status | Evidence | Test Coverage | +| ---------- | --------------------------------------------------------------------------------------------- | ------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R1-E1 | Render into marketplace root → files under `plugins/security`, entry added alongside existing | CLI + Backend | Covered | `renderPluginHandler.ts:107-129` (default `plugins/<name>/`, `upsertPluginEntry` `source:./plugins/<name>`, desc from response); `RenderPackageAsPluginUseCase.ts:96-126`; entry shape `{name,source,description}` `pluginsContext.ts:23-27`; siblings preserved `:55-68` | `renderPluginHandler.spec.ts:91-187` (exact entry equals); e2e `plugins.spec.ts:139-185`. Strong | +| R1-E2 | Update existing plugin → confirm; writes to EXISTING source path | CLI | Covered | `renderPluginHandler.ts:54-105`: prompt; no→exit0; yes→`existingPluginRoot` from `existing.source` (`./backend/plugins/security`→`backend/plugins/security/`), renders there | `renderPluginHandler.spec.ts:189-299` (prompt path, renders at existing path, decline=no-op); e2e `:218-285`. Strong | +| R1-E3 | Delete → folder deleted AND marketplace.json entry removed | CLI | Covered | `deletePluginHandler.ts:59-72`: `rmSync(join(cwd,entry.source))` + `removePluginEntry` + write | `deletePluginHandler.spec.ts:71-132` (folder gone, entry undefined, sibling kept); e2e `:187-216`. Strong | +| R1-E4 / T2 | Remote git source not rendered; user told to use remote workspace | CLI | Covered | `renderPluginHandler.ts:55-61` + `deletePluginHandler.ts:59-65`: `isRemoteSource` `pluginsContext.ts:44-46` → error + exit1, no mutation | `renderPluginHandler.spec.ts:301-342`; `deletePluginHandler.spec.ts:134-168`; e2e `:286-314`. Strong. Message wording differs slightly from spec | +| R2-E1 | Only plugin.json `{name:security}` → confirm; no→nothing, yes→render in folder | CLI + Backend | Covered | standalone detect `pluginsContext.ts:11-21`; `renderPluginHandler.ts:133-169`: match→confirm; no→exit0; yes→render `pluginRoot:'/'` | `renderPluginHandler.spec.ts:344-425`; e2e `:317-355`. Strong | +| R2-E2 | plugin.json name mismatch → exact error string | CLI | Covered | `renderPluginHandler.ts:139-143` `The plugin '${name}' is not handled in this repo.`; same `deletePluginHandler.ts:81-85` | `renderPluginHandler.spec.ts:432-438` (exact assert); `deletePluginHandler.spec.ts:281-288`; e2e `:357-378`. **Note: trailing period vs spec — see MED finding** | +| R3-E1 | Standards skipped; skills+commands rendered + message | Backend + CLI | Covered | `ClaudePluginDeployer.ts:125-147` (empty + `lastSkippedStandardsCount`); `RenderPackageAsPluginUseCase.ts:120-144`; CLI msg `renderPluginHandler.ts:176-185` | `RenderPackageAsPluginUseCase.spec.ts:280-353` (count=5); `renderPluginHandler.spec.ts:154-170`; e2e `:380-411`. Strong | +| R4-E1 | Render in marketplace tracked + distribution-history line | Backend | Covered (conditional) | `RenderPackageAsPluginUseCase.ts:157-291`: writes `Distribution` `renderModes:[CLAUDE_PLUGIN]`, `source:'cli'`, success. **Condition:** row written only when `gitRemoteUrl` present (`:175`); marketplace=git repo so holds | `RenderPackageAsPluginUseCase.spec.ts:401-512` + `:515-551` (no remote→no row); e2e `:446-464`, `:548-552`. Strong. **See MED finding** | +| T1 | Works with marketplace.json; out-of-marketplace (only plugin.json) defined | CLI | Covered | `detectPluginMode` `pluginsContext.ts:11-21` → marketplace/standalone/none; `none`→error+exit1 `renderPluginHandler.ts:35-41`, `deletePluginHandler.ts:35-41` | both specs cover all 3 modes (`renderPluginHandler.spec.ts:79-89`); e2e `:413-431`. Strong | +| T2 | Remote sources must NOT render locally | CLI | Covered | `isRemoteSource` guard both handlers (same as R1-E4) | same as R1-E4. Strong. **See MED finding on heuristic** | +| EV1 | PluginRenderedEvent/`plugin_rendered` + PluginDeletedEvent/`plugin_deleted` | Backend + Amplitude | Covered | emit `RenderPackageAsPluginUseCase.ts:188-199` (always; `marketplaceRepo` when remote), `TrackPluginDeletedUseCase.ts:62-71`; subscribed `AmplitudeEventListener.ts:136-137`, mapped `:535-551` | `RenderPackageAsPluginUseCase.spec.ts:478-540`; `AmplitudeEventListener.spec.ts:992-1097`. Strong | + +All rules and examples functionally covered. No reproduction-step gaps. + +## Code Review + +### Findings + +#### [HIGH] Marketplace delete does irreversible `rmSync` with no confirmation and can wipe unintended directories + +- **Category**: Bug / Edge Case +- **File**: `apps/cli/src/infra/commands/plugins/deletePluginHandler.ts:67` +- **Description**: Marketplace `delete` runs `rmSync(join(cwd, entry.source), { recursive: true, force: true })` immediately — no `confirmOverwrite` prompt, unlike standalone mode (`:87-94`) which always confirms. `entry.source` is taken verbatim from `marketplace.json`. A hand-edited/malformed entry like `source:"./"` is classified local (`"./".startsWith("./")` → not remote), and `join(cwd,"./")` resolves to `cwd`, so the whole working dir (incl. `.claude-plugin/`) is force-deleted. No guard that source resolves strictly inside `cwd`. +- **Spec Reference**: R1-E3; categories B (destructive ops), E (path handling) +- **Suggested Fix**: Add confirmation prompt for marketplace delete (parity with standalone); resolve `entry.source` to absolute and assert it is a strict subdir of `cwd` (reject `.`/`./`/`/` and `..` escapes) before `rmSync`. + +--- + +#### [MEDIUM] `isRemoteSource` misclassifies bare relative paths as remote + +- **Category**: Technical Rule Violation (T2) +- **File**: `apps/cli/src/infra/commands/plugins/pluginsContext.ts:44-46` +- **Description**: Returns `true` for any source not starting with `./` or `/`. Legitimate local entries using bare relative paths (`source:"plugins/security"`, `"backend/plugins/security"`) are treated as remote, so render and delete refuse with the "remote source" message and the user can never operate on a valid local plugin. The CLI writes `./`-prefixed sources, but the file is user/tool-editable and other tooling commonly omits `./`. +- **Spec Reference**: T2 (local path OR git remote; remote must not render locally) +- **Suggested Fix**: Treat as remote only when matching a remote URL shape (`^[a-z]+://`, `^git@`, scp-like `host:path`); default unknown/relative strings to local. + +--- + +#### [MEDIUM] R2-E2 error string deviates from spec's "exactly" wording (trailing period) + +- **Category**: Inconsistency / Missing Validation +- **File**: `apps/cli/src/infra/commands/plugins/renderPluginHandler.ts:140`, `deletePluginHandler.ts:82` +- **Description**: Spec requires exactly `The plugin 'security' is not handled in this repo`. Impl (and its tests) emit it with a trailing period. Tests assert the period variant, so they pass but lock in the deviation. Fails any acceptance test asserting exact spec match. +- **Spec Reference**: R2-E2 +- **Suggested Fix**: Confirm canonical string with spec owner; drop trailing period to match verbatim, or update spec. Keep render+delete consistent. + +--- + +#### [MEDIUM] Marketplace render outside a git repo writes no distribution row, partially contradicting R4 + +- **Category**: Missing Validation / Technical Rule +- **File**: `packages/deployments/src/application/useCases/renderPackageAsPlugin/RenderPackageAsPluginUseCase.ts:175-186` (gate), `trackRender:157-206` +- **Description**: `writeDistribution` is only invoked when `gitRemoteUrl` is truthy. A marketplace render in a non-git workspace (or repo with no remote) emits the `plugin_rendered` analytics event but creates NO distribution-history record. R4 states marketplace renders must be "tracked in Packmind" with a history line. Deliberate, commented choice (no git target ⇒ no `Target`), but it silently skips R4 tracking for the no-remote case. +- **Spec Reference**: R4 +- **Suggested Fix**: Confirm with spec owner whether R4 tracking is required without a git remote. If so, support a remote-less/synthetic local target so the history line still records; otherwise document the limitation in the spec. + +--- + +#### [LOW] Description-only re-render never clears a removed description; absolute-path sources drift from written files + +- **Category**: Edge Case +- **File**: `apps/cli/src/infra/commands/plugins/renderPluginHandler.ts:86-97, 72-74` +- **Description**: (1) Entry `description` only rewritten when `response.pluginDescription` is truthy AND differs; if package description was cleared (`undefined`), stale description persists in `marketplace.json`. (2) `existingPluginRoot = existing.source.replace(/^\.?\//,'')` strips one leading `/`, so absolute source `/plugins/security` → `plugins/security/` (written relative to `cwd`) while the entry still records `/plugins/security` — entry no longer points where files were written. Absolute local sources are unusual; limited impact. +- **Spec Reference**: R1-E2 +- **Suggested Fix**: Update description unconditionally to mirror rendered value (incl. clearing); reject absolute local sources or write at the declared absolute location. + +### Verified correct (no finding) + +- Hexa/NestJS wiring complete: `PluginsService` → `IDeploymentPort` (`@InjectDeploymentAdapter`) → `DeploymentsAdapter`; `PluginsController` guarded by `OrganizationAccessGuard` (`plugins.controller.ts:27`). +- Event payload consistency across event class / `emit()` / `AmplitudeEventListener` handlers; contract + event barrels exported (`contracts/index.ts:41-42`, `events/index.ts:4-5`); gateway↔contract↔controller shapes match. +- Standards: no `@packmind/editions` import, no `Object.setPrototypeOf`, PII masked (email/userId first-6-chars + `*`). + +## Deferred Items + +Not assessed (out of scope per spec): + +- Package versioning and plugin auto-update _(separate US)_ +- Handling hooks, MCP servers and agents in rendered plugin _(separate US)_ +- Rendering standards as part of the plugin _(explicitly excluded by R3)_ + +--- + +_Static analysis only. No code was executed during this review._ diff --git a/em-plugin.md b/em-plugin.md new file mode 100644 index 000000000..914d77805 --- /dev/null +++ b/em-plugin.md @@ -0,0 +1,92 @@ +# Render a Packmind package into a usable plugin + +## User story + +As a **developer (CLI user)**, +I want **to render a Packmind package into a usable Claude Code plugin**, +so that **I can distribute and consume playbook artifacts as a plugin, either inside a plugin marketplace or as a standalone plugin in a workspace**. + +## Business value + +Packmind packages today live inside Packmind. Rendering a package as a Claude Code plugin lets teams ship their playbook artifacts (commands, skills) through plugin marketplaces or directly into a workspace, so they become installable and consumable like any other Claude Code plugin. This closes the gap between authoring artifacts in Packmind and actually using them in coding assistants. + +## Acceptance criteria + +### Rule: Render a plugin in a marketplace + +**Scenario: Render a package into a marketplace root** + +- **Given** package "security" is in org Foo, and an "internal-marketplace" repository exists +- **When** Vincent runs `packmind-cli plugins render @global/security` in the root directory of "internal-marketplace" +- **Then** files from the security package are rendered under `plugins/security` +- **And** `marketplace.json` is updated with a plugin entry `{ "name": "security", "source": "./plugins/security", "description": "Security" }` alongside existing plugins + +**Scenario: Updating an existing plugin requires confirmation** + +- **Given** package "security" is in org Foo, and "internal-marketplace" already has a `marketplace.json` entry for "security" with source `./backend/plugins/security` +- **When** Vincent runs `packmind-cli plugins render @global/security` in the root directory +- **Then** Vincent is asked to confirm whether the existing plugin should be updated +- **And** if he answers no, nothing happens +- **And** if he answers yes, the plugin is updated in `./backend/plugins/security` + +**Scenario: Delete a rendered plugin** + +- **Given** package "security" has been rendered as a plugin in the marketplace +- **When** Vincent runs `packmind-cli plugins delete @global/security` in the root directory of "internal-marketplace" +- **Then** the folder `plugins/security` is deleted +- **And** there are no more references to the "security" plugin under the "plugins" section in `marketplace.json` + +**Scenario: A remote git source is not rendered locally** + +- **Given** "internal-marketplace" has a `marketplace.json` entry for "security" whose source is a git remote (e.g. `git@my-provider.com/security-repo.git`) +- **When** Vincent runs `packmind-cli plugins render @global/security` in the root directory +- **Then** the plugin is not rendered +- **And** Vincent is informed he should run the command on a workspace of the remote plugin + +### Rule: Render a plugin out of a marketplace + +**Scenario: plugin.json name matches the package** + +- **Given** a "backend-plugins" repository with no `.claude-plugin/marketplace.json` and a `.claude-plugin/plugin.json` containing `{ "name": "security" }` +- **When** Vincent runs `packmind-cli plugins render @global/security` in the root directory of the workspace +- **Then** Vincent is asked to confirm whether the existing plugin should be updated +- **And** if he answers no, nothing happens +- **And** if he answers yes, the plugin is updated in the folder + +**Scenario: plugin.json name does not match the package** + +- **Given** a "backend-plugins" repository with no `.claude-plugin/marketplace.json` and a `.claude-plugin/plugin.json` containing `{ "name": "nodejs" }` +- **When** Vincent runs `packmind-cli plugins render @global/security` in the root directory of the workspace +- **Then** Vincent gets the error message "The plugin 'security' is not handled in this repo" + +### Rule: Playbook artifacts are rendered, except standards + +**Scenario: Standards are skipped when rendering a package** + +- **Given** package "security" contains 2 commands, 5 standards and 2 skills +- **When** Steven renders the package as a plugin +- **Then** skills and commands are rendered +- **And** a message is displayed saying standards are not rendered + +### Rule: Plugins rendered are tracked + +**Scenario: Rendering a plugin into a marketplace is tracked in Packmind** + +- **Given** package "security" is in org Foo, and an "internal-marketplace" repository exists +- **When** Vincent runs `packmind-cli plugins render @global/security` in the root directory of "internal-marketplace" +- **Then** the installation of package `@global/security` is tracked in Packmind +- **And** a new line appears in the distribution history indicating it is distributed in a marketplace + +## Out of scope + +- Package versioning and plugin auto-update _(inferred — confirm; tracked as a separate user story on the frame)_ +- Handling hooks, MCP servers and agents in the rendered plugin _(inferred — confirm; tracked as a separate user story on the frame)_ +- Rendering standards as part of the plugin _(inferred — confirm; explicitly excluded by the "except standards" rule)_ + +## Technical hints + +- **Likely affected areas**: CLI `plugins render` / `plugins delete` commands; package rendering pipeline; `marketplace.json` and `.claude-plugin/plugin.json` read/write logic; distribution-history tracking. +- **Considerations**: + - CLI commands only work when `.claude-plugin/marketplace.json` exists in the working directory (per the workshop note) — define behavior for the "out of a marketplace" case where only `plugin.json` is present. + - Marketplace entries can point to a local path or a git remote; remote sources must not be rendered locally. + - Reference: Claude plugin sources — https://code.claude.com/docs/en/plugin-marketplaces#plugin-sources diff --git a/em-to-github-issue/SKILL.md b/em-to-github-issue/SKILL.md new file mode 100644 index 000000000..38600615c --- /dev/null +++ b/em-to-github-issue/SKILL.md @@ -0,0 +1,237 @@ +--- +name: 'em-to-github-issue' +description: 'Turn a user story from an Example Mapping Miro frame into a ready-to-develop GitHub issue. Reads the yellow user story, blue rules, green examples (transformed into Gherkin acceptance criteria) and red questions from the frame, prefers an existing curated EM spec when one is available locally, warns on unresolved questions, builds an English issue body with Definition-of-Done reminders (tests, lint, changelog, end-user docs, feature flag wiring when applicable, Amplitude events to track) tuned to the project''s trunk-based workflow, shows a preview, then publishes via `gh issue create` after explicit human confirmation. Use whenever a Miro EM frame URL is paired with intent to ship the story to developers — phrasing like "create the GitHub issue", "open the ticket", "publish this user story", "push to GitHub", "make the issue from this EM", or French equivalents ("crée l''issue", "publie la US", "fais le ticket", "envoie sur GitHub") all trigger it. Also trigger when the user pipes the output of `curate-em-workshop` toward GitHub or asks for "the dev-ready ticket" from a workshop.' +--- + +# EM to GitHub Issue + +You turn the output of an Example Mapping workshop into a GitHub issue developers can pick up and ship. The Miro frame holds the team's thinking; the issue is the contract that crosses into the dev world. Your job is to lift the workshop into a clean, complete, action-oriented ticket — and to refuse to publish anything half-baked. + +The issue body is always in **English**, even when the workshop and the conversation are in another language. Developers across the repo work in English in code and tickets; the workshop language stays on Miro. + +## When this skill is in play + +The user shares (or has already shared) a Miro frame URL pointing at an Example Mapping board, and signals intent to move the story toward implementation. Typical phrasings: + +- "ok this US is ready, create the GitHub issue" +- "publish this user story to GitHub" +- "make the dev ticket from this frame" +- "crée l'issue GitHub pour cette US" / "publie cette user story sur GitHub" / "fais le ticket" +- "ship this workshop to dev" + +If the user gave only a Miro board URL (no specific frame), call `mcp__miro__context_explore` first to list frames and ask which one to publish. Never guess. + +If the user is mid-conversation with `curate-em-workshop` and the curation is approved, the natural next move is this skill. You can offer it proactively: "Want me to publish this as a GitHub issue?" + +## Workflow + +Five phases. Phase 4 (preview + confirmation) is a **hard human checkpoint** — do not call `gh issue create` until the user has explicitly approved the rendered body. + +### Phase 1 — Locate the source material + +The richest input is a **curated EM spec** produced by `curate-em-workshop`. The skill prefers it when available because it carries cleaned-up wording, named examples, scored criteria, and a clear separation between resolved and open questions. But you can only know whether a spec exists once you know the story slug — and that requires reading the frame first. So the order is: read Miro → derive slug → look for spec → decide. + +1. Parse the Miro URL — extract the board ID and (if present) the `moveToWidget=` frame ID. +2. Read the Miro frame via `mcp__miro__board_list_items` (item_type `sticky_note`). Capture for each sticky: content, fill colour, position (x, y, width, height), and id. This is the single source-of-truth read — don't re-fetch later in this phase. +3. Identify the yellow user story (or `light_yellow` — Miro returns either depending on the team's sticky styling). Slugify its goal portion into kebab-case (e.g. "Apply loyalty discount at checkout" → `apply-loyalty-discount-at-checkout`). +4. Look under `tmp/em-curations/` at the project root for a file matching `{slug}-{YYYY-MM-DD}.md` (or a fuzzy slug match — workshops sometimes get titled slightly differently). If multiple dates exist, take the most recent. +5. **If a curated spec is found**, read it and use it as the primary source — the Miro read from step 2 becomes a sanity check (e.g. confirming sticky count hasn't changed since curation). Tell the user one line: "Using curated spec from `tmp/em-curations/{filename}` — this is richer than the raw frame." +6. **If no curated spec is found**, use the Miro data you already have. Tell the user: "No curated spec found for this frame. Working from raw stickies — issue quality will depend on the workshop's quality. Consider running `curate-em-workshop` first if the workshop wasn't curated." + +When working from raw Miro data: + +- Cluster green examples under the nearest blue rule using spatial proximity (same approach as `curate-em-workshop`). +- Detect **adjacent answer stickies** (see below) before classifying red stickies as open questions. +- Translate non-English sticky content into clean English when constructing the issue body — but flag in the preview that translation happened, so the user can correct any term that has a canonical form in code. + +#### Detecting adjacent answer stickies + +Teams routinely place a non-EM sticky (often violet, but any colour outside yellow/blue/green/red counts) next to a red question to record a proposed answer or decision the workshop reached. The standard EM palette doesn't model this — but missing it means treating already-discussed questions as if they were still open, which puts noise in the issue and erodes trust in the curation. + +Detection rule, applied to each red sticky: + +- Look for any sticky whose fill colour is **not** yellow / `light_yellow` / blue / `dark_blue` / green / `dark_green` / red. +- Pair it to the red sticky if it sits within roughly one sticky width (use the red sticky's `width` as the threshold) on the same row or directly adjacent on either side. +- When a pair is detected, treat the red sticky as a **proposed-answer question**: the workshop discussed it and parked a candidate disposition, but it hasn't been formally validated. + +Render proposed-answer questions in the "Open questions to clarify" section with the candidate inline and flagged for confirmation: `*(proposed: {answer text} — confirm before starting)*`. Reds that have no adjacent answer sticky stay fully open. + +In Phase 2, count proposed-answer questions as open for the purpose of the warning — they still warrant the user's decision — but mention in the prompt that {M} of {N} have proposed answers, so the user knows what they're choosing between. + +### Phase 2 — Check for unresolved questions + +Red stickies are the workshop's open questions. An issue is a "ready to develop" artefact; shipping it with unresolved questions means a developer will spin out as soon as they pick it up. + +Detect open questions: + +- **Curated spec source**: any item under `## 5. Questions (red)` → `### Still open` counts as open. Items under `### Resolved by curated examples` are fine — they're answered. +- **Raw Miro source**: every red sticky counts toward the "open" set, but separate them into two buckets using the adjacency detection from Phase 1: + - **Fully open** — no adjacent answer sticky; the team hasn't discussed a candidate. + - **Proposed answer** — adjacent non-EM sticky detected; the team parked a candidate but hasn't formally validated it. + +If any open questions exist (in either bucket), **stop and ask the user** which path to take: + +1. **Curate first** — back out and run `curate-em-workshop` (or a manual resolution session) on the frame. Recommended when the curation skill hasn't been run yet and many questions are fully open. +2. **Include as "Open questions to clarify"** — publish anyway, but surface the items as a dedicated section in the issue (with proposed answers inline when present) so the developer triggers a clarification round before starting. Use when the team consciously decided these questions are for the implementation phase. +3. **Promote proposed answers into the body, drop the rest** — for each proposed-answer question, treat the candidate as a decision and fold it into Technical hints or the relevant rule; ignore fully-open questions. Use when the team validated the candidates offline and you want a clean issue. +4. **Drop everything and publish clean** — the user takes full responsibility for the gap. + +Wait for the user's pick. Default messaging (adapt counts and examples to the actual frame): + +> {N} question(s) detected on the frame — {M} have a proposed answer, {N-M} are fully open: +> +> - "How do we do the linking?" _(proposed: Github App)_ +> - "Refresh Git → background job?" _(proposed: Yes)_ +> - "What happens to in-flight carts on price change?" _(fully open)_ +> +> An issue should normally be free of open questions. How do you want to handle these? +> +> 1. Curate the frame first (recommended when many are fully open) +> 2. Include them in the issue as "Open questions to clarify" (with proposed answers inline) +> 3. Promote proposed answers into the body, drop the fully-open ones +> 4. Skip everything and publish clean + +If no open questions exist, say one line ("No unresolved questions on this frame — good to go.") and continue. + +### Phase 3 — Detect the target repository + +The skill assumes the user is in the right working directory. Run `gh repo view --json nameWithOwner,defaultBranchRef -q .nameWithOwner` to confirm the target repo. If `gh` reports no repository (e.g. the cwd isn't a GitHub repo), tell the user and ask where the issue should go — never invent a repo. + +Then ask three optional questions in **one** turn (use `AskUserQuestion` if available — multiSelect, low pressure): + +- **Labels**: any labels to apply? List existing labels via `gh label list --limit 50` to give the user choices. +- **Assignees**: any assignee? Default: none. +- **Milestone**: milestone to attach? Default: none. + +Keep all three optional — the issue is valid without them, and the user can always add them post-publication. + +### Phase 4 — Build the body, preview, and wait for approval + +Construct the issue body following the template below. Save it to `tmp/github-issues/{slug}-{YYYY-MM-DD}.md` (create the directory if missing) so the user has a local copy regardless of whether publication succeeds. + +Then show the **full rendered body** in the conversation along with the proposed title, target repo, and metadata. Use this exact closing line (translate to the workshop's language if it isn't English): + +> Here is the issue I will open against `{owner/repo}` — title, body, labels, assignees, milestone. Read it through and tell me what to adjust. I won't call `gh issue create` until you've approved. + +Wait for explicit approval ("ok", "publish", "go", "ship it", "publie", "envoie"). If the user asks for changes, iterate on the body and present it again. Repeat until approved. + +### Phase 5 — Publish via `gh` + +Once approved, call: + +```bash +gh issue create \ + --repo {owner/repo} \ + --title "{title}" \ + --body-file tmp/github-issues/{slug}-{YYYY-MM-DD}.md \ + --label "{label1}" --label "{label2}" \ + --assignee "{user}" \ + --milestone "{milestone}" +``` + +Use `--body-file` rather than `--body "..."` — it preserves multi-line formatting and avoids quoting hell. Pass `--label`, `--assignee`, `--milestone` only when set; omit the flag entirely otherwise (passing an empty string makes `gh` reject the call). + +Report the resulting issue URL to the user. If the call fails (auth, missing label, etc.), surface the exact error and propose a fix — never silently retry with mutated inputs. + +## Issue body template + +The body is in English. Strip sticky ids from the rendered output — they belong in the Miro frame and (if applicable) the curated spec, not in the developer-facing ticket. Keep links so a developer can jump back to the workshop. + +```markdown +## User story + +As a **{persona}**, +I want **{capability}**, +so that **{outcome}**. + +## Business value + +{2–4 lines: why this matters now, what problem it solves, what changes for the user once shipped. Pull from the curated spec's "Agreed understanding" if available; otherwise infer from the yellow sticky and the rules.} + +## Acceptance criteria + +{One Gherkin scenario per green example, grouped under their blue rule. Use the example's title as the scenario name. Derive Given/When/Then from the example's content. Stay close to the team's wording — translate to English but don't paraphrase aggressively. If an example is too vague to Gherkinize, mark the scenario "(needs sharpening)" rather than inventing precision.} + +### Rule: {curated rule wording} + +**Scenario: {example title}** + +- **Given** {precondition} +- **When** {action} +- **Then** {expected outcome} + +**Scenario: {next example title}** + +- … + +### Rule: {next curated rule wording} + +… + +## Out of scope + +{Bullet list of what is explicitly NOT in this ticket. Lift any "Out of scope" notes from the curated spec. If none, infer 2–3 anti-scope bullets from adjacent rules or product context, and mark them "(inferred — confirm)".} + +## Technical hints + +{Best-effort. Optional if there is nothing useful to say.} + +- **Likely affected areas**: {packages, endpoints, components — from light grep over key nouns in the story} +- **Considerations**: {perf, security, edge cases the workshop touched on} + +## Links + +- Example Mapping (Miro): {miro_frame_url} +- Curated spec: `tmp/em-curations/{filename}` _(only when a curated spec was used)_ + +## Open questions to clarify + +{Only present if Phase 2 path 2 was chosen. List each red sticky as a short question in plain English. For questions with a proposed answer detected on the frame, append the candidate inline so the developer knows what was discussed.} + +- {fully-open question} +- {proposed-answer question} _(proposed: {answer} — confirm before starting)_ + +## Definition of Done + +- [ ] Implementation matches the acceptance criteria above +- [ ] Unit tests written and passing +- [ ] Integration tests written and passing (where relevant) +- [ ] `nx lint` passes on edited projects +- [ ] `nx typecheck` / build passes on edited projects +- [ ] CHANGELOG updated under the Unreleased section +- [ ] End-user documentation updated under `apps/doc/` (only if user-facing) +- [ ] Feature flag wired in (only if the change is gated — name the flag, audience, and rollback plan) +- [ ] Amplitude events tracked (list each event name and the properties to capture; skip only if the change has no user-observable interaction) +``` + +The project uses **trunk-based development** — there is no PR-linking reminder. Work lands on `main` via small, self-contained commits referencing this issue. + +### How to derive each section + +**Title**: short, imperative, ≤70 chars. Take the _goal_ part of the user story ("I want X") and rewrite it as an action — e.g. story "As a buyer, I want to apply a loyalty discount at checkout, so that…" becomes title "Apply loyalty discount at checkout". Avoid trailing periods. Don't prefix with `[FEATURE]` or similar tags unless the repo's other issues do. + +**User story block**: take the yellow sticky. If the workshop ran in French ("En tant que / Je veux / Pour"), translate to "As a / I want / so that". Preserve the persona's domain term (e.g. "tech lead", "Packmind admin") — don't generalise to "user". + +**Acceptance criteria**: one Gherkin scenario per green example. Group scenarios under their rule using a `### Rule: …` heading. Don't omit a rule that has no green examples — render it with a placeholder scenario noting "(no concrete example — needs one before merge)". Better to expose the gap than to silently drop the rule. + +**Open questions**: only render the section when Phase 2 path 2 was chosen. Don't include it as an empty placeholder otherwise. + +**Definition of Done**: keep the checklist exactly as in the template — those reminders are the whole point of the skill for the team. If the user has additional repo-specific reminders they want by default, add them to the skill rather than baking them per-issue. + +## Failure modes to watch for + +- **Publishing with open questions**: never call `gh issue create` if the user didn't pick a path in Phase 2. The whole point of the warning is to keep half-baked stories out of the dev queue. +- **Inventing acceptance criteria the workshop didn't cover**: if a rule has no examples, expose the gap in the issue ("needs one before merge") rather than fabricating a scenario. Inventing scenarios = inventing requirements. +- **Skipping the preview**: Phase 4 is non-negotiable. Even when the user says "just publish it" upfront, render the body once and wait for approval. The cost of a wrong issue (developer confusion, public artefact, edit history) far exceeds the cost of one extra turn. +- **Posting non-English content**: even when the workshop is in French/Spanish/etc., the issue body is in English. Translate sticky content; flag any term you weren't sure how to translate so the user can correct it. +- **Hard-coding the repo**: the target repo comes from `gh repo view` in the cwd. Don't assume `packmind/packmind` or any specific repo — the skill should work for any project the user runs it in. +- **Quoting sticky ids in the issue**: `s001`, `s010` etc. belong in the curated Markdown for traceability with Miro. They have no place in the developer-facing ticket — they make the body noisier and have no meaning to the developer. Strip them. + +## Iterating on an existing issue + +If the user comes back saying "the issue I opened needs an update" with the same Miro frame: + +- Look up the existing issue: ask the user for the number, or search by title via `gh issue list --search "{title}"`. +- Re-run Phases 1–3 to rebuild the body from the (possibly updated) frame or curated spec. +- Show the diff between the existing issue's body and the new body in the preview. +- On approval, call `gh issue edit {number} --body-file …` rather than creating a new issue. diff --git a/lorem-ipsum-test.playbook.json b/lorem-ipsum-test.playbook.json new file mode 100644 index 000000000..aa8397755 --- /dev/null +++ b/lorem-ipsum-test.playbook.json @@ -0,0 +1,31 @@ +{ + "name": "Lorem Ipsum Test Standard2", + "description": "A placeholder standard created for testing purposes. Verifies the standard creation and distribution workflow with dummy lorem ipsum rules.", + "scope": "TypeScript files (*.ts)", + "rules": [ + { + "content": "Use `lorem` declarations instead of `ipsum` for variable naming consistency", + "examples": { + "positive": "const lorem = getData();", + "negative": "const ipsum = getData();", + "language": "TYPESCRIPT" + } + }, + { + "content": "Prefer `dolor sit amet` pattern for function return types", + "examples": { + "positive": "function getConsectetur(): DolorSitAmet {\n return { dolor: true, sit: 'amet' };\n}", + "negative": "function getConsectetur() {\n return { dolor: true, sit: 'amet' };\n}", + "language": "TYPESCRIPT" + } + }, + { + "content": "Place `adipiscing` helpers in dedicated utility files", + "examples": { + "positive": "// utils/adipiscing.ts\nexport function adipiscingHelper() { /* ... */ }", + "negative": "// components/MyComponent.ts\nfunction adipiscingHelper() { /* ... */ }", + "language": "TYPESCRIPT" + } + } + ] +} diff --git a/oss-list-user-spaces-role.patch b/oss-list-user-spaces-role.patch new file mode 100644 index 000000000..17fd8b5b7 --- /dev/null +++ b/oss-list-user-spaces-role.patch @@ -0,0 +1,78 @@ +diff --git a/packages/spaces/src/application/usecases/ListUserSpacesUseCase.spec.ts b/packages/spaces/src/application/usecases/ListUserSpacesUseCase.spec.ts +index 3bd13a849..e1e303d1e 100644 +--- a/packages/spaces/src/application/usecases/ListUserSpacesUseCase.spec.ts ++++ b/packages/spaces/src/application/usecases/ListUserSpacesUseCase.spec.ts +@@ -59,10 +59,15 @@ describe('ListUserSpacesUseCase', () => { + ); + }); + +- it('returns spaces from memberships', async () => { ++ it('returns spaces with roles from memberships', async () => { + const result = await useCase.execute(buildCommand()); + +- expect(result).toEqual({ spaces: [space1, space2] }); ++ expect(result).toEqual({ ++ spaces: [ ++ { ...space1, role: UserSpaceRole.MEMBER }, ++ { ...space2, role: UserSpaceRole.ADMIN }, ++ ], ++ }); + }); + + it('calls membership service with correct params', async () => { +@@ -114,7 +119,9 @@ describe('ListUserSpacesUseCase', () => { + it('filters out memberships with undefined space', async () => { + const result = await useCase.execute(buildCommand()); + +- expect(result).toEqual({ spaces: [space1] }); ++ expect(result).toEqual({ ++ spaces: [{ ...space1, role: UserSpaceRole.MEMBER }], ++ }); + }); + }); + }); +diff --git a/packages/spaces/src/application/usecases/ListUserSpacesUseCase.ts b/packages/spaces/src/application/usecases/ListUserSpacesUseCase.ts +index 7384aff76..910438b6d 100644 +--- a/packages/spaces/src/application/usecases/ListUserSpacesUseCase.ts ++++ b/packages/spaces/src/application/usecases/ListUserSpacesUseCase.ts +@@ -4,10 +4,13 @@ import { + ListUserSpacesResponse, + OrganizationId, + Space, ++ UserSpaceMembership, + UserId, + } from '@packmind/types'; + import { UserSpaceMembershipService } from '../services/UserSpaceMembershipService'; + ++type MembershipWithSpace = UserSpaceMembership & { space: Space }; ++ + export class ListUserSpacesUseCase implements IListUserSpaces { + constructor( + private readonly userSpaceMembershipService: UserSpaceMembershipService, +@@ -23,8 +26,8 @@ export class ListUserSpacesUseCase implements IListUserSpaces { + ); + + const spaces = memberships +- .map((m) => m.space) +- .filter((s): s is Space => s !== undefined); ++ .filter((m): m is MembershipWithSpace => m.space !== undefined) ++ .map((m) => ({ ...m.space, role: m.role })); + + return { spaces }; + } +diff --git a/packages/types/src/spaces/contracts/IListUserSpaces.ts b/packages/types/src/spaces/contracts/IListUserSpaces.ts +index c2c88d0bf..a8c2cdfef 100644 +--- a/packages/types/src/spaces/contracts/IListUserSpaces.ts ++++ b/packages/types/src/spaces/contracts/IListUserSpaces.ts +@@ -1,8 +1,10 @@ + import { IUseCase, PackmindCommand } from '../../UseCase'; + import { Space } from '../Space'; ++import { UserSpaceRole } from '../UserSpaceMembership'; + + export type ListUserSpacesCommand = PackmindCommand; +-export type ListUserSpacesResponse = { spaces: Space[] }; ++export type UserSpaceWithRole = Space & { role: UserSpaceRole }; ++export type ListUserSpacesResponse = { spaces: UserSpaceWithRole[] }; + + export type IListUserSpaces = IUseCase< + ListUserSpacesCommand, diff --git a/oss-space-domain-events.patch b/oss-space-domain-events.patch new file mode 100644 index 000000000..9172d0b2c --- /dev/null +++ b/oss-space-domain-events.patch @@ -0,0 +1,1514 @@ +From 3c807b46e65f9ba80038283a701d0b8a017cf762 Mon Sep 17 00:00:00 2001 +From: Quentin Le Bourles <quentin.lebourles@promyze.com> +Date: Wed, 8 Apr 2026 16:32:30 +0200 +Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20feat(spaces):=20add=20visibilit?= + =?UTF-8?q?y=20field=20to=20SpaceCreatedEvent?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> +--- + .../amplitude/src/application/AmplitudeEventListener.spec.ts | 3 +++ + packages/amplitude/src/application/AmplitudeEventListener.ts | 1 + + .../spaces/src/application/usecases/CreateSpaceUseCase.spec.ts | 3 ++- + packages/spaces/src/application/usecases/CreateSpaceUseCase.ts | 1 + + packages/types/src/spaces/events/SpaceCreatedEvent.ts | 2 ++ + 5 files changed, 9 insertions(+), 1 deletion(-) + +diff --git a/packages/amplitude/src/application/AmplitudeEventListener.spec.ts b/packages/amplitude/src/application/AmplitudeEventListener.spec.ts +index 5aa009a05..66aa76458 100644 +--- a/packages/amplitude/src/application/AmplitudeEventListener.spec.ts ++++ b/packages/amplitude/src/application/AmplitudeEventListener.spec.ts +@@ -18,6 +18,7 @@ import { + ChangeProposalAcceptedEvent, + ChangeProposalRejectedEvent, + SpaceCreatedEvent, ++ SpaceType, + PlaybookArtefactMovedEvent, + createUserId, + createOrganizationId, +@@ -529,6 +530,7 @@ describe('AmplitudeEventListener', () => { + organizationId: createOrganizationId('org-456'), + spaceName: 'My Space', + spaceSlug: 'my-space', ++ visibility: SpaceType.open, + source: 'ui', + }); + +@@ -543,6 +545,7 @@ describe('AmplitudeEventListener', () => { + { + spaceName: 'My Space', + spaceSlug: 'my-space', ++ visibility: 'open', + source: 'ui', + }, + ); +diff --git a/packages/amplitude/src/application/AmplitudeEventListener.ts b/packages/amplitude/src/application/AmplitudeEventListener.ts +index cebc612a3..2371dcac7 100644 +--- a/packages/amplitude/src/application/AmplitudeEventListener.ts ++++ b/packages/amplitude/src/application/AmplitudeEventListener.ts +@@ -353,6 +353,7 @@ export class AmplitudeEventListener extends PackmindListener<EventTrackingAdapte + return this.emitAmplitudeEvent(event, 'space_created', (payload) => ({ + spaceName: payload.spaceName, + spaceSlug: payload.spaceSlug, ++ visibility: payload.visibility, + })); + }; + +diff --git a/packages/spaces/src/application/usecases/CreateSpaceUseCase.spec.ts b/packages/spaces/src/application/usecases/CreateSpaceUseCase.spec.ts +index 5f0acf7e0..a5e4e7444 100644 +--- a/packages/spaces/src/application/usecases/CreateSpaceUseCase.spec.ts ++++ b/packages/spaces/src/application/usecases/CreateSpaceUseCase.spec.ts +@@ -95,7 +95,7 @@ describe('CreateSpaceUseCase', () => { + ); + }); + +- it('emits a SpaceCreatedEvent with space name and slug', async () => { ++ it('emits a SpaceCreatedEvent with space name, slug, and visibility', async () => { + await useCase.execute(buildCommand()); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( +@@ -106,6 +106,7 @@ describe('CreateSpaceUseCase', () => { + source: 'ui', + spaceName: createdSpace.name, + spaceSlug: createdSpace.slug, ++ visibility: createdSpace.type, + }), + }), + ); +diff --git a/packages/spaces/src/application/usecases/CreateSpaceUseCase.ts b/packages/spaces/src/application/usecases/CreateSpaceUseCase.ts +index 95eb95d1d..530ad64e3 100644 +--- a/packages/spaces/src/application/usecases/CreateSpaceUseCase.ts ++++ b/packages/spaces/src/application/usecases/CreateSpaceUseCase.ts +@@ -49,6 +49,7 @@ export class CreateSpaceUseCase extends AbstractAdminUseCase< + source: command.source ?? 'ui', + spaceName: space.name, + spaceSlug: space.slug, ++ visibility: space.type, + }), + ); + +diff --git a/packages/types/src/spaces/events/SpaceCreatedEvent.ts b/packages/types/src/spaces/events/SpaceCreatedEvent.ts +index 0fb3b2304..9e22efe39 100644 +--- a/packages/types/src/spaces/events/SpaceCreatedEvent.ts ++++ b/packages/types/src/spaces/events/SpaceCreatedEvent.ts +@@ -1,8 +1,10 @@ + import { UserEvent } from '../../events'; ++import { SpaceType } from '../Space'; + + export interface SpaceCreatedPayload { + spaceName: string; + spaceSlug: string; ++ visibility: SpaceType; + } + + export class SpaceCreatedEvent extends UserEvent<SpaceCreatedPayload> { +-- +2.50.1 (Apple Git-155) + + +From b37dbc293a9db79afb3535b00604c28f252df43b Mon Sep 17 00:00:00 2001 +From: Quentin Le Bourles <quentin.lebourles@promyze.com> +Date: Wed, 8 Apr 2026 16:39:27 +0200 +Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20feat(spaces):=20emit=20SpaceVis?= + =?UTF-8?q?ibilityUpdatedEvent=20on=20space=20type=20change?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Add SpaceVisibilityUpdatedEvent domain event and emit it from +UpdateSpaceUseCase when the space type (visibility) is changed. +Wire up Amplitude tracking for the new event. + +Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> +--- + .../AmplitudeEventListener.spec.ts | 28 +++ + .../src/application/AmplitudeEventListener.ts | 15 ++ + .../adapters/SpacesManagementAdapter.ts | 6 +- + .../usecases/UpdateSpaceUseCase.spec.ts | 174 ++++++++++++++++++ + .../usecases/UpdateSpaceUseCase.ts | 25 ++- + .../events/SpaceVisibilityUpdatedEvent.ts | 12 ++ + packages/types/src/spaces/events/index.ts | 1 + + 7 files changed, 258 insertions(+), 3 deletions(-) + create mode 100644 packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.spec.ts + create mode 100644 packages/types/src/spaces/events/SpaceVisibilityUpdatedEvent.ts + +diff --git a/packages/amplitude/src/application/AmplitudeEventListener.spec.ts b/packages/amplitude/src/application/AmplitudeEventListener.spec.ts +index 66aa76458..14ff205e6 100644 +--- a/packages/amplitude/src/application/AmplitudeEventListener.spec.ts ++++ b/packages/amplitude/src/application/AmplitudeEventListener.spec.ts +@@ -18,6 +18,7 @@ import { + ChangeProposalAcceptedEvent, + ChangeProposalRejectedEvent, + SpaceCreatedEvent, ++ SpaceVisibilityUpdatedEvent, + SpaceType, + PlaybookArtefactMovedEvent, + createUserId, +@@ -552,6 +553,33 @@ describe('AmplitudeEventListener', () => { + }); + }); + ++ describe('SpaceVisibilityUpdatedEvent', () => { ++ it('tracks space_visibility_updated event with correct payload', async () => { ++ const event = new SpaceVisibilityUpdatedEvent({ ++ userId: createUserId('user-123'), ++ organizationId: createOrganizationId('org-456'), ++ spaceId: createSpaceId('space-789'), ++ newVisibility: SpaceType.private, ++ source: 'ui', ++ }); ++ ++ eventEmitterService.emit(event); ++ ++ await flushPromises(); ++ ++ expect(mockAdapter.trackEvent).toHaveBeenCalledWith( ++ 'user-123', ++ 'org-456', ++ 'space_visibility_updated', ++ { ++ spaceId: 'space-789', ++ newVisibility: 'private', ++ source: 'ui', ++ }, ++ ); ++ }); ++ }); ++ + describe('PlaybookArtefactMovedEvent', () => { + it('tracks playbook_artefact_moved event with correct payload', async () => { + const event = new PlaybookArtefactMovedEvent({ +diff --git a/packages/amplitude/src/application/AmplitudeEventListener.ts b/packages/amplitude/src/application/AmplitudeEventListener.ts +index 2371dcac7..4ce4104e8 100644 +--- a/packages/amplitude/src/application/AmplitudeEventListener.ts ++++ b/packages/amplitude/src/application/AmplitudeEventListener.ts +@@ -26,6 +26,7 @@ import { + ChangeProposalAcceptedEvent, + ChangeProposalRejectedEvent, + SpaceCreatedEvent, ++ SpaceVisibilityUpdatedEvent, + PlaybookArtefactMovedEvent, + } from '@packmind/types'; + import { EventTrackingAdapter } from './EventTrackingAdapter'; +@@ -73,6 +74,7 @@ export class AmplitudeEventListener extends PackmindListener<EventTrackingAdapte + this.subscribe(ChangeProposalAcceptedEvent, this.onChangeProposalAccepted); + this.subscribe(ChangeProposalRejectedEvent, this.onChangeProposalRejected); + this.subscribe(SpaceCreatedEvent, this.onSpaceCreated); ++ this.subscribe(SpaceVisibilityUpdatedEvent, this.onSpaceVisibilityUpdated); + this.subscribe(PlaybookArtefactMovedEvent, this.onPlaybookArtefactMoved); + } + +@@ -349,6 +351,19 @@ export class AmplitudeEventListener extends PackmindListener<EventTrackingAdapte + ); + }; + ++ private onSpaceVisibilityUpdated = async ( ++ event: SpaceVisibilityUpdatedEvent, ++ ): Promise<void> => { ++ return this.emitAmplitudeEvent( ++ event, ++ 'space_visibility_updated', ++ (payload) => ({ ++ spaceId: payload.spaceId, ++ newVisibility: payload.newVisibility, ++ }), ++ ); ++ }; ++ + private onSpaceCreated = async (event: SpaceCreatedEvent): Promise<void> => { + return this.emitAmplitudeEvent(event, 'space_created', (payload) => ({ + spaceName: payload.spaceName, +diff --git a/packages/spaces-management/src/application/adapters/SpacesManagementAdapter.ts b/packages/spaces-management/src/application/adapters/SpacesManagementAdapter.ts +index 817b6f601..01955ef96 100644 +--- a/packages/spaces-management/src/application/adapters/SpacesManagementAdapter.ts ++++ b/packages/spaces-management/src/application/adapters/SpacesManagementAdapter.ts +@@ -77,7 +77,11 @@ export class SpacesManagementAdapter + } + + async updateSpace(command: UpdateSpaceCommand): Promise<UpdateSpaceResponse> { +- const useCase = new UpdateSpaceUseCase(this.accountsPort, this.spacesPort); ++ const useCase = new UpdateSpaceUseCase( ++ this.accountsPort, ++ this.spacesPort, ++ this.eventEmitterService, ++ ); + return useCase.execute(command); + } + +diff --git a/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.spec.ts b/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.spec.ts +new file mode 100644 +index 000000000..d5396da9d +--- /dev/null ++++ b/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.spec.ts +@@ -0,0 +1,174 @@ ++import { ++ createOrganizationId, ++ createSpaceId, ++ createUserId, ++ IAccountsPort, ++ ISpacesPort, ++ SpaceType, ++ UpdateSpaceCommand, ++} from '@packmind/types'; ++import { PackmindEventEmitterService } from '@packmind/node-utils'; ++import { userFactory } from '@packmind/accounts/test/userFactory'; ++import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; ++import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; ++import { SpaceNotFoundError } from '../../domain/errors/SpaceNotFoundError'; ++import { UpdateSpaceUseCase } from './UpdateSpaceUseCase'; ++ ++describe('UpdateSpaceUseCase', () => { ++ const organizationId = createOrganizationId('org-1'); ++ const userId = createUserId('user-1'); ++ const spaceId = createSpaceId('space-1'); ++ ++ const organization = organizationFactory({ id: organizationId }); ++ const user = userFactory({ ++ id: userId, ++ memberships: [{ userId, organizationId, role: 'member' }], ++ }); ++ ++ let useCase: UpdateSpaceUseCase; ++ let accountsPort: jest.Mocked<IAccountsPort>; ++ let spacesPort: jest.Mocked< ++ Pick<ISpacesPort, 'getSpaceById' | 'updateSpace'> ++ >; ++ let eventEmitterService: jest.Mocked< ++ Pick<PackmindEventEmitterService, 'emit'> ++ >; ++ ++ const buildCommand = ( ++ overrides?: Partial<UpdateSpaceCommand>, ++ ): UpdateSpaceCommand => ({ ++ userId: userId as unknown as string, ++ organizationId: organizationId as unknown as string, ++ spaceId: spaceId as unknown as string, ++ ...overrides, ++ }); ++ ++ beforeEach(() => { ++ accountsPort = { ++ getUserById: jest.fn().mockResolvedValue(user), ++ getOrganizationById: jest.fn().mockResolvedValue(organization), ++ } as unknown as jest.Mocked<IAccountsPort>; ++ ++ spacesPort = { ++ getSpaceById: jest.fn(), ++ updateSpace: jest.fn(), ++ }; ++ ++ eventEmitterService = { ++ emit: jest.fn().mockReturnValue(true), ++ }; ++ ++ useCase = new UpdateSpaceUseCase( ++ accountsPort, ++ spacesPort as unknown as ISpacesPort, ++ eventEmitterService as unknown as PackmindEventEmitterService, ++ ); ++ }); ++ ++ afterEach(() => jest.clearAllMocks()); ++ ++ describe('when updating space type', () => { ++ const existingSpace = spaceFactory({ ++ id: spaceId, ++ organizationId, ++ type: SpaceType.open, ++ }); ++ const updatedSpace = spaceFactory({ ++ id: spaceId, ++ organizationId, ++ type: SpaceType.private, ++ }); ++ ++ beforeEach(() => { ++ spacesPort.getSpaceById.mockResolvedValue(existingSpace); ++ spacesPort.updateSpace.mockResolvedValue(updatedSpace); ++ }); ++ ++ it('returns the updated space', async () => { ++ const result = await useCase.execute( ++ buildCommand({ type: SpaceType.private }), ++ ); ++ ++ expect(result).toEqual(updatedSpace); ++ }); ++ ++ it('emits SpaceVisibilityUpdatedEvent', async () => { ++ await useCase.execute(buildCommand({ type: SpaceType.private })); ++ ++ expect(eventEmitterService.emit).toHaveBeenCalledWith( ++ expect.objectContaining({ ++ payload: expect.objectContaining({ ++ spaceId, ++ newVisibility: SpaceType.private, ++ }), ++ }), ++ ); ++ }); ++ }); ++ ++ describe('when updating only the name', () => { ++ const existingSpace = spaceFactory({ ++ id: spaceId, ++ organizationId, ++ type: SpaceType.open, ++ }); ++ const updatedSpace = spaceFactory({ ++ ...existingSpace, ++ name: 'New Name', ++ }); ++ ++ beforeEach(() => { ++ spacesPort.getSpaceById.mockResolvedValue(existingSpace); ++ spacesPort.updateSpace.mockResolvedValue(updatedSpace); ++ }); ++ ++ it('does not emit SpaceVisibilityUpdatedEvent', async () => { ++ await useCase.execute(buildCommand({ name: 'New Name' })); ++ ++ expect(eventEmitterService.emit).not.toHaveBeenCalled(); ++ }); ++ }); ++ ++ describe('when no fields are provided', () => { ++ const existingSpace = spaceFactory({ ++ id: spaceId, ++ organizationId, ++ }); ++ ++ beforeEach(() => { ++ spacesPort.getSpaceById.mockResolvedValue(existingSpace); ++ }); ++ ++ it('returns the existing space without updating', async () => { ++ const result = await useCase.execute(buildCommand()); ++ ++ expect(result).toEqual(existingSpace); ++ }); ++ ++ it('does not emit any event', async () => { ++ await useCase.execute(buildCommand()); ++ ++ expect(eventEmitterService.emit).not.toHaveBeenCalled(); ++ }); ++ }); ++ ++ describe('when space is not found', () => { ++ beforeEach(() => { ++ spacesPort.getSpaceById.mockResolvedValue(null); ++ }); ++ ++ it('throws SpaceNotFoundError', async () => { ++ await expect( ++ useCase.execute(buildCommand({ type: SpaceType.private })), ++ ).rejects.toThrow(SpaceNotFoundError); ++ }); ++ ++ it('does not emit any event', async () => { ++ await useCase ++ .execute(buildCommand({ type: SpaceType.private })) ++ .catch(() => undefined); ++ ++ expect(eventEmitterService.emit).not.toHaveBeenCalled(); ++ }); ++ }); ++}); +diff --git a/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.ts b/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.ts +index 31cf83759..565be566f 100644 +--- a/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.ts ++++ b/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.ts +@@ -1,10 +1,16 @@ +-import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; ++import { ++ AbstractMemberUseCase, ++ MemberContext, ++ PackmindEventEmitterService, ++} from '@packmind/node-utils'; + import { + createOrganizationId, + createSpaceId, ++ createUserId, + IAccountsPort, + ISpacesPort, + SpaceType, ++ SpaceVisibilityUpdatedEvent, + UpdateSpaceCommand, + UpdateSpaceResponse, + } from '@packmind/types'; +@@ -17,6 +23,7 @@ export class UpdateSpaceUseCase extends AbstractMemberUseCase< + constructor( + accountsPort: IAccountsPort, + private readonly spacesPort: ISpacesPort, ++ private readonly eventEmitterService: PackmindEventEmitterService, + ) { + super(accountsPort); + } +@@ -47,6 +54,20 @@ export class UpdateSpaceUseCase extends AbstractMemberUseCase< + return space; + } + +- return this.spacesPort.updateSpace(spaceId, fields); ++ const updatedSpace = await this.spacesPort.updateSpace(spaceId, fields); ++ ++ if (command.type !== undefined) { ++ this.eventEmitterService.emit( ++ new SpaceVisibilityUpdatedEvent({ ++ userId: createUserId(command.userId), ++ organizationId, ++ source: command.source ?? 'ui', ++ spaceId, ++ newVisibility: command.type, ++ }), ++ ); ++ } ++ ++ return updatedSpace; + } + } +diff --git a/packages/types/src/spaces/events/SpaceVisibilityUpdatedEvent.ts b/packages/types/src/spaces/events/SpaceVisibilityUpdatedEvent.ts +new file mode 100644 +index 000000000..e9e0b9bd2 +--- /dev/null ++++ b/packages/types/src/spaces/events/SpaceVisibilityUpdatedEvent.ts +@@ -0,0 +1,12 @@ ++import { UserEvent } from '../../events'; ++import { SpaceId } from '../SpaceId'; ++import { SpaceType } from '../Space'; ++ ++export interface SpaceVisibilityUpdatedPayload { ++ spaceId: SpaceId; ++ newVisibility: SpaceType; ++} ++ ++export class SpaceVisibilityUpdatedEvent extends UserEvent<SpaceVisibilityUpdatedPayload> { ++ static override readonly eventName = 'spaces.space.visibility-updated'; ++} +diff --git a/packages/types/src/spaces/events/index.ts b/packages/types/src/spaces/events/index.ts +index 51b88638d..7617f074e 100644 +--- a/packages/types/src/spaces/events/index.ts ++++ b/packages/types/src/spaces/events/index.ts +@@ -1 +1,2 @@ + export * from './SpaceCreatedEvent'; ++export * from './SpaceVisibilityUpdatedEvent'; +-- +2.50.1 (Apple Git-155) + + +From 764bc316a80eb5c717121adcdcd56b6a08de6aaf Mon Sep 17 00:00:00 2001 +From: Quentin Le Bourles <quentin.lebourles@promyze.com> +Date: Wed, 8 Apr 2026 16:47:38 +0200 +Subject: [PATCH 3/5] =?UTF-8?q?=E2=9C=A8=20feat(spaces):=20emit=20SpaceMem?= + =?UTF-8?q?bersAddedEvent=20from=20AddMembers=20and=20JoinSpace=20use=20ca?= + =?UTF-8?q?ses?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Create SpaceMembersAddedEvent domain event and emit it when members are +added to a space (admin flow) and when a user self-joins a space, with +Amplitude tracking for the new event. + +Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> +--- + .../AmplitudeEventListener.spec.ts | 28 +++++++++++++++++ + .../src/application/AmplitudeEventListener.ts | 11 +++++++ + .../adapters/SpacesManagementAdapter.ts | 6 +++- + .../usecases/JoinSpaceUseCase.spec.ts | 29 +++++++++++++++++ + .../application/usecases/JoinSpaceUseCase.ts | 18 ++++++++++- + .../src/application/adapters/SpacesAdapter.ts | 1 + + .../usecases/AddMembersToSpaceUseCase.spec.ts | 31 +++++++++++++++++++ + .../usecases/AddMembersToSpaceUseCase.ts | 16 ++++++++++ + .../spaces/events/SpaceMembersAddedEvent.ts | 12 +++++++ + packages/types/src/spaces/events/index.ts | 1 + + 10 files changed, 151 insertions(+), 2 deletions(-) + create mode 100644 packages/types/src/spaces/events/SpaceMembersAddedEvent.ts + +diff --git a/packages/amplitude/src/application/AmplitudeEventListener.spec.ts b/packages/amplitude/src/application/AmplitudeEventListener.spec.ts +index 14ff205e6..21b940f0d 100644 +--- a/packages/amplitude/src/application/AmplitudeEventListener.spec.ts ++++ b/packages/amplitude/src/application/AmplitudeEventListener.spec.ts +@@ -18,6 +18,7 @@ import { + ChangeProposalAcceptedEvent, + ChangeProposalRejectedEvent, + SpaceCreatedEvent, ++ SpaceMembersAddedEvent, + SpaceVisibilityUpdatedEvent, + SpaceType, + PlaybookArtefactMovedEvent, +@@ -553,6 +554,33 @@ describe('AmplitudeEventListener', () => { + }); + }); + ++ describe('SpaceMembersAddedEvent', () => { ++ it('tracks space_members_added event with correct payload', async () => { ++ const event = new SpaceMembersAddedEvent({ ++ userId: createUserId('user-123'), ++ organizationId: createOrganizationId('org-456'), ++ spaceId: createSpaceId('space-789'), ++ memberUserIds: [createUserId('member-1'), createUserId('member-2')], ++ source: 'ui', ++ }); ++ ++ eventEmitterService.emit(event); ++ ++ await flushPromises(); ++ ++ expect(mockAdapter.trackEvent).toHaveBeenCalledWith( ++ 'user-123', ++ 'org-456', ++ 'space_members_added', ++ { ++ spaceId: 'space-789', ++ memberCount: 2, ++ source: 'ui', ++ }, ++ ); ++ }); ++ }); ++ + describe('SpaceVisibilityUpdatedEvent', () => { + it('tracks space_visibility_updated event with correct payload', async () => { + const event = new SpaceVisibilityUpdatedEvent({ +diff --git a/packages/amplitude/src/application/AmplitudeEventListener.ts b/packages/amplitude/src/application/AmplitudeEventListener.ts +index 4ce4104e8..5e8779774 100644 +--- a/packages/amplitude/src/application/AmplitudeEventListener.ts ++++ b/packages/amplitude/src/application/AmplitudeEventListener.ts +@@ -26,6 +26,7 @@ import { + ChangeProposalAcceptedEvent, + ChangeProposalRejectedEvent, + SpaceCreatedEvent, ++ SpaceMembersAddedEvent, + SpaceVisibilityUpdatedEvent, + PlaybookArtefactMovedEvent, + } from '@packmind/types'; +@@ -74,6 +75,7 @@ export class AmplitudeEventListener extends PackmindListener<EventTrackingAdapte + this.subscribe(ChangeProposalAcceptedEvent, this.onChangeProposalAccepted); + this.subscribe(ChangeProposalRejectedEvent, this.onChangeProposalRejected); + this.subscribe(SpaceCreatedEvent, this.onSpaceCreated); ++ this.subscribe(SpaceMembersAddedEvent, this.onSpaceMembersAdded); + this.subscribe(SpaceVisibilityUpdatedEvent, this.onSpaceVisibilityUpdated); + this.subscribe(PlaybookArtefactMovedEvent, this.onPlaybookArtefactMoved); + } +@@ -372,6 +374,15 @@ export class AmplitudeEventListener extends PackmindListener<EventTrackingAdapte + })); + }; + ++ private onSpaceMembersAdded = async ( ++ event: SpaceMembersAddedEvent, ++ ): Promise<void> => { ++ return this.emitAmplitudeEvent(event, 'space_members_added', (payload) => ({ ++ spaceId: payload.spaceId, ++ memberCount: payload.memberUserIds.length, ++ })); ++ }; ++ + private onPlaybookArtefactMoved = async ( + event: PlaybookArtefactMovedEvent, + ): Promise<void> => { +diff --git a/packages/spaces-management/src/application/adapters/SpacesManagementAdapter.ts b/packages/spaces-management/src/application/adapters/SpacesManagementAdapter.ts +index 01955ef96..28f59c16c 100644 +--- a/packages/spaces-management/src/application/adapters/SpacesManagementAdapter.ts ++++ b/packages/spaces-management/src/application/adapters/SpacesManagementAdapter.ts +@@ -72,7 +72,11 @@ export class SpacesManagementAdapter + } + + async joinSpace(command: JoinSpaceCommand): Promise<JoinSpaceResponse> { +- const useCase = new JoinSpaceUseCase(this.accountsPort, this.spacesPort); ++ const useCase = new JoinSpaceUseCase( ++ this.accountsPort, ++ this.spacesPort, ++ this.eventEmitterService, ++ ); + return useCase.execute(command); + } + +diff --git a/packages/spaces-management/src/application/usecases/JoinSpaceUseCase.spec.ts b/packages/spaces-management/src/application/usecases/JoinSpaceUseCase.spec.ts +index 5dcf62600..3d04ab0e8 100644 +--- a/packages/spaces-management/src/application/usecases/JoinSpaceUseCase.spec.ts ++++ b/packages/spaces-management/src/application/usecases/JoinSpaceUseCase.spec.ts +@@ -1,3 +1,4 @@ ++import { PackmindEventEmitterService } from '@packmind/node-utils'; + import { + createOrganizationId, + createSpaceId, +@@ -31,6 +32,9 @@ describe('JoinSpaceUseCase', () => { + let spacesPort: jest.Mocked< + Pick<ISpacesPort, 'getSpaceById' | 'findMembership' | 'addSpaceMembership'> + >; ++ let eventEmitterService: jest.Mocked< ++ Pick<PackmindEventEmitterService, 'emit'> ++ >; + + const buildCommand = ( + overrides?: Partial<JoinSpaceCommand>, +@@ -52,9 +56,15 @@ describe('JoinSpaceUseCase', () => { + findMembership: jest.fn(), + addSpaceMembership: jest.fn(), + }; ++ ++ eventEmitterService = { ++ emit: jest.fn().mockReturnValue(true), ++ }; ++ + useCase = new JoinSpaceUseCase( + accountsPort, + spacesPort as unknown as ISpacesPort, ++ eventEmitterService as unknown as PackmindEventEmitterService, + ); + }); + +@@ -89,6 +99,19 @@ describe('JoinSpaceUseCase', () => { + createdBy: userId, + }); + }); ++ ++ it('emits SpaceMembersAddedEvent with the joining user', async () => { ++ await useCase.execute(buildCommand()); ++ ++ expect(eventEmitterService.emit).toHaveBeenCalledWith( ++ expect.objectContaining({ ++ payload: expect.objectContaining({ ++ spaceId, ++ memberUserIds: [userId], ++ }), ++ }), ++ ); ++ }); + }); + + describe('when the space does not exist', () => { +@@ -187,6 +210,12 @@ describe('JoinSpaceUseCase', () => { + await useCase.execute(buildCommand()); + expect(spacesPort.addSpaceMembership).not.toHaveBeenCalled(); + }); ++ ++ it('does not emit SpaceMembersAddedEvent', async () => { ++ await useCase.execute(buildCommand()); ++ ++ expect(eventEmitterService.emit).not.toHaveBeenCalled(); ++ }); + }); + + describe('when the user is an organization admin', () => { +diff --git a/packages/spaces-management/src/application/usecases/JoinSpaceUseCase.ts b/packages/spaces-management/src/application/usecases/JoinSpaceUseCase.ts +index e7788ca6b..36aa5a79c 100644 +--- a/packages/spaces-management/src/application/usecases/JoinSpaceUseCase.ts ++++ b/packages/spaces-management/src/application/usecases/JoinSpaceUseCase.ts +@@ -1,4 +1,8 @@ +-import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; ++import { ++ AbstractMemberUseCase, ++ MemberContext, ++ PackmindEventEmitterService, ++} from '@packmind/node-utils'; + import { + createOrganizationId, + createSpaceId, +@@ -7,6 +11,7 @@ import { + ISpacesPort, + JoinSpaceCommand, + JoinSpaceResponse, ++ SpaceMembersAddedEvent, + SpaceType, + UserSpaceRole, + } from '@packmind/types'; +@@ -20,6 +25,7 @@ export class JoinSpaceUseCase extends AbstractMemberUseCase< + constructor( + accountsPort: IAccountsPort, + private readonly spacesPort: ISpacesPort, ++ private readonly eventEmitterService: PackmindEventEmitterService, + ) { + super(accountsPort); + } +@@ -64,6 +70,16 @@ export class JoinSpaceUseCase extends AbstractMemberUseCase< + createdBy: userId, + }); + ++ this.eventEmitterService.emit( ++ new SpaceMembersAddedEvent({ ++ userId, ++ organizationId, ++ source: command.source ?? 'ui', ++ spaceId, ++ memberUserIds: [userId], ++ }), ++ ); ++ + return {} as JoinSpaceResponse; + } + } +diff --git a/packages/spaces/src/application/adapters/SpacesAdapter.ts b/packages/spaces/src/application/adapters/SpacesAdapter.ts +index 48034da5a..c4fc21ed7 100644 +--- a/packages/spaces/src/application/adapters/SpacesAdapter.ts ++++ b/packages/spaces/src/application/adapters/SpacesAdapter.ts +@@ -162,6 +162,7 @@ export class SpacesAdapter implements IBaseAdapter<ISpacesPort>, ISpacesPort { + const useCase = new AddMembersToSpaceUseCase( + membershipService, + this.accountsPort, ++ this.eventEmitterService, + ); + return useCase.execute(command); + } +diff --git a/packages/spaces/src/application/usecases/AddMembersToSpaceUseCase.spec.ts b/packages/spaces/src/application/usecases/AddMembersToSpaceUseCase.spec.ts +index 33fe3176b..284651b49 100644 +--- a/packages/spaces/src/application/usecases/AddMembersToSpaceUseCase.spec.ts ++++ b/packages/spaces/src/application/usecases/AddMembersToSpaceUseCase.spec.ts +@@ -1,3 +1,4 @@ ++import { PackmindEventEmitterService } from '@packmind/node-utils'; + import { + AddMembersToSpaceCommand, + IAccountsPort, +@@ -28,6 +29,9 @@ describe('AddMembersToSpaceUseCase', () => { + let useCase: AddMembersToSpaceUseCase; + let membershipService: jest.Mocked<UserSpaceMembershipService>; + let accountsPort: jest.Mocked<IAccountsPort>; ++ let eventEmitterService: jest.Mocked< ++ Pick<PackmindEventEmitterService, 'emit'> ++ >; + + const buildCommand = ( + overrides?: Partial<AddMembersToSpaceCommand>, +@@ -53,9 +57,14 @@ describe('AddMembersToSpaceUseCase', () => { + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + ++ eventEmitterService = { ++ emit: jest.fn().mockReturnValue(true), ++ }; ++ + useCase = new AddMembersToSpaceUseCase( + membershipService, + accountsPort, ++ eventEmitterService as unknown as PackmindEventEmitterService, + stubLogger(), + ); + }); +@@ -99,6 +108,22 @@ describe('AddMembersToSpaceUseCase', () => { + + expect(membershipService.addSpaceMembership).toHaveBeenCalledTimes(2); + }); ++ ++ it('emits SpaceMembersAddedEvent with added member IDs', async () => { ++ await useCase.execute(buildCommand()); ++ ++ expect(eventEmitterService.emit).toHaveBeenCalledWith( ++ expect.objectContaining({ ++ payload: expect.objectContaining({ ++ spaceId, ++ memberUserIds: [ ++ createUserId('member-1'), ++ createUserId('member-2'), ++ ], ++ }), ++ }), ++ ); ++ }); + }); + + describe('when one member fails to be added', () => { +@@ -147,6 +172,12 @@ describe('AddMembersToSpaceUseCase', () => { + + expect(result).toEqual([]); + }); ++ ++ it('does not emit SpaceMembersAddedEvent', async () => { ++ await useCase.execute(buildCommand()); ++ ++ expect(eventEmitterService.emit).not.toHaveBeenCalled(); ++ }); + }); + + describe('when members array is empty', () => { +diff --git a/packages/spaces/src/application/usecases/AddMembersToSpaceUseCase.ts b/packages/spaces/src/application/usecases/AddMembersToSpaceUseCase.ts +index a5ee55ae7..32949395b 100644 +--- a/packages/spaces/src/application/usecases/AddMembersToSpaceUseCase.ts ++++ b/packages/spaces/src/application/usecases/AddMembersToSpaceUseCase.ts +@@ -1,9 +1,12 @@ + import { PackmindLogger } from '@packmind/logger'; ++import { PackmindEventEmitterService } from '@packmind/node-utils'; + import { + AddMembersToSpaceCommand, + AddMembersToSpaceResponse, ++ createOrganizationId, + createUserId, + IAccountsPort, ++ SpaceMembersAddedEvent, + UserSpaceMembership, + } from '@packmind/types'; + import { UserSpaceMembershipService } from '../services/UserSpaceMembershipService'; +@@ -21,6 +24,7 @@ export class AddMembersToSpaceUseCase extends AbstractSpaceAdminUseCase< + constructor( + membershipService: UserSpaceMembershipService, + accountsPort: IAccountsPort, ++ private readonly eventEmitterService: PackmindEventEmitterService, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(membershipService, accountsPort, logger); +@@ -48,6 +52,18 @@ export class AddMembersToSpaceUseCase extends AbstractSpaceAdminUseCase< + } + } + ++ if (createdMemberships.length > 0) { ++ this.eventEmitterService.emit( ++ new SpaceMembersAddedEvent({ ++ userId: createUserId(command.userId), ++ organizationId: createOrganizationId(command.organizationId), ++ source: command.source ?? 'ui', ++ spaceId: command.spaceId, ++ memberUserIds: createdMemberships.map((m) => m.userId), ++ }), ++ ); ++ } ++ + return createdMemberships; + } + } +diff --git a/packages/types/src/spaces/events/SpaceMembersAddedEvent.ts b/packages/types/src/spaces/events/SpaceMembersAddedEvent.ts +new file mode 100644 +index 000000000..232d86025 +--- /dev/null ++++ b/packages/types/src/spaces/events/SpaceMembersAddedEvent.ts +@@ -0,0 +1,12 @@ ++import { UserEvent } from '../../events'; ++import { SpaceId } from '../SpaceId'; ++import { UserId } from '../../accounts/User'; ++ ++export interface SpaceMembersAddedPayload { ++ spaceId: SpaceId; ++ memberUserIds: UserId[]; ++} ++ ++export class SpaceMembersAddedEvent extends UserEvent<SpaceMembersAddedPayload> { ++ static override readonly eventName = 'spaces.space.members-added'; ++} +diff --git a/packages/types/src/spaces/events/index.ts b/packages/types/src/spaces/events/index.ts +index 7617f074e..002ab3b1e 100644 +--- a/packages/types/src/spaces/events/index.ts ++++ b/packages/types/src/spaces/events/index.ts +@@ -1,2 +1,3 @@ + export * from './SpaceCreatedEvent'; ++export * from './SpaceMembersAddedEvent'; + export * from './SpaceVisibilityUpdatedEvent'; +-- +2.50.1 (Apple Git-155) + + +From 70fa9bc1e96fabf785cb8bf81b881ee6d471dddc Mon Sep 17 00:00:00 2001 +From: Quentin Le Bourles <quentin.lebourles@promyze.com> +Date: Wed, 8 Apr 2026 16:53:43 +0200 +Subject: [PATCH 4/5] =?UTF-8?q?=E2=9C=A8=20feat(spaces):=20emit=20SpaceMem?= + =?UTF-8?q?bersRemovedEvent=20from=20RemoveMemberFromSpace=20use=20case?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> +--- + .../AmplitudeEventListener.spec.ts | 28 +++++++++++++++++++ + .../src/application/AmplitudeEventListener.ts | 15 ++++++++++ + .../src/application/adapters/SpacesAdapter.ts | 1 + + .../RemoveMemberFromSpaceUseCase.spec.ts | 28 +++++++++++++++++++ + .../usecases/RemoveMemberFromSpaceUseCase.ts | 17 +++++++++++ + .../spaces/events/SpaceMembersRemovedEvent.ts | 12 ++++++++ + packages/types/src/spaces/events/index.ts | 1 + + 7 files changed, 102 insertions(+) + create mode 100644 packages/types/src/spaces/events/SpaceMembersRemovedEvent.ts + +diff --git a/packages/amplitude/src/application/AmplitudeEventListener.spec.ts b/packages/amplitude/src/application/AmplitudeEventListener.spec.ts +index 21b940f0d..dee6bc9ba 100644 +--- a/packages/amplitude/src/application/AmplitudeEventListener.spec.ts ++++ b/packages/amplitude/src/application/AmplitudeEventListener.spec.ts +@@ -19,6 +19,7 @@ import { + ChangeProposalRejectedEvent, + SpaceCreatedEvent, + SpaceMembersAddedEvent, ++ SpaceMembersRemovedEvent, + SpaceVisibilityUpdatedEvent, + SpaceType, + PlaybookArtefactMovedEvent, +@@ -581,6 +582,33 @@ describe('AmplitudeEventListener', () => { + }); + }); + ++ describe('SpaceMembersRemovedEvent', () => { ++ it('tracks space_members_removed event with correct payload', async () => { ++ const event = new SpaceMembersRemovedEvent({ ++ userId: createUserId('user-123'), ++ organizationId: createOrganizationId('org-456'), ++ spaceId: createSpaceId('space-789'), ++ memberUserIds: [createUserId('member-1')], ++ source: 'ui', ++ }); ++ ++ eventEmitterService.emit(event); ++ ++ await flushPromises(); ++ ++ expect(mockAdapter.trackEvent).toHaveBeenCalledWith( ++ 'user-123', ++ 'org-456', ++ 'space_members_removed', ++ { ++ spaceId: 'space-789', ++ memberCount: 1, ++ source: 'ui', ++ }, ++ ); ++ }); ++ }); ++ + describe('SpaceVisibilityUpdatedEvent', () => { + it('tracks space_visibility_updated event with correct payload', async () => { + const event = new SpaceVisibilityUpdatedEvent({ +diff --git a/packages/amplitude/src/application/AmplitudeEventListener.ts b/packages/amplitude/src/application/AmplitudeEventListener.ts +index 5e8779774..78b3cd8f6 100644 +--- a/packages/amplitude/src/application/AmplitudeEventListener.ts ++++ b/packages/amplitude/src/application/AmplitudeEventListener.ts +@@ -27,6 +27,7 @@ import { + ChangeProposalRejectedEvent, + SpaceCreatedEvent, + SpaceMembersAddedEvent, ++ SpaceMembersRemovedEvent, + SpaceVisibilityUpdatedEvent, + PlaybookArtefactMovedEvent, + } from '@packmind/types'; +@@ -76,6 +77,7 @@ export class AmplitudeEventListener extends PackmindListener<EventTrackingAdapte + this.subscribe(ChangeProposalRejectedEvent, this.onChangeProposalRejected); + this.subscribe(SpaceCreatedEvent, this.onSpaceCreated); + this.subscribe(SpaceMembersAddedEvent, this.onSpaceMembersAdded); ++ this.subscribe(SpaceMembersRemovedEvent, this.onSpaceMembersRemoved); + this.subscribe(SpaceVisibilityUpdatedEvent, this.onSpaceVisibilityUpdated); + this.subscribe(PlaybookArtefactMovedEvent, this.onPlaybookArtefactMoved); + } +@@ -383,6 +385,19 @@ export class AmplitudeEventListener extends PackmindListener<EventTrackingAdapte + })); + }; + ++ private onSpaceMembersRemoved = async ( ++ event: SpaceMembersRemovedEvent, ++ ): Promise<void> => { ++ return this.emitAmplitudeEvent( ++ event, ++ 'space_members_removed', ++ (payload) => ({ ++ spaceId: payload.spaceId, ++ memberCount: payload.memberUserIds.length, ++ }), ++ ); ++ }; ++ + private onPlaybookArtefactMoved = async ( + event: PlaybookArtefactMovedEvent, + ): Promise<void> => { +diff --git a/packages/spaces/src/application/adapters/SpacesAdapter.ts b/packages/spaces/src/application/adapters/SpacesAdapter.ts +index c4fc21ed7..aa116047d 100644 +--- a/packages/spaces/src/application/adapters/SpacesAdapter.ts ++++ b/packages/spaces/src/application/adapters/SpacesAdapter.ts +@@ -174,6 +174,7 @@ export class SpacesAdapter implements IBaseAdapter<ISpacesPort>, ISpacesPort { + const useCase = new RemoveMemberFromSpaceUseCase( + membershipService, + this.accountsPort, ++ this.eventEmitterService, + ); + return useCase.execute(command); + } +diff --git a/packages/spaces/src/application/usecases/RemoveMemberFromSpaceUseCase.spec.ts b/packages/spaces/src/application/usecases/RemoveMemberFromSpaceUseCase.spec.ts +index 7246f7728..2b6b0c9b5 100644 +--- a/packages/spaces/src/application/usecases/RemoveMemberFromSpaceUseCase.spec.ts ++++ b/packages/spaces/src/application/usecases/RemoveMemberFromSpaceUseCase.spec.ts +@@ -6,6 +6,7 @@ import { + createUserId, + UserSpaceRole, + } from '@packmind/types'; ++import { PackmindEventEmitterService } from '@packmind/node-utils'; + import { stubLogger } from '@packmind/test-utils'; + import { userFactory } from '@packmind/accounts/test/userFactory'; + import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +@@ -32,6 +33,9 @@ describe('RemoveMemberFromSpaceUseCase', () => { + let useCase: RemoveMemberFromSpaceUseCase; + let membershipService: jest.Mocked<UserSpaceMembershipService>; + let accountsPort: jest.Mocked<IAccountsPort>; ++ let eventEmitterService: jest.Mocked< ++ Pick<PackmindEventEmitterService, 'emit'> ++ >; + + const buildCommand = ( + overrides?: Partial<RemoveMemberFromSpaceCommand>, +@@ -55,9 +59,14 @@ describe('RemoveMemberFromSpaceUseCase', () => { + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + ++ eventEmitterService = { ++ emit: jest.fn().mockReturnValue(true), ++ }; ++ + useCase = new RemoveMemberFromSpaceUseCase( + membershipService, + accountsPort, ++ eventEmitterService as unknown as PackmindEventEmitterService, + stubLogger(), + ); + }); +@@ -94,6 +103,19 @@ describe('RemoveMemberFromSpaceUseCase', () => { + spaceId, + ); + }); ++ ++ it('emits SpaceMembersRemovedEvent', async () => { ++ await useCase.execute(buildCommand()); ++ ++ expect(eventEmitterService.emit).toHaveBeenCalledWith( ++ expect.objectContaining({ ++ payload: expect.objectContaining({ ++ spaceId, ++ memberUserIds: [targetUserId], ++ }), ++ }), ++ ); ++ }); + }); + + describe('when the caller is not a space admin', () => { +@@ -120,6 +142,12 @@ describe('RemoveMemberFromSpaceUseCase', () => { + + expect(membershipService.removeSpaceMembership).not.toHaveBeenCalled(); + }); ++ ++ it('does not emit SpaceMembersRemovedEvent', async () => { ++ await useCase.execute(buildCommand()).catch(() => undefined); ++ ++ expect(eventEmitterService.emit).not.toHaveBeenCalled(); ++ }); + }); + + describe('when the caller has no space membership', () => { +diff --git a/packages/spaces/src/application/usecases/RemoveMemberFromSpaceUseCase.ts b/packages/spaces/src/application/usecases/RemoveMemberFromSpaceUseCase.ts +index adf97d1bc..3be6c1d27 100644 +--- a/packages/spaces/src/application/usecases/RemoveMemberFromSpaceUseCase.ts ++++ b/packages/spaces/src/application/usecases/RemoveMemberFromSpaceUseCase.ts +@@ -1,8 +1,12 @@ + import { PackmindLogger } from '@packmind/logger'; ++import { PackmindEventEmitterService } from '@packmind/node-utils'; + import { ++ createOrganizationId, ++ createUserId, + IAccountsPort, + RemoveMemberFromSpaceCommand, + RemoveMemberFromSpaceResponse, ++ SpaceMembersRemovedEvent, + } from '@packmind/types'; + import { CannotRemoveFromDefaultSpaceError } from '../../domain/errors/CannotRemoveFromDefaultSpaceError'; + import { CannotRemoveSelfError } from '../../domain/errors/CannotRemoveSelfError'; +@@ -21,6 +25,7 @@ export class RemoveMemberFromSpaceUseCase extends AbstractSpaceAdminUseCase< + constructor( + membershipService: UserSpaceMembershipService, + accountsPort: IAccountsPort, ++ private readonly eventEmitterService: PackmindEventEmitterService, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(membershipService, accountsPort, logger); +@@ -44,6 +49,18 @@ export class RemoveMemberFromSpaceUseCase extends AbstractSpaceAdminUseCase< + command.spaceId, + ); + ++ if (removed) { ++ this.eventEmitterService.emit( ++ new SpaceMembersRemovedEvent({ ++ userId: createUserId(command.userId), ++ organizationId: createOrganizationId(command.organizationId), ++ source: command.source ?? 'ui', ++ spaceId: command.spaceId, ++ memberUserIds: [command.targetUserId], ++ }), ++ ); ++ } ++ + return { removed }; + } + } +diff --git a/packages/types/src/spaces/events/SpaceMembersRemovedEvent.ts b/packages/types/src/spaces/events/SpaceMembersRemovedEvent.ts +new file mode 100644 +index 000000000..1cd516e2b +--- /dev/null ++++ b/packages/types/src/spaces/events/SpaceMembersRemovedEvent.ts +@@ -0,0 +1,12 @@ ++import { UserEvent } from '../../events'; ++import { SpaceId } from '../SpaceId'; ++import { UserId } from '../../accounts/User'; ++ ++export interface SpaceMembersRemovedPayload { ++ spaceId: SpaceId; ++ memberUserIds: UserId[]; ++} ++ ++export class SpaceMembersRemovedEvent extends UserEvent<SpaceMembersRemovedPayload> { ++ static override readonly eventName = 'spaces.space.members-removed'; ++} +diff --git a/packages/types/src/spaces/events/index.ts b/packages/types/src/spaces/events/index.ts +index 002ab3b1e..5360e19c1 100644 +--- a/packages/types/src/spaces/events/index.ts ++++ b/packages/types/src/spaces/events/index.ts +@@ -1,3 +1,4 @@ + export * from './SpaceCreatedEvent'; + export * from './SpaceMembersAddedEvent'; ++export * from './SpaceMembersRemovedEvent'; + export * from './SpaceVisibilityUpdatedEvent'; +-- +2.50.1 (Apple Git-155) + + +From 4f3cd779fdaf37855a339b74414bf6c37e1412cf Mon Sep 17 00:00:00 2001 +From: Quentin Le Bourles <quentin.lebourles@promyze.com> +Date: Wed, 8 Apr 2026 16:59:11 +0200 +Subject: [PATCH 5/5] =?UTF-8?q?=E2=9C=A8=20feat(spaces):=20emit=20SpaceMem?= + =?UTF-8?q?bersRoleUpdatedEvent=20from=20UpdateMemberRole=20use=20case?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Add domain event for member role changes: create SpaceMembersRoleUpdatedEvent, +emit it from UpdateMemberRoleUseCase when role update succeeds, wire Amplitude +tracking, and add comprehensive tests. + +Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> +--- + .../AmplitudeEventListener.spec.ts | 31 +++++++++++ + .../src/application/AmplitudeEventListener.ts | 19 +++++++ + .../usecases/UpdateSpaceUseCase.spec.ts | 54 +++++++++++++++++++ + .../src/application/adapters/SpacesAdapter.ts | 1 + + .../usecases/UpdateMemberRoleUseCase.spec.ts | 31 +++++++++++ + .../usecases/UpdateMemberRoleUseCase.ts | 18 +++++++ + .../events/SpaceMembersRoleUpdatedEvent.ts | 14 +++++ + packages/types/src/spaces/events/index.ts | 1 + + 8 files changed, 169 insertions(+) + create mode 100644 packages/types/src/spaces/events/SpaceMembersRoleUpdatedEvent.ts + +diff --git a/packages/amplitude/src/application/AmplitudeEventListener.spec.ts b/packages/amplitude/src/application/AmplitudeEventListener.spec.ts +index dee6bc9ba..cea984109 100644 +--- a/packages/amplitude/src/application/AmplitudeEventListener.spec.ts ++++ b/packages/amplitude/src/application/AmplitudeEventListener.spec.ts +@@ -20,8 +20,10 @@ import { + SpaceCreatedEvent, + SpaceMembersAddedEvent, + SpaceMembersRemovedEvent, ++ SpaceMembersRoleUpdatedEvent, + SpaceVisibilityUpdatedEvent, + SpaceType, ++ UserSpaceRole, + PlaybookArtefactMovedEvent, + createUserId, + createOrganizationId, +@@ -609,6 +611,35 @@ describe('AmplitudeEventListener', () => { + }); + }); + ++ describe('SpaceMembersRoleUpdatedEvent', () => { ++ it('tracks space_members_role_updated event with correct payload', async () => { ++ const event = new SpaceMembersRoleUpdatedEvent({ ++ userId: createUserId('user-123'), ++ organizationId: createOrganizationId('org-456'), ++ spaceId: createSpaceId('space-789'), ++ memberUserIds: [createUserId('member-1')], ++ newRole: UserSpaceRole.ADMIN, ++ source: 'ui', ++ }); ++ ++ eventEmitterService.emit(event); ++ ++ await flushPromises(); ++ ++ expect(mockAdapter.trackEvent).toHaveBeenCalledWith( ++ 'user-123', ++ 'org-456', ++ 'space_members_role_updated', ++ { ++ spaceId: 'space-789', ++ memberCount: 1, ++ newRole: 'admin', ++ source: 'ui', ++ }, ++ ); ++ }); ++ }); ++ + describe('SpaceVisibilityUpdatedEvent', () => { + it('tracks space_visibility_updated event with correct payload', async () => { + const event = new SpaceVisibilityUpdatedEvent({ +diff --git a/packages/amplitude/src/application/AmplitudeEventListener.ts b/packages/amplitude/src/application/AmplitudeEventListener.ts +index 78b3cd8f6..1cbd2472a 100644 +--- a/packages/amplitude/src/application/AmplitudeEventListener.ts ++++ b/packages/amplitude/src/application/AmplitudeEventListener.ts +@@ -28,6 +28,7 @@ import { + SpaceCreatedEvent, + SpaceMembersAddedEvent, + SpaceMembersRemovedEvent, ++ SpaceMembersRoleUpdatedEvent, + SpaceVisibilityUpdatedEvent, + PlaybookArtefactMovedEvent, + } from '@packmind/types'; +@@ -78,6 +79,10 @@ export class AmplitudeEventListener extends PackmindListener<EventTrackingAdapte + this.subscribe(SpaceCreatedEvent, this.onSpaceCreated); + this.subscribe(SpaceMembersAddedEvent, this.onSpaceMembersAdded); + this.subscribe(SpaceMembersRemovedEvent, this.onSpaceMembersRemoved); ++ this.subscribe( ++ SpaceMembersRoleUpdatedEvent, ++ this.onSpaceMembersRoleUpdated, ++ ); + this.subscribe(SpaceVisibilityUpdatedEvent, this.onSpaceVisibilityUpdated); + this.subscribe(PlaybookArtefactMovedEvent, this.onPlaybookArtefactMoved); + } +@@ -398,6 +403,20 @@ export class AmplitudeEventListener extends PackmindListener<EventTrackingAdapte + ); + }; + ++ private onSpaceMembersRoleUpdated = async ( ++ event: SpaceMembersRoleUpdatedEvent, ++ ): Promise<void> => { ++ return this.emitAmplitudeEvent( ++ event, ++ 'space_members_role_updated', ++ (payload) => ({ ++ spaceId: payload.spaceId, ++ memberCount: payload.memberUserIds.length, ++ newRole: payload.newRole, ++ }), ++ ); ++ }; ++ + private onPlaybookArtefactMoved = async ( + event: PlaybookArtefactMovedEvent, + ): Promise<void> => { +diff --git a/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.spec.ts b/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.spec.ts +index d5396da9d..a5f9bcf41 100644 +--- a/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.spec.ts ++++ b/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.spec.ts +@@ -11,6 +11,7 @@ import { PackmindEventEmitterService } from '@packmind/node-utils'; + import { userFactory } from '@packmind/accounts/test/userFactory'; + import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; + import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; ++import { CannotUpdateDefaultSpaceVisibilityError } from '../../domain/errors/CannotUpdateDefaultSpaceVisibilityError'; + import { SpaceNotFoundError } from '../../domain/errors/SpaceNotFoundError'; + import { UpdateSpaceUseCase } from './UpdateSpaceUseCase'; + +@@ -152,6 +153,59 @@ describe('UpdateSpaceUseCase', () => { + }); + }); + ++ describe('when space is the default space', () => { ++ const defaultSpace = spaceFactory({ ++ id: spaceId, ++ organizationId, ++ isDefaultSpace: true, ++ type: SpaceType.open, ++ }); ++ ++ beforeEach(() => { ++ spacesPort.getSpaceById.mockResolvedValue(defaultSpace); ++ }); ++ ++ describe('when updating type', () => { ++ it('throws CannotUpdateDefaultSpaceVisibilityError', async () => { ++ await expect( ++ useCase.execute(buildCommand({ type: SpaceType.private })), ++ ).rejects.toThrow(CannotUpdateDefaultSpaceVisibilityError); ++ }); ++ ++ it('does not call updateSpace', async () => { ++ await useCase ++ .execute(buildCommand({ type: SpaceType.private })) ++ .catch(() => undefined); ++ ++ expect(spacesPort.updateSpace).not.toHaveBeenCalled(); ++ }); ++ ++ it('does not emit any event', async () => { ++ await useCase ++ .execute(buildCommand({ type: SpaceType.private })) ++ .catch(() => undefined); ++ ++ expect(eventEmitterService.emit).not.toHaveBeenCalled(); ++ }); ++ }); ++ ++ describe('when updating name', () => { ++ it('allows updating the name', async () => { ++ const updatedSpace = spaceFactory({ ++ ...defaultSpace, ++ name: 'New Name', ++ }); ++ spacesPort.updateSpace.mockResolvedValue(updatedSpace); ++ ++ const result = await useCase.execute( ++ buildCommand({ name: 'New Name' }), ++ ); ++ ++ expect(result).toEqual(updatedSpace); ++ }); ++ }); ++ }); ++ + describe('when space is not found', () => { + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(null); +diff --git a/packages/spaces/src/application/adapters/SpacesAdapter.ts b/packages/spaces/src/application/adapters/SpacesAdapter.ts +index aa116047d..eb05e237a 100644 +--- a/packages/spaces/src/application/adapters/SpacesAdapter.ts ++++ b/packages/spaces/src/application/adapters/SpacesAdapter.ts +@@ -194,6 +194,7 @@ export class SpacesAdapter implements IBaseAdapter<ISpacesPort>, ISpacesPort { + const useCase = new UpdateMemberRoleUseCase( + membershipService, + this.accountsPort, ++ this.eventEmitterService, + ); + return useCase.execute(command); + } +diff --git a/packages/spaces/src/application/usecases/UpdateMemberRoleUseCase.spec.ts b/packages/spaces/src/application/usecases/UpdateMemberRoleUseCase.spec.ts +index cb89a11e9..a0cebf7d0 100644 +--- a/packages/spaces/src/application/usecases/UpdateMemberRoleUseCase.spec.ts ++++ b/packages/spaces/src/application/usecases/UpdateMemberRoleUseCase.spec.ts +@@ -1,3 +1,4 @@ ++import { PackmindEventEmitterService } from '@packmind/node-utils'; + import { + UpdateMemberRoleCommand, + IAccountsPort, +@@ -31,6 +32,9 @@ describe('UpdateMemberRoleUseCase', () => { + let useCase: UpdateMemberRoleUseCase; + let membershipService: jest.Mocked<UserSpaceMembershipService>; + let accountsPort: jest.Mocked<IAccountsPort>; ++ let eventEmitterService: jest.Mocked< ++ Pick<PackmindEventEmitterService, 'emit'> ++ >; + + const buildCommand = ( + overrides?: Partial<UpdateMemberRoleCommand>, +@@ -54,9 +58,14 @@ describe('UpdateMemberRoleUseCase', () => { + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + ++ eventEmitterService = { ++ emit: jest.fn().mockReturnValue(true), ++ }; ++ + useCase = new UpdateMemberRoleUseCase( + membershipService, + accountsPort, ++ eventEmitterService as unknown as PackmindEventEmitterService, + stubLogger(), + ); + }); +@@ -91,6 +100,20 @@ describe('UpdateMemberRoleUseCase', () => { + UserSpaceRole.MEMBER, + ); + }); ++ ++ it('emits SpaceMembersRoleUpdatedEvent', async () => { ++ await useCase.execute(buildCommand({ role: UserSpaceRole.MEMBER })); ++ ++ expect(eventEmitterService.emit).toHaveBeenCalledWith( ++ expect.objectContaining({ ++ payload: expect.objectContaining({ ++ spaceId, ++ memberUserIds: [targetUserId], ++ newRole: UserSpaceRole.MEMBER, ++ }), ++ }), ++ ); ++ }); + }); + + describe('when the caller is not a space admin', () => { +@@ -179,6 +202,14 @@ describe('UpdateMemberRoleUseCase', () => { + + expect(membershipService.updateMembershipRole).not.toHaveBeenCalled(); + }); ++ ++ it('does not emit SpaceMembersRoleUpdatedEvent', async () => { ++ await useCase ++ .execute(buildCommand({ targetUserId: userId })) ++ .catch(() => undefined); ++ ++ expect(eventEmitterService.emit).not.toHaveBeenCalled(); ++ }); + }); + + describe('when the user is not a member of the organization', () => { +diff --git a/packages/spaces/src/application/usecases/UpdateMemberRoleUseCase.ts b/packages/spaces/src/application/usecases/UpdateMemberRoleUseCase.ts +index ef5095462..3db99a032 100644 +--- a/packages/spaces/src/application/usecases/UpdateMemberRoleUseCase.ts ++++ b/packages/spaces/src/application/usecases/UpdateMemberRoleUseCase.ts +@@ -1,6 +1,10 @@ + import { PackmindLogger } from '@packmind/logger'; ++import { PackmindEventEmitterService } from '@packmind/node-utils'; + import { ++ createOrganizationId, ++ createUserId, + IAccountsPort, ++ SpaceMembersRoleUpdatedEvent, + UpdateMemberRoleCommand, + UpdateMemberRoleResponse, + } from '@packmind/types'; +@@ -21,6 +25,7 @@ export class UpdateMemberRoleUseCase extends AbstractSpaceAdminUseCase< + constructor( + membershipService: UserSpaceMembershipService, + accountsPort: IAccountsPort, ++ private readonly eventEmitterService: PackmindEventEmitterService, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(membershipService, accountsPort, logger); +@@ -47,6 +52,19 @@ export class UpdateMemberRoleUseCase extends AbstractSpaceAdminUseCase< + command.role, + ); + ++ if (updated) { ++ this.eventEmitterService.emit( ++ new SpaceMembersRoleUpdatedEvent({ ++ userId: createUserId(command.userId), ++ organizationId: createOrganizationId(command.organizationId), ++ source: command.source ?? 'ui', ++ spaceId: command.spaceId, ++ memberUserIds: [command.targetUserId], ++ newRole: command.role, ++ }), ++ ); ++ } ++ + return { updated }; + } + } +diff --git a/packages/types/src/spaces/events/SpaceMembersRoleUpdatedEvent.ts b/packages/types/src/spaces/events/SpaceMembersRoleUpdatedEvent.ts +new file mode 100644 +index 000000000..e2d2018fa +--- /dev/null ++++ b/packages/types/src/spaces/events/SpaceMembersRoleUpdatedEvent.ts +@@ -0,0 +1,14 @@ ++import { UserEvent } from '../../events'; ++import { SpaceId } from '../SpaceId'; ++import { UserId } from '../../accounts/User'; ++import { UserSpaceRole } from '../UserSpaceMembership'; ++ ++export interface SpaceMembersRoleUpdatedPayload { ++ spaceId: SpaceId; ++ memberUserIds: UserId[]; ++ newRole: UserSpaceRole; ++} ++ ++export class SpaceMembersRoleUpdatedEvent extends UserEvent<SpaceMembersRoleUpdatedPayload> { ++ static override readonly eventName = 'spaces.space.members-role-updated'; ++} +diff --git a/packages/types/src/spaces/events/index.ts b/packages/types/src/spaces/events/index.ts +index 5360e19c1..3f7a1e91e 100644 +--- a/packages/types/src/spaces/events/index.ts ++++ b/packages/types/src/spaces/events/index.ts +@@ -1,4 +1,5 @@ + export * from './SpaceCreatedEvent'; + export * from './SpaceMembersAddedEvent'; + export * from './SpaceMembersRemovedEvent'; ++export * from './SpaceMembersRoleUpdatedEvent'; + export * from './SpaceVisibilityUpdatedEvent'; +-- +2.50.1 (Apple Git-155) + diff --git a/package.json b/package.json index 117f4b734..519cffa98 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@types/body-parser": "^1.19.6", "@types/cookie-parser": "^1.4.9", "@types/express": "^5.0.0", + "@types/diff": "^7.0.2", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/node": "20.19.9", @@ -84,6 +85,7 @@ "eslint-plugin-react-hooks": "5.0.0", "eslint-plugin-storybook": "10.3.3", "fork-ts-checker-webpack-plugin": "^9.1.0", + "glob": "^11.1.0", "husky": "^9.1.7", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", @@ -117,6 +119,7 @@ }, "dependencies": { "@amplitude/analytics-browser": "^2.25.4", + "@amplitude/analytics-core": "^2.48.2", "@amplitude/analytics-node": "^1.5.18", "@anthropic-ai/sdk": "^0.78.0", "@azure/openai": "^2.0.0", @@ -163,6 +166,7 @@ "@uiw/codemirror-theme-dracula": "^4.25.1", "@uiw/react-codemirror": "^4.25.1", "@workos-inc/node": "^8.5.0", + "async-mutex": "^0.5.0", "archiver": "^7.0.1", "axios": "^1.15.0", "bcrypt": "^6.0.0", diff --git a/packages/.claude/rules/packmind/standard-back-end-repositories-sql-queries-using-typeorm.md b/packages/.claude/rules/packmind/standard-back-end-repositories-sql-queries-using-typeorm.md index ae5d7d192..ef6f2adb5 100644 --- a/packages/.claude/rules/packmind/standard-back-end-repositories-sql-queries-using-typeorm.md +++ b/packages/.claude/rules/packmind/standard-back-end-repositories-sql-queries-using-typeorm.md @@ -3,12 +3,12 @@ name: 'Back-end repositories SQL queries using TypeORM' paths: - "**/infra/repositories/*.ts" alwaysApply: false -description: 'Standardize use of TypeORM QueryBuilder with parameterized WHERE/AND WHERE and IN (:...param) clauses in /infra/repositories/*.ts, including correct handling of soft-deleted entities via withDeleted() or includeDeleted options, to ensure type safety, prevent SQL injection, and improve maintainability and testability of all repository queries.' +description: 'This standard provides guidelines for writing SQL queries using TypeORM in back-end repositories located in /infra/repositories/\*Repository.ts. TypeORM offers multiple approaches to query data, but following consistent patterns enhances type safety, ensures automatic parameterization to prevent SQL injection, improves code maintainability, and makes queries easier to test and debug. This standard applies to all database queries in repository classes, including simple lookups, complex joins, filtering with WHERE clauses, and handling soft-deleted entities.' --- # Standard: Back-end repositories SQL queries using TypeORM -Standardize use of TypeORM QueryBuilder with parameterized WHERE/AND WHERE and IN (:...param) clauses in /infra/repositories/*.ts, including correct handling of soft-deleted entities via withDeleted() or includeDeleted options, to ensure type safety, prevent SQL injection, and improve maintainability and testability of all repository queries. : +This standard provides guidelines for writing SQL queries using TypeORM in back-end repositories located in /infra/repositories/\*Repository.ts. TypeORM offers multiple approaches to query data, but f... : * Handle soft-deleted entities properly using withDeleted() or includeDeleted options. Always respect the QueryOption parameter when provided, and only include deleted entities when explicitly requested. * Use IN clause with array parameterization for filtering by multiple values. Always pass arrays as spread parameters using :...paramName syntax to ensure proper parameterization. * Use TypeORM's QueryBuilder with parameterized queries instead of raw SQL strings. Always pass parameters as objects to where(), andWhere(), and other query methods to prevent SQL injection and ensure type safety. diff --git a/packages/.claude/rules/packmind/standard-back-end-typescript-clean-code-practices.md b/packages/.claude/rules/packmind/standard-back-end-typescript-clean-code-practices.md index a7391308b..20a0584d6 100644 --- a/packages/.claude/rules/packmind/standard-back-end-typescript-clean-code-practices.md +++ b/packages/.claude/rules/packmind/standard-back-end-typescript-clean-code-practices.md @@ -3,12 +3,12 @@ name: 'Back-end TypeScript Clean Code Practices' paths: - "**/packages/**/*.ts" alwaysApply: false -description: 'Establish back-end TypeScript clean code rules in the Packmind monorepo (/packages/**/*.ts)—including PackmindLogger constructor injection, disciplined logger.info/error usage, top-of-file static imports, custom Error subclasses, and adapter-created use cases with their own loggers—to improve maintainability, debuggability, and consistent architecture across services.' +description: 'This standard establishes clean code practices in TypeScript for back-end development to enhance maintainability and ensure consistent patterns across services. It covers logging best practices, error handling, code organization, and dependency injection patterns. These rules apply when writing services, use cases, controllers, and any back-end TypeScript code in the Packmind monorepo. Following these practices ensures code is maintainable, debuggable, and follows established architectural patterns.' --- # Standard: Back-end TypeScript Clean Code Practices -Establish back-end TypeScript clean code rules in the Packmind monorepo (/packages/**/*.ts)—including PackmindLogger constructor injection, disciplined logger.info/error usage, top-of-file static imports, custom Error subclasses, and adapter-created use cases with their own loggers—to improve maintainability, debuggability, and consistent architecture across services. : +This standard establishes clean code practices in TypeScript for back-end development to enhance maintainability and ensure consistent patterns across services. It covers logging best practices, error... : * Avoid excessive logger.debug calls in production code and limit logging to essential logger.info statements. Use logger.info for important business events, logger.error for error handling, and add logger.debug manually only when debugging specific issues. * Inject PackmindLogger as a constructor parameter with a default value using a variable or a string representing the class name. * Instantiate use cases in adapters without passing the adapter's logger; use cases must create their own logger for proper origin tracking. diff --git a/packages/.claude/rules/packmind/standard-domain-events.md b/packages/.claude/rules/packmind/standard-domain-events.md index ad7f8334a..d4993f960 100644 --- a/packages/.claude/rules/packmind/standard-domain-events.md +++ b/packages/.claude/rules/packmind/standard-domain-events.md @@ -3,12 +3,12 @@ name: 'Domain Events' paths: - "**/*.ts" alwaysApply: false -description: 'Standardize TypeScript domain events by defining `{EventName}Payload` interfaces and `Event`-suffixed classes in `packages/types/src/{domain}/events/` (barrel `index.ts`) extending `UserEvent`/`SystemEvent` with `static override readonly eventName` in `domain.entity.action` format, emitting via `eventEmitterService.emit(new MyEvent(payload))`, and handling via `PackmindListener<TAdapter>.registerHandlers()` with `this.subscribe(EventClass, this.handlerMethod)` and arrow-function handlers to enable decoupled cross-domain communication and reliable subscriptions.' +description: 'Domain events enable communication between hexas without creating direct dependencies. Apply these rules when creating events, emitting them, or implementing listeners to react to events from other domains.' --- # Standard: Domain Events -Standardize TypeScript domain events by defining `{EventName}Payload` interfaces and `Event`-suffixed classes in `packages/types/src/{domain}/events/` (barrel `index.ts`) extending `UserEvent`/`SystemEvent` with `static override readonly eventName` in `domain.entity.action` format, emitting via `eventEmitterService.emit(new MyEvent(payload))`, and handling via `PackmindListener<TAdapter>.registerHandlers()` with `this.subscribe(EventClass, this.handlerMethod)` and arrow-function handlers to enable decoupled cross-domain communication and reliable subscriptions. : +Domain events enable communication between hexas without creating direct dependencies. Apply these rules when creating events, emitting them, or implementing listeners to react to events from other do... : * Define event classes in `packages/types/src/{domain}/events/` with an `index.ts` barrel file * Define payload as a separate `{EventName}Payload` interface * Extend `PackmindListener<TAdapter>` and implement `registerHandlers()` to subscribe to events diff --git a/packages/.claude/rules/packmind/standard-port-adapter-cross-domain-integration.md b/packages/.claude/rules/packmind/standard-port-adapter-cross-domain-integration.md index 1d7a9b7fb..488a2fc09 100644 --- a/packages/.claude/rules/packmind/standard-port-adapter-cross-domain-integration.md +++ b/packages/.claude/rules/packmind/standard-port-adapter-cross-domain-integration.md @@ -4,12 +4,12 @@ paths: - "**/*Adapter.ts" - "**/*Hexa.ts" alwaysApply: false -description: 'Define port interfaces and cross-domain contracts in @packmind/types and packages/types/src/<domain>/contracts/, expose adapters via Hexa getters, and use async HexaFactory.initialize() with registry isRegistered()/get() checks to prevent circular dependencies, maintain loose coupling, and support resilient synchronous and asynchronous cross-domain operations.' +description: 'This standard defines how domain packages communicate with each other through the Port/Adapter pattern in our DDD monorepo architecture. By following these rules, you prevent circular dependencies, maintain loose coupling between domains, and enable both synchronous and asynchronous cross-domain operations with graceful degradation for optional dependencies.' --- # Standard: Port-Adapter Cross-Domain Integration -Define port interfaces and cross-domain contracts in @packmind/types and packages/types/src/<domain>/contracts/, expose adapters via Hexa getters, and use async HexaFactory.initialize() with registry isRegistered()/get() checks to prevent circular dependencies, maintain loose coupling, and support resilient synchronous and asynchronous cross-domain operations. : +This standard defines how domain packages communicate with each other through the Port/Adapter pattern in our DDD monorepo architecture. By following these rules, you prevent circular dependencies, ma... : * Declare all Command and Response types that define contracts between domains in packages/types/src/<domain>/contracts/ to ensure a single source of truth and prevent import cycles between domain packages. * Define port interfaces in @packmind/types with domain-specific contracts that expose only the operations needed by consumers, where each method accepts a Command type and returns a Response type or domain entity. * Expose adapters through public getter methods in the Hexa class that return the port interface implementation, as this is the only way external domains should access another domain's functionality. diff --git a/packages/.claude/rules/packmind/standard-scoped-repository-patterns.md b/packages/.claude/rules/packmind/standard-scoped-repository-patterns.md index 27021c980..e42f6ec24 100644 --- a/packages/.claude/rules/packmind/standard-scoped-repository-patterns.md +++ b/packages/.claude/rules/packmind/standard-scoped-repository-patterns.md @@ -3,12 +3,12 @@ name: 'Scoped Repository Patterns' paths: - "Repositories extending SpaceScopedRepository or OrganizationScopedRepository (**/infra/repositories/*Repository.ts)" alwaysApply: false -description: 'Enforce tenant-safe repository query and write patterns in Packmind repositories extending SpaceScopedRepository or OrganizationScopedRepository by using createScopedQueryBuilder, avoiding findById overrides, requiring scope IDs on collection methods, delegating saves/updates to this.add(), and testing cross-scope isolation to ensure consistent data isolation and soft-delete correctness.' +description: 'Enforce data isolation and consistent query patterns in repositories extending SpaceScopedRepository or OrganizationScopedRepository, ensuring tenant-safe data access across the Packmind codebase.' --- # Standard: Scoped Repository Patterns -Enforce tenant-safe repository query and write patterns in Packmind repositories extending SpaceScopedRepository or OrganizationScopedRepository by using createScopedQueryBuilder, avoiding findById overrides, requiring scope IDs on collection methods, delegating saves/updates to this.add(), and testing cross-scope isolation to ensure consistent data isolation and soft-delete correctness. : +Enforce data isolation and consistent query patterns in repositories extending SpaceScopedRepository or OrganizationScopedRepository, ensuring tenant-safe data access across the Packmind codebase. : * Delegate write operations (`save`, `update`) to the inherited `this.add()` method * Do not override `findById` in scoped repositories — the base class handles soft delete via `QueryOption.includeDeleted` * Include `spaceId` or `organizationId` as a parameter on all collection-returning domain interface methods diff --git a/packages/.claude/rules/packmind/standard-use-case-architecture-patterns.md b/packages/.claude/rules/packmind/standard-use-case-architecture-patterns.md index 4da7ddd62..c972ee698 100644 --- a/packages/.claude/rules/packmind/standard-use-case-architecture-patterns.md +++ b/packages/.claude/rules/packmind/standard-use-case-architecture-patterns.md @@ -1,12 +1,12 @@ --- name: 'Use Case Architecture Patterns' alwaysApply: true -description: 'Standardize Packmind monorepo hexagonal-architecture use cases with contract-per-file Command/Response/IUseCase types, PackmindCommand/PublicPackmindCommand/SpaceMemberCommand inputs, and AbstractMemberUseCase/AbstractAdminUseCase/AbstractSpaceMemberUseCase execution methods to enforce consistent auth validation, separation of concerns, and type-safe command passing.' +description: 'This standard defines how to structure use cases in the Packmind monorepo following hexagonal architecture principles. Use cases represent the entry points to domain logic and must follow consistent patterns for authentication, authorization, and command/response structures. This standard applies when creating new use cases, refactoring existing ones, or implementing cross-domain integrations through hexagon facades. Each use case corresponds to a single business operation and must be properly typed, validated, and accessible through a clean contract interface. The standard enforces separation of concerns between public (unauthenticated), member (organization member), and admin (organization admin) operations.' --- # Standard: Use Case Architecture Patterns -Standardize Packmind monorepo hexagonal-architecture use cases with contract-per-file Command/Response/IUseCase types, PackmindCommand/PublicPackmindCommand/SpaceMemberCommand inputs, and AbstractMemberUseCase/AbstractAdminUseCase/AbstractSpaceMemberUseCase execution methods to enforce consistent auth validation, separation of concerns, and type-safe command passing. : +This standard defines how to structure use cases in the Packmind monorepo following hexagonal architecture principles. Use cases represent the entry points to domain logic and must follow consistent p... : * Accept commands as single parameters in adapter methods rather than multiple individual parameters to ensure consistency and easier parameter additions * Define each use case contract in its own file at packages/types/src/{domain}/contracts/{UseCaseName}.ts with Command type, Response type, and UseCase interface exports * Export exactly three type definitions from each use case contract file: {Name}Command for input parameters, {Name}Response for return value, and I{Name}UseCase as the interface combining both diff --git a/packages/.cursor/rules/packmind/standard-back-end-repositories-sql-queries-using-typeorm.mdc b/packages/.cursor/rules/packmind/standard-back-end-repositories-sql-queries-using-typeorm.mdc index 642581f46..a0cab7cd4 100644 --- a/packages/.cursor/rules/packmind/standard-back-end-repositories-sql-queries-using-typeorm.mdc +++ b/packages/.cursor/rules/packmind/standard-back-end-repositories-sql-queries-using-typeorm.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: Back-end repositories SQL queries using TypeORM -Standardize use of TypeORM QueryBuilder with parameterized WHERE/AND WHERE and IN (:...param) clauses in /infra/repositories/*.ts, including correct handling of soft-deleted entities via withDeleted() or includeDeleted options, to ensure type safety, prevent SQL injection, and improve maintainability and testability of all repository queries. : +This standard provides guidelines for writing SQL queries using TypeORM in back-end repositories located in /infra/repositories/\*Repository.ts. TypeORM offers multiple approaches to query data, but f... : * Handle soft-deleted entities properly using withDeleted() or includeDeleted options. Always respect the QueryOption parameter when provided, and only include deleted entities when explicitly requested. * Use IN clause with array parameterization for filtering by multiple values. Always pass arrays as spread parameters using :...paramName syntax to ensure proper parameterization. * Use TypeORM's QueryBuilder with parameterized queries instead of raw SQL strings. Always pass parameters as objects to where(), andWhere(), and other query methods to prevent SQL injection and ensure type safety. diff --git a/packages/.cursor/rules/packmind/standard-back-end-typescript-clean-code-practices.mdc b/packages/.cursor/rules/packmind/standard-back-end-typescript-clean-code-practices.mdc index b76db64ea..145fea339 100644 --- a/packages/.cursor/rules/packmind/standard-back-end-typescript-clean-code-practices.mdc +++ b/packages/.cursor/rules/packmind/standard-back-end-typescript-clean-code-practices.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: Back-end TypeScript Clean Code Practices -Establish back-end TypeScript clean code rules in the Packmind monorepo (/packages/**/*.ts)—including PackmindLogger constructor injection, disciplined logger.info/error usage, top-of-file static imports, custom Error subclasses, and adapter-created use cases with their own loggers—to improve maintainability, debuggability, and consistent architecture across services. : +This standard establishes clean code practices in TypeScript for back-end development to enhance maintainability and ensure consistent patterns across services. It covers logging best practices, error... : * Avoid excessive logger.debug calls in production code and limit logging to essential logger.info statements. Use logger.info for important business events, logger.error for error handling, and add logger.debug manually only when debugging specific issues. * Inject PackmindLogger as a constructor parameter with a default value using a variable or a string representing the class name. * Instantiate use cases in adapters without passing the adapter's logger; use cases must create their own logger for proper origin tracking. diff --git a/packages/.cursor/rules/packmind/standard-domain-events.mdc b/packages/.cursor/rules/packmind/standard-domain-events.mdc index 0872b784a..1c28e885d 100644 --- a/packages/.cursor/rules/packmind/standard-domain-events.mdc +++ b/packages/.cursor/rules/packmind/standard-domain-events.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: Domain Events -Standardize TypeScript domain events by defining `{EventName}Payload` interfaces and `Event`-suffixed classes in `packages/types/src/{domain}/events/` (barrel `index.ts`) extending `UserEvent`/`SystemEvent` with `static override readonly eventName` in `domain.entity.action` format, emitting via `eventEmitterService.emit(new MyEvent(payload))`, and handling via `PackmindListener<TAdapter>.registerHandlers()` with `this.subscribe(EventClass, this.handlerMethod)` and arrow-function handlers to enable decoupled cross-domain communication and reliable subscriptions. : +Domain events enable communication between hexas without creating direct dependencies. Apply these rules when creating events, emitting them, or implementing listeners to react to events from other do... : * Define event classes in `packages/types/src/{domain}/events/` with an `index.ts` barrel file * Define payload as a separate `{EventName}Payload` interface * Extend `PackmindListener<TAdapter>` and implement `registerHandlers()` to subscribe to events diff --git a/packages/.cursor/rules/packmind/standard-port-adapter-cross-domain-integration.mdc b/packages/.cursor/rules/packmind/standard-port-adapter-cross-domain-integration.mdc index 98bf5c41c..6e598d32f 100644 --- a/packages/.cursor/rules/packmind/standard-port-adapter-cross-domain-integration.mdc +++ b/packages/.cursor/rules/packmind/standard-port-adapter-cross-domain-integration.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: Port-Adapter Cross-Domain Integration -Define port interfaces and cross-domain contracts in @packmind/types and packages/types/src/<domain>/contracts/, expose adapters via Hexa getters, and use async HexaFactory.initialize() with registry isRegistered()/get() checks to prevent circular dependencies, maintain loose coupling, and support resilient synchronous and asynchronous cross-domain operations. : +This standard defines how domain packages communicate with each other through the Port/Adapter pattern in our DDD monorepo architecture. By following these rules, you prevent circular dependencies, ma... : * Declare all Command and Response types that define contracts between domains in packages/types/src/<domain>/contracts/ to ensure a single source of truth and prevent import cycles between domain packages. * Define port interfaces in @packmind/types with domain-specific contracts that expose only the operations needed by consumers, where each method accepts a Command type and returns a Response type or domain entity. * Expose adapters through public getter methods in the Hexa class that return the port interface implementation, as this is the only way external domains should access another domain's functionality. diff --git a/packages/.cursor/rules/packmind/standard-scoped-repository-patterns.mdc b/packages/.cursor/rules/packmind/standard-scoped-repository-patterns.mdc index ab127884b..c0b31b5f5 100644 --- a/packages/.cursor/rules/packmind/standard-scoped-repository-patterns.mdc +++ b/packages/.cursor/rules/packmind/standard-scoped-repository-patterns.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: Scoped Repository Patterns -Enforce tenant-safe repository query and write patterns in Packmind repositories extending SpaceScopedRepository or OrganizationScopedRepository by using createScopedQueryBuilder, avoiding findById overrides, requiring scope IDs on collection methods, delegating saves/updates to this.add(), and testing cross-scope isolation to ensure consistent data isolation and soft-delete correctness. : +Enforce data isolation and consistent query patterns in repositories extending SpaceScopedRepository or OrganizationScopedRepository, ensuring tenant-safe data access across the Packmind codebase. : * Delegate write operations (`save`, `update`) to the inherited `this.add()` method * Do not override `findById` in scoped repositories — the base class handles soft delete via `QueryOption.includeDeleted` * Include `spaceId` or `organizationId` as a parameter on all collection-returning domain interface methods diff --git a/packages/.cursor/rules/packmind/standard-use-case-architecture-patterns.mdc b/packages/.cursor/rules/packmind/standard-use-case-architecture-patterns.mdc index e1cfa43bf..1f3427d84 100644 --- a/packages/.cursor/rules/packmind/standard-use-case-architecture-patterns.mdc +++ b/packages/.cursor/rules/packmind/standard-use-case-architecture-patterns.mdc @@ -3,7 +3,7 @@ alwaysApply: true --- # Standard: Use Case Architecture Patterns -Standardize Packmind monorepo hexagonal-architecture use cases with contract-per-file Command/Response/IUseCase types, PackmindCommand/PublicPackmindCommand/SpaceMemberCommand inputs, and AbstractMemberUseCase/AbstractAdminUseCase/AbstractSpaceMemberUseCase execution methods to enforce consistent auth validation, separation of concerns, and type-safe command passing. : +This standard defines how to structure use cases in the Packmind monorepo following hexagonal architecture principles. Use cases represent the entry points to domain logic and must follow consistent p... : * Accept commands as single parameters in adapter methods rather than multiple individual parameters to ensure consistency and easier parameter additions * Define each use case contract in its own file at packages/types/src/{domain}/contracts/{UseCaseName}.ts with Command type, Response type, and UseCase interface exports * Export exactly three type definitions from each use case contract file: {Name}Command for input parameters, {Name}Response for return value, and I{Name}UseCase as the interface combining both diff --git a/packages/.github/instructions/packmind-back-end-repositories-sql-queries-using-typeorm.instructions.md b/packages/.github/instructions/packmind-back-end-repositories-sql-queries-using-typeorm.instructions.md index 43633b59c..21a60a6b1 100644 --- a/packages/.github/instructions/packmind-back-end-repositories-sql-queries-using-typeorm.instructions.md +++ b/packages/.github/instructions/packmind-back-end-repositories-sql-queries-using-typeorm.instructions.md @@ -3,7 +3,7 @@ applyTo: '**/infra/repositories/*.ts' --- # Standard: Back-end repositories SQL queries using TypeORM -Standardize use of TypeORM QueryBuilder with parameterized WHERE/AND WHERE and IN (:...param) clauses in /infra/repositories/*.ts, including correct handling of soft-deleted entities via withDeleted() or includeDeleted options, to ensure type safety, prevent SQL injection, and improve maintainability and testability of all repository queries. : +This standard provides guidelines for writing SQL queries using TypeORM in back-end repositories located in /infra/repositories/\*Repository.ts. TypeORM offers multiple approaches to query data, but f... : * Handle soft-deleted entities properly using withDeleted() or includeDeleted options. Always respect the QueryOption parameter when provided, and only include deleted entities when explicitly requested. * Use IN clause with array parameterization for filtering by multiple values. Always pass arrays as spread parameters using :...paramName syntax to ensure proper parameterization. * Use TypeORM's QueryBuilder with parameterized queries instead of raw SQL strings. Always pass parameters as objects to where(), andWhere(), and other query methods to prevent SQL injection and ensure type safety. diff --git a/packages/.github/instructions/packmind-back-end-typescript-clean-code-practices.instructions.md b/packages/.github/instructions/packmind-back-end-typescript-clean-code-practices.instructions.md index 7505aeb80..6a77b92c3 100644 --- a/packages/.github/instructions/packmind-back-end-typescript-clean-code-practices.instructions.md +++ b/packages/.github/instructions/packmind-back-end-typescript-clean-code-practices.instructions.md @@ -3,7 +3,7 @@ applyTo: '**/packages/**/*.ts' --- # Standard: Back-end TypeScript Clean Code Practices -Establish back-end TypeScript clean code rules in the Packmind monorepo (/packages/**/*.ts)—including PackmindLogger constructor injection, disciplined logger.info/error usage, top-of-file static imports, custom Error subclasses, and adapter-created use cases with their own loggers—to improve maintainability, debuggability, and consistent architecture across services. : +This standard establishes clean code practices in TypeScript for back-end development to enhance maintainability and ensure consistent patterns across services. It covers logging best practices, error... : * Avoid excessive logger.debug calls in production code and limit logging to essential logger.info statements. Use logger.info for important business events, logger.error for error handling, and add logger.debug manually only when debugging specific issues. * Inject PackmindLogger as a constructor parameter with a default value using a variable or a string representing the class name. * Instantiate use cases in adapters without passing the adapter's logger; use cases must create their own logger for proper origin tracking. diff --git a/packages/.github/instructions/packmind-domain-events.instructions.md b/packages/.github/instructions/packmind-domain-events.instructions.md index 5403f8839..b2f28dca3 100644 --- a/packages/.github/instructions/packmind-domain-events.instructions.md +++ b/packages/.github/instructions/packmind-domain-events.instructions.md @@ -3,7 +3,7 @@ applyTo: '**/*.ts' --- # Standard: Domain Events -Standardize TypeScript domain events by defining `{EventName}Payload` interfaces and `Event`-suffixed classes in `packages/types/src/{domain}/events/` (barrel `index.ts`) extending `UserEvent`/`SystemEvent` with `static override readonly eventName` in `domain.entity.action` format, emitting via `eventEmitterService.emit(new MyEvent(payload))`, and handling via `PackmindListener<TAdapter>.registerHandlers()` with `this.subscribe(EventClass, this.handlerMethod)` and arrow-function handlers to enable decoupled cross-domain communication and reliable subscriptions. : +Domain events enable communication between hexas without creating direct dependencies. Apply these rules when creating events, emitting them, or implementing listeners to react to events from other do... : * Define event classes in `packages/types/src/{domain}/events/` with an `index.ts` barrel file * Define payload as a separate `{EventName}Payload` interface * Extend `PackmindListener<TAdapter>` and implement `registerHandlers()` to subscribe to events diff --git a/packages/.github/instructions/packmind-port-adapter-cross-domain-integration.instructions.md b/packages/.github/instructions/packmind-port-adapter-cross-domain-integration.instructions.md index 026b55ed1..798ac9344 100644 --- a/packages/.github/instructions/packmind-port-adapter-cross-domain-integration.instructions.md +++ b/packages/.github/instructions/packmind-port-adapter-cross-domain-integration.instructions.md @@ -3,7 +3,7 @@ applyTo: '**/*Adapter.ts,**/*Hexa.ts' --- # Standard: Port-Adapter Cross-Domain Integration -Define port interfaces and cross-domain contracts in @packmind/types and packages/types/src/<domain>/contracts/, expose adapters via Hexa getters, and use async HexaFactory.initialize() with registry isRegistered()/get() checks to prevent circular dependencies, maintain loose coupling, and support resilient synchronous and asynchronous cross-domain operations. : +This standard defines how domain packages communicate with each other through the Port/Adapter pattern in our DDD monorepo architecture. By following these rules, you prevent circular dependencies, ma... : * Declare all Command and Response types that define contracts between domains in packages/types/src/<domain>/contracts/ to ensure a single source of truth and prevent import cycles between domain packages. * Define port interfaces in @packmind/types with domain-specific contracts that expose only the operations needed by consumers, where each method accepts a Command type and returns a Response type or domain entity. * Expose adapters through public getter methods in the Hexa class that return the port interface implementation, as this is the only way external domains should access another domain's functionality. diff --git a/packages/.github/instructions/packmind-scoped-repository-patterns.instructions.md b/packages/.github/instructions/packmind-scoped-repository-patterns.instructions.md index 1540ffec5..13b240034 100644 --- a/packages/.github/instructions/packmind-scoped-repository-patterns.instructions.md +++ b/packages/.github/instructions/packmind-scoped-repository-patterns.instructions.md @@ -3,7 +3,7 @@ applyTo: 'Repositories extending SpaceScopedRepository or OrganizationScopedRepo --- # Standard: Scoped Repository Patterns -Enforce tenant-safe repository query and write patterns in Packmind repositories extending SpaceScopedRepository or OrganizationScopedRepository by using createScopedQueryBuilder, avoiding findById overrides, requiring scope IDs on collection methods, delegating saves/updates to this.add(), and testing cross-scope isolation to ensure consistent data isolation and soft-delete correctness. : +Enforce data isolation and consistent query patterns in repositories extending SpaceScopedRepository or OrganizationScopedRepository, ensuring tenant-safe data access across the Packmind codebase. : * Delegate write operations (`save`, `update`) to the inherited `this.add()` method * Do not override `findById` in scoped repositories — the base class handles soft delete via `QueryOption.includeDeleted` * Include `spaceId` or `organizationId` as a parameter on all collection-returning domain interface methods diff --git a/packages/.github/instructions/packmind-use-case-architecture-patterns.instructions.md b/packages/.github/instructions/packmind-use-case-architecture-patterns.instructions.md index a9db7bf88..09b78d3c3 100644 --- a/packages/.github/instructions/packmind-use-case-architecture-patterns.instructions.md +++ b/packages/.github/instructions/packmind-use-case-architecture-patterns.instructions.md @@ -3,7 +3,7 @@ applyTo: '**' --- # Standard: Use Case Architecture Patterns -Standardize Packmind monorepo hexagonal-architecture use cases with contract-per-file Command/Response/IUseCase types, PackmindCommand/PublicPackmindCommand/SpaceMemberCommand inputs, and AbstractMemberUseCase/AbstractAdminUseCase/AbstractSpaceMemberUseCase execution methods to enforce consistent auth validation, separation of concerns, and type-safe command passing. : +This standard defines how to structure use cases in the Packmind monorepo following hexagonal architecture principles. Use cases represent the entry points to domain logic and must follow consistent p... : * Accept commands as single parameters in adapter methods rather than multiple individual parameters to ensure consistency and easier parameter additions * Define each use case contract in its own file at packages/types/src/{domain}/contracts/{UseCaseName}.ts with Command type, Response type, and UseCase interface exports * Export exactly three type definitions from each use case contract file: {Name}Command for input parameters, {Name}Response for return value, and I{Name}UseCase as the interface combining both diff --git a/packages/.gitlab/duo/chat-rules.md b/packages/.gitlab/duo/chat-rules.md index 2c27873c0..da98fc9f8 100644 --- a/packages/.gitlab/duo/chat-rules.md +++ b/packages/.gitlab/duo/chat-rules.md @@ -11,7 +11,7 @@ Failure to follow these standards may lead to inconsistencies, errors, or rework # Standard: Back-end repositories SQL queries using TypeORM -Standardize use of TypeORM QueryBuilder with parameterized WHERE/AND WHERE and IN (:...param) clauses in /infra/repositories/*.ts, including correct handling of soft-deleted entities via withDeleted() or includeDeleted options, to ensure type safety, prevent SQL injection, and improve maintainability and testability of all repository queries. : +This standard provides guidelines for writing SQL queries using TypeORM in back-end repositories located in /infra/repositories/\*Repository.ts. TypeORM offers multiple approaches to query data, but f... : * Handle soft-deleted entities properly using withDeleted() or includeDeleted options. Always respect the QueryOption parameter when provided, and only include deleted entities when explicitly requested. * Use IN clause with array parameterization for filtering by multiple values. Always pass arrays as spread parameters using :...paramName syntax to ensure proper parameterization. * Use TypeORM's QueryBuilder with parameterized queries instead of raw SQL strings. Always pass parameters as objects to where(), andWhere(), and other query methods to prevent SQL injection and ensure type safety. @@ -20,7 +20,7 @@ Full standard is available here for further request: [Back-end repositories SQL # Standard: Back-end TypeScript Clean Code Practices -Establish back-end TypeScript clean code rules in the Packmind monorepo (/packages/**/*.ts)—including PackmindLogger constructor injection, disciplined logger.info/error usage, top-of-file static imports, custom Error subclasses, and adapter-created use cases with their own loggers—to improve maintainability, debuggability, and consistent architecture across services. : +This standard establishes clean code practices in TypeScript for back-end development to enhance maintainability and ensure consistent patterns across services. It covers logging best practices, error... : * Avoid excessive logger.debug calls in production code and limit logging to essential logger.info statements. Use logger.info for important business events, logger.error for error handling, and add logger.debug manually only when debugging specific issues. * Inject PackmindLogger as a constructor parameter with a default value using a variable or a string representing the class name. * Instantiate use cases in adapters without passing the adapter's logger; use cases must create their own logger for proper origin tracking. @@ -31,7 +31,7 @@ Full standard is available here for further request: [Back-end TypeScript Clean # Standard: Domain Events -Standardize TypeScript domain events by defining `{EventName}Payload` interfaces and `Event`-suffixed classes in `packages/types/src/{domain}/events/` (barrel `index.ts`) extending `UserEvent`/`SystemEvent` with `static override readonly eventName` in `domain.entity.action` format, emitting via `eventEmitterService.emit(new MyEvent(payload))`, and handling via `PackmindListener<TAdapter>.registerHandlers()` with `this.subscribe(EventClass, this.handlerMethod)` and arrow-function handlers to enable decoupled cross-domain communication and reliable subscriptions. : +Domain events enable communication between hexas without creating direct dependencies. Apply these rules when creating events, emitting them, or implementing listeners to react to events from other do... : * Define event classes in `packages/types/src/{domain}/events/` with an `index.ts` barrel file * Define payload as a separate `{EventName}Payload` interface * Extend `PackmindListener<TAdapter>` and implement `registerHandlers()` to subscribe to events @@ -47,7 +47,7 @@ Full standard is available here for further request: [Domain Events](../../.pack # Standard: Port-Adapter Cross-Domain Integration -Define port interfaces and cross-domain contracts in @packmind/types and packages/types/src/<domain>/contracts/, expose adapters via Hexa getters, and use async HexaFactory.initialize() with registry isRegistered()/get() checks to prevent circular dependencies, maintain loose coupling, and support resilient synchronous and asynchronous cross-domain operations. : +This standard defines how domain packages communicate with each other through the Port/Adapter pattern in our DDD monorepo architecture. By following these rules, you prevent circular dependencies, ma... : * Declare all Command and Response types that define contracts between domains in packages/types/src/<domain>/contracts/ to ensure a single source of truth and prevent import cycles between domain packages. * Define port interfaces in @packmind/types with domain-specific contracts that expose only the operations needed by consumers, where each method accepts a Command type and returns a Response type or domain entity. * Expose adapters through public getter methods in the Hexa class that return the port interface implementation, as this is the only way external domains should access another domain's functionality. @@ -58,7 +58,7 @@ Full standard is available here for further request: [Port-Adapter Cross-Domain # Standard: Scoped Repository Patterns -Enforce tenant-safe repository query and write patterns in Packmind repositories extending SpaceScopedRepository or OrganizationScopedRepository by using createScopedQueryBuilder, avoiding findById overrides, requiring scope IDs on collection methods, delegating saves/updates to this.add(), and testing cross-scope isolation to ensure consistent data isolation and soft-delete correctness. : +Enforce data isolation and consistent query patterns in repositories extending SpaceScopedRepository or OrganizationScopedRepository, ensuring tenant-safe data access across the Packmind codebase. : * Delegate write operations (`save`, `update`) to the inherited `this.add()` method * Do not override `findById` in scoped repositories — the base class handles soft delete via `QueryOption.includeDeleted` * Include `spaceId` or `organizationId` as a parameter on all collection-returning domain interface methods @@ -69,7 +69,7 @@ Full standard is available here for further request: [Scoped Repository Patterns # Standard: Use Case Architecture Patterns -Standardize Packmind monorepo hexagonal-architecture use cases with contract-per-file Command/Response/IUseCase types, PackmindCommand/PublicPackmindCommand/SpaceMemberCommand inputs, and AbstractMemberUseCase/AbstractAdminUseCase/AbstractSpaceMemberUseCase execution methods to enforce consistent auth validation, separation of concerns, and type-safe command passing. : +This standard defines how to structure use cases in the Packmind monorepo following hexagonal architecture principles. Use cases represent the entry points to domain logic and must follow consistent p... : * Accept commands as single parameters in adapter methods rather than multiple individual parameters to ensure consistency and easier parameter additions * Define each use case contract in its own file at packages/types/src/{domain}/contracts/{UseCaseName}.ts with Command type, Response type, and UseCase interface exports * Export exactly three type definitions from each use case contract file: {Name}Command for input parameters, {Name}Response for return value, and I{Name}UseCase as the interface combining both diff --git a/packages/.packmind/standards-index.md b/packages/.packmind/standards-index.md index 674a8878f..37904af7b 100644 --- a/packages/.packmind/standards-index.md +++ b/packages/.packmind/standards-index.md @@ -4,12 +4,12 @@ This standards index contains all available coding standards that can be used by ## Available Standards -- [Back-end repositories SQL queries using TypeORM](./standards/back-end-repositories-sql-queries-using-typeorm.md) : Standardize use of TypeORM QueryBuilder with parameterized WHERE/AND WHERE and IN (:...param) clauses in /infra/repositories/*.ts, including correct handling of soft-deleted entities via withDeleted() or includeDeleted options, to ensure type safety, prevent SQL injection, and improve maintainability and testability of all repository queries. -- [Back-end TypeScript Clean Code Practices](./standards/back-end-typescript-clean-code-practices.md) : Establish back-end TypeScript clean code rules in the Packmind monorepo (/packages/**/*.ts)—including PackmindLogger constructor injection, disciplined logger.info/error usage, top-of-file static imports, custom Error subclasses, and adapter-created use cases with their own loggers—to improve maintainability, debuggability, and consistent architecture across services. -- [Domain Events](./standards/domain-events.md) : Standardize TypeScript domain events by defining `{EventName}Payload` interfaces and `Event`-suffixed classes in `packages/types/src/{domain}/events/` (barrel `index.ts`) extending `UserEvent`/`SystemEvent` with `static override readonly eventName` in `domain.entity.action` format, emitting via `eventEmitterService.emit(new MyEvent(payload))`, and handling via `PackmindListener<TAdapter>.registerHandlers()` with `this.subscribe(EventClass, this.handlerMethod)` and arrow-function handlers to enable decoupled cross-domain communication and reliable subscriptions. -- [Port-Adapter Cross-Domain Integration](./standards/port-adapter-cross-domain-integration.md) : Define port interfaces and cross-domain contracts in @packmind/types and packages/types/src/<domain>/contracts/, expose adapters via Hexa getters, and use async HexaFactory.initialize() with registry isRegistered()/get() checks to prevent circular dependencies, maintain loose coupling, and support resilient synchronous and asynchronous cross-domain operations. -- [Scoped Repository Patterns](./standards/scoped-repository-patterns.md) : Enforce tenant-safe repository query and write patterns in Packmind repositories extending SpaceScopedRepository or OrganizationScopedRepository by using createScopedQueryBuilder, avoiding findById overrides, requiring scope IDs on collection methods, delegating saves/updates to this.add(), and testing cross-scope isolation to ensure consistent data isolation and soft-delete correctness. -- [Use Case Architecture Patterns](./standards/use-case-architecture-patterns.md) : Standardize Packmind monorepo hexagonal-architecture use cases with contract-per-file Command/Response/IUseCase types, PackmindCommand/PublicPackmindCommand/SpaceMemberCommand inputs, and AbstractMemberUseCase/AbstractAdminUseCase/AbstractSpaceMemberUseCase execution methods to enforce consistent auth validation, separation of concerns, and type-safe command passing. +- [Back-end repositories SQL queries using TypeORM](./standards/back-end-repositories-sql-queries-using-typeorm.md) : This standard provides guidelines for writing SQL queries using TypeORM in back-end repositories located in /infra/repositories/\*Repository.ts. TypeORM offers multiple approaches to query data, but following consistent patterns enhances type safety, ensures automatic parameterization to prevent SQL injection, improves code maintainability, and makes queries easier to test and debug. This standard applies to all database queries in repository classes, including simple lookups, complex joins, filtering with WHERE clauses, and handling soft-deleted entities. +- [Back-end TypeScript Clean Code Practices](./standards/back-end-typescript-clean-code-practices.md) : This standard establishes clean code practices in TypeScript for back-end development to enhance maintainability and ensure consistent patterns across services. It covers logging best practices, error handling, code organization, and dependency injection patterns. These rules apply when writing services, use cases, controllers, and any back-end TypeScript code in the Packmind monorepo. Following these practices ensures code is maintainable, debuggable, and follows established architectural patterns. +- [Domain Events](./standards/domain-events.md) : Domain events enable communication between hexas without creating direct dependencies. Apply these rules when creating events, emitting them, or implementing listeners to react to events from other domains. +- [Port-Adapter Cross-Domain Integration](./standards/port-adapter-cross-domain-integration.md) : This standard defines how domain packages communicate with each other through the Port/Adapter pattern in our DDD monorepo architecture. By following these rules, you prevent circular dependencies, maintain loose coupling between domains, and enable both synchronous and asynchronous cross-domain operations with graceful degradation for optional dependencies. +- [Scoped Repository Patterns](./standards/scoped-repository-patterns.md) : Enforce data isolation and consistent query patterns in repositories extending SpaceScopedRepository or OrganizationScopedRepository, ensuring tenant-safe data access across the Packmind codebase. +- [Use Case Architecture Patterns](./standards/use-case-architecture-patterns.md) : This standard defines how to structure use cases in the Packmind monorepo following hexagonal architecture principles. Use cases represent the entry points to domain logic and must follow consistent patterns for authentication, authorization, and command/response structures. This standard applies when creating new use cases, refactoring existing ones, or implementing cross-domain integrations through hexagon facades. Each use case corresponds to a single business operation and must be properly typed, validated, and accessible through a clean contract interface. The standard enforces separation of concerns between public (unauthenticated), member (organization member), and admin (organization admin) operations. --- diff --git a/packages/AGENTS.md b/packages/AGENTS.md index d9d91e36b..2a7dd6600 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -65,7 +65,7 @@ Failure to follow these standards may lead to inconsistencies, errors, or rework # Standard: Back-end repositories SQL queries using TypeORM -Standardize use of TypeORM QueryBuilder with parameterized WHERE/AND WHERE and IN (:...param) clauses in /infra/repositories/*.ts, including correct handling of soft-deleted entities via withDeleted() or includeDeleted options, to ensure type safety, prevent SQL injection, and improve maintainability and testability of all repository queries. : +This standard provides guidelines for writing SQL queries using TypeORM in back-end repositories located in /infra/repositories/\*Repository.ts. TypeORM offers multiple approaches to query data, but f... : * Handle soft-deleted entities properly using withDeleted() or includeDeleted options. Always respect the QueryOption parameter when provided, and only include deleted entities when explicitly requested. * Use IN clause with array parameterization for filtering by multiple values. Always pass arrays as spread parameters using :...paramName syntax to ensure proper parameterization. * Use TypeORM's QueryBuilder with parameterized queries instead of raw SQL strings. Always pass parameters as objects to where(), andWhere(), and other query methods to prevent SQL injection and ensure type safety. @@ -74,7 +74,7 @@ Full standard is available here for further request: [Back-end repositories SQL # Standard: Back-end TypeScript Clean Code Practices -Establish back-end TypeScript clean code rules in the Packmind monorepo (/packages/**/*.ts)—including PackmindLogger constructor injection, disciplined logger.info/error usage, top-of-file static imports, custom Error subclasses, and adapter-created use cases with their own loggers—to improve maintainability, debuggability, and consistent architecture across services. : +This standard establishes clean code practices in TypeScript for back-end development to enhance maintainability and ensure consistent patterns across services. It covers logging best practices, error... : * Avoid excessive logger.debug calls in production code and limit logging to essential logger.info statements. Use logger.info for important business events, logger.error for error handling, and add logger.debug manually only when debugging specific issues. * Inject PackmindLogger as a constructor parameter with a default value using a variable or a string representing the class name. * Instantiate use cases in adapters without passing the adapter's logger; use cases must create their own logger for proper origin tracking. @@ -85,7 +85,7 @@ Full standard is available here for further request: [Back-end TypeScript Clean # Standard: Domain Events -Standardize TypeScript domain events by defining `{EventName}Payload` interfaces and `Event`-suffixed classes in `packages/types/src/{domain}/events/` (barrel `index.ts`) extending `UserEvent`/`SystemEvent` with `static override readonly eventName` in `domain.entity.action` format, emitting via `eventEmitterService.emit(new MyEvent(payload))`, and handling via `PackmindListener<TAdapter>.registerHandlers()` with `this.subscribe(EventClass, this.handlerMethod)` and arrow-function handlers to enable decoupled cross-domain communication and reliable subscriptions. : +Domain events enable communication between hexas without creating direct dependencies. Apply these rules when creating events, emitting them, or implementing listeners to react to events from other do... : * Define event classes in `packages/types/src/{domain}/events/` with an `index.ts` barrel file * Define payload as a separate `{EventName}Payload` interface * Extend `PackmindListener<TAdapter>` and implement `registerHandlers()` to subscribe to events @@ -101,7 +101,7 @@ Full standard is available here for further request: [Domain Events](.packmind/s # Standard: Port-Adapter Cross-Domain Integration -Define port interfaces and cross-domain contracts in @packmind/types and packages/types/src/<domain>/contracts/, expose adapters via Hexa getters, and use async HexaFactory.initialize() with registry isRegistered()/get() checks to prevent circular dependencies, maintain loose coupling, and support resilient synchronous and asynchronous cross-domain operations. : +This standard defines how domain packages communicate with each other through the Port/Adapter pattern in our DDD monorepo architecture. By following these rules, you prevent circular dependencies, ma... : * Declare all Command and Response types that define contracts between domains in packages/types/src/<domain>/contracts/ to ensure a single source of truth and prevent import cycles between domain packages. * Define port interfaces in @packmind/types with domain-specific contracts that expose only the operations needed by consumers, where each method accepts a Command type and returns a Response type or domain entity. * Expose adapters through public getter methods in the Hexa class that return the port interface implementation, as this is the only way external domains should access another domain's functionality. @@ -112,7 +112,7 @@ Full standard is available here for further request: [Port-Adapter Cross-Domain # Standard: Scoped Repository Patterns -Enforce tenant-safe repository query and write patterns in Packmind repositories extending SpaceScopedRepository or OrganizationScopedRepository by using createScopedQueryBuilder, avoiding findById overrides, requiring scope IDs on collection methods, delegating saves/updates to this.add(), and testing cross-scope isolation to ensure consistent data isolation and soft-delete correctness. : +Enforce data isolation and consistent query patterns in repositories extending SpaceScopedRepository or OrganizationScopedRepository, ensuring tenant-safe data access across the Packmind codebase. : * Delegate write operations (`save`, `update`) to the inherited `this.add()` method * Do not override `findById` in scoped repositories — the base class handles soft delete via `QueryOption.includeDeleted` * Include `spaceId` or `organizationId` as a parameter on all collection-returning domain interface methods @@ -123,7 +123,7 @@ Full standard is available here for further request: [Scoped Repository Patterns # Standard: Use Case Architecture Patterns -Standardize Packmind monorepo hexagonal-architecture use cases with contract-per-file Command/Response/IUseCase types, PackmindCommand/PublicPackmindCommand/SpaceMemberCommand inputs, and AbstractMemberUseCase/AbstractAdminUseCase/AbstractSpaceMemberUseCase execution methods to enforce consistent auth validation, separation of concerns, and type-safe command passing. : +This standard defines how to structure use cases in the Packmind monorepo following hexagonal architecture principles. Use cases represent the entry points to domain logic and must follow consistent p... : * Accept commands as single parameters in adapter methods rather than multiple individual parameters to ensure consistency and easier parameter additions * Define each use case contract in its own file at packages/types/src/{domain}/contracts/{UseCaseName}.ts with Command type, Response type, and UseCase interface exports * Export exactly three type definitions from each use case contract file: {Name}Command for input parameters, {Name}Response for return value, and I{Name}UseCase as the interface combining both diff --git a/packages/amplitude/.swcrc b/packages/amplitude/.swcrc new file mode 100644 index 000000000..83bfbeb3f --- /dev/null +++ b/packages/amplitude/.swcrc @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "jsc": { + "parser": { "syntax": "typescript", "decorators": true }, + "transform": { "legacyDecorator": true, "decoratorMetadata": true }, + "target": "es2022", + "keepClassNames": true + }, + "module": { "type": "commonjs" }, + "sourceMaps": true +} diff --git a/packages/amplitude/README.md b/packages/amplitude/README.md new file mode 100644 index 000000000..6c4d67a64 --- /dev/null +++ b/packages/amplitude/README.md @@ -0,0 +1,11 @@ +# amplitude + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build amplitude` to build the library. + +## Running unit tests + +Run `nx test amplitude` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/packages/amplitude/eslint.config.mjs b/packages/amplitude/eslint.config.mjs new file mode 100644 index 000000000..c334bc0bc --- /dev/null +++ b/packages/amplitude/eslint.config.mjs @@ -0,0 +1,19 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, +]; diff --git a/packages/amplitude/jest.config.ts b/packages/amplitude/jest.config.ts new file mode 100644 index 000000000..6b58d468f --- /dev/null +++ b/packages/amplitude/jest.config.ts @@ -0,0 +1,22 @@ +const { compilerOptions } = require('../../tsconfig.base.effective.json'); + +const { + pathsToModuleNameMapper, + swcTransformWithDecorators, + standardTransformIgnorePatterns, + standardModuleFileExtensions, +} = require('../../jest-utils.ts'); + +module.exports = { + displayName: 'amplitude', + preset: '../../jest.preset.ts', + testEnvironment: 'node', + transform: swcTransformWithDecorators, + transformIgnorePatterns: standardTransformIgnorePatterns, + moduleFileExtensions: standardModuleFileExtensions, + coverageDirectory: '../../coverage/packages/amplitude', + moduleNameMapper: pathsToModuleNameMapper( + compilerOptions.paths, + '<rootDir>/../../', + ), +}; diff --git a/packages/amplitude/package.json b/packages/amplitude/package.json new file mode 100644 index 000000000..c5c31f6dc --- /dev/null +++ b/packages/amplitude/package.json @@ -0,0 +1,21 @@ +{ + "name": "@packmind/amplitude", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "@nestjs/testing": "^11.0.0", + "@packmind/logger": "workspace:*", + "@packmind/types": "workspace:*", + "@nestjs/common": "^11.1.6", + "@packmind/node-utils": "workspace:*", + "http-proxy-middleware": "^3.0.5", + "@amplitude/analytics-node": "^1.3.8", + "@amplitude/analytics-core": "^2.3.8", + "@packmind/test-utils": "workspace:*", + "async-mutex": "^0.5.0", + "typeorm": "^0.3.20" + } +} diff --git a/packages/amplitude/project.json b/packages/amplitude/project.json new file mode 100644 index 000000000..10da97485 --- /dev/null +++ b/packages/amplitude/project.json @@ -0,0 +1,20 @@ +{ + "name": "amplitude", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/amplitude/src", + "projectType": "library", + "tags": ["env:node"], + "targets": { + "build": { + "executor": "@nx/js:swc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/amplitude", + "tsConfig": "packages/amplitude/tsconfig.lib.json", + "packageJson": "packages/amplitude/package.json", + "main": "packages/amplitude/src/index.ts", + "assets": ["packages/amplitude/*.md"] + } + } + } +} diff --git a/packages/amplitude/src/AmplitudeHexa.ts b/packages/amplitude/src/AmplitudeHexa.ts new file mode 100644 index 000000000..4660634f0 --- /dev/null +++ b/packages/amplitude/src/AmplitudeHexa.ts @@ -0,0 +1,106 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + BaseHexa, + BaseHexaOpts, + HexaRegistry, + PackmindEventEmitterService, +} from '@packmind/node-utils'; +import { + IAccountsPort, + IAccountsPortName, + IEventTrackingPort, + IEventTrackingPortName, +} from '@packmind/types'; +import { DataSource } from 'typeorm'; +import { AmplitudeEventListener } from './application/AmplitudeEventListener'; +import { EventTrackingAdapter } from './application/EventTrackingAdapter'; + +const origin = 'AmplitudeHexa'; + +/** + * AmplitudeHexa - Facade for the Amplitude event tracking domain following the Hexa pattern. + * + * This class serves as the main entry point for event tracking functionality. + * This is the proprietary edition implementation that integrates with Amplitude SDK. + */ +export class AmplitudeHexa extends BaseHexa<BaseHexaOpts, IEventTrackingPort> { + private readonly adapter: EventTrackingAdapter; + private readonly listener: AmplitudeEventListener; + + constructor( + dataSource: DataSource, + opts: Partial<BaseHexaOpts> = { logger: new PackmindLogger(origin) }, + ) { + super(dataSource, opts); + + this.logger.info('Constructing AmplitudeHexa (proprietary)'); + + try { + this.logger.debug( + 'Creating EventTrackingAdapter with Amplitude integration', + ); + this.adapter = new EventTrackingAdapter(); + this.listener = new AmplitudeEventListener(this.adapter); + + this.logger.info('AmplitudeHexa construction completed'); + } catch (error) { + this.logger.error('Failed to construct AmplitudeHexa', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * Initialize the hexa with access to the registry for adapter retrieval. + * Sets up event listeners to forward domain events to Amplitude. + */ + public async initialize(registry: HexaRegistry): Promise<void> { + this.logger.info('Initializing AmplitudeHexa'); + + const eventEmitterService = registry.getService( + PackmindEventEmitterService, + ); + this.listener.initialize(eventEmitterService); + + try { + const accountsPort = + registry.getAdapter<IAccountsPort>(IAccountsPortName); + this.listener.setAccountsAdapter(accountsPort); + this.logger.info('AccountsPort wired into AmplitudeEventListener'); + } catch (error) { + this.logger.warn( + 'AccountsPort not available; test-user filtering disabled', + { + error: error instanceof Error ? error.message : String(error), + }, + ); + } + + this.logger.info('AmplitudeHexa initialized successfully'); + } + + /** + * Get the EventTracking adapter for cross-domain access. + * This adapter implements IEventTrackingPort and can be injected into other domains. + */ + public getAdapter(): IEventTrackingPort { + return this.adapter; + } + + /** + * Get the port name for this hexa. + */ + public getPortName(): string { + return IEventTrackingPortName; + } + + /** + * Destroys the AmplitudeHexa and cleans up resources + */ + public destroy(): void { + this.logger.info('Destroying AmplitudeHexa'); + this.listener.destroy(); + this.logger.info('AmplitudeHexa destroyed'); + } +} diff --git a/packages/amplitude/src/application/AmplitudeEventListener.spec.ts b/packages/amplitude/src/application/AmplitudeEventListener.spec.ts new file mode 100644 index 000000000..4b083722b --- /dev/null +++ b/packages/amplitude/src/application/AmplitudeEventListener.spec.ts @@ -0,0 +1,1274 @@ +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { + ArtifactsPulledEvent, + DeploymentCompletedEvent, + IAccountsPort, + IEventTrackingPort, + CommandCreatedEvent, + CommandUpdatedEvent, + RuleAddedEvent, + StandardCreatedEvent, + StandardUpdatedEvent, + StandardSampleSelectedEvent, + LinterCalledEvent, + LinterRuleSeverityUpdatedEvent, + SkillCreatedEvent, + OrganizationCreatedEvent, + User, + UserSignedInEvent, + ChangeProposalSubmittedEvent, + ChangeProposalAcceptedEvent, + ChangeProposalRejectedEvent, + SpaceCreatedEvent, + SpaceDeletedEvent, + SpaceMembersAddedEvent, + SpaceMembersRemovedEvent, + SpaceMembersRoleUpdatedEvent, + SpaceRenamedEvent, + SpacePinnedEvent, + SpaceUnpinnedEvent, + SpaceVisibilityUpdatedEvent, + SpaceType, + UserSpaceRole, + PlaybookArtefactMovedEvent, + PluginRenderedEvent, + PluginDeletedEvent, + MarketplaceLinkedEvent, + MarketplacePluginRemovalInitiatedEvent, + MarketplaceUnlinkedEvent, + createMarketplaceDistributionId, + createMarketplaceId, + createGitRepoId, + createPackageId, + createUserId, + createOrganizationId, + createRecipeId, + createSpaceId, + createSkillId, + createRuleId, + createStandardId, + createStandardVersionId, + createTargetId, + createChangeProposalId, +} from '@packmind/types'; +import { DataSource } from 'typeorm'; +import { AmplitudeEventListener } from './AmplitudeEventListener'; + +function buildUser(overrides: Partial<User> = {}): User { + return { + id: createUserId('user-123'), + email: 'real-user@example.com', + displayName: null, + passwordHash: null, + active: true, + memberships: [], + trial: false, + ...overrides, + }; +} + +describe('AmplitudeEventListener', () => { + let listener: AmplitudeEventListener; + let mockAdapter: jest.Mocked<IEventTrackingPort>; + let eventEmitterService: PackmindEventEmitterService; + let mockDataSource: DataSource; + + beforeEach(() => { + mockDataSource = { + isInitialized: true, + options: {}, + } as unknown as DataSource; + + mockAdapter = { + trackEvent: jest.fn().mockResolvedValue(undefined), + identifyOrganizationGroup: jest.fn().mockResolvedValue(undefined), + }; + + eventEmitterService = new PackmindEventEmitterService(mockDataSource); + listener = new AmplitudeEventListener(mockAdapter); + listener.initialize(eventEmitterService); + }); + + afterEach(() => { + jest.clearAllMocks(); + eventEmitterService.removeAllListeners(); + }); + + describe('StandardCreatedEvent', () => { + it('tracks standard_created event with correct payload', async () => { + const event = new StandardCreatedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + standardId: createStandardId('std-789'), + spaceId: createSpaceId('space-abc'), + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'standard_created', + { + standardId: 'std-789', + spaceId: 'space-abc', + source: 'ui', + }, + ); + }); + }); + + describe('StandardUpdatedEvent', () => { + it('tracks standard_updated event with correct payload', async () => { + const event = new StandardUpdatedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + standardId: createStandardId('std-789'), + spaceId: createSpaceId('space-abc'), + newVersion: 2, + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'standard_updated', + { + standardId: 'std-789', + spaceId: 'space-abc', + newVersion: 2, + source: 'ui', + }, + ); + }); + }); + + describe('StandardSampleSelectedEvent', () => { + it('tracks standard_sample_selected event with correct payload', async () => { + const event = new StandardSampleSelectedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + sampleId: 'sample-typescript', + sampleType: 'language', + spaceId: createSpaceId('space-abc'), + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'standard_sample_selected', + { + name: 'sample-typescript', + spaceId: 'space-abc', + source: 'ui', + }, + ); + }); + }); + + describe('RuleAddedEvent', () => { + it('tracks rule_added event with correct payload', async () => { + const event = new RuleAddedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + standardId: createStandardId('std-789'), + standardVersionId: createStandardVersionId('sv-001'), + newVersion: 3, + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'rule_added', + { + standardId: 'std-789', + standardVersionId: 'sv-001', + newVersion: 3, + source: 'ui', + }, + ); + }); + }); + + describe('CommandCreatedEvent', () => { + it('tracks command_created event with correct payload', async () => { + const event = new CommandCreatedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + id: createRecipeId('command-789'), + spaceId: createSpaceId('space-abc'), + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'command_created', + { + id: 'command-789', + spaceId: 'space-abc', + source: 'ui', + }, + ); + }); + }); + + describe('CommandUpdatedEvent', () => { + it('tracks command_updated event with correct payload', async () => { + const event = new CommandUpdatedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + id: createRecipeId('command-789'), + spaceId: createSpaceId('space-abc'), + newVersion: 4, + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'command_updated', + { + id: 'command-789', + spaceId: 'space-abc', + newVersion: 4, + source: 'ui', + }, + ); + }); + }); + + describe('DeploymentCompletedEvent', () => { + it('tracks deployment_done event with correct payload', async () => { + const event = new DeploymentCompletedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + targetIds: ['target-1', 'target-2', 'target-3'].map(createTargetId), + recipeCount: 5, + standardCount: 3, + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'deployment_done', + { + targetCount: 3, + recipeCount: 5, + standardCount: 3, + source: 'ui', + }, + ); + }); + }); + + describe('ArtifactsPulledEvent', () => { + it('tracks artifacts_pulled event with correct payload', async () => { + const event = new ArtifactsPulledEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + packageSlugs: ['pkg-1', 'pkg-2'], + recipeCount: 10, + standardCount: 5, + skillCount: 2, + source: 'cli', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'artifacts_pulled', + { + packageCount: 2, + recipeCount: 10, + standardCount: 5, + skillCount: 2, + source: 'cli', + }, + ); + }); + }); + + describe('LinterCalledEvent', () => { + it('tracks linter_called event with correct payload', async () => { + const event = new LinterCalledEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + targetCount: 3, + standardCount: 5, + source: 'cli', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'linter_called', + { + targetCount: 3, + standardCount: 5, + source: 'cli', + }, + ); + }); + }); + + describe('LinterRuleSeverityUpdatedEvent', () => { + it('tracks linter_rule_severity_updated event with correct payload', async () => { + const event = new LinterRuleSeverityUpdatedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + ruleId: createRuleId('rule-789'), + severity: 'warning', + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'linter_rule_severity_updated', + { + ruleId: 'rule-789', + severity: 'warning', + source: 'ui', + }, + ); + }); + }); + + describe('SkillCreatedEvent', () => { + it('tracks skill_created event with correct payload', async () => { + const event = new SkillCreatedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + skillId: createSkillId('skill-789'), + spaceId: createSpaceId('space-abc'), + source: 'ui', + fileCount: 3, + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'skill_created', + { + skillId: 'skill-789', + spaceId: 'space-abc', + source: 'ui', + fileCount: 3, + }, + ); + }); + }); + + describe('UserSignedInEvent', () => { + it('tracks user_signed_in event with correct payload', async () => { + const event = new UserSignedInEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + email: 'test@example.com', + method: 'social', + socialProvider: 'google', + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'user_signed_in', + { + authType: 'social', + socialProvider: 'google', + source: 'ui', + }, + ); + }); + + describe('when signing in with password', () => { + it('tracks user_signed_in event with empty socialProvider', async () => { + const event = new UserSignedInEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + email: 'test@example.com', + method: 'password', + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'user_signed_in', + { + authType: 'password', + socialProvider: '', + source: 'ui', + }, + ); + }); + }); + }); + + describe('ChangeProposalSubmittedEvent', () => { + it('tracks change_proposal_submitted event with correct payload', async () => { + const event = new ChangeProposalSubmittedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + changeProposalId: createChangeProposalId('cp-789'), + itemType: 'standard', + itemId: 'std-001', + changeType: 'addRule', + captureMode: 'commit', + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'change_proposal_submitted', + { + changeProposalId: 'cp-789', + itemType: 'standard', + itemId: 'std-001', + changeType: 'addRule', + captureMode: 'commit', + source: 'ui', + }, + ); + }); + }); + + describe('ChangeProposalAcceptedEvent', () => { + it('tracks change_proposal_accepted event with correct payload', async () => { + const event = new ChangeProposalAcceptedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + changeProposalId: createChangeProposalId('cp-789'), + itemType: 'command', + itemId: 'cmd-001', + changeType: 'updateCommandName', + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'change_proposal_accepted', + { + changeProposalId: 'cp-789', + itemType: 'command', + itemId: 'cmd-001', + changeType: 'updateCommandName', + source: 'ui', + }, + ); + }); + }); + + describe('ChangeProposalRejectedEvent', () => { + it('tracks change_proposal_rejected event with correct payload', async () => { + const event = new ChangeProposalRejectedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + changeProposalId: createChangeProposalId('cp-789'), + itemType: 'skill', + itemId: 'skill-001', + changeType: 'updateSkillName', + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'change_proposal_rejected', + { + changeProposalId: 'cp-789', + itemType: 'skill', + itemId: 'skill-001', + changeType: 'updateSkillName', + source: 'ui', + }, + ); + }); + }); + + describe('SpaceCreatedEvent', () => { + it('tracks space_created event with correct payload', async () => { + const event = new SpaceCreatedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + spaceName: 'My Space', + spaceSlug: 'my-space', + visibility: SpaceType.open, + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'space_created', + { + spaceName: 'My Space', + spaceSlug: 'my-space', + visibility: 'open', + source: 'ui', + }, + ); + }); + }); + + describe('SpaceDeletedEvent', () => { + it('tracks space_deleted event with correct payload', async () => { + const event = new SpaceDeletedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + spaceId: createSpaceId('space-789'), + spaceName: 'My Space', + spaceSlug: 'my-space', + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'space_deleted', + { + spaceId: 'space-789', + source: 'ui', + }, + ); + }); + }); + + describe('SpaceMembersAddedEvent', () => { + it('tracks space_members_added event with correct payload', async () => { + const event = new SpaceMembersAddedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + spaceId: createSpaceId('space-789'), + memberUserIds: [createUserId('member-1'), createUserId('member-2')], + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'space_members_added', + { + spaceId: 'space-789', + memberCount: 2, + source: 'ui', + }, + ); + }); + }); + + describe('SpaceMembersRemovedEvent', () => { + it('tracks space_members_removed event with correct payload', async () => { + const event = new SpaceMembersRemovedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + spaceId: createSpaceId('space-789'), + memberUserIds: [createUserId('member-1')], + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'space_members_removed', + { + spaceId: 'space-789', + memberCount: 1, + source: 'ui', + }, + ); + }); + }); + + describe('SpaceMembersRoleUpdatedEvent', () => { + it('tracks space_members_role_updated event with correct payload', async () => { + const event = new SpaceMembersRoleUpdatedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + spaceId: createSpaceId('space-789'), + memberUserIds: [createUserId('member-1')], + newRole: UserSpaceRole.ADMIN, + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'space_members_role_updated', + { + spaceId: 'space-789', + memberCount: 1, + newRole: 'admin', + source: 'ui', + }, + ); + }); + }); + + describe('SpaceRenamedEvent', () => { + it('tracks space_renamed event with correct payload', async () => { + const event = new SpaceRenamedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + spaceId: createSpaceId('space-789'), + spaceSlug: 'my-space', + oldName: 'Old Space', + newName: 'New Space', + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'space_renamed', + { + spaceId: 'space-789', + spaceSlug: 'my-space', + oldName: 'Old Space', + newName: 'New Space', + source: 'ui', + }, + ); + }); + }); + + describe('SpaceVisibilityUpdatedEvent', () => { + it('tracks space_visibility_updated event with correct payload', async () => { + const event = new SpaceVisibilityUpdatedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + spaceId: createSpaceId('space-789'), + newVisibility: SpaceType.private, + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'space_visibility_updated', + { + spaceId: 'space-789', + newVisibility: 'private', + source: 'ui', + }, + ); + }); + }); + + describe('SpacePinnedEvent', () => { + it('tracks space_pinned event with correct payload', async () => { + const event = new SpacePinnedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + spaceId: createSpaceId('space-789'), + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'space_pinned', + { + spaceId: 'space-789', + source: 'ui', + }, + ); + }); + }); + + describe('SpaceUnpinnedEvent', () => { + it('tracks space_unpinned event with correct payload', async () => { + const event = new SpaceUnpinnedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + spaceId: createSpaceId('space-789'), + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'space_unpinned', + { + spaceId: 'space-789', + source: 'ui', + }, + ); + }); + }); + + describe('PlaybookArtefactMovedEvent', () => { + it('tracks playbook_artefact_moved event with correct payload', async () => { + const event = new PlaybookArtefactMovedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + artifactType: 'standard', + oldArtifactId: 'old-standard-id', + newArtifactId: 'new-standard-id', + sourceSpaceId: createSpaceId('space-source'), + destinationSpaceId: createSpaceId('space-dest'), + source: 'ui', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'playbook_artefact_moved', + { + artifactType: 'standard', + oldArtifactId: 'old-standard-id', + newArtifactId: 'new-standard-id', + sourceSpaceId: 'space-source', + destinationSpaceId: 'space-dest', + source: 'ui', + }, + ); + }); + }); + + describe('OrganizationCreatedEvent', () => { + it('identifies organization group with name', async () => { + const event = new OrganizationCreatedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + name: 'trial-abc123', + method: 'trial', + source: 'api', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.identifyOrganizationGroup).toHaveBeenCalledWith( + 'org-456', + 'trial-abc123', + ); + }); + + it('tracks new_organization_created event with correct payload', async () => { + const event = new OrganizationCreatedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + name: 'trial-abc123', + method: 'trial', + source: 'api', + }); + + eventEmitterService.emit(event); + + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'new_organization_created', + { + name: 'trial-abc123', + method: 'trial', + source: 'api', + }, + ); + }); + }); + + describe('test-user filtering', () => { + let mockAccountsPort: jest.Mocked<Pick<IAccountsPort, 'getUserById'>>; + + const buildEvent = (userIdValue: string) => + new StandardCreatedEvent({ + userId: createUserId(userIdValue), + organizationId: createOrganizationId('org-456'), + standardId: createStandardId('std-789'), + spaceId: createSpaceId('space-abc'), + source: 'ui', + }); + + beforeEach(() => { + mockAccountsPort = { + getUserById: jest.fn(), + }; + listener.setAccountsAdapter(mockAccountsPort as unknown as IAccountsPort); + }); + + describe('when user email starts with test-', () => { + it('skips trackEvent', async () => { + mockAccountsPort.getUserById.mockResolvedValue( + buildUser({ + id: createUserId('user-test'), + email: 'test-abc@example.com', + }), + ); + + eventEmitterService.emit(buildEvent('user-test')); + await flushPromises(); + + expect(mockAdapter.trackEvent).not.toHaveBeenCalled(); + }); + }); + + it('matches test- prefix case-insensitively', async () => { + mockAccountsPort.getUserById.mockResolvedValue( + buildUser({ + id: createUserId('user-test-upper'), + email: 'TEST-foo@example.com', + }), + ); + + eventEmitterService.emit(buildEvent('user-test-upper')); + await flushPromises(); + + expect(mockAdapter.trackEvent).not.toHaveBeenCalled(); + }); + + it('still tracks events for users without test- prefix', async () => { + mockAccountsPort.getUserById.mockResolvedValue( + buildUser({ + id: createUserId('user-real'), + email: 'someone@packmind.com', + }), + ); + + eventEmitterService.emit(buildEvent('user-real')); + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledTimes(1); + }); + + it('calls getUserById only once for repeated events for the same user', async () => { + mockAccountsPort.getUserById.mockResolvedValue( + buildUser({ + id: createUserId('user-test'), + email: 'test-abc@example.com', + }), + ); + + eventEmitterService.emit(buildEvent('user-test')); + await flushPromises(); + eventEmitterService.emit(buildEvent('user-test')); + await flushPromises(); + eventEmitterService.emit(buildEvent('user-test')); + await flushPromises(); + + expect(mockAccountsPort.getUserById).toHaveBeenCalledTimes(1); + }); + + it('skips trackEvent for all repeated events for a test- user', async () => { + mockAccountsPort.getUserById.mockResolvedValue( + buildUser({ + id: createUserId('user-test'), + email: 'test-abc@example.com', + }), + ); + + eventEmitterService.emit(buildEvent('user-test')); + await flushPromises(); + eventEmitterService.emit(buildEvent('user-test')); + await flushPromises(); + eventEmitterService.emit(buildEvent('user-test')); + await flushPromises(); + + expect(mockAdapter.trackEvent).not.toHaveBeenCalled(); + }); + + describe('when getUserById throws', () => { + it('falls through and tracks the event', async () => { + mockAccountsPort.getUserById.mockRejectedValue(new Error('db down')); + + eventEmitterService.emit(buildEvent('user-unknown')); + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledTimes(1); + }); + }); + + it('skips identifyOrganizationGroup for test users on OrganizationCreatedEvent', async () => { + mockAccountsPort.getUserById.mockResolvedValue( + buildUser({ + id: createUserId('user-test'), + email: 'test-abc@example.com', + }), + ); + + const event = new OrganizationCreatedEvent({ + userId: createUserId('user-test'), + organizationId: createOrganizationId('org-test'), + name: 'test-org', + method: 'trial', + source: 'api', + }); + + eventEmitterService.emit(event); + await flushPromises(); + + expect(mockAdapter.identifyOrganizationGroup).not.toHaveBeenCalled(); + }); + + it('skips trackEvent for test users on OrganizationCreatedEvent', async () => { + mockAccountsPort.getUserById.mockResolvedValue( + buildUser({ + id: createUserId('user-test'), + email: 'test-abc@example.com', + }), + ); + + const event = new OrganizationCreatedEvent({ + userId: createUserId('user-test'), + organizationId: createOrganizationId('org-test'), + name: 'test-org', + method: 'trial', + source: 'api', + }); + + eventEmitterService.emit(event); + await flushPromises(); + + expect(mockAdapter.trackEvent).not.toHaveBeenCalled(); + }); + }); + + describe('PluginRenderedEvent', () => { + it('tracks plugin_rendered event with marketplace metadata', async () => { + const event = new PluginRenderedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + packageId: createPackageId('pkg-789'), + packageSlug: 'default/my-package', + mode: 'marketplace', + pluginRoot: '/tmp/plugins/my-package', + marketplaceRepo: 'git@github.com:org/marketplace.git', + source: 'cli', + }); + + eventEmitterService.emit(event); + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'plugin_rendered', + { + packageId: 'pkg-789', + packageSlug: 'default/my-package', + mode: 'marketplace', + pluginRoot: '/tmp/plugins/my-package', + marketplaceRepo: 'git@github.com:org/marketplace.git', + source: 'cli', + }, + ); + }); + + describe('when absent (standalone mode)', () => { + it('omits marketplaceRepo from the tracked event', async () => { + const event = new PluginRenderedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + packageId: createPackageId('pkg-789'), + packageSlug: 'default/my-package', + mode: 'standalone', + pluginRoot: '/tmp/plugins/my-package', + source: 'cli', + }); + + eventEmitterService.emit(event); + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'plugin_rendered', + { + packageId: 'pkg-789', + packageSlug: 'default/my-package', + mode: 'standalone', + pluginRoot: '/tmp/plugins/my-package', + source: 'cli', + }, + ); + }); + }); + }); + + describe('PluginDeletedEvent', () => { + it('tracks plugin_deleted event with marketplace metadata', async () => { + const event = new PluginDeletedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + packageId: createPackageId('pkg-789'), + packageSlug: 'default/my-package', + marketplaceRepo: 'git@github.com:org/marketplace.git', + source: 'cli', + }); + + eventEmitterService.emit(event); + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'plugin_deleted', + { + packageId: 'pkg-789', + packageSlug: 'default/my-package', + marketplaceRepo: 'git@github.com:org/marketplace.git', + source: 'cli', + }, + ); + }); + + describe('when absent', () => { + it('omits marketplaceRepo from the tracked event', async () => { + const event = new PluginDeletedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + packageId: createPackageId('pkg-789'), + packageSlug: 'default/my-package', + source: 'cli', + }); + + eventEmitterService.emit(event); + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'plugin_deleted', + { + packageId: 'pkg-789', + packageSlug: 'default/my-package', + source: 'cli', + }, + ); + }); + }); + }); + describe('MarketplaceLinkedEvent', () => { + it('tracks marketplace_linked event with marketplace metadata', async () => { + const event = new MarketplaceLinkedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + marketplaceId: createMarketplaceId('mkt-789'), + gitRepoId: createGitRepoId('repo-101'), + addedBy: createUserId('user-123'), + source: 'ui', + }); + + eventEmitterService.emit(event); + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'marketplace_linked', + { + marketplaceId: 'mkt-789', + gitRepoId: 'repo-101', + addedBy: 'user-123', + source: 'ui', + }, + ); + }); + }); + + describe('MarketplaceUnlinkedEvent', () => { + it('tracks marketplace_unlinked event with marketplace metadata', async () => { + const event = new MarketplaceUnlinkedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + marketplaceId: createMarketplaceId('mkt-789'), + gitRepoId: createGitRepoId('repo-101'), + source: 'ui', + }); + + eventEmitterService.emit(event); + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'marketplace_unlinked', + { + marketplaceId: 'mkt-789', + gitRepoId: 'repo-101', + source: 'ui', + }, + ); + }); + }); + + describe('MarketplacePluginRemovalInitiatedEvent', () => { + it('tracks marketplace_plugin_removal_initiated event with correct payload', async () => { + const event = new MarketplacePluginRemovalInitiatedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + marketplaceId: createMarketplaceId('mkt-789'), + distributionId: createMarketplaceDistributionId('dist-001'), + packageId: createPackageId('pkg-789'), + packageSlug: 'default/my-package', + pluginSlug: 'my-plugin', + trigger: 'from_marketplace', + source: 'ui', + }); + + eventEmitterService.emit(event); + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'marketplace_plugin_removal_initiated', + { + marketplaceId: 'mkt-789', + pluginSlug: 'my-plugin', + trigger: 'from_marketplace', + source: 'ui', + }, + ); + }); + + describe('when triggered by package deletion cascade', () => { + it('tracks the cascade trigger', async () => { + const event = new MarketplacePluginRemovalInitiatedEvent({ + userId: createUserId('user-123'), + organizationId: createOrganizationId('org-456'), + marketplaceId: createMarketplaceId('mkt-789'), + distributionId: createMarketplaceDistributionId('dist-002'), + packageId: createPackageId('pkg-790'), + packageSlug: 'default/other-package', + pluginSlug: 'other-plugin', + trigger: 'from_packmind_package', + source: 'ui', + }); + + eventEmitterService.emit(event); + await flushPromises(); + + expect(mockAdapter.trackEvent).toHaveBeenCalledWith( + 'user-123', + 'org-456', + 'marketplace_plugin_removal_initiated', + { + marketplaceId: 'mkt-789', + pluginSlug: 'other-plugin', + trigger: 'from_packmind_package', + source: 'ui', + }, + ); + }); + }); + }); +}); + +function flushPromises(): Promise<void> { + return new Promise((resolve) => setImmediate(resolve)); +} diff --git a/packages/amplitude/src/application/AmplitudeEventListener.ts b/packages/amplitude/src/application/AmplitudeEventListener.ts new file mode 100644 index 000000000..80a80d59d --- /dev/null +++ b/packages/amplitude/src/application/AmplitudeEventListener.ts @@ -0,0 +1,656 @@ +import { PackmindListener } from '@packmind/node-utils'; +import { PackmindLogger } from '@packmind/logger'; +import { + StandardCreatedEvent, + StandardUpdatedEvent, + RuleAddedEvent, + RuleDeletedEvent, + CommandCreatedEvent, + CommandUpdatedEvent, + DeploymentCompletedEvent, + ArtifactsPulledEvent, + AnonymousTrialStartedEvent, + AnonymousTrialAccountActivatedEvent, + StandardDeletedEvent, + CommandDeletedEvent, + LinterCalledEvent, + LinterRuleSeverityUpdatedEvent, + SkillCreatedEvent, + SkillUpdatedEvent, + RuleUpdatedEvent, + UserEvent, + UserId, + UserSignedUpEvent, + UserSignedInEvent, + IAccountsPort, + OrganizationCreatedEvent, + StandardSampleSelectedEvent, + ChangeProposalSubmittedEvent, + ChangeProposalAcceptedEvent, + ChangeProposalRejectedEvent, + SpaceCreatedEvent, + SpaceDeletedEvent, + SpaceMembersAddedEvent, + SpaceMembersRemovedEvent, + SpaceMembersRoleUpdatedEvent, + SpaceRenamedEvent, + SpacePinnedEvent, + SpaceUnpinnedEvent, + SpaceVisibilityUpdatedEvent, + PlaybookArtefactMovedEvent, + PluginRenderedEvent, + PluginDeletedEvent, + PluginPublishAttemptedEvent, + PluginPublishedEvent, + PluginPublishFailedEvent, + MarketplaceLinkedEvent, + MarketplacePluginRemovalInitiatedEvent, + MarketplaceUnlinkedEvent, +} from '@packmind/types'; +import { EventTrackingAdapter } from './EventTrackingAdapter'; +import { AmplitudeMetadata } from '../domain/entities/AmplitudeNodeEvent'; + +const TEST_USER_EMAIL_PREFIX = 'test-'; + +/** + * Listens to domain events and forwards them to Amplitude for tracking. + * + * This listener subscribes to various domain events emitted through + * PackmindEventEmitterService and translates them into Amplitude tracking calls. + */ +export class AmplitudeEventListener extends PackmindListener<EventTrackingAdapter> { + private readonly logger = new PackmindLogger('AmplitudeEventListener'); + private accountsAdapter?: IAccountsPort; + private readonly testUserCache = new Map<string, boolean>(); + + setAccountsAdapter(adapter: IAccountsPort): void { + this.accountsAdapter = adapter; + } + + private async isTestUser(userId: UserId): Promise<boolean> { + const cached = this.testUserCache.get(userId); + if (cached !== undefined) { + return cached; + } + + if (!this.accountsAdapter) { + return false; + } + + try { + const user = await this.accountsAdapter.getUserById(userId); + const isTest = + !!user && user.email.toLowerCase().startsWith(TEST_USER_EMAIL_PREFIX); + this.testUserCache.set(userId, isTest); + return isTest; + } catch (error) { + this.logger.debug('Failed to resolve user for test-user filter', { + userId: userId.substring(0, 6) + '*', + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + + protected registerHandlers(): void { + this.subscribe(StandardCreatedEvent, this.onStandardCreated); + this.subscribe(StandardUpdatedEvent, this.onStandardUpdated); + this.subscribe(StandardDeletedEvent, this.onStandardDeleted); + this.subscribe(StandardSampleSelectedEvent, this.onStandardSampleSelected); + this.subscribe(RuleAddedEvent, this.onRuleAdded); + this.subscribe(RuleUpdatedEvent, this.onRuleUpdated); + this.subscribe(RuleDeletedEvent, this.onRuleDeleted); + this.subscribe(CommandCreatedEvent, this.onCommandCreated); + this.subscribe(CommandUpdatedEvent, this.onCommandUpdated); + this.subscribe(CommandDeletedEvent, this.onCommandDeleted); + this.subscribe(DeploymentCompletedEvent, this.onDeploymentCompleted); + this.subscribe(ArtifactsPulledEvent, this.onArtifactsPulled); + this.subscribe(AnonymousTrialStartedEvent, this.handleTrialStarted); + this.subscribe( + AnonymousTrialAccountActivatedEvent, + this.handleTrialAccountActivated, + ); + this.subscribe(LinterCalledEvent, this.onLinterCalled); + this.subscribe( + LinterRuleSeverityUpdatedEvent, + this.onLinterRuleSeverityUpdated, + ); + this.subscribe(SkillCreatedEvent, this.onSkillCreated); + this.subscribe(SkillUpdatedEvent, this.onSkillUpdated); + this.subscribe(UserSignedUpEvent, this.onUserSignedUpEvent); + this.subscribe(UserSignedInEvent, this.onUserSignedInEvent); + this.subscribe(OrganizationCreatedEvent, this.onOrganizationCreatedEvent); + this.subscribe( + ChangeProposalSubmittedEvent, + this.onChangeProposalSubmitted, + ); + this.subscribe(ChangeProposalAcceptedEvent, this.onChangeProposalAccepted); + this.subscribe(ChangeProposalRejectedEvent, this.onChangeProposalRejected); + this.subscribe(SpaceCreatedEvent, this.onSpaceCreated); + this.subscribe(SpaceDeletedEvent, this.onSpaceDeleted); + this.subscribe(SpaceMembersAddedEvent, this.onSpaceMembersAdded); + this.subscribe(SpaceMembersRemovedEvent, this.onSpaceMembersRemoved); + this.subscribe( + SpaceMembersRoleUpdatedEvent, + this.onSpaceMembersRoleUpdated, + ); + this.subscribe(SpaceRenamedEvent, this.onSpaceRenamed); + this.subscribe(SpaceVisibilityUpdatedEvent, this.onSpaceVisibilityUpdated); + this.subscribe(SpacePinnedEvent, this.onSpacePinned); + this.subscribe(SpaceUnpinnedEvent, this.onSpaceUnpinned); + this.subscribe(PlaybookArtefactMovedEvent, this.onPlaybookArtefactMoved); + this.subscribe(PluginRenderedEvent, this.onPluginRendered); + this.subscribe(PluginDeletedEvent, this.onPluginDeleted); + this.subscribe(PluginPublishAttemptedEvent, this.onPluginPublishAttempted); + this.subscribe(PluginPublishedEvent, this.onPluginPublished); + this.subscribe(PluginPublishFailedEvent, this.onPluginPublishFailed); + this.subscribe(MarketplaceLinkedEvent, this.onMarketplaceLinked); + this.subscribe(MarketplaceUnlinkedEvent, this.onMarketplaceUnlinked); + this.subscribe( + MarketplacePluginRemovalInitiatedEvent, + this.onMarketplacePluginRemovalInitiated, + ); + } + + private async emitAmplitudeEvent<T extends UserEvent>( + event: T, + eventName: string, + transformer: (payload: T['payload']) => AmplitudeMetadata, + ) { + const { userId, organizationId } = event.payload; + + if (await this.isTestUser(userId)) { + this.logger.debug('Skipping Amplitude event for test user', { + eventName, + userId: userId.substring(0, 6) + '*', + }); + return; + } + + await this.adapter.trackEvent(userId, organizationId, eventName, { + ...transformer(event.payload), + source: event.payload.source, + ...(event.payload.originSkill && { + originSkill: event.payload.originSkill, + }), + }); + } + + private onUserSignedUpEvent(event: UserSignedUpEvent) { + return this.emitAmplitudeEvent(event, 'user_signed_up', (payload) => ({ + quickStart: payload.quickStart, + method: payload.method, + socialProvider: payload.socialProvider ?? '', + })); + } + + private onUserSignedInEvent = async ( + event: UserSignedInEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent(event, 'user_signed_in', (payload) => ({ + authType: payload.method, + socialProvider: payload.socialProvider ?? '', + })); + }; + + private onStandardCreated = async ( + event: StandardCreatedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent(event, 'standard_created', (payload) => ({ + standardId: payload.standardId, + spaceId: payload.spaceId, + method: payload.method, + })); + }; + + private onStandardUpdated = async ( + event: StandardUpdatedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent(event, 'standard_updated', (payload) => ({ + standardId: payload.standardId, + spaceId: payload.spaceId, + newVersion: payload.newVersion, + })); + }; + + private onStandardDeleted = async ( + event: StandardDeletedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent(event, 'standard_deleted', (payload) => ({ + standardId: payload.standardId, + spaceId: payload.spaceId, + })); + }; + + private onStandardSampleSelected = async ( + event: StandardSampleSelectedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent( + event, + 'standard_sample_selected', + (payload) => ({ + name: payload.sampleId, + spaceId: payload.spaceId, + }), + ); + }; + + private onRuleAdded = async (event: RuleAddedEvent): Promise<void> => { + return this.emitAmplitudeEvent(event, 'rule_added', (payload) => ({ + standardId: payload.standardId, + standardVersionId: payload.standardVersionId, + newVersion: payload.newVersion, + })); + }; + + private onRuleUpdated = async (event: RuleUpdatedEvent): Promise<void> => { + return this.emitAmplitudeEvent(event, 'rule_updated', (payload) => ({ + standardId: payload.standardId, + standardVersionId: payload.standardVersionId, + newVersion: payload.newVersion, + })); + }; + + private onRuleDeleted = async (event: RuleDeletedEvent): Promise<void> => { + return this.emitAmplitudeEvent(event, 'rule_deleted', (payload) => ({ + standardId: payload.standardId, + standardVersionId: payload.standardVersionId, + newVersion: payload.newVersion, + })); + }; + + private onCommandCreated = async ( + event: CommandCreatedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent(event, 'command_created', (payload) => ({ + id: payload.id, + spaceId: payload.spaceId, + })); + }; + + private onCommandUpdated = async ( + event: CommandUpdatedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent(event, 'command_updated', (payload) => ({ + id: payload.id, + spaceId: payload.spaceId, + newVersion: payload.newVersion, + })); + }; + + private onCommandDeleted = async ( + event: CommandDeletedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent(event, 'command_deleted', (payload) => ({ + id: payload.id, + spaceId: payload.spaceId, + })); + }; + + private onDeploymentCompleted = async ( + event: DeploymentCompletedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent(event, 'deployment_done', (payload) => ({ + targetCount: payload.targetIds.length, + recipeCount: payload.recipeCount, + standardCount: payload.standardCount, + })); + }; + + private onArtifactsPulled = async ( + event: ArtifactsPulledEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent(event, 'artifacts_pulled', (payload) => ({ + packageCount: payload.packageSlugs.length, + recipeCount: payload.recipeCount, + standardCount: payload.standardCount, + skillCount: payload.skillCount, + })); + }; + + private handleTrialStarted = async ( + event: AnonymousTrialStartedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent( + event, + 'anonymous_trial_started', + (payload) => ({ + agent: payload.agent, + startedAt: payload.startedAt.toISOString(), + }), + ); + }; + + private handleTrialAccountActivated = async ( + event: AnonymousTrialAccountActivatedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent( + event, + 'anonymous_trial_account_activated', + (payload) => ({ + userId: payload.userId, + organizationId: payload.organizationId, + }), + ); + }; + + private onLinterCalled = async (event: LinterCalledEvent): Promise<void> => { + return this.emitAmplitudeEvent(event, 'linter_called', (payload) => ({ + targetCount: payload.targetCount, + standardCount: payload.standardCount, + })); + }; + + private onLinterRuleSeverityUpdated = async ( + event: LinterRuleSeverityUpdatedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent( + event, + 'linter_rule_severity_updated', + (payload) => ({ + ruleId: payload.ruleId, + severity: payload.severity, + }), + ); + }; + + private onSkillCreated = async (event: SkillCreatedEvent): Promise<void> => { + return this.emitAmplitudeEvent(event, 'skill_created', (payload) => ({ + skillId: payload.skillId, + spaceId: payload.spaceId, + fileCount: payload.fileCount, + })); + }; + + private onSkillUpdated = async (event: SkillUpdatedEvent) => { + return this.emitAmplitudeEvent(event, 'skill_updated', (payload) => ({ + fileCount: payload.fileCount, + })); + }; + + private onOrganizationCreatedEvent = async ( + event: OrganizationCreatedEvent, + ): Promise<void> => { + const { userId, organizationId, name } = event.payload; + + if (await this.isTestUser(userId)) { + this.logger.debug( + 'Skipping Amplitude organization identify for test user', + { + eventName: 'new_organization_created', + userId: userId.substring(0, 6) + '*', + }, + ); + return; + } + + // Set the organization group name in Amplitude + await this.adapter.identifyOrganizationGroup(organizationId, name); + + // Track the event + return this.emitAmplitudeEvent( + event, + 'new_organization_created', + (payload) => ({ + name: payload.name, + method: payload.method, + }), + ); + }; + + private onChangeProposalSubmitted = async ( + event: ChangeProposalSubmittedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent( + event, + 'change_proposal_submitted', + (payload) => ({ + changeProposalId: payload.changeProposalId, + itemType: payload.itemType, + itemId: payload.itemId, + changeType: payload.changeType, + captureMode: payload.captureMode, + }), + ); + }; + + private onChangeProposalAccepted = async ( + event: ChangeProposalAcceptedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent( + event, + 'change_proposal_accepted', + (payload) => ({ + changeProposalId: payload.changeProposalId, + itemType: payload.itemType, + itemId: payload.itemId, + changeType: payload.changeType, + }), + ); + }; + + private onChangeProposalRejected = async ( + event: ChangeProposalRejectedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent( + event, + 'change_proposal_rejected', + (payload) => ({ + changeProposalId: payload.changeProposalId, + itemType: payload.itemType, + itemId: payload.itemId, + changeType: payload.changeType, + }), + ); + }; + + private onSpaceRenamed = async (event: SpaceRenamedEvent): Promise<void> => { + return this.emitAmplitudeEvent(event, 'space_renamed', (payload) => ({ + spaceId: payload.spaceId, + spaceSlug: payload.spaceSlug, + oldName: payload.oldName, + newName: payload.newName, + })); + }; + + private onSpaceVisibilityUpdated = async ( + event: SpaceVisibilityUpdatedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent( + event, + 'space_visibility_updated', + (payload) => ({ + spaceId: payload.spaceId, + newVisibility: payload.newVisibility, + }), + ); + }; + + private onSpaceCreated = async (event: SpaceCreatedEvent): Promise<void> => { + return this.emitAmplitudeEvent(event, 'space_created', (payload) => ({ + spaceName: payload.spaceName, + spaceSlug: payload.spaceSlug, + visibility: payload.visibility, + })); + }; + + private onSpaceDeleted = async (event: SpaceDeletedEvent): Promise<void> => { + return this.emitAmplitudeEvent(event, 'space_deleted', (payload) => ({ + spaceId: payload.spaceId, + })); + }; + + private onSpaceMembersAdded = async ( + event: SpaceMembersAddedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent(event, 'space_members_added', (payload) => ({ + spaceId: payload.spaceId, + memberCount: payload.memberUserIds.length, + })); + }; + + private onSpaceMembersRemoved = async ( + event: SpaceMembersRemovedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent( + event, + 'space_members_removed', + (payload) => ({ + spaceId: payload.spaceId, + memberCount: payload.memberUserIds.length, + }), + ); + }; + + private onSpaceMembersRoleUpdated = async ( + event: SpaceMembersRoleUpdatedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent( + event, + 'space_members_role_updated', + (payload) => ({ + spaceId: payload.spaceId, + memberCount: payload.memberUserIds.length, + newRole: payload.newRole, + }), + ); + }; + + private onSpacePinned = async (event: SpacePinnedEvent): Promise<void> => { + return this.emitAmplitudeEvent(event, 'space_pinned', (payload) => ({ + spaceId: payload.spaceId, + })); + }; + + private onSpaceUnpinned = async ( + event: SpaceUnpinnedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent(event, 'space_unpinned', (payload) => ({ + spaceId: payload.spaceId, + })); + }; + + private onPlaybookArtefactMoved = async ( + event: PlaybookArtefactMovedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent( + event, + 'playbook_artefact_moved', + (payload) => ({ + artifactType: payload.artifactType, + oldArtifactId: payload.oldArtifactId, + newArtifactId: payload.newArtifactId, + sourceSpaceId: payload.sourceSpaceId, + destinationSpaceId: payload.destinationSpaceId, + }), + ); + }; + + private onPluginRendered = async ( + event: PluginRenderedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent(event, 'plugin_rendered', (payload) => ({ + packageId: payload.packageId, + packageSlug: payload.packageSlug, + mode: payload.mode, + pluginRoot: payload.pluginRoot, + ...(payload.marketplaceRepo && { + marketplaceRepo: payload.marketplaceRepo, + }), + })); + }; + + private onPluginDeleted = async ( + event: PluginDeletedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent(event, 'plugin_deleted', (payload) => ({ + packageId: payload.packageId, + packageSlug: payload.packageSlug, + ...(payload.marketplaceRepo && { + marketplaceRepo: payload.marketplaceRepo, + }), + })); + }; + + private onPluginPublishAttempted = async ( + event: PluginPublishAttemptedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent( + event, + 'plugin_publish_attempted', + (payload) => ({ + marketplaceDistributionId: payload.marketplaceDistributionId, + marketplaceId: payload.marketplaceId, + packageId: payload.packageId, + isFirstPublishForPackage: payload.isFirstPublishForPackage, + }), + ); + }; + + private onPluginPublished = async ( + event: PluginPublishedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent(event, 'plugin_published', (payload) => ({ + marketplaceDistributionId: payload.marketplaceDistributionId, + marketplaceId: payload.marketplaceId, + packageId: payload.packageId, + wasNoop: payload.wasNoop, + ...(payload.prUrl !== undefined && { prUrl: payload.prUrl }), + ...(payload.commitCountAfter !== undefined && { + commitCountAfter: payload.commitCountAfter, + }), + })); + }; + + private onPluginPublishFailed = async ( + event: PluginPublishFailedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent( + event, + 'plugin_publish_failed', + (payload) => ({ + marketplaceDistributionId: payload.marketplaceDistributionId, + marketplaceId: payload.marketplaceId, + packageId: payload.packageId, + failureReason: payload.failureReason, + }), + ); + }; + + private onMarketplaceLinked = async ( + event: MarketplaceLinkedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent(event, 'marketplace_linked', (payload) => ({ + marketplaceId: payload.marketplaceId, + gitRepoId: payload.gitRepoId, + addedBy: payload.addedBy, + })); + }; + + private onMarketplaceUnlinked = async ( + event: MarketplaceUnlinkedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent( + event, + 'marketplace_unlinked', + (payload) => ({ + marketplaceId: payload.marketplaceId, + gitRepoId: payload.gitRepoId, + }), + ); + }; + + private onMarketplacePluginRemovalInitiated = async ( + event: MarketplacePluginRemovalInitiatedEvent, + ): Promise<void> => { + return this.emitAmplitudeEvent( + event, + 'marketplace_plugin_removal_initiated', + (payload) => ({ + marketplaceId: payload.marketplaceId, + pluginSlug: payload.pluginSlug, + trigger: payload.trigger, + }), + ); + }; +} diff --git a/packages/amplitude/src/application/AmplitudeTrackEventService.ts b/packages/amplitude/src/application/AmplitudeTrackEventService.ts new file mode 100644 index 000000000..1370538a4 --- /dev/null +++ b/packages/amplitude/src/application/AmplitudeTrackEventService.ts @@ -0,0 +1,85 @@ +import { AmplitudeNodeEvent } from '../domain/entities/AmplitudeNodeEvent'; +import { + track, + init, + groupIdentify, + Identify, +} from '@amplitude/analytics-node'; +import { AmplitudeService } from '../nest-api/amplitude/amplitude.service'; +import { ServerZoneType } from '@amplitude/analytics-core/lib/esm/types/server-zone'; +import { PackmindLogger } from '@packmind/logger'; +import { Mutex } from 'async-mutex'; + +const origin = 'AmplitudeTrackEventService'; + +export class AmplitudeTrackEventService { + private initialized = false; + private static readonly mutex = new Mutex(); + + constructor(private readonly logger = new PackmindLogger(origin)) { + this.logger.info('AmplitudeTrackEventService (proprietary) initialized'); + } + + private async initializeAmplitude() { + try { + await AmplitudeTrackEventService.mutex.acquire(); + + if (this.initialized) return; + + const config = await new AmplitudeService().getConfig(); + + if (!config.amplitudeKey) { + this.logger.debug('No API Key provisioned for Amplitude, skip'); + return; + } + + init(config.amplitudeKey, { + serverZone: config.amplitudeRegion as ServerZoneType, + }); + this.initialized = true; + } finally { + await AmplitudeTrackEventService.mutex.release(); + } + } + + async pushEventToAmplitude(event: AmplitudeNodeEvent) { + await this.initializeAmplitude(); + + if (!this.initialized) { + return; + } + + this.logger.info('Tracking event', { + event: event.event, + userId: event.userId.substring(0, 6) + '*', + }); + + track( + event.event, + { + ...event.metadata, + ...{ organizationId: event.orgaId }, + }, + { + user_id: event.userId, + groups: { organization: event.orgaId }, + }, + ); + } + + async identifyOrganizationGroup(orgId: string, name: string) { + await this.initializeAmplitude(); + + if (!this.initialized) { + return; + } + + this.logger.info('Identifying organization group', { + orgId: orgId.substring(0, 6) + '*', + }); + + const identify = new Identify(); + identify.set('name', name); + groupIdentify('organization', orgId, identify); + } +} diff --git a/packages/amplitude/src/application/EventTrackingAdapter.ts b/packages/amplitude/src/application/EventTrackingAdapter.ts new file mode 100644 index 000000000..f78998688 --- /dev/null +++ b/packages/amplitude/src/application/EventTrackingAdapter.ts @@ -0,0 +1,46 @@ +import { IEventTrackingPort, UserId, OrganizationId } from '@packmind/types'; +import { PackmindLogger } from '@packmind/logger'; +import { AmplitudeTrackEventService } from './AmplitudeTrackEventService'; +import { AmplitudeNodeEvent } from '../domain/entities/AmplitudeNodeEvent'; + +const origin = 'EventTrackingAdapter'; + +export class EventTrackingAdapter implements IEventTrackingPort { + private readonly amplitudeService: AmplitudeTrackEventService; + + constructor( + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) { + this.amplitudeService = new AmplitudeTrackEventService(); + this.logger.info('EventTrackingAdapter (proprietary version) initialized'); + } + + async trackEvent( + userId: UserId, + organizationId: OrganizationId, + eventName: string, + metadata?: Record<string, string | number>, + ): Promise<void> { + this.logger.info('EventTrackingAdapter.trackEvent called (proprietary)', { + eventName, + userId: userId.substring(0, 6) + '*', + }); + + // Convert to AmplitudeNodeEvent format and delegate to service + const event: AmplitudeNodeEvent = { + event: eventName, + userId, + orgaId: organizationId, + metadata: metadata || {}, + }; + + await this.amplitudeService.pushEventToAmplitude(event); + } + + async identifyOrganizationGroup( + organizationId: OrganizationId, + name: string, + ): Promise<void> { + await this.amplitudeService.identifyOrganizationGroup(organizationId, name); + } +} diff --git a/packages/amplitude/src/domain/entities/AmplitudeConfig.ts b/packages/amplitude/src/domain/entities/AmplitudeConfig.ts new file mode 100644 index 000000000..f2f5a13b4 --- /dev/null +++ b/packages/amplitude/src/domain/entities/AmplitudeConfig.ts @@ -0,0 +1,4 @@ +export type AmplitudeConfig = { + amplitudeKey: string | null; + amplitudeRegion: string | null; +}; diff --git a/packages/amplitude/src/domain/entities/AmplitudeNodeEvent.ts b/packages/amplitude/src/domain/entities/AmplitudeNodeEvent.ts new file mode 100644 index 000000000..8fcc86ded --- /dev/null +++ b/packages/amplitude/src/domain/entities/AmplitudeNodeEvent.ts @@ -0,0 +1,10 @@ +import { UserId, OrganizationId } from '@packmind/types'; + +export type AmplitudeMetadata = Record<string, string | number | boolean>; + +export type AmplitudeNodeEvent = { + userId: UserId; + orgaId: OrganizationId; + event: string; + metadata?: AmplitudeMetadata; +}; diff --git a/packages/amplitude/src/index.ts b/packages/amplitude/src/index.ts new file mode 100644 index 000000000..db314a8c2 --- /dev/null +++ b/packages/amplitude/src/index.ts @@ -0,0 +1,6 @@ +export * from './domain/entities/AmplitudeConfig'; +export { AmplitudeModule } from './nest-api/amplitude/amplitude.module'; +export { enableAmplitudeProxy } from './nest-api/enableAmplitudeProxy'; +export { EventTrackingAdapter } from './application/EventTrackingAdapter'; +export { AmplitudeHexa } from './AmplitudeHexa'; +export type { AmplitudeNodeEvent } from './domain/entities/AmplitudeNodeEvent'; diff --git a/packages/amplitude/src/nest-api/amplitude/amplitude.controller.spec.ts b/packages/amplitude/src/nest-api/amplitude/amplitude.controller.spec.ts new file mode 100644 index 000000000..c52480ea2 --- /dev/null +++ b/packages/amplitude/src/nest-api/amplitude/amplitude.controller.spec.ts @@ -0,0 +1,64 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AmplitudeController } from './amplitude.controller'; +import { AmplitudeService } from './amplitude.service'; +import { PackmindLogger } from '@packmind/logger'; +import { stubLogger } from '@packmind/test-utils'; + +describe('AmplitudeController', () => { + let controller: AmplitudeController; + let service: AmplitudeService; + + beforeEach(async () => { + const mockAmplitudeService = { + getConfig: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AmplitudeController], + providers: [ + { + provide: AmplitudeService, + useValue: mockAmplitudeService, + }, + { + provide: PackmindLogger, + useValue: stubLogger(), + }, + ], + }).compile(); + + controller = module.get<AmplitudeController>(AmplitudeController); + service = module.get<AmplitudeService>(AmplitudeService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('is defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getConfig', () => { + const mockConfig = { + amplitudeKey: 'test-key', + amplitudeRegion: 'EU', + }; + + beforeEach(() => { + jest.spyOn(service, 'getConfig').mockResolvedValue(mockConfig); + }); + + it('returns amplitude configuration from service', async () => { + const result = await controller.getConfig(); + + expect(result).toEqual(mockConfig); + }); + + it('calls the service getConfig method once', async () => { + await controller.getConfig(); + + expect(service.getConfig).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/amplitude/src/nest-api/amplitude/amplitude.controller.ts b/packages/amplitude/src/nest-api/amplitude/amplitude.controller.ts new file mode 100644 index 000000000..8e33376d9 --- /dev/null +++ b/packages/amplitude/src/nest-api/amplitude/amplitude.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get } from '@nestjs/common'; +import { AmplitudeService } from './amplitude.service'; +import { Public } from '@packmind/node-utils'; +import { LogLevel, PackmindLogger } from '@packmind/logger'; + +const origin = 'AmplitudeController'; + +@Public() +@Controller('amplitude') +export class AmplitudeController { + constructor( + private readonly amplitudeService: AmplitudeService, + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.INFO, + ), + ) { + this.logger.info('AmplitudeController initialized'); + } + + @Get('config') + async getConfig(): Promise<{ + amplitudeKey: string | null; + amplitudeRegion: string | null; + }> { + this.logger.info( + 'GET /amplitude/config - Fetching Amplitude configuration', + ); + return this.amplitudeService.getConfig(); + } +} diff --git a/packages/amplitude/src/nest-api/amplitude/amplitude.module.ts b/packages/amplitude/src/nest-api/amplitude/amplitude.module.ts new file mode 100644 index 000000000..5ef60728f --- /dev/null +++ b/packages/amplitude/src/nest-api/amplitude/amplitude.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { AmplitudeController } from './amplitude.controller'; +import { AmplitudeService } from './amplitude.service'; +import { LogLevel, PackmindLogger } from '@packmind/logger'; + +@Module({ + controllers: [AmplitudeController], + providers: [ + AmplitudeService, + { + provide: PackmindLogger, + useFactory: () => new PackmindLogger('AmplitudeModule', LogLevel.INFO), + }, + ], +}) +export class AmplitudeModule {} diff --git a/packages/amplitude/src/nest-api/amplitude/amplitude.service.spec.ts b/packages/amplitude/src/nest-api/amplitude/amplitude.service.spec.ts new file mode 100644 index 000000000..9f6b0fe1e --- /dev/null +++ b/packages/amplitude/src/nest-api/amplitude/amplitude.service.spec.ts @@ -0,0 +1,80 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Configuration } from '@packmind/node-utils'; +import { stubLogger } from '@packmind/test-utils'; +import { AmplitudeService } from './amplitude.service'; + +jest.mock('@packmind/node-utils', () => ({ + ...jest.requireActual('@packmind/node-utils'), + Configuration: { + getConfig: jest.fn(), + }, +})); + +describe('AmplitudeService', () => { + let service: AmplitudeService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: AmplitudeService, + useFactory: () => new AmplitudeService(stubLogger()), + }, + ], + }).compile(); + + service = module.get<AmplitudeService>(AmplitudeService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('is defined', () => { + expect(service).toBeDefined(); + }); + + describe('getConfig', () => { + it('returns amplitude configuration with key and region', async () => { + const mockKey = 'test-api-key'; + const mockRegion = 'EU'; + + (Configuration.getConfig as jest.Mock) + .mockResolvedValueOnce(mockKey) + .mockResolvedValueOnce(mockRegion); + + const result = await service.getConfig(); + + expect(result).toEqual({ + amplitudeKey: mockKey, + amplitudeRegion: mockRegion, + }); + }); + + it('calls Configuration.getConfig twice', async () => { + const mockKey = 'test-api-key'; + const mockRegion = 'EU'; + + (Configuration.getConfig as jest.Mock) + .mockResolvedValueOnce(mockKey) + .mockResolvedValueOnce(mockRegion); + + await service.getConfig(); + + expect(Configuration.getConfig).toHaveBeenCalledTimes(2); + }); + + describe('when environment variables are not set', () => { + it('returns undefined values', async () => { + (Configuration.getConfig as jest.Mock).mockResolvedValue(undefined); + + const result = await service.getConfig(); + + expect(result).toEqual({ + amplitudeKey: undefined, + amplitudeRegion: undefined, + }); + }); + }); + }); +}); diff --git a/packages/amplitude/src/nest-api/amplitude/amplitude.service.ts b/packages/amplitude/src/nest-api/amplitude/amplitude.service.ts new file mode 100644 index 000000000..3f60eb65e --- /dev/null +++ b/packages/amplitude/src/nest-api/amplitude/amplitude.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { Configuration } from '@packmind/node-utils'; + +const origin = 'AmplitudeService'; + +@Injectable() +export class AmplitudeService { + constructor( + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.INFO, + ), + ) { + this.logger.info('AmplitudeService initialized'); + } + + async getConfig(): Promise<{ + amplitudeKey: string | null; + amplitudeRegion: string | null; + }> { + this.logger.info('Fetching Amplitude configuration'); + + const amplitudeKey = await Configuration.getConfig( + 'AMPLITUDE_API_KEY', + process.env, + this.logger, + ); + const amplitudeRegion = await Configuration.getConfig( + 'AMPLITUDE_REGION', + process.env, + this.logger, + ); + + return { + amplitudeKey, + amplitudeRegion, + }; + } +} diff --git a/packages/amplitude/src/nest-api/enableAmplitudeProxy.ts b/packages/amplitude/src/nest-api/enableAmplitudeProxy.ts new file mode 100644 index 000000000..d90edc5c6 --- /dev/null +++ b/packages/amplitude/src/nest-api/enableAmplitudeProxy.ts @@ -0,0 +1,48 @@ +import { INestApplication } from '@nestjs/common'; +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { Configuration } from '@packmind/node-utils'; +import { createProxyMiddleware } from 'http-proxy-middleware'; + +const origin = 'enableAmplitudeProxy'; +const logger = new PackmindLogger(origin, LogLevel.INFO); + +export async function enableAmplitudeProxy(app: INestApplication<unknown>) { + // Fetch Amplitude region from configuration + const amplitudeRegion = await Configuration.getConfig( + 'AMPLITUDE_REGION', + process.env, + ); + + if (!amplitudeRegion) { + return; + } + + // Map region to Amplitude API endpoint + const getAmplitudeApiUrl = (region: string | undefined): string => { + switch (region?.toLowerCase()) { + case 'eu': + return 'https://api.eu.amplitude.com'; + case 'us': + default: + return 'https://api2.amplitude.com'; + } + }; + + const target = getAmplitudeApiUrl(amplitudeRegion); + + logger.info('Setting up Amplitude proxy', { + region: amplitudeRegion || 'us (default)', + target, + }); + + app.use( + '/api/v0/amplitude/collect', + createProxyMiddleware({ + target, + changeOrigin: true, + secure: true, + // Force the upstream path to be exactly "/2/httpapi" + pathRewrite: () => '/2/httpapi', + }), + ); +} diff --git a/packages/amplitude/tsconfig.json b/packages/amplitude/tsconfig.json new file mode 100644 index 000000000..19b9eece4 --- /dev/null +++ b/packages/amplitude/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/amplitude/tsconfig.lib.json b/packages/amplitude/tsconfig.lib.json new file mode 100644 index 000000000..33eca2c2c --- /dev/null +++ b/packages/amplitude/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/amplitude/tsconfig.spec.json b/packages/amplitude/tsconfig.spec.json new file mode 100644 index 000000000..75e3ca2be --- /dev/null +++ b/packages/amplitude/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"], + "resolveJsonModule": true + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/packages/coding-agent/eslint.config.mjs b/packages/coding-agent/eslint.config.mjs index c334bc0bc..2a3464adf 100644 --- a/packages/coding-agent/eslint.config.mjs +++ b/packages/coding-agent/eslint.config.mjs @@ -9,6 +9,7 @@ export default [ 'error', { ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + ignoredDependencies: ['semver'], }, ], }, diff --git a/packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.spec.ts b/packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.spec.ts index 7591452e7..9b176bf24 100644 --- a/packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.spec.ts +++ b/packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.spec.ts @@ -9,6 +9,9 @@ import { StandardVersion, StandardVersionId, StandardId, + SkillVersion, + SkillVersionId, + SkillId, UserId, } from '@packmind/types'; import { ICodingAgentDeployer } from '../../domain/repository/ICodingAgentDeployer'; @@ -43,6 +46,17 @@ describe('PreviewArtifactRenderingUseCase', () => { rules: [{ id: 'r1', content: 'Rule 1', examples: [] }], }; + const skillVersion: SkillVersion = { + id: 'skv-1' as SkillVersionId, + skillId: 'sk-1' as SkillId, + version: 1, + userId: 'user-1' as UserId, + name: 'Test Skill', + slug: 'test-skill', + description: 'A test skill', + prompt: '# Skill prompt', + }; + beforeEach(() => { mockDeployer = { generateFileUpdatesForStandards: jest.fn(), @@ -103,7 +117,106 @@ describe('PreviewArtifactRenderingUseCase', () => { const result = await useCase.execute(command); - expect(result.fileName).toBe('packmind-claude-preview.zip'); + expect(result.fileName).toBe('packmind-claude-test-command.zip'); + }); + + describe('filename computation', () => { + beforeEach(() => { + mockDeployer.deployArtifacts.mockResolvedValue({ + createOrUpdate: [], + delete: [], + }); + }); + + describe('when the command carries a single standard', () => { + it('uses the standard slug', async () => { + const result = await useCase.execute({ + codingAgent: 'claude', + recipeVersions: [], + standardVersions: [standardVersion], + skillVersions: [], + }); + + expect(result.fileName).toBe('packmind-claude-test-standard.zip'); + }); + }); + + describe('when the command carries a single skill', () => { + it('uses the skill slug', async () => { + const result = await useCase.execute({ + codingAgent: 'cursor', + recipeVersions: [], + standardVersions: [], + skillVersions: [skillVersion], + }); + + expect(result.fileName).toBe('packmind-cursor-test-skill.zip'); + }); + }); + + describe('when slug is empty', () => { + it('slugifies the artifact name', async () => { + const withEmptySlug: RecipeVersion = { + ...recipeVersion, + slug: '', + name: 'My Cool Thing!', + }; + + const result = await useCase.execute({ + codingAgent: 'claude', + recipeVersions: [withEmptySlug], + standardVersions: [], + skillVersions: [], + }); + + expect(result.fileName).toBe('packmind-claude-my-cool-thing.zip'); + }); + }); + + describe('when both slug and name are empty', () => { + it('falls back to "preview"', async () => { + const emptyArtifact: RecipeVersion = { + ...recipeVersion, + slug: '', + name: '', + }; + + const result = await useCase.execute({ + codingAgent: 'claude', + recipeVersions: [emptyArtifact], + standardVersions: [], + skillVersions: [], + }); + + expect(result.fileName).toBe('packmind-claude-preview.zip'); + }); + }); + + describe('when the command carries multiple artifacts', () => { + it('falls back to "preview"', async () => { + const result = await useCase.execute({ + codingAgent: 'claude', + recipeVersions: [recipeVersion], + standardVersions: [standardVersion], + skillVersions: [], + }); + + expect(result.fileName).toBe('packmind-claude-preview.zip'); + }); + }); + + describe('when the command carries no artifacts', () => { + it('falls back to "preview"', async () => { + const result = await useCase.execute({ + codingAgent: 'copilot', + recipeVersions: [], + standardVersions: [], + skillVersions: [], + }); + + expect(result.fileName).toBe('packmind-copilot-preview.zip'); + }); + }); }); describe('when extracting the zip', () => { diff --git a/packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.ts b/packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.ts index 9f7255794..466cb3bb3 100644 --- a/packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.ts +++ b/packages/coding-agent/src/application/useCases/PreviewArtifactRenderingUseCase.ts @@ -53,7 +53,7 @@ export class PreviewArtifactRenderingUseCase { const zipBuffer = await this.createZipFromFiles(filesWithContent); const base64Content = zipBuffer.toString('base64'); - const fileName = `packmind-${codingAgent}-preview.zip`; + const fileName = this.computeFileName(codingAgent, command); this.logger.info('Preview rendering zip created', { codingAgent, @@ -64,6 +64,38 @@ export class PreviewArtifactRenderingUseCase { return { fileName, fileContent: base64Content }; } + private computeFileName( + codingAgent: PreviewArtifactRenderingCommand['codingAgent'], + command: PreviewArtifactRenderingCommand, + ): string { + const artifacts = [ + ...command.recipeVersions, + ...command.standardVersions, + ...command.skillVersions, + ]; + + if (artifacts.length !== 1) { + return `packmind-${codingAgent}-preview.zip`; + } + + const [artifact] = artifacts; + const slug = + artifact.slug && artifact.slug.length > 0 + ? artifact.slug + : this.slugify(artifact.name); + + return `packmind-${codingAgent}-${slug}.zip`; + } + + private slugify(value: string): string { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return slug.length > 0 ? slug : 'preview'; + } + private async createZipFromFiles(files: FileWithContent[]): Promise<Buffer> { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; diff --git a/packages/coding-agent/src/infra/repositories/claudePlugin/ClaudePluginDeployer.spec.ts b/packages/coding-agent/src/infra/repositories/claudePlugin/ClaudePluginDeployer.spec.ts index e6a178980..5253b1d97 100644 --- a/packages/coding-agent/src/infra/repositories/claudePlugin/ClaudePluginDeployer.spec.ts +++ b/packages/coding-agent/src/infra/repositories/claudePlugin/ClaudePluginDeployer.spec.ts @@ -1,4 +1,7 @@ -import { ClaudePluginDeployer } from './ClaudePluginDeployer'; +import { + ClaudePluginDeployer, + PluginTrackingHooksInput, +} from './ClaudePluginDeployer'; import { GitRepo, RecipeVersion, @@ -439,4 +442,572 @@ describe('ClaudePluginDeployer', () => { expect(new ClaudePluginDeployer().getSkillsFolderPath()).toBe('skills/'); }); }); + + describe('deployTrackingHooks', () => { + const trackingInput: PluginTrackingHooksInput = { + apiBaseUrl: 'https://app.packmind.io/api', + marketplaceName: 'acme-marketplace', + pluginSlug: 'security', + trackingToken: 'tok_abc123', + }; + + describe('emits exactly four hook files under <plugin-root>/hooks/', () => { + let updates: ReturnType<ClaudePluginDeployer['deployTrackingHooks']>; + + beforeEach(() => { + updates = new ClaudePluginDeployer().deployTrackingHooks( + trackingInput, + makeTarget('plugins/security/'), + ); + }); + + it('creates exactly four files', () => { + expect(updates.createOrUpdate).toHaveLength(4); + }); + + it('emits hooks/hooks.json at the correct path', () => { + const paths = updates.createOrUpdate.map((f) => f.path); + expect(paths).toContain('plugins/security/hooks/hooks.json'); + }); + + it('emits hooks/track-install.sh at the correct path', () => { + const paths = updates.createOrUpdate.map((f) => f.path); + expect(paths).toContain('plugins/security/hooks/track-install.sh'); + }); + + it('emits hooks/track-install.ps1 at the correct path', () => { + const paths = updates.createOrUpdate.map((f) => f.path); + expect(paths).toContain('plugins/security/hooks/track-install.ps1'); + }); + + it('emits hooks/packmind-tracking.env at the correct path', () => { + const paths = updates.createOrUpdate.map((f) => f.path); + expect(paths).toContain('plugins/security/hooks/packmind-tracking.env'); + }); + + it('has an empty delete list', () => { + expect(updates.delete).toEqual([]); + }); + }); + + describe('when target.path is "/"', () => { + let rootPaths: string[]; + + beforeEach(() => { + const updates = new ClaudePluginDeployer().deployTrackingHooks( + trackingInput, + makeTarget('/'), + ); + rootPaths = updates.createOrUpdate.map((f) => f.path); + }); + + it('emits hooks/hooks.json without a path prefix', () => { + expect(rootPaths).toContain('hooks/hooks.json'); + }); + + it('emits hooks/track-install.sh without a path prefix', () => { + expect(rootPaths).toContain('hooks/track-install.sh'); + }); + + it('emits hooks/track-install.ps1 without a path prefix', () => { + expect(rootPaths).toContain('hooks/track-install.ps1'); + }); + + it('emits hooks/packmind-tracking.env without a path prefix', () => { + expect(rootPaths).toContain('hooks/packmind-tracking.env'); + }); + }); + + describe('hooks.json content', () => { + let hooksJsonContent: string; + + beforeEach(() => { + const updates = new ClaudePluginDeployer().deployTrackingHooks( + trackingInput, + makeTarget('/'), + ); + const file = updates.createOrUpdate.find( + (f) => f.path === 'hooks/hooks.json', + ); + if (!file?.content) throw new Error('expected hooks.json content'); + hooksJsonContent = file.content; + }); + + it('is valid JSON', () => { + expect(() => JSON.parse(hooksJsonContent)).not.toThrow(); + }); + + it('registers a SessionStart hook', () => { + const parsed = JSON.parse(hooksJsonContent); + expect(parsed.hooks).toHaveProperty('SessionStart'); + }); + + it('uses the "startup" matcher', () => { + const parsed = JSON.parse(hooksJsonContent); + const entry = parsed.hooks.SessionStart[0]; + expect(entry.matcher).toBe('startup'); + }); + + it('sets suppressOutput to true', () => { + const parsed = JSON.parse(hooksJsonContent); + const hook = parsed.hooks.SessionStart[0].hooks[0]; + expect(hook.suppressOutput).toBe(true); + }); + + it('sets a numeric timeout', () => { + const parsed = JSON.parse(hooksJsonContent); + const hook = parsed.hooks.SessionStart[0].hooks[0]; + expect(typeof hook.timeout).toBe('number'); + }); + + it('sets a timeout of 30 seconds or less', () => { + const parsed = JSON.parse(hooksJsonContent); + const hook = parsed.hooks.SessionStart[0].hooks[0]; + expect(hook.timeout).toBeLessThanOrEqual(30); + }); + + it('references track-install.sh in the command', () => { + const parsed = JSON.parse(hooksJsonContent); + const hook = parsed.hooks.SessionStart[0].hooks[0]; + expect(hook.command).toContain('track-install.sh'); + }); + + it('uses || to chain sh and powershell in the command', () => { + const parsed = JSON.parse(hooksJsonContent); + const hook = parsed.hooks.SessionStart[0].hooks[0]; + expect(hook.command).toContain('||'); + }); + + it('references track-install.ps1 in the command', () => { + const parsed = JSON.parse(hooksJsonContent); + const hook = parsed.hooks.SessionStart[0].hooks[0]; + expect(hook.command).toContain('track-install.ps1'); + }); + + it('ends with a trailing newline', () => { + expect(hooksJsonContent.endsWith('\n')).toBe(true); + }); + }); + + describe('packmind-tracking.env sidecar content', () => { + let envContent: string; + + beforeEach(() => { + const updates = new ClaudePluginDeployer().deployTrackingHooks( + trackingInput, + makeTarget('/'), + ); + const file = updates.createOrUpdate.find( + (f) => f.path === 'hooks/packmind-tracking.env', + ); + if (!file?.content) throw new Error('expected env content'); + envContent = file.content; + }); + + it('contains PACKMIND_API_BASE_URL', () => { + expect(envContent).toContain( + 'PACKMIND_API_BASE_URL=https://app.packmind.io/api', + ); + }); + + it('contains PACKMIND_MARKETPLACE_NAME', () => { + expect(envContent).toContain( + 'PACKMIND_MARKETPLACE_NAME=acme-marketplace', + ); + }); + + it('contains PACKMIND_PLUGIN_SLUG', () => { + expect(envContent).toContain('PACKMIND_PLUGIN_SLUG=security'); + }); + + it('contains PACKMIND_TRACKING_TOKEN', () => { + expect(envContent).toContain('PACKMIND_TRACKING_TOKEN=tok_abc123'); + }); + + it('is in flat KEY=VALUE format (no JSON)', () => { + // Each non-empty line should be KEY=VALUE + const lines = envContent.split('\n').filter((l) => l.trim() !== ''); + for (const line of lines) { + expect(line).toMatch(/^[A-Z_]+=.+$/); + } + }); + }); + + describe('track-install.sh content', () => { + let shContent: string; + + beforeEach(() => { + const updates = new ClaudePluginDeployer().deployTrackingHooks( + trackingInput, + makeTarget('/'), + ); + const file = updates.createOrUpdate.find( + (f) => f.path === 'hooks/track-install.sh', + ); + if (!file?.content) throw new Error('expected sh content'); + shContent = file.content; + }); + + it('starts with a POSIX shebang', () => { + expect(shContent.startsWith('#!/bin/sh')).toBe(true); + }); + + it('injects the plugin slug', () => { + expect(shContent).toContain('security'); + }); + + it('does not hardcode the marketplace name as a literal in the script body', () => { + // The marketplace name must NOT be TS-interpolated into the script; it + // is loaded from the sidecar via $PACKMIND_MARKETPLACE_NAME at runtime. + expect(shContent).not.toContain(`MARKETPLACE_NAME="acme-marketplace"`); + }); + + it('references the PACKMIND_MARKETPLACE_NAME sidecar variable', () => { + expect(shContent).toContain('PACKMIND_MARKETPLACE_NAME'); + }); + + it('reads the sidecar env file', () => { + expect(shContent).toContain('packmind-tracking.env'); + }); + + it('sources the sidecar (POSIX . command)', () => { + expect(shContent).toMatch(/\.\s+["']?\$[{(]?SIDECAR/); + }); + + it('checks settings.local.json for local scope', () => { + expect(shContent).toContain('settings.local.json'); + }); + + it('checks settings.json for project scope', () => { + expect(shContent).toContain('settings.json'); + }); + + it('checks ~/.claude/settings.json for user scope', () => { + expect(shContent).toContain('HOME'); + }); + + it('invokes git for repo detection', () => { + expect(shContent).toContain('git'); + }); + + it('uses remote get-url origin to detect the repo remote', () => { + expect(shContent).toContain('remote get-url origin'); + }); + + it('skips repo detection for user scope', () => { + expect(shContent).toContain('[ "$SCOPE" != "user" ]'); + }); + + it('reads the installed version from the plugin manifest', () => { + expect(shContent).toContain( + '_json_get version "${CLAUDE_PLUGIN_ROOT%/}/.claude-plugin/plugin.json"', + ); + }); + + it('falls back to the CLAUDE_PLUGIN_ROOT version segment', () => { + expect(shContent).toContain('basename "${CLAUDE_PLUGIN_ROOT%/}"'); + }); + + it('includes installedVersion in the payload', () => { + expect(shContent).toContain('installedVersion'); + }); + + it('invokes curl for the HTTP call', () => { + expect(shContent).toContain('curl'); + }); + + it('uses 3s max-time for curl', () => { + expect(shContent).toContain('--max-time 3'); + }); + + it('posts to the tracking endpoint', () => { + expect(shContent).toContain('/tracking/plugin-installs'); + }); + + it('sends the X-Packmind-Tracking-Token header', () => { + expect(shContent).toContain('X-Packmind-Tracking-Token'); + }); + + it('always exits 0', () => { + expect(shContent).toContain('exit 0'); + }); + + it('backgrounds the curl call', () => { + // Backgrounding is done with & in a subshell + expect(shContent).toMatch(/\([^)]*curl[^)]*\)[^&]*&|\([^)]*&[^)]*\)/s); + }); + + it('includes shasum in the SHA-256 fallback chain', () => { + expect(shContent).toContain('shasum'); + }); + + it('includes sha256sum in the SHA-256 fallback chain', () => { + expect(shContent).toContain('sha256sum'); + }); + + it('includes openssl dgst -sha256 in the SHA-256 fallback chain', () => { + expect(shContent).toContain('openssl dgst -sha256'); + }); + + it('checks the PACKMIND_API_KEY env var for credentials', () => { + expect(shContent).toContain('PACKMIND_API_KEY:-'); + }); + + it('falls back to the PACKMIND_API_KEY_V3 env var for credentials', () => { + expect(shContent).toContain('PACKMIND_API_KEY_V3:-'); + }); + + it('checks PACKMIND_API_KEY_V3 before the credentials file', () => { + expect(shContent.indexOf('PACKMIND_API_KEY_V3')).toBeLessThan( + shContent.indexOf('credentials.json'), + ); + }); + + it('reads the project .env via _env_get for the API key', () => { + expect(shContent).toContain('_env_get PACKMIND_API_KEY "$ENV_FILE"'); + }); + + it('resolves the .env from the launch/project directory', () => { + expect(shContent).toContain('ENV_FILE="${PROJECT_DIR}/.env"'); + }); + + it('checks env vars before the project .env', () => { + expect(shContent.indexOf('PACKMIND_API_KEY_V3:-')).toBeLessThan( + shContent.indexOf('_env_get PACKMIND_API_KEY'), + ); + }); + + it('checks the project .env before the credentials file', () => { + expect(shContent.indexOf('_env_get PACKMIND_API_KEY')).toBeLessThan( + shContent.indexOf('credentials.json'), + ); + }); + + it('parses the .env without sourcing it', () => { + // Sourcing/dotting a repo-controlled .env would execute arbitrary code. + expect(shContent).not.toMatch(/\.\s+["']?\$\{?ENV_FILE/); + }); + + it('does not contain jq', () => { + expect(shContent).not.toContain('jq'); + }); + + it('does not contain bash array declarations', () => { + // bash array syntax: var=( ... ) — POSIX sh does not support this + expect(shContent).not.toMatch(/\w+=\s*\(/); + }); + }); + + describe('track-install.ps1 content', () => { + let ps1Content: string; + + beforeEach(() => { + const updates = new ClaudePluginDeployer().deployTrackingHooks( + trackingInput, + makeTarget('/'), + ); + const file = updates.createOrUpdate.find( + (f) => f.path === 'hooks/track-install.ps1', + ); + if (!file?.content) throw new Error('expected ps1 content'); + ps1Content = file.content; + }); + + it('injects the plugin slug', () => { + expect(ps1Content).toContain('security'); + }); + + it('does not hardcode the marketplace name as a literal in the script body', () => { + // The marketplace name must NOT be TS-interpolated into the script; it + // is loaded from the sidecar via $PACKMIND_MARKETPLACE_NAME at runtime. + expect(ps1Content).not.toContain( + `$MarketplaceName = 'acme-marketplace'`, + ); + }); + + it('references the PACKMIND_MARKETPLACE_NAME sidecar variable', () => { + expect(ps1Content).toContain('PACKMIND_MARKETPLACE_NAME'); + }); + + it('reads the sidecar env file', () => { + expect(ps1Content).toContain('packmind-tracking.env'); + }); + + it('uses ConvertFrom-Json to parse JSON files', () => { + expect(ps1Content).toContain('ConvertFrom-Json'); + }); + + it('uses Invoke-RestMethod for the HTTP call', () => { + expect(ps1Content).toContain('Invoke-RestMethod'); + }); + + it('uses Start-Job to background the HTTP call', () => { + expect(ps1Content).toContain('Start-Job'); + }); + + it('checks settings.local.json for local scope', () => { + expect(ps1Content).toContain('settings.local.json'); + }); + + it('skips repo detection for user scope', () => { + expect(ps1Content).toContain("$Scope -ne 'user'"); + }); + + it('reads the installed version from the plugin manifest', () => { + expect(ps1Content).toContain('.claude-plugin\\plugin.json'); + }); + + it('includes installedVersion in the payload', () => { + expect(ps1Content).toContain('installedVersion'); + }); + + it('posts to the tracking endpoint', () => { + expect(ps1Content).toContain('/tracking/plugin-installs'); + }); + + it('sends the X-Packmind-Tracking-Token header', () => { + expect(ps1Content).toContain('X-Packmind-Tracking-Token'); + }); + + it('always exits 0', () => { + expect(ps1Content).toContain('exit 0'); + }); + + it('uses .NET SHA-256 (no external tools)', () => { + expect(ps1Content).toContain('SHA256'); + }); + + it('uses 3s timeout on Invoke-RestMethod', () => { + expect(ps1Content).toContain('TimeoutSec 3'); + }); + + it('falls back to the PACKMIND_API_KEY_V3 env var for credentials', () => { + expect(ps1Content).toContain('env:PACKMIND_API_KEY_V3'); + }); + + it('checks PACKMIND_API_KEY_V3 before the credentials file', () => { + expect(ps1Content.indexOf('PACKMIND_API_KEY_V3')).toBeLessThan( + ps1Content.indexOf('credentials.json'), + ); + }); + + it('reads the project .env via Get-DotEnvValue for the API key', () => { + expect(ps1Content).toContain( + "Get-DotEnvValue $EnvFile 'PACKMIND_API_KEY'", + ); + }); + + it('resolves the .env from the launch/project directory', () => { + expect(ps1Content).toContain("$EnvFile = Join-Path $ProjectDir '.env'"); + }); + + it('checks the project .env before the credentials file', () => { + expect( + ps1Content.indexOf("Get-DotEnvValue $EnvFile 'PACKMIND_API_KEY'"), + ).toBeLessThan(ps1Content.indexOf('credentials.json')); + }); + }); + + describe('token and name injection', () => { + it('injects the tracking token into the sidecar', () => { + const customInput: PluginTrackingHooksInput = { + ...trackingInput, + trackingToken: 'my-secret-token', + }; + const updates = new ClaudePluginDeployer().deployTrackingHooks( + customInput, + makeTarget('/'), + ); + const env = updates.createOrUpdate.find( + (f) => f.path === 'hooks/packmind-tracking.env', + ); + expect(env?.content).toContain( + 'PACKMIND_TRACKING_TOKEN=my-secret-token', + ); + }); + + it('injects the marketplace name into the sidecar', () => { + const customInput: PluginTrackingHooksInput = { + ...trackingInput, + marketplaceName: 'my-custom-marketplace', + }; + const updates = new ClaudePluginDeployer().deployTrackingHooks( + customInput, + makeTarget('/'), + ); + const env = updates.createOrUpdate.find( + (f) => f.path === 'hooks/packmind-tracking.env', + ); + expect(env?.content).toContain( + 'PACKMIND_MARKETPLACE_NAME=my-custom-marketplace', + ); + }); + + it('does not embed the raw marketplace name in the sh script body', () => { + // sh script reads from $PACKMIND_MARKETPLACE_NAME at runtime to prevent + // shell injection from free-text marketplace names + const customInput: PluginTrackingHooksInput = { + ...trackingInput, + marketplaceName: 'my-custom-marketplace', + }; + const updates = new ClaudePluginDeployer().deployTrackingHooks( + customInput, + makeTarget('/'), + ); + const sh = updates.createOrUpdate.find( + (f) => f.path === 'hooks/track-install.sh', + ); + expect(sh?.content).not.toContain( + `MARKETPLACE_NAME="my-custom-marketplace"`, + ); + }); + + it('does not embed the raw marketplace name in the ps1 script body', () => { + // ps1 script reads from $PACKMIND_MARKETPLACE_NAME at runtime + const customInput: PluginTrackingHooksInput = { + ...trackingInput, + marketplaceName: 'my-custom-marketplace', + }; + const updates = new ClaudePluginDeployer().deployTrackingHooks( + customInput, + makeTarget('/'), + ); + const ps1 = updates.createOrUpdate.find( + (f) => f.path === 'hooks/track-install.ps1', + ); + expect(ps1?.content).not.toContain(`'my-custom-marketplace'`); + }); + + describe('when marketplace name contains a newline (injection attempt)', () => { + let envLines: string[]; + + beforeEach(() => { + const customInput: PluginTrackingHooksInput = { + ...trackingInput, + marketplaceName: 'evil\nNEW_KEY=injected', + }; + const updates = new ClaudePluginDeployer().deployTrackingHooks( + customInput, + makeTarget('/'), + ); + const env = updates.createOrUpdate.find( + (f) => f.path === 'hooks/packmind-tracking.env', + ); + envLines = (env?.content ?? '').split('\n'); + }); + + it('does not produce a line starting with the injected key', () => { + const injectedKeyLine = envLines.find((l) => + l.startsWith('NEW_KEY='), + ); + expect(injectedKeyLine).toBeUndefined(); + }); + + it('produces exactly 4 KEY=VALUE lines plus a trailing empty line', () => { + // 4 KEY=VALUE lines + 1 trailing empty line = 5 lines + expect(envLines).toHaveLength(5); + }); + }); + }); + }); }); diff --git a/packages/coding-agent/src/infra/repositories/claudePlugin/ClaudePluginDeployer.ts b/packages/coding-agent/src/infra/repositories/claudePlugin/ClaudePluginDeployer.ts index 9734900b2..56468d516 100644 --- a/packages/coding-agent/src/infra/repositories/claudePlugin/ClaudePluginDeployer.ts +++ b/packages/coding-agent/src/infra/repositories/claudePlugin/ClaudePluginDeployer.ts @@ -16,6 +16,13 @@ import { PluginManifestInput, } from './buildPluginManifest'; +export type PluginTrackingHooksInput = { + apiBaseUrl: string; + marketplaceName: string; + pluginSlug: string; + trackingToken: string; +}; + const origin = 'ClaudePluginDeployer'; const EMPTY_UPDATES: FileUpdates = { createOrUpdate: [], delete: [] }; @@ -183,6 +190,50 @@ export class ClaudePluginDeployer implements ICodingAgentDeployer { }; } + /** + * Emits the four tracking-hook files under `<plugin-root>/hooks/`: + * - `hooks/hooks.json` — registers the SessionStart hook + * - `hooks/track-install.sh` — POSIX sh implementation + * - `hooks/track-install.ps1` — PowerShell implementation + * - `hooks/packmind-tracking.env` — KEY=VALUE sidecar with publish-time config + * + * This method is specific to marketplace-mode renders and lives outside the + * shared `ICodingAgentDeployer` contract (same pattern as `deployPluginManifest`). + */ + deployTrackingHooks( + input: PluginTrackingHooksInput, + target: Target, + ): FileUpdates { + const root = pluginRoot(target); + + const hooksJson = buildTrackingHooksJson(); + const trackInstallSh = buildTrackInstallSh(input); + const trackInstallPs1 = buildTrackInstallPs1(input); + const trackingEnv = buildTrackingEnv(input); + + return { + createOrUpdate: [ + { + path: `${root}hooks/hooks.json`, + content: hooksJson, + }, + { + path: `${root}hooks/track-install.sh`, + content: trackInstallSh, + }, + { + path: `${root}hooks/track-install.ps1`, + content: trackInstallPs1, + }, + { + path: `${root}hooks/packmind-tracking.env`, + content: trackingEnv, + }, + ], + delete: [], + }; + } + async generateFileUpdatesForRecipes( recipeVersions: RecipeVersion[], ): Promise<FileUpdates> { @@ -227,3 +278,506 @@ export class ClaudePluginDeployer implements ICodingAgentDeployer { return ClaudePluginDeployer.SKILLS_FOLDER_PATH; } } + +// ─── Tracking-hook file builders ───────────────────────────────────────────── + +/** + * Builds the `hooks/hooks.json` content that registers the SessionStart hook. + * The launcher chains the sh implementation first (macOS/Linux), with the + * PowerShell implementation as a fallback (Windows). + */ +function buildTrackingHooksJson(): string { + const hooks = { + hooks: { + SessionStart: [ + { + matcher: 'startup', + hooks: [ + { + type: 'command', + command: + 'sh "${CLAUDE_PLUGIN_ROOT}/hooks/track-install.sh" || powershell -NoProfile -ExecutionPolicy Bypass -File "${CLAUDE_PLUGIN_ROOT}/hooks/track-install.ps1"', + timeout: 10, + suppressOutput: true, + }, + ], + }, + ], + }, + }; + return JSON.stringify(hooks, null, 2) + '\n'; +} + +/** + * Builds the `hooks/track-install.sh` POSIX sh script. + * Pure POSIX — no bashisms, no jq. Uses grep/sed to read JSON files. + * + * Security: `pluginSlug` is safe (charset [a-z0-9-] enforced by the `slug` + * package). `marketplaceName` is free-text and must NOT be interpolated into + * the executable script body — it is stored only in the KEY=VALUE sidecar and + * read back via the shell variable `$PACKMIND_MARKETPLACE_NAME` at runtime. + * This prevents shell injection on installer machines (spec §5, threat model). + * + * NOTE: all `\${...}` in the template literal are shell variable references — + * the backslash escapes them from TypeScript template interpolation so the + * generated script contains literal `${...}` as required by sh. + */ +function buildTrackInstallSh(input: PluginTrackingHooksInput): string { + const { pluginSlug } = input; + // Only ${pluginSlug} is a TypeScript interpolation — it is safe (slug charset). + // marketplaceName is intentionally NOT interpolated here; the script reads it + // from the sidecar via $PACKMIND_MARKETPLACE_NAME to prevent shell injection. + // Every other ${...} is prefixed with \ so it passes through as shell syntax. + return ( + '#!/bin/sh\n' + + '# Packmind plugin install tracking — POSIX sh\n' + + '# Fires on SessionStart; always exits 0 (never delays the session).\n' + + '# Pure POSIX — no bashisms, no external tools.\n' + + '\n' + + 'SIDECAR_DIR="$(dirname "$0")"\n' + + 'SIDECAR="${SIDECAR_DIR}/packmind-tracking.env"\n' + + '\n' + + '# Load sidecar config (provides PACKMIND_MARKETPLACE_NAME, PACKMIND_PLUGIN_SLUG,\n' + + '# PACKMIND_TRACKING_TOKEN, PACKMIND_API_BASE_URL)\n' + + 'if [ ! -f "$SIDECAR" ]; then exit 0; fi\n' + + '# shellcheck disable=SC1090\n' + + '. "$SIDECAR"\n' + + '\n' + + `PLUGIN_SLUG="${pluginSlug}"\n` + + '# MARKETPLACE_NAME is read from the sidecar as $PACKMIND_MARKETPLACE_NAME\n' + + '# to prevent shell injection from free-text marketplace names.\n' + + 'MARKETPLACE_NAME="${PACKMIND_MARKETPLACE_NAME}"\n' + + '\n' + + '# The enabledPlugins key Claude Code writes is "<pluginName>@<marketplace>",\n' + + '# where both names come from the marketplace descriptor on the client — NOT\n' + + '# the Packmind package slug / marketplace entity name baked into the sidecar\n' + + '# (which often differ in case or value). Derive the real key components from\n' + + '# CLAUDE_PLUGIN_ROOT (.../<marketplace>/<plugin>/<version>) so scope detection\n' + + '# matches; fall back to the baked values when the path is unavailable.\n' + + 'ENABLED_PLUGIN_NAME="${PLUGIN_SLUG}"\n' + + 'ENABLED_MARKETPLACE_NAME="${MARKETPLACE_NAME}"\n' + + 'if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then\n' + + ' _pr="${CLAUDE_PLUGIN_ROOT%/}"\n' + + ' _plugin_dir="$(dirname "$_pr")"\n' + + ' _marketplace_dir="$(dirname "$_plugin_dir")"\n' + + ' ENABLED_PLUGIN_NAME="$(basename "$_plugin_dir")"\n' + + ' ENABLED_MARKETPLACE_NAME="$(basename "$_marketplace_dir")"\n' + + 'fi\n' + + '\n' + + '# ── 1. Scope detection (local → project → user; first match wins) ────────\n' + + 'PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"\n' + + 'SCOPE=""\n' + + '\n' + + '_json_get() {\n' + + ' # Extract a simple string value from a flat JSON object by key.\n' + + ' # Usage: _json_get <key> <file>\n' + + ' grep -o "\\"$1\\"[[:space:]]*:[[:space:]]*\\"[^\\"]*\\"" "$2" 2>/dev/null \\\n' + + ' | sed \'s/.*"[^"]*"[[:space:]]*:[[:space:]]*"\\([^"]*\\)"/\\1/\' 2>/dev/null \\\n' + + ' | head -n1\n' + + '}\n' + + '\n' + + '_env_get() {\n' + + ' # Extract a KEY=value from a flat .env file. Usage: _env_get <key> <file>\n' + + ' # Tolerates leading whitespace and an `export ` prefix; first match wins;\n' + + ' # strips one layer of surrounding quotes. Parse-only — the file is NEVER\n' + + ' # sourced, so a hostile repo .env cannot execute code on SessionStart.\n' + + ' [ -f "$2" ] || return 0\n' + + ' _v="$(grep -E "^[[:space:]]*(export[[:space:]]+)?$1=" "$2" 2>/dev/null | head -n1)"\n' + + ' _v="${_v#*=}"\n' + + ' _v="${_v%\\"}"; _v="${_v#\\"}"\n' + + ' _v="${_v%\\\'}"; _v="${_v#\\\'}"\n' + + ' printf \'%s\' "$_v"\n' + + '}\n' + + '\n' + + '_enabled_plugins_has_plugin() {\n' + + ' # Check if enabledPlugins contains the key "<pluginName>@<marketplace>".\n' + + ' # Uses positional parameter instead of local (pure POSIX).\n' + + ' # The key components are derived at runtime from CLAUDE_PLUGIN_ROOT (shell\n' + + ' # variables, not TS interpolations) so no free-text injection is possible.\n' + + ' [ -f "$1" ] || return 1\n' + + ' grep -q "\\"${ENABLED_PLUGIN_NAME}@${ENABLED_MARKETPLACE_NAME}\\"" "$1" 2>/dev/null\n' + + '}\n' + + '\n' + + 'if _enabled_plugins_has_plugin "${PROJECT_DIR}/.claude/settings.local.json"; then\n' + + ' SCOPE="local"\n' + + 'elif _enabled_plugins_has_plugin "${PROJECT_DIR}/.claude/settings.json"; then\n' + + ' SCOPE="project"\n' + + 'elif _enabled_plugins_has_plugin "${HOME}/.claude/settings.json"; then\n' + + ' SCOPE="user"\n' + + 'else\n' + + ' # Plugin not found in any known settings — skip POST (scope is required).\n' + + ' exit 0\n' + + 'fi\n' + + '\n' + + '# ── 2. Repo detection (skipped for user scope — not repo-bound) ──────────\n' + + 'REPO_REMOTE_URL=""\n' + + 'if [ "$SCOPE" != "user" ] && command -v git >/dev/null 2>&1; then\n' + + ' REPO_REMOTE_URL="$(git -C "$PROJECT_DIR" remote get-url origin 2>/dev/null || true)"\n' + + 'fi\n' + + '\n' + + '# ── 3. Identity resolution ───────────────────────────────────────────────\n' + + 'AUTH_HEADER=""\n' + + 'ANON_ID_HASH=""\n' + + 'ANON_EMAIL_MASKED=""\n' + + '\n' + + '# Packmind API key resolution order (same precedence as the CLI, plus a\n' + + '# project .env between the env vars and the global credentials file):\n' + + '# PACKMIND_API_KEY env → PACKMIND_API_KEY_V3 env\n' + + '# → <project>/.env (PACKMIND_API_KEY, then PACKMIND_API_KEY_V3)\n' + + '# → the ~/.packmind credentials file\n' + + 'API_KEY=""\n' + + 'if [ -n "${PACKMIND_API_KEY:-}" ]; then\n' + + ' API_KEY="${PACKMIND_API_KEY}"\n' + + 'elif [ -n "${PACKMIND_API_KEY_V3:-}" ]; then\n' + + ' API_KEY="${PACKMIND_API_KEY_V3}"\n' + + 'else\n' + + ' ENV_FILE="${PROJECT_DIR}/.env"\n' + + ' API_KEY="$(_env_get PACKMIND_API_KEY "$ENV_FILE")"\n' + + ' if [ -z "$API_KEY" ]; then\n' + + ' API_KEY="$(_env_get PACKMIND_API_KEY_V3 "$ENV_FILE")"\n' + + ' fi\n' + + ' if [ -z "$API_KEY" ]; then\n' + + ' CREDS_FILE="${HOME}/.packmind/credentials.json"\n' + + ' if [ -f "$CREDS_FILE" ]; then\n' + + ' API_KEY="$(_json_get apiKey "$CREDS_FILE")"\n' + + ' fi\n' + + ' fi\n' + + 'fi\n' + + 'if [ -n "$API_KEY" ]; then\n' + + ' AUTH_HEADER="Authorization: Bearer ${API_KEY}"\n' + + 'fi\n' + + '\n' + + '# Claude account email → mask + hash (email never leaves the machine)\n' + + 'CLAUDE_JSON="${HOME}/.claude.json"\n' + + 'CLAUDE_EMAIL=""\n' + + 'if [ -f "$CLAUDE_JSON" ]; then\n' + + ' CLAUDE_EMAIL="$(_json_get emailAddress "$CLAUDE_JSON")"\n' + + 'fi\n' + + '\n' + + 'if [ -n "$CLAUDE_EMAIL" ]; then\n' + + ' # Mask: first char of each local-part segment, e.g. b**.s***@acme.com\n' + + ' LOCAL_PART="$(printf \'%s\' "$CLAUDE_EMAIL" | sed \'s/@.*//\')"\n' + + ' DOMAIN_PART="$(printf \'%s\' "$CLAUDE_EMAIL" | sed \'s/[^@]*@//\')"\n' + + ' MASKED_LOCAL=""\n' + + " # Iterate segments separated by '.'\n" + + ' _remaining="$LOCAL_PART"\n' + + ' while [ -n "$_remaining" ]; do\n' + + ' _seg="${_remaining%%.*}"\n' + + ' if [ "${_remaining}" = "${_seg}" ]; then\n' + + ' _remaining=""\n' + + ' else\n' + + ' _remaining="${_remaining#*.}"\n' + + ' fi\n' + + ' _first="$(printf \'%s\' "$_seg" | cut -c1)"\n' + + ' _stars="$(printf \'%s\' "$_seg" | cut -c2- | sed \'s/./*/g\')"\n' + + ' if [ -n "$MASKED_LOCAL" ]; then\n' + + ' MASKED_LOCAL="${MASKED_LOCAL}.${_first}${_stars}"\n' + + ' else\n' + + ' MASKED_LOCAL="${_first}${_stars}"\n' + + ' fi\n' + + ' done\n' + + ' ANON_EMAIL_MASKED="${MASKED_LOCAL}@${DOMAIN_PART}"\n' + + '\n' + + ' # SHA-256 hash of the lowercased email\n' + + " EMAIL_LOWER=\"$(printf '%s' \"$CLAUDE_EMAIL\" | tr '[:upper:]' '[:lower:]')\"\n" + + ' if command -v shasum >/dev/null 2>&1; then\n' + + ' ANON_ID_HASH="$(printf \'%s\' "$EMAIL_LOWER" | shasum -a 256 | awk \'{print $1}\')"\n' + + ' elif command -v sha256sum >/dev/null 2>&1; then\n' + + ' ANON_ID_HASH="$(printf \'%s\' "$EMAIL_LOWER" | sha256sum | awk \'{print $1}\')"\n' + + ' elif command -v openssl >/dev/null 2>&1; then\n' + + ' ANON_ID_HASH="$(printf \'%s\' "$EMAIL_LOWER" | openssl dgst -sha256 | awk \'{print $NF}\')"\n' + + ' fi\n' + + 'fi\n' + + '\n' + + '# ── 3b. Installed version ────────────────────────────────────────────────\n' + + '# Read the installed plugin version from its manifest; fall back to the\n' + + '# version segment of CLAUDE_PLUGIN_ROOT (.../<plugin>/<version>).\n' + + 'INSTALLED_VERSION=""\n' + + 'if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then\n' + + ' INSTALLED_VERSION="$(_json_get version "${CLAUDE_PLUGIN_ROOT%/}/.claude-plugin/plugin.json")"\n' + + ' if [ -z "$INSTALLED_VERSION" ]; then\n' + + ' INSTALLED_VERSION="$(basename "${CLAUDE_PLUGIN_ROOT%/}")"\n' + + ' fi\n' + + 'fi\n' + + '\n' + + '# ── 4. Build JSON payload ─────────────────────────────────────────────────\n' + + 'PAYLOAD="{' + + '\\"pluginSlug\\":\\"${PLUGIN_SLUG}\\",\\"marketplaceName\\":\\"${MARKETPLACE_NAME}\\",\\"scope\\":\\"${SCOPE}\\"' + + '"\n' + + 'if [ -n "$INSTALLED_VERSION" ]; then\n' + + ' PAYLOAD="${PAYLOAD},' + + '\\"installedVersion\\":\\"${INSTALLED_VERSION}\\"' + + '"\n' + + 'fi\n' + + 'if [ -n "$REPO_REMOTE_URL" ]; then\n' + + ' PAYLOAD="${PAYLOAD},' + + '\\"repoRemoteUrl\\":\\"${REPO_REMOTE_URL}\\"' + + '"\n' + + 'fi\n' + + 'if [ -n "$ANON_ID_HASH" ]; then\n' + + ' PAYLOAD="${PAYLOAD},' + + '\\"anonymousIdHash\\":\\"${ANON_ID_HASH}\\"' + + '"\n' + + 'fi\n' + + 'if [ -n "$ANON_EMAIL_MASKED" ]; then\n' + + ' PAYLOAD="${PAYLOAD},' + + '\\"anonymousEmailMasked\\":\\"${ANON_EMAIL_MASKED}\\"' + + '"\n' + + 'fi\n' + + 'PAYLOAD="${PAYLOAD}}"\n' + + '\n' + + '# ── 5. POST heartbeat (backgrounded, 3s timeout) ─────────────────────────\n' + + 'if command -v curl >/dev/null 2>&1; then\n' + + ' (\n' + + ' if [ -n "$AUTH_HEADER" ]; then\n' + + ' curl --max-time 3 --silent --output /dev/null --show-error \\\n' + + ' -X POST "${PACKMIND_API_BASE_URL}/tracking/plugin-installs" \\\n' + + ' -H "Content-Type: application/json" \\\n' + + ' -H "X-Packmind-Tracking-Token: ${PACKMIND_TRACKING_TOKEN}" \\\n' + + ' -H "${AUTH_HEADER}" \\\n' + + ' -d "$PAYLOAD" 2>/dev/null || true\n' + + ' else\n' + + ' curl --max-time 3 --silent --output /dev/null --show-error \\\n' + + ' -X POST "${PACKMIND_API_BASE_URL}/tracking/plugin-installs" \\\n' + + ' -H "Content-Type: application/json" \\\n' + + ' -H "X-Packmind-Tracking-Token: ${PACKMIND_TRACKING_TOKEN}" \\\n' + + ' -d "$PAYLOAD" 2>/dev/null || true\n' + + ' fi\n' + + ' ) &\n' + + 'fi\n' + + '\n' + + 'exit 0\n' + ); +} + +/** + * Builds the `hooks/track-install.ps1` PowerShell script. + * Uses ConvertFrom-Json and Invoke-RestMethod — zero external tools required. + * + * Security: `pluginSlug` is safe (charset [a-z0-9-]). `marketplaceName` is + * free-text and must NOT be interpolated into the script body. It is stored + * only in the KEY=VALUE sidecar and read back via the `$PACKMIND_MARKETPLACE_NAME` + * variable loaded by the sidecar parser at runtime. + * + * NOTE: PowerShell variable names start with `$`, which is also the TypeScript + * template literal interpolation sigil. We use string concatenation here to + * avoid escaping every single PowerShell variable reference. + */ +function buildTrackInstallPs1(input: PluginTrackingHooksInput): string { + const { pluginSlug } = input; + // Only ${pluginSlug} is a TypeScript interpolation — safe (slug charset). + // marketplaceName is intentionally NOT interpolated; the script reads it from + // the sidecar variable $PACKMIND_MARKETPLACE_NAME to prevent injection. + const S = '$'; // alias to avoid template-literal collisions with PowerShell $ + return ( + '# Packmind plugin install tracking — PowerShell\n' + + '# Fires on SessionStart; always exits 0 (never delays the session).\n' + + '\n' + + `${S}ErrorActionPreference = 'SilentlyContinue'\n` + + '\n' + + `${S}ScriptDir = Split-Path -Parent ${S}MyInvocation.MyCommand.Path\n` + + `${S}Sidecar = Join-Path ${S}ScriptDir 'packmind-tracking.env'\n` + + '\n' + + `if (-not (Test-Path ${S}Sidecar)) { exit 0 }\n` + + '\n' + + '# Load sidecar KEY=VALUE pairs\n' + + `Get-Content ${S}Sidecar | ForEach-Object {\n` + + ` if (${S}_ -match '^([A-Z_]+)=(.*)${S}') {\n` + + ` Set-Variable -Name ${S}Matches[1] -Value ${S}Matches[2] -Scope Script\n` + + ' }\n' + + '}\n' + + '\n' + + `${S}PluginSlug = '${pluginSlug}'\n` + + `# ${S}MarketplaceName is read from the sidecar as ${S}PACKMIND_MARKETPLACE_NAME\n` + + `# to prevent shell injection from free-text marketplace names.\n` + + `${S}MarketplaceName = ${S}PACKMIND_MARKETPLACE_NAME\n` + + '\n' + + '# ── 1. Scope detection (local → project → user; first match wins) ────────\n' + + `${S}ProjectDir = if (${S}env:CLAUDE_PROJECT_DIR) { ${S}env:CLAUDE_PROJECT_DIR } else { Get-Location }\n` + + `${S}Scope = ${S}null\n` + + '# The enabledPlugins key is "<pluginName>@<marketplace>", where both names\n' + + '# come from the client marketplace descriptor — not the Packmind slug /\n' + + '# marketplace entity name in the sidecar. Derive them from CLAUDE_PLUGIN_ROOT\n' + + '# (.../<marketplace>/<plugin>/<version>); fall back to the baked values.\n' + + `${S}EnabledPluginName = ${S}PluginSlug\n` + + `${S}EnabledMarketplaceName = ${S}MarketplaceName\n` + + `if (${S}env:CLAUDE_PLUGIN_ROOT) {\n` + + ` ${S}PluginDir = Split-Path -Parent ${S}env:CLAUDE_PLUGIN_ROOT\n` + + ` ${S}MarketplaceDir = Split-Path -Parent ${S}PluginDir\n` + + ` ${S}EnabledPluginName = Split-Path -Leaf ${S}PluginDir\n` + + ` ${S}EnabledMarketplaceName = Split-Path -Leaf ${S}MarketplaceDir\n` + + '}\n' + + `${S}PluginKey = "${S}EnabledPluginName@${S}EnabledMarketplaceName"\n` + + '\n' + + '# Installed version — read from the plugin manifest, fall back to the version\n' + + '# segment of CLAUDE_PLUGIN_ROOT (.../<plugin>/<version>).\n' + + `${S}InstalledVersion = ${S}null\n` + + `if (${S}env:CLAUDE_PLUGIN_ROOT) {\n` + + ` ${S}ManifestPath = Join-Path ${S}env:CLAUDE_PLUGIN_ROOT '.claude-plugin\\plugin.json'\n` + + ` if (Test-Path ${S}ManifestPath) {\n` + + ` ${S}Manifest = Get-Content ${S}ManifestPath -Raw | ConvertFrom-Json\n` + + ` if (${S}Manifest.version) { ${S}InstalledVersion = ${S}Manifest.version }\n` + + ' }\n' + + ` if (-not ${S}InstalledVersion) { ${S}InstalledVersion = Split-Path -Leaf ${S}env:CLAUDE_PLUGIN_ROOT }\n` + + '}\n' + + '\n' + + 'function Find-PluginScope {\n' + + ` param([string]${S}FilePath)\n` + + ` if (-not (Test-Path ${S}FilePath)) { return ${S}false }\n` + + ` ${S}Content = Get-Content ${S}FilePath -Raw\n` + + ` ${S}Json = ${S}Content | ConvertFrom-Json\n` + + ` if (${S}Json.enabledPlugins -and ${S}Json.enabledPlugins.PSObject.Properties.Name -contains ${S}PluginKey) {\n` + + ` return ${S}true\n` + + ' }\n' + + ` return ${S}false\n` + + '}\n' + + '\n' + + '# Extract a KEY=value from a flat .env file (parse-only; never sourced).\n' + + '# Tolerates leading whitespace / `export `; strips one layer of quotes.\n' + + 'function Get-DotEnvValue {\n' + + ` param([string]${S}FilePath, [string]${S}Key)\n` + + ` if (-not (Test-Path ${S}FilePath)) { return ${S}null }\n` + + ` foreach (${S}Line in Get-Content ${S}FilePath) {\n` + + ` if (${S}Line -match "^\\s*(export\\s+)?${S}Key=(.*)${S}") {\n` + + ` ${S}Val = ${S}Matches[2].Trim()\n` + + ` if (${S}Val.StartsWith('"') -and ${S}Val.EndsWith('"')) { ${S}Val = ${S}Val.Trim('"') }\n` + + ` elseif (${S}Val.StartsWith("'") -and ${S}Val.EndsWith("'")) { ${S}Val = ${S}Val.Trim("'") }\n` + + ` if (${S}Val) { return ${S}Val }\n` + + ' }\n' + + ' }\n' + + ` return ${S}null\n` + + '}\n' + + '\n' + + `if (Find-PluginScope (Join-Path ${S}ProjectDir '.claude\\settings.local.json')) {\n` + + ` ${S}Scope = 'local'\n` + + `} elseif (Find-PluginScope (Join-Path ${S}ProjectDir '.claude\\settings.json')) {\n` + + ` ${S}Scope = 'project'\n` + + `} elseif (Find-PluginScope (Join-Path ${S}HOME '.claude\\settings.json')) {\n` + + ` ${S}Scope = 'user'\n` + + '} else {\n' + + ' exit 0\n' + + '}\n' + + '\n' + + '# ── 2. Repo detection (skipped for user scope — not repo-bound) ──────────\n' + + `${S}RepoRemoteUrl = ${S}null\n` + + `if (${S}Scope -ne 'user') {\n` + + ` ${S}GitOutput = & git -C ${S}ProjectDir remote get-url origin 2>${S}null\n` + + ` if (${S}LASTEXITCODE -eq 0 -and ${S}GitOutput) { ${S}RepoRemoteUrl = ${S}GitOutput.Trim() }\n` + + '}\n' + + '\n' + + '# ── 3. Identity resolution ───────────────────────────────────────────────\n' + + `${S}AuthHeader = ${S}null\n` + + `${S}AnonIdHash = ${S}null\n` + + `${S}AnonEmailMasked = ${S}null\n` + + '\n' + + '# Packmind API key resolution order (same precedence as the CLI, plus a\n' + + '# project .env between the env vars and the global credentials file):\n' + + '# PACKMIND_API_KEY env -> PACKMIND_API_KEY_V3 env\n' + + '# -> <project>/.env (PACKMIND_API_KEY, then PACKMIND_API_KEY_V3)\n' + + '# -> the ~/.packmind credentials file\n' + + `${S}ApiKey = ${S}null\n` + + `if (${S}env:PACKMIND_API_KEY) {\n` + + ` ${S}ApiKey = ${S}env:PACKMIND_API_KEY\n` + + `} elseif (${S}env:PACKMIND_API_KEY_V3) {\n` + + ` ${S}ApiKey = ${S}env:PACKMIND_API_KEY_V3\n` + + '} else {\n' + + ` ${S}EnvFile = Join-Path ${S}ProjectDir '.env'\n` + + ` ${S}ApiKey = Get-DotEnvValue ${S}EnvFile 'PACKMIND_API_KEY'\n` + + ` if (-not ${S}ApiKey) { ${S}ApiKey = Get-DotEnvValue ${S}EnvFile 'PACKMIND_API_KEY_V3' }\n` + + ` if (-not ${S}ApiKey) {\n` + + ` ${S}CredsFile = Join-Path ${S}HOME '.packmind\\credentials.json'\n` + + ` if (Test-Path ${S}CredsFile) {\n` + + ` ${S}Creds = Get-Content ${S}CredsFile -Raw | ConvertFrom-Json\n` + + ` if (${S}Creds.apiKey) { ${S}ApiKey = ${S}Creds.apiKey }\n` + + ' }\n' + + ' }\n' + + '}\n' + + `if (${S}ApiKey) { ${S}AuthHeader = "Bearer ${S}ApiKey" }\n` + + '\n' + + '# Claude account email → mask + hash\n' + + `${S}ClaudeJson = Join-Path ${S}HOME '.claude.json'\n` + + `if (Test-Path ${S}ClaudeJson) {\n` + + ` ${S}ClaudeData = Get-Content ${S}ClaudeJson -Raw | ConvertFrom-Json\n` + + ` ${S}Email = ${S}ClaudeData.oauthAccount.emailAddress\n` + + ` if (${S}Email) {\n` + + ' # Mask: first char of each local-part segment\n' + + ` ${S}Parts = ${S}Email -split '@', 2\n` + + ` ${S}LocalPart = ${S}Parts[0]\n` + + ` ${S}Domain = ${S}Parts[1]\n` + + ` ${S}MaskedSegments = (${S}LocalPart -split '\\.') | ForEach-Object {\n` + + ` if (${S}_.Length -gt 0) { ${S}_[0] + ('*' * (${S}_.Length - 1)) } else { '' }\n` + + ' }\n' + + ` ${S}AnonEmailMasked = (${S}MaskedSegments -join '.') + '@' + ${S}Domain\n` + + '\n' + + ' # SHA-256 hash of lowercased email\n' + + ` ${S}EmailLower = ${S}Email.ToLower()\n` + + ` ${S}Bytes = [System.Text.Encoding]::UTF8.GetBytes(${S}EmailLower)\n` + + ` ${S}Hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash(${S}Bytes)\n` + + ` ${S}AnonIdHash = (${S}Hash | ForEach-Object { ${S}_.ToString('x2') }) -join ''\n` + + ' }\n' + + '}\n' + + '\n' + + '# ── 4. Build payload ─────────────────────────────────────────────────────\n' + + `${S}Payload = @{\n` + + ` pluginSlug = ${S}PluginSlug\n` + + ` marketplaceName = ${S}MarketplaceName\n` + + ` scope = ${S}Scope\n` + + '}\n' + + `if (${S}InstalledVersion) { ${S}Payload['installedVersion'] = ${S}InstalledVersion }\n` + + `if (${S}RepoRemoteUrl) { ${S}Payload['repoRemoteUrl'] = ${S}RepoRemoteUrl }\n` + + `if (${S}AnonIdHash) { ${S}Payload['anonymousIdHash'] = ${S}AnonIdHash }\n` + + `if (${S}AnonEmailMasked) { ${S}Payload['anonymousEmailMasked'] = ${S}AnonEmailMasked }\n` + + '\n' + + `${S}Headers = @{\n` + + " 'Content-Type' = 'application/json'\n" + + ` 'X-Packmind-Tracking-Token' = ${S}PACKMIND_TRACKING_TOKEN\n` + + '}\n' + + `if (${S}AuthHeader) { ${S}Headers['Authorization'] = ${S}AuthHeader }\n` + + '\n' + + `${S}Body = ${S}Payload | ConvertTo-Json -Compress\n` + + '\n' + + '# ── 5. POST heartbeat (backgrounded via Start-Job) ───────────────────────\n' + + `${S}Url = "${S}PACKMIND_API_BASE_URL/tracking/plugin-installs"\n` + + `Start-Job -ScriptBlock {\n` + + ` param(${S}Url, ${S}Headers, ${S}Body)\n` + + ` Invoke-RestMethod -Method Post -Uri ${S}Url -Headers ${S}Headers -Body ${S}Body -TimeoutSec 3 | Out-Null\n` + + `} -ArgumentList ${S}Url, ${S}Headers, ${S}Body | Out-Null\n` + + '\n' + + 'exit 0\n' + ); +} + +/** + * Sanitizes a KEY=VALUE sidecar value by stripping characters that would + * break the flat `source`-able format: + * - Newlines (\r, \n) — would prematurely terminate the KEY=VALUE line and + * allow injecting arbitrary new KEY=VALUE entries. + * - Null bytes — defensive; should never appear in legitimate names. + * + * Note: the value is not shell-quoted in the sidecar (the sh script `source`s + * it without quoting), so newlines are the primary injection vector. + */ +function sanitizeSidecarValue(value: string): string { + // eslint-disable-next-line no-control-regex + return value.replace(/[\r\n\x00]/g, ''); +} + +/** + * Builds the `hooks/packmind-tracking.env` KEY=VALUE sidecar. + * Flat format so the sh script can `source` it without needing `jq`. + * + * Security: all free-text values (marketplaceName, apiBaseUrl) are sanitized + * to strip newlines before writing — a newline in a KEY=VALUE file would allow + * injecting arbitrary variables into the shell environment when the sidecar is + * sourced. pluginSlug and trackingToken are generated values with controlled + * charsets and do not require sanitization, but are sanitized defensively. + */ +function buildTrackingEnv(input: PluginTrackingHooksInput): string { + return [ + `PACKMIND_API_BASE_URL=${sanitizeSidecarValue(input.apiBaseUrl)}`, + `PACKMIND_MARKETPLACE_NAME=${sanitizeSidecarValue(input.marketplaceName)}`, + `PACKMIND_PLUGIN_SLUG=${sanitizeSidecarValue(input.pluginSlug)}`, + `PACKMIND_TRACKING_TOKEN=${sanitizeSidecarValue(input.trackingToken)}`, + '', + ].join('\n'); +} diff --git a/packages/crisp/.swcrc b/packages/crisp/.swcrc new file mode 100644 index 000000000..8d79fc02a --- /dev/null +++ b/packages/crisp/.swcrc @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "jsc": { + "parser": { "syntax": "typescript", "decorators": false }, + "target": "es2022", + "keepClassNames": true + }, + "module": { "type": "commonjs" }, + "sourceMaps": true +} diff --git a/packages/crisp/eslint.config.mjs b/packages/crisp/eslint.config.mjs new file mode 100644 index 000000000..c334bc0bc --- /dev/null +++ b/packages/crisp/eslint.config.mjs @@ -0,0 +1,19 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, +]; diff --git a/packages/crisp/jest.config.ts b/packages/crisp/jest.config.ts new file mode 100644 index 000000000..a29c3de5b --- /dev/null +++ b/packages/crisp/jest.config.ts @@ -0,0 +1,13 @@ +const { + swcTransform, + standardModuleFileExtensions, +} = require('../../jest-utils.ts'); + +module.exports = { + displayName: 'crisp', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: swcTransform, + moduleFileExtensions: standardModuleFileExtensions, + coverageDirectory: '../../coverage/packages/crisp', +}; diff --git a/packages/crisp/package.json b/packages/crisp/package.json new file mode 100644 index 000000000..3291b6133 --- /dev/null +++ b/packages/crisp/package.json @@ -0,0 +1,16 @@ +{ + "name": "@packmind/crisp", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "crisp-api": "^10.0.17", + "typeorm": "^0.3.20", + "@packmind/logger": "workspace:*", + "@packmind/node-utils": "workspace:*", + "@packmind/types": "workspace:*", + "@packmind/test-utils": "workspace:*" + } +} diff --git a/packages/crisp/project.json b/packages/crisp/project.json new file mode 100644 index 000000000..0b6d0dca2 --- /dev/null +++ b/packages/crisp/project.json @@ -0,0 +1,20 @@ +{ + "name": "crisp", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/crisp/src", + "projectType": "library", + "tags": ["env:node"], + "targets": { + "build": { + "executor": "@nx/js:swc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/crisp", + "tsConfig": "packages/crisp/tsconfig.lib.json", + "packageJson": "packages/crisp/package.json", + "main": "packages/crisp/src/index.ts", + "assets": ["packages/crisp/*.md"] + } + } + } +} diff --git a/packages/crisp/src/CrispHexa.ts b/packages/crisp/src/CrispHexa.ts new file mode 100644 index 000000000..b7e49869c --- /dev/null +++ b/packages/crisp/src/CrispHexa.ts @@ -0,0 +1,79 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + BaseHexa, + BaseHexaOpts, + HexaRegistry, + PackmindEventEmitterService, +} from '@packmind/node-utils'; +import { DataSource } from 'typeorm'; +import { CrispEventListener } from './application/CrispEventListener'; +import { CrispTrackEventService } from './application/CrispTrackEventService'; + +const origin = 'CrispHexa'; + +/** + * CrispHexa - Facade for the Crisp CRM integration following the Hexa pattern. + * + * This class serves as the main entry point for Crisp event tracking functionality. + * It listens to domain events (user signup, user joined organization) and forwards + * them to Crisp for CRM tracking. + * + * Crisp integration is enabled at runtime only if CRISP_API_KEY, CRISP_PLUGIN_IDENTIFIER, + * and CRISP_WEBSITE_ID are configured. + */ +export class CrispHexa extends BaseHexa<BaseHexaOpts, void> { + private readonly crispService: CrispTrackEventService; + private readonly listener: CrispEventListener; + + constructor( + dataSource: DataSource, + opts: Partial<BaseHexaOpts> = { logger: new PackmindLogger(origin) }, + ) { + super(dataSource, opts); + + this.logger.info('Constructing CrispHexa (proprietary)'); + + this.crispService = new CrispTrackEventService(); + this.listener = new CrispEventListener(this.crispService); + + this.logger.info('CrispHexa construction completed'); + } + + /** + * Initialize the hexa with access to the registry for adapter retrieval. + * Sets up event listeners to forward domain events to Crisp. + */ + public async initialize(registry: HexaRegistry): Promise<void> { + this.logger.info('Initializing CrispHexa'); + + const eventEmitterService = registry.getService( + PackmindEventEmitterService, + ); + this.listener.initialize(eventEmitterService); + + this.logger.info('CrispHexa initialized successfully'); + } + + /** + * CrispHexa does not expose an adapter for cross-domain access. + */ + public getAdapter(): void { + return undefined; + } + + /** + * Get the port name for this hexa. + */ + public getPortName(): string { + return 'crisp'; + } + + /** + * Destroys the CrispHexa and cleans up resources. + */ + public destroy(): void { + this.logger.info('Destroying CrispHexa'); + this.listener.destroy(); + this.logger.info('CrispHexa destroyed'); + } +} diff --git a/packages/crisp/src/application/CrispEventListener.ts b/packages/crisp/src/application/CrispEventListener.ts new file mode 100644 index 000000000..63770424a --- /dev/null +++ b/packages/crisp/src/application/CrispEventListener.ts @@ -0,0 +1,33 @@ +import { PackmindListener } from '@packmind/node-utils'; +import { + UserSignedUpEvent, + UserJoinedOrganizationEvent, +} from '@packmind/types'; +import { CrispTrackEventService } from './CrispTrackEventService'; + +/** + * Listens to domain events and forwards them to Crisp for CRM tracking. + * + * This listener subscribes to user lifecycle events and translates them + * into Crisp people events for tracking user activity. + */ +export class CrispEventListener extends PackmindListener<CrispTrackEventService> { + protected registerHandlers(): void { + this.subscribe(UserSignedUpEvent, this.onUserSignedUp); + this.subscribe(UserJoinedOrganizationEvent, this.onUserJoinedOrganization); + } + + private onUserSignedUp = async (event: UserSignedUpEvent): Promise<void> => { + const { email } = event.payload; + await this.adapter.createPeopleIfNotAlreadyExists(email); + await this.adapter.addPeopleEvent(email, 'user_signup'); + }; + + private onUserJoinedOrganization = async ( + event: UserJoinedOrganizationEvent, + ): Promise<void> => { + const { email } = event.payload; + await this.adapter.createPeopleIfNotAlreadyExists(email); + await this.adapter.addPeopleEvent(email, 'user_joined_orga'); + }; +} diff --git a/packages/crisp/src/application/CrispTrackEventService.spec.ts b/packages/crisp/src/application/CrispTrackEventService.spec.ts new file mode 100644 index 000000000..18a92f0ec --- /dev/null +++ b/packages/crisp/src/application/CrispTrackEventService.spec.ts @@ -0,0 +1,226 @@ +import { Crisp } from 'crisp-api'; +import { stubLogger } from '@packmind/test-utils'; +import { Configuration } from '@packmind/node-utils'; +import { CrispTrackEventService } from './CrispTrackEventService'; + +jest.mock('crisp-api'); +jest.mock('@packmind/node-utils', () => ({ + ...jest.requireActual('@packmind/node-utils'), + Configuration: { + getConfig: jest.fn(), + }, +})); + +describe('CrispTrackEventService', () => { + let service: CrispTrackEventService; + let logger: ReturnType<typeof stubLogger>; + let mockCrispClient: jest.Mocked<Crisp>; + + beforeEach(() => { + logger = stubLogger(); + mockCrispClient = { + authenticateTier: jest.fn(), + website: { + checkPeopleProfileExists: jest.fn(), + addNewPeopleProfile: jest.fn(), + addPeopleEvent: jest.fn(), + }, + } as unknown as jest.Mocked<Crisp>; + + (Crisp as jest.MockedClass<typeof Crisp>).mockImplementation( + () => mockCrispClient, + ); + + service = new CrispTrackEventService(logger); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createPeopleIfNotAlreadyExists', () => { + describe('when email is trial', () => { + const trialEmail = + 'trial-550e8400-e29b-41d4-a716-446655440000@packmind.trial'; + + it('does not check for existing profile', async () => { + await service.createPeopleIfNotAlreadyExists(trialEmail); + + expect( + mockCrispClient.website.checkPeopleProfileExists, + ).not.toHaveBeenCalled(); + }); + + it('does not create profile', async () => { + await service.createPeopleIfNotAlreadyExists(trialEmail); + + expect( + mockCrispClient.website.addNewPeopleProfile, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when email is not trial', () => { + const regularEmail = 'user@example.com'; + + beforeEach(() => { + (Configuration.getConfig as jest.Mock).mockResolvedValue('test-config'); + }); + + describe('when profile exists', () => { + beforeEach(() => { + mockCrispClient.website.checkPeopleProfileExists.mockResolvedValue( + undefined, + ); + }); + + it('checks for existing profile', async () => { + await service.createPeopleIfNotAlreadyExists(regularEmail); + + expect( + mockCrispClient.website.checkPeopleProfileExists, + ).toHaveBeenCalledWith('test-config', regularEmail); + }); + + it('does not create profile', async () => { + await service.createPeopleIfNotAlreadyExists(regularEmail); + + expect( + mockCrispClient.website.addNewPeopleProfile, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when profile does not exist', () => { + beforeEach(() => { + mockCrispClient.website.checkPeopleProfileExists.mockRejectedValue({ + code: 404, + }); + }); + + it('creates profile', async () => { + await service.createPeopleIfNotAlreadyExists(regularEmail); + + expect( + mockCrispClient.website.addNewPeopleProfile, + ).toHaveBeenCalledWith('test-config', { + email: regularEmail, + person: { + nickname: regularEmail, + }, + }); + }); + }); + + describe('when addNewPeopleProfile fails with subscription error', () => { + const subscriptionError = { + reason: 'error', + message: 'subscription_upgrade_required', + code: 402, + data: { + namespace: 'response', + message: 'Got response error: subscription_upgrade_required', + }, + }; + + beforeEach(() => { + mockCrispClient.website.checkPeopleProfileExists.mockRejectedValue({ + code: 404, + }); + mockCrispClient.website.addNewPeopleProfile.mockRejectedValue( + subscriptionError, + ); + }); + + it('does not throw', async () => { + await expect( + service.createPeopleIfNotAlreadyExists(regularEmail), + ).resolves.toBeUndefined(); + }); + + it('attempted to create the profile before encountering the error', async () => { + await service.createPeopleIfNotAlreadyExists(regularEmail); + + expect( + mockCrispClient.website.addNewPeopleProfile, + ).toHaveBeenCalled(); + }); + }); + + describe('when checkPeopleProfileExists fails with non-404 error', () => { + beforeEach(() => { + mockCrispClient.website.checkPeopleProfileExists.mockRejectedValue({ + code: 500, + message: 'Internal Server Error', + }); + }); + + it('does not throw', async () => { + await expect( + service.createPeopleIfNotAlreadyExists(regularEmail), + ).resolves.toBeUndefined(); + }); + + it('does not attempt to create the profile', async () => { + await service.createPeopleIfNotAlreadyExists(regularEmail); + + expect( + mockCrispClient.website.addNewPeopleProfile, + ).not.toHaveBeenCalled(); + }); + + it('queried for profile existence before failing', async () => { + await service.createPeopleIfNotAlreadyExists(regularEmail); + + expect( + mockCrispClient.website.checkPeopleProfileExists, + ).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('addPeopleEvent', () => { + describe('when email is trial', () => { + const trialEmail = + 'trial-550e8400-e29b-41d4-a716-446655440000@packmind.trial'; + const eventName = 'User Signed Up'; + + it('does not create profile', async () => { + await service.addPeopleEvent(trialEmail, eventName); + + expect( + mockCrispClient.website.checkPeopleProfileExists, + ).not.toHaveBeenCalled(); + }); + + it('does not add event', async () => { + await service.addPeopleEvent(trialEmail, eventName); + + expect(mockCrispClient.website.addPeopleEvent).not.toHaveBeenCalled(); + }); + }); + + describe('when email is not trial', () => { + const regularEmail = 'user@example.com'; + const eventName = 'User Signed Up'; + + beforeEach(() => { + (Configuration.getConfig as jest.Mock).mockResolvedValue('test-config'); + mockCrispClient.website.checkPeopleProfileExists.mockResolvedValue( + undefined, + ); + }); + + it('adds event to Crisp', async () => { + await service.addPeopleEvent(regularEmail, eventName); + + expect(mockCrispClient.website.addPeopleEvent).toHaveBeenCalledWith( + 'test-config', + regularEmail, + { text: eventName }, + ); + }); + }); + }); +}); diff --git a/packages/crisp/src/application/CrispTrackEventService.ts b/packages/crisp/src/application/CrispTrackEventService.ts new file mode 100644 index 000000000..d6b9dab97 --- /dev/null +++ b/packages/crisp/src/application/CrispTrackEventService.ts @@ -0,0 +1,157 @@ +import { Crisp } from 'crisp-api'; +import { maskEmail, PackmindLogger } from '@packmind/logger'; +import { Configuration } from '@packmind/node-utils'; +import { isTrialEmail } from './utils/email.utils'; + +const origin = 'CrispTrackEventService'; + +function describeCrispError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (error && typeof error === 'object') { + return JSON.stringify(error); + } + return String(error); +} + +export class CrispTrackEventService { + private initialized = false; + private configurationChecked = false; + private crispClient: Crisp = new Crisp(); + private webSite = ''; + + constructor(private readonly logger = new PackmindLogger(origin)) { + this.logger.info(`${origin} (proprietary) initialized`); + } + + private async initializeCrisp() { + this.configurationChecked = true; + this.logger.info('[Crisp API] Starting initialization...'); + const pluginIdentifier = await Configuration.getConfig( + 'CRISP_PLUGIN_IDENTIFIER', + ); + const apiKey = await Configuration.getConfig('CRISP_API_KEY'); + const webSiteId = await Configuration.getConfig('CRISP_WEBSITE_ID'); + if (!pluginIdentifier?.length || !apiKey?.length || !webSiteId?.length) { + this.logger.info( + '[Crisp API] Initialization skipped - missing configuration (CRISP_PLUGIN_IDENTIFIER, CRISP_API_KEY, or CRISP_WEBSITE_ID)', + ); + return; + } + this.webSite = webSiteId; + this.logger.info( + `[Crisp API] Before authenticateTier for websiteId=${webSiteId}`, + ); + this.crispClient.authenticateTier('plugin', pluginIdentifier, apiKey); + this.logger.info( + `[Crisp API] After authenticateTier - authentication configured successfully`, + ); + this.initialized = true; + this.logger.info('[Crisp API] Initialization complete'); + } + + async createPeopleIfNotAlreadyExists(email: string) { + // Early return for trial emails + if (isTrialEmail(email)) { + this.logger.info( + `[Crisp API] Skipping profile creation for trial email ${maskEmail(email)}`, + ); + return; + } + + if (!this.configurationChecked) { + await this.initializeCrisp(); + } + + if (!this.initialized) { + return; + } + + this.logger.info( + `[Crisp API] Before checkPeopleProfileExists for ${maskEmail(email)}`, + ); + try { + try { + await this.crispClient.website.checkPeopleProfileExists( + this.webSite, + email, + ); + this.logger.info( + `[Crisp API] After checkPeopleProfileExists for ${maskEmail(email)} - profile exists`, + ); + return; + } catch (error: unknown) { + const isNotFound = + error && + typeof error === 'object' && + 'code' in error && + error.code === 404; + if (!isNotFound) { + throw error; + } + } + + const peopleProfile = { + email, + person: { + nickname: email, + }, + }; + this.logger.info( + `[Crisp API] Before addNewPeopleProfile for ${maskEmail(email)}`, + ); + await this.crispClient.website.addNewPeopleProfile( + this.webSite, + peopleProfile, + ); + this.logger.info( + `[Crisp API] After addNewPeopleProfile for ${maskEmail(email)} - success`, + ); + } catch (error) { + this.logger.error( + `[Crisp API] createPeopleIfNotAlreadyExists for ${maskEmail(email)} failed: ${describeCrispError(error)}`, + ); + } + } + + async addPeopleEvent(email: string, eventName: string) { + // Early return for trial emails + if (isTrialEmail(email)) { + this.logger.info( + `[Crisp API] Skipping event '${eventName}' for trial email ${maskEmail(email)}`, + ); + return; + } + + if (!this.configurationChecked) { + await this.initializeCrisp(); + } + + if (!this.initialized) { + return; + } + + try { + await this.createPeopleIfNotAlreadyExists(email); + const peopleEvent = { + text: eventName, + }; + this.logger.info( + `[Crisp API] Before addPeopleEvent '${eventName}' for ${maskEmail(email)}`, + ); + await this.crispClient.website.addPeopleEvent( + this.webSite, + email, + peopleEvent, + ); + this.logger.info( + `[Crisp API] After addPeopleEvent '${eventName}' for ${maskEmail(email)} - success`, + ); + } catch (error) { + this.logger.error( + `[Crisp API] addPeopleEvent '${eventName}' for ${maskEmail(email)} failed: ${describeCrispError(error)}`, + ); + } + } +} diff --git a/packages/crisp/src/application/utils/email.utils.spec.ts b/packages/crisp/src/application/utils/email.utils.spec.ts new file mode 100644 index 000000000..58d777caa --- /dev/null +++ b/packages/crisp/src/application/utils/email.utils.spec.ts @@ -0,0 +1,47 @@ +import { isTrialEmail } from './email.utils'; + +describe('isTrialEmail', () => { + describe('when email starts with trial-', () => { + it('returns true for standard trial email format', () => { + const email = 'trial-550e8400-e29b-41d4-a716-446655440000@packmind.trial'; + + const result = isTrialEmail(email); + + expect(result).toBe(true); + }); + + it('returns true for trial email with different domain', () => { + const email = 'trial-123@example.com'; + + const result = isTrialEmail(email); + + expect(result).toBe(true); + }); + }); + + describe('when email does not start with trial-', () => { + it('returns false for regular email', () => { + const email = 'user@example.com'; + + const result = isTrialEmail(email); + + expect(result).toBe(false); + }); + + it('returns false for email containing trial but not as prefix', () => { + const email = 'user-trial@example.com'; + + const result = isTrialEmail(email); + + expect(result).toBe(false); + }); + + it('returns false for empty string', () => { + const email = ''; + + const result = isTrialEmail(email); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/crisp/src/application/utils/email.utils.ts b/packages/crisp/src/application/utils/email.utils.ts new file mode 100644 index 000000000..0a905e889 --- /dev/null +++ b/packages/crisp/src/application/utils/email.utils.ts @@ -0,0 +1,10 @@ +/** + * Determines if an email is a trial account email. + * Trial emails follow the pattern: trial-{uuid}@packmind.trial + * + * @param email - The email address to check + * @returns true if the email is a trial email, false otherwise + */ +export function isTrialEmail(email: string): boolean { + return email.startsWith('trial-'); +} diff --git a/packages/crisp/src/index.ts b/packages/crisp/src/index.ts new file mode 100644 index 000000000..ae6668ae0 --- /dev/null +++ b/packages/crisp/src/index.ts @@ -0,0 +1,2 @@ +export { CrispHexa } from './CrispHexa'; +export { CrispEventListener } from './application/CrispEventListener'; diff --git a/packages/crisp/tsconfig.json b/packages/crisp/tsconfig.json new file mode 100644 index 000000000..19b9eece4 --- /dev/null +++ b/packages/crisp/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/crisp/tsconfig.lib.json b/packages/crisp/tsconfig.lib.json new file mode 100644 index 000000000..33eca2c2c --- /dev/null +++ b/packages/crisp/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/crisp/tsconfig.spec.json b/packages/crisp/tsconfig.spec.json new file mode 100644 index 000000000..0d3c604ea --- /dev/null +++ b/packages/crisp/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/packages/deployments/package.json b/packages/deployments/package.json index 3623d8a73..b1bd688f0 100644 --- a/packages/deployments/package.json +++ b/packages/deployments/package.json @@ -21,7 +21,8 @@ "bullmq": "^5.58.2", "uuid": "^11.1.0", "slug": "^11.0.0", - "archiver": "^7.0.1" + "archiver": "^7.0.1", + "zod": "^3.25.76" }, "devDependencies": { "@types/archiver": "^6.0.3" diff --git a/packages/deployments/src/DeploymentsHexa.spec.ts b/packages/deployments/src/DeploymentsHexa.spec.ts new file mode 100644 index 000000000..bdeba9425 --- /dev/null +++ b/packages/deployments/src/DeploymentsHexa.spec.ts @@ -0,0 +1,58 @@ +import { DataSource, Repository } from 'typeorm'; +import { + HexaRegistry, + JobsService, + PackmindEventEmitterService, +} from '@packmind/node-utils'; +import { stubLogger } from '@packmind/test-utils'; +import { DeploymentsHexa } from './DeploymentsHexa'; + +describe('DeploymentsHexa', () => { + describe('initialize', () => { + it('initializes job queues so the marketplace-reconciliation worker starts at boot', async () => { + const dataSource = { + getRepository: jest.fn().mockReturnValue({} as Repository<unknown>), + } as unknown as DataSource; + + const hexa = new DeploymentsHexa(dataSource, { logger: stubLogger() }); + + // Bypass the heavy adapter wiring — we only care that initialize() drives + // job-queue initialization. The adapter is exercised in DeploymentsAdapter.spec.ts. + const removePluginJob = { + addJob: jest.fn(), + } as unknown as ReturnType< + DeploymentsHexa['adapter']['getRemovePluginFromMarketplaceJob'] + >; + const adapterStub = { + initialize: jest.fn().mockResolvedValue(undefined), + getRemovePluginFromMarketplaceJob: jest + .fn() + .mockReturnValue(removePluginJob), + }; + (hexa as unknown as { adapter: typeof adapterStub }).adapter = + adapterStub; + + const initJobQueues = jest.fn().mockResolvedValue(undefined); + const jobsService = { + initJobQueues, + } as unknown as JobsService; + + const eventEmitterService = { + on: jest.fn(), + } as unknown as PackmindEventEmitterService; + + const registry = { + getAdapter: jest.fn().mockReturnValue({}), + getService: jest.fn().mockImplementation((cls: unknown) => { + if (cls === JobsService) return jobsService; + if (cls === PackmindEventEmitterService) return eventEmitterService; + return undefined; + }), + } as unknown as HexaRegistry; + + await hexa.initialize(registry); + + expect(initJobQueues).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/deployments/src/DeploymentsHexa.ts b/packages/deployments/src/DeploymentsHexa.ts index 10a3921a6..55fac9cc1 100644 --- a/packages/deployments/src/DeploymentsHexa.ts +++ b/packages/deployments/src/DeploymentsHexa.ts @@ -23,12 +23,29 @@ import { ISpacesPortName, IStandardsPort, IStandardsPortName, + Marketplace, + MarketplaceDistribution, + PluginInstallation, } from '@packmind/types'; -import { DataSource } from 'typeorm'; +import { + GitRepoRepository, + GitRepoSchema, + GitRepoService, +} from '@packmind/git'; +import { DataSource, Repository } from 'typeorm'; import { DeploymentsAdapter } from './application/adapter/DeploymentsAdapter'; import { DeploymentsListener } from './application/listeners/DeploymentsListener'; +import { PackageDeletedDistributionsListener } from './application/listeners/PackageDeletedDistributionsListener'; import { DeploymentsServices } from './application/services/DeploymentsServices'; +import { MarketplaceDescriptorParserRegistry } from './application/services/MarketplaceDescriptorParserRegistry'; +import { AnthropicMarketplaceDescriptorParser } from './application/services/parsers/AnthropicMarketplaceDescriptorParser'; import { DeploymentsRepositories } from './infra/repositories/DeploymentsRepositories'; +import { MarketplaceDistributionRepository } from './infra/repositories/MarketplaceDistributionRepository'; +import { MarketplaceRepository } from './infra/repositories/MarketplaceRepository'; +import { PluginInstallationRepository } from './infra/repositories/PluginInstallationRepository'; +import { MarketplaceDistributionSchema } from './infra/schemas/MarketplaceDistributionSchema'; +import { MarketplaceSchema } from './infra/schemas/MarketplaceSchema'; +import { PluginInstallationSchema } from './infra/schemas/PluginInstallationSchema'; const origin = 'DeploymentsHexa'; @@ -47,8 +64,16 @@ export class DeploymentsHexa extends BaseHexa< > { private readonly repositories: DeploymentsRepositories; private readonly services: DeploymentsServices; + private readonly marketplaceRepository: MarketplaceRepository; + private readonly marketplaceDistributionRepository: MarketplaceDistributionRepository; + private readonly pluginInstallationRepository: PluginInstallationRepository; + private readonly marketplaceDescriptorParserRegistry: MarketplaceDescriptorParserRegistry; + private readonly gitRepoService: GitRepoService; private readonly adapter: DeploymentsAdapter; private readonly listener: DeploymentsListener; + // Built during initialize() — needs the removal delayed job, which only + // exists once the adapter has built its delayed jobs. + private packageDeletedDistributionsListener!: PackageDeletedDistributionsListener; constructor( dataSource: DataSource, @@ -64,11 +89,59 @@ export class DeploymentsHexa extends BaseHexa< // Initialize services (no longer depends on GitPort) this.services = new DeploymentsServices(this.repositories); + // Build the marketplace repository against MarketplaceSchema. Kept + // outside DeploymentsRepositories for now because marketplace use + // cases are the only consumers. + this.marketplaceRepository = new MarketplaceRepository( + this.dataSource.getRepository( + MarketplaceSchema, + ) as Repository<Marketplace>, + ); + + // Persistence for marketplace publish attempts. Passed through to the + // adapter so the publish use case and its delayed job can read/write + // the marketplace distribution rows. + this.marketplaceDistributionRepository = + new MarketplaceDistributionRepository( + this.dataSource.getRepository( + MarketplaceDistributionSchema, + ) as Repository<MarketplaceDistribution>, + ); + + // Persistence for plugin install heartbeat rows (tracking installs by scope). + this.pluginInstallationRepository = new PluginInstallationRepository( + this.dataSource.getRepository( + PluginInstallationSchema, + ) as Repository<PluginInstallation>, + ); + + // Vendor-agnostic parser registry. New marketplace vendors plug in by + // appending to this array — link/unlink use cases do not branch on + // vendor. + this.marketplaceDescriptorParserRegistry = + new MarketplaceDescriptorParserRegistry([ + new AnthropicMarketplaceDescriptorParser(), + ]); + + // LinkMarketplaceUseCase needs the GitRepoService for the cross-type + // collision check (`findGitRepoIgnoringType`) and to persist the + // marketplace-typed GitRepo. The service is constructed from the + // GitRepoRepository (re-exported from @packmind/git so this Hexa can + // wire it without reaching into git's internals). + this.gitRepoService = new GitRepoService( + new GitRepoRepository(this.dataSource.getRepository(GitRepoSchema)), + ); + // Create adapter in constructor - ports will be set during initialize() this.adapter = new DeploymentsAdapter( this.services, this.repositories.getDistributionRepository(), this.repositories.getDistributedPackageRepository(), + this.marketplaceRepository, + this.marketplaceDistributionRepository, + this.pluginInstallationRepository, + this.marketplaceDescriptorParserRegistry, + this.gitRepoService, ); // Create listener - will be initialized during initialize() @@ -76,6 +149,10 @@ export class DeploymentsHexa extends BaseHexa< this.repositories.getPackageRepository(), ); + // The package-deletion cascade listener is built in initialize(): it + // needs the removal delayed job, which the adapter only creates once its + // delayed jobs are wired. + this.logger.info('DeploymentsHexa construction completed'); } catch (error) { this.logger.error('Failed to construct DeploymentsHexa', { @@ -124,6 +201,25 @@ export class DeploymentsHexa extends BaseHexa< // Initialize listener with event emitter service this.listener.initialize(eventEmitterService); + // Build + initialize the cascade listener now that the adapter's delayed + // jobs (incl. the removal job) exist. + this.packageDeletedDistributionsListener = + new PackageDeletedDistributionsListener({ + marketplaceDistributionRepository: + this.marketplaceDistributionRepository, + packageService: this.services.getPackageService(), + removePluginFromMarketplaceJob: + this.adapter.getRemovePluginFromMarketplaceJob(), + }); + this.packageDeletedDistributionsListener.initialize(eventEmitterService); + + // Start the workers for every queue this domain registered through the + // adapter (publish-artifacts, marketplace-reconciliation, publish-plugin, + // remove-plugin). Without this, queues that have no synchronous trigger + // — notably the cron-driven reconciliation queue — never get a worker + // and their scheduled jobs sit in Redis with no consumer. + await jobsService.initJobQueues(); + this.logger.info('DeploymentsHexa initialized successfully'); } catch (error) { this.logger.error('Failed to initialize DeploymentsHexa', { diff --git a/packages/deployments/src/application/adapter/DeploymentsAdapter.spec.ts b/packages/deployments/src/application/adapter/DeploymentsAdapter.spec.ts index 5050bb377..23d3a4fb0 100644 --- a/packages/deployments/src/application/adapter/DeploymentsAdapter.spec.ts +++ b/packages/deployments/src/application/adapter/DeploymentsAdapter.spec.ts @@ -1,7 +1,12 @@ import { + ListMarketplaceDistributionsCommand, + Marketplace, + MarkPluginForRemovalCommand, RenderPackageAsPluginCommand, TrackPluginDeletedCommand, } from '@packmind/types'; +import { Configuration } from '@packmind/node-utils'; +import { marketplaceFactory } from '../../infra/repositories/__factories__/marketplaceFactory'; import { DeploymentsAdapter } from './DeploymentsAdapter'; describe('DeploymentsAdapter', () => { @@ -20,7 +25,15 @@ describe('DeploymentsAdapter', () => { beforeEach(async () => { execute = jest.fn().mockResolvedValue(response); - adapter = new DeploymentsAdapter({} as never, {} as never, {} as never); + adapter = new DeploymentsAdapter( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); ( adapter as unknown as { _renderPackageAsPluginUseCase: { execute: typeof execute }; @@ -58,7 +71,15 @@ describe('DeploymentsAdapter', () => { beforeEach(async () => { execute = jest.fn().mockResolvedValue(response); - adapter = new DeploymentsAdapter({} as never, {} as never, {} as never); + adapter = new DeploymentsAdapter( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); ( adapter as unknown as { _trackPluginDeletedUseCase: { execute: typeof execute }; @@ -82,4 +103,267 @@ describe('DeploymentsAdapter', () => { expect(result).toBe(response); }); }); + + describe('markPluginForRemoval', () => { + const response = { distribution: { id: 'd1' } } as never; + let execute: jest.Mock; + let adapter: DeploymentsAdapter; + let command: MarkPluginForRemovalCommand; + let result: typeof response; + + beforeEach(async () => { + execute = jest.fn().mockResolvedValue(response); + + adapter = new DeploymentsAdapter( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + ( + adapter as unknown as { + _markPluginForRemovalUseCase: { execute: typeof execute }; + } + )._markPluginForRemovalUseCase = { execute }; + + command = { + userId: 'u', + organizationId: 'o', + marketplaceId: 'm', + distributionId: 'd1', + } as MarkPluginForRemovalCommand; + + result = await adapter.markPluginForRemoval(command); + }); + + it('delegates to MarkPluginForRemovalUseCase with the command', () => { + expect(execute).toHaveBeenCalledWith(command); + }); + + it('returns the use case response', () => { + expect(result).toBe(response); + }); + }); + + describe('listMarketplaceDistributions', () => { + const response = [] as never; + let execute: jest.Mock; + let adapter: DeploymentsAdapter; + let command: ListMarketplaceDistributionsCommand; + let result: typeof response; + + beforeEach(async () => { + execute = jest.fn().mockResolvedValue(response); + + adapter = new DeploymentsAdapter( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + ( + adapter as unknown as { + _listMarketplaceDistributionsUseCase: { execute: typeof execute }; + } + )._listMarketplaceDistributionsUseCase = { execute }; + + command = { + userId: 'u', + organizationId: 'o', + marketplaceId: 'm', + } as ListMarketplaceDistributionsCommand; + + result = await adapter.listMarketplaceDistributions(command); + }); + + it('delegates to ListMarketplaceDistributionsUseCase with the command', () => { + expect(execute).toHaveBeenCalledWith(command); + }); + + it('returns the use case response', () => { + expect(result).toBe(response); + }); + }); + + describe('renderPluginForPublishJob (install-tracking metadata)', () => { + type RenderForPublishJob = (params: { + marketplace: Marketplace; + package: { id: string; slug: string; name: string; spaceId: string }; + userId: string; + organizationId: string; + }) => Promise<unknown>; + + const pkg = { + id: 'pkg-1', + slug: 'security', + name: 'Security', + spaceId: 'space-1', + } as unknown as import('@packmind/types').Package; + + const space = { + id: 'space-1', + slug: 'default', + organizationId: 'org-1', + } as unknown as import('@packmind/types').Space; + + let execute: jest.Mock; + let adapter: DeploymentsAdapter; + + beforeEach(() => { + jest + .spyOn(Configuration, 'getConfig') + .mockResolvedValue('https://app.packmind.io'); + + execute = jest.fn().mockResolvedValue({ + files: [], + skippedStandardsCount: 0, + pluginName: 'Security', + pluginVersion: '0.1.0', + }); + + adapter = new DeploymentsAdapter( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + ( + adapter as unknown as { + _renderPackageAsPluginUseCase: { execute: typeof execute }; + spacesPort: { getSpaceById: jest.Mock }; + } + )._renderPackageAsPluginUseCase = { execute }; + ( + adapter as unknown as { + spacesPort: { getSpaceById: jest.Mock }; + } + ).spacesPort = { + getSpaceById: jest.fn().mockResolvedValue(space), + }; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('when the marketplace has a tracking token', () => { + let marketplace: Marketplace; + + beforeEach(async () => { + marketplace = marketplaceFactory({ + name: 'ACME Marketplace', + trackingToken: 'tok-abc-123', + }); + + await ( + adapter as unknown as { + renderPluginForPublishJob: RenderForPublishJob; + } + ).renderPluginForPublishJob({ + marketplace, + package: pkg, + userId: 'u1', + organizationId: 'org-1', + }); + }); + + it('passes installTracking with the token to RenderPackageAsPluginUseCase', () => { + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ + installTracking: expect.objectContaining({ + trackingToken: 'tok-abc-123', + }), + }), + ); + }); + + it('sets apiBaseUrl from APP_WEB_URL with the /api/v0 suffix', () => { + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ + installTracking: expect.objectContaining({ + apiBaseUrl: 'https://app.packmind.io/api/v0', + }), + }), + ); + }); + + it('sets marketplaceName from the marketplace entity', () => { + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ + installTracking: expect.objectContaining({ + marketplaceName: 'ACME Marketplace', + }), + }), + ); + }); + + it('sets pluginSlug from the package slug', () => { + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ + installTracking: expect.objectContaining({ + pluginSlug: 'security', + }), + }), + ); + }); + }); + + describe('when the marketplace has a null tracking token', () => { + beforeEach(async () => { + const marketplace = marketplaceFactory({ trackingToken: null }); + + await ( + adapter as unknown as { + renderPluginForPublishJob: RenderForPublishJob; + } + ).renderPluginForPublishJob({ + marketplace, + package: pkg, + userId: 'u1', + organizationId: 'org-1', + }); + }); + + it('omits installTracking from the render command', () => { + expect(execute).toHaveBeenCalledWith( + expect.not.objectContaining({ installTracking: expect.anything() }), + ); + }); + }); + + describe('when APP_WEB_URL is not configured', () => { + beforeEach(async () => { + jest.spyOn(Configuration, 'getConfig').mockResolvedValue(undefined); + const marketplace = marketplaceFactory({ + trackingToken: 'tok-abc-123', + }); + + await ( + adapter as unknown as { + renderPluginForPublishJob: RenderForPublishJob; + } + ).renderPluginForPublishJob({ + marketplace, + package: pkg, + userId: 'u1', + organizationId: 'org-1', + }); + }); + + it('omits installTracking rather than baking an unreachable URL', () => { + expect(execute).toHaveBeenCalledWith( + expect.not.objectContaining({ installTracking: expect.anything() }), + ); + }); + }); + }); }); diff --git a/packages/deployments/src/application/adapter/DeploymentsAdapter.ts b/packages/deployments/src/application/adapter/DeploymentsAdapter.ts index ac41c121f..fb15cba5b 100644 --- a/packages/deployments/src/application/adapter/DeploymentsAdapter.ts +++ b/packages/deployments/src/application/adapter/DeploymentsAdapter.ts @@ -1,5 +1,6 @@ import { PackmindLogger } from '@packmind/logger'; import { + Configuration, IBaseAdapter, JobsService, PackmindEventEmitterService, @@ -7,6 +8,12 @@ import { import { AddArtefactsToPackageCommand, AddArtefactsToPackageResponse, + ListMarketplaceDistributionsCommand, + ListMarketplaceDistributionsResponse, + MarkPluginForRemovalCommand, + MarkPluginForRemovalResponse, + SyncMarketplaceNowCommand, + SyncMarketplaceNowResponse, AddTargetCommand, CreatePackageCommand, CreatePackageResponse, @@ -63,6 +70,12 @@ import { ISpacesPortName, IStandardsPort, IStandardsPortName, + FindMarketplaceDistributionByIdCommand, + FindMarketplaceDistributionByIdResponse, + GetMarketplaceDistributionChangesCommand, + GetMarketplaceDistributionChangesResponse, + LinkMarketplaceCommand, + LinkMarketplaceResponse, ListDeploymentsByPackageCommand, IListActiveDistributedPackagesBySpaceUseCase, IListDriftedPackagesByOrgUseCase, @@ -73,6 +86,12 @@ import { ListDistributionsByRecipeCommand, ListDistributionsByStandardCommand, ListDistributionsBySkillCommand, + ListMarketplaceDistributionsForPackageCommand, + ListMarketplaceDistributionsForPackageResponse, + ListMarketplacesCommand, + ListMarketplacesResponse, + ListMarketplacePluginInstallsCommand, + ListMarketplacePluginInstallsResponse, ListPackagesCommand, ListPackagesResponse, ListPackagesBySpaceCommand, @@ -86,6 +105,9 @@ import { RemovePackageFromTargetsResponse, PublishArtifactsCommand, PublishArtifactsResponse, + PublishPackageOnMarketplaceCommand, + PublishPackageOnMarketplaceResponse, + Marketplace, PublishPackagesCommand, PullContentCommand, RenderModeConfiguration, @@ -95,14 +117,31 @@ import { TargetWithRepository, TrackPluginDeletedCommand, TrackPluginDeletedResponse, + TrackPluginInstallHeartbeatCommand, + TrackPluginInstallHeartbeatResponse, + UnlinkMarketplaceCommand, + UnlinkMarketplaceResponse, UpdateRenderModeConfigurationCommand, UpdateTargetCommand, + ValidateMarketplaceUrlCommand, + ValidateMarketplaceUrlResponse, } from '@packmind/types'; +import { GitRepoService } from '@packmind/git'; import { IDeploymentsDelayedJobs } from '../../domain/jobs'; import { IDistributionRepository } from '../../domain/repositories/IDistributionRepository'; import { IDistributedPackageRepository } from '../../domain/repositories/IDistributedPackageRepository'; +import { IMarketplaceDistributionRepository } from '../../domain/repositories/IMarketplaceDistributionRepository'; +import { IMarketplaceRepository } from '../../domain/repositories/IMarketplaceRepository'; +import { IPluginInstallationRepository } from '../../domain/repositories/IPluginInstallationRepository'; +import { MarketplaceReconciliationJobFactory } from '../../infra/jobs/MarketplaceReconciliationJobFactory'; import { PublishArtifactsJobFactory } from '../../infra/jobs/PublishArtifactsJobFactory'; +import { PublishPluginToMarketplaceJobFactory } from '../../infra/jobs/PublishPluginToMarketplaceJobFactory'; +import { RemovePluginFromMarketplaceJobFactory } from '../../infra/jobs/RemovePluginFromMarketplaceJobFactory'; +import { RemovePluginFromMarketplaceDelayedJob } from '../jobs/RemovePluginFromMarketplaceDelayedJob'; import { DeploymentsServices } from '../services/DeploymentsServices'; +import { MarketplaceDescriptorParserRegistry } from '../services/MarketplaceDescriptorParserRegistry'; +import { PackageVersionFingerprintService } from '../services/PackageVersionFingerprintService'; +import { formatMarketplacePluginDescription } from '../services/formatMarketplacePluginDescription'; import { TargetResolutionService } from '../services/TargetResolutionService'; import { AddArtefactsToPackageUseCase } from '../useCases/addArtefactsToPackage/AddArtefactsToPackageUseCase'; import { AddTargetUseCase } from '../useCases/AddTargetUseCase'; @@ -115,6 +154,17 @@ import { DeployDefaultSkillsUseCase } from '../useCases/DeployDefaultSkillsUseCa import { DownloadSkillZipForAgentUseCase } from '../useCases/DownloadSkillZipForAgentUseCase'; import { FindActiveStandardVersionsByTargetUseCase } from '../useCases/FindActiveStandardVersionsByTargetUseCase'; import { GetPackageByIdUseCase } from '../useCases/getPackageById/GetPackageByIdUseCase'; +import { FindMarketplaceDistributionByIdUseCase } from '../useCases/findMarketplaceDistributionById'; +import { LinkMarketplaceUseCase } from '../useCases/linkMarketplace'; +import { ListMarketplaceDistributionsForPackageUseCase } from '../useCases/listMarketplaceDistributionsForPackage'; +import { ListMarketplaceDistributionsUseCase } from '../useCases/listMarketplaceDistributions'; +import { GetMarketplaceDistributionChangesUseCase } from '../useCases/getMarketplaceDistributionChanges'; +import { ListMarketplacesUseCase } from '../useCases/listMarketplaces'; +import { MarkPluginForRemovalUseCase } from '../useCases/markPluginForRemoval'; +import { SyncMarketplaceNowUseCase } from '../useCases/syncMarketplaceNow'; +import { PublishPackageOnMarketplaceUseCase } from '../useCases/publishPackageOnMarketplace'; +import { UnlinkMarketplaceUseCase } from '../useCases/unlinkMarketplace'; +import { ValidateMarketplaceUrlUseCase } from '../useCases/validateMarketplaceUrl'; import { GetRenderModeConfigurationUseCase } from '../useCases/GetRenderModeConfigurationUseCase'; import { GetTargetByIdUseCase } from '../useCases/GetTargetByIdUseCase'; import { GetTargetsByGitRepoUseCase } from '../useCases/GetTargetsByGitRepoUseCase'; @@ -143,6 +193,8 @@ import { InstallPackagesUseCase } from '../useCases/InstallPackagesUseCase'; import { PullContentUseCase } from '../useCases/PullContentUseCase'; import { RenderPackageAsPluginUseCase } from '../useCases/renderPackageAsPlugin/RenderPackageAsPluginUseCase'; import { TrackPluginDeletedUseCase } from '../useCases/trackPluginDeleted/TrackPluginDeletedUseCase'; +import { TrackPluginInstallHeartbeatUseCase } from '../useCases/trackPluginInstallHeartbeat/TrackPluginInstallHeartbeatUseCase'; +import { ListMarketplacePluginInstallsUseCase } from '../useCases/listMarketplacePluginInstalls/ListMarketplacePluginInstallsUseCase'; import { UpdateRenderModeConfigurationUseCase } from '../useCases/UpdateRenderModeConfigurationUseCase'; import { UpdateTargetUseCase } from '../useCases/UpdateTargetUseCase'; @@ -202,11 +254,29 @@ export class DeploymentsAdapter private _listActiveDistributedPackagesBySpaceUseCase!: ListActiveDistributedPackagesBySpaceUseCase; private _listDriftedPackagesByOrgUseCase!: ListDriftedPackagesByOrgUseCase; private _getLastDistributionDateByProvidersUseCase!: GetLastDistributionDateByProvidersUseCase; + private _linkMarketplaceUseCase!: LinkMarketplaceUseCase; + private _unlinkMarketplaceUseCase!: UnlinkMarketplaceUseCase; + private _listMarketplacesUseCase!: ListMarketplacesUseCase; + private _validateMarketplaceUrlUseCase!: ValidateMarketplaceUrlUseCase; + private _publishPackageOnMarketplaceUseCase!: PublishPackageOnMarketplaceUseCase; + private _listMarketplaceDistributionsForPackageUseCase!: ListMarketplaceDistributionsForPackageUseCase; + private _findMarketplaceDistributionByIdUseCase!: FindMarketplaceDistributionByIdUseCase; + private _markPluginForRemovalUseCase!: MarkPluginForRemovalUseCase; + private _syncMarketplaceNowUseCase!: SyncMarketplaceNowUseCase; + private _listMarketplaceDistributionsUseCase!: ListMarketplaceDistributionsUseCase; + private _getMarketplaceDistributionChangesUseCase!: GetMarketplaceDistributionChangesUseCase; + private _trackPluginInstallHeartbeatUseCase!: TrackPluginInstallHeartbeatUseCase; + private _listMarketplacePluginInstallsUseCase!: ListMarketplacePluginInstallsUseCase; constructor( private readonly deploymentsServices: DeploymentsServices, private readonly distributionRepository: IDistributionRepository, private readonly distributedPackageRepository: IDistributedPackageRepository, + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly marketplaceDistributionRepository: IMarketplaceDistributionRepository, + private readonly pluginInstallationRepository: IPluginInstallationRepository, + private readonly marketplaceDescriptorParserRegistry: MarketplaceDescriptorParserRegistry, + private readonly gitRepoService: GitRepoService, private readonly logger: PackmindLogger = new PackmindLogger(origin), ) {} @@ -237,6 +307,7 @@ export class DeploymentsAdapter // Step 2: Build delayed jobs this.deploymentsDelayedJobs = await this.buildDelayedJobs( ports.jobsService, + ports.eventEmitterService, ); // Step 3: Validate all required ports are set @@ -412,6 +483,21 @@ export class DeploymentsAdapter ports.eventEmitterService, ); + this._trackPluginInstallHeartbeatUseCase = + new TrackPluginInstallHeartbeatUseCase( + this.pluginInstallationRepository, + this.marketplaceRepository, + this.deploymentsServices.getPackageService(), + ports.eventEmitterService, + ); + + this._listMarketplacePluginInstallsUseCase = + new ListMarketplacePluginInstallsUseCase( + this.pluginInstallationRepository, + this.marketplaceRepository, + this.accountsPort, + ); + this._getDeployedContentUseCase = new GetDeployedContentUseCase( targetResolutionService, this.distributionRepository, @@ -471,6 +557,9 @@ export class DeploymentsAdapter this.accountsPort, this.distributionRepository, this.deploymentsServices.getRepositories().getPackageRepository(), + this.standardsPort, + this.recipesPort, + this.skillsPort, ); this._listPackagesUseCase = new ListPackagesUseCase( @@ -565,6 +654,111 @@ export class DeploymentsAdapter this.codingAgentPort, this.deploymentsServices.getRenderModeConfigurationService(), ); + + // Marketplace use cases. The reconciliation job is wired through here so + // both Link/Unlink can drive the BullMQ repeatable schedule. + this._linkMarketplaceUseCase = new LinkMarketplaceUseCase( + this.marketplaceRepository, + this.gitRepoService, + this.gitPort, + this.marketplaceDescriptorParserRegistry, + ports.eventEmitterService, + this.deploymentsDelayedJobs.marketplaceReconciliationDelayedJob, + this.accountsPort, + ); + + this._unlinkMarketplaceUseCase = new UnlinkMarketplaceUseCase( + this.marketplaceRepository, + this.gitPort, + ports.eventEmitterService, + this.deploymentsDelayedJobs.marketplaceReconciliationDelayedJob, + this.accountsPort, + ); + + this._listMarketplacesUseCase = new ListMarketplacesUseCase( + this.marketplaceRepository, + this.gitRepoService, + this.gitPort, + this.accountsPort, + ); + + this._validateMarketplaceUrlUseCase = new ValidateMarketplaceUrlUseCase( + this.gitPort, + this.marketplaceDescriptorParserRegistry, + this.accountsPort, + ); + + this._publishPackageOnMarketplaceUseCase = + new PublishPackageOnMarketplaceUseCase( + this.marketplaceRepository, + this.marketplaceDistributionRepository, + this.deploymentsServices.getPackageService(), + this.spacesPort, + this.gitPort, + this.gitRepoService, + this.marketplaceDescriptorParserRegistry, + ports.eventEmitterService, + this.deploymentsDelayedJobs.publishPluginToMarketplaceDelayedJob, + this.accountsPort, + ); + + this._listMarketplaceDistributionsForPackageUseCase = + new ListMarketplaceDistributionsForPackageUseCase( + this.marketplaceDistributionRepository, + this.deploymentsServices.getPackageService(), + this.spacesPort, + this.accountsPort, + ); + + this._findMarketplaceDistributionByIdUseCase = + new FindMarketplaceDistributionByIdUseCase( + this.marketplaceDistributionRepository, + this.accountsPort, + ); + + // Plugin removal use cases (gated behind marketplace-plugin-removal flag + // on the frontend; backend remains symmetric to link/unlink). + this._markPluginForRemovalUseCase = new MarkPluginForRemovalUseCase( + this.marketplaceRepository, + this.marketplaceDistributionRepository, + this.deploymentsServices.getPackageService(), + ports.eventEmitterService, + this.deploymentsDelayedJobs.removePluginFromMarketplaceDelayedJob, + this.accountsPort, + ); + + // On-demand "Sync now" reconciliation (member-scoped). Reuses the + // reconciliation job so a manual refresh runs the same sweep as the cron. + this._syncMarketplaceNowUseCase = new SyncMarketplaceNowUseCase( + this.marketplaceRepository, + this.deploymentsDelayedJobs.marketplaceReconciliationDelayedJob, + this.accountsPort, + ); + + this._listMarketplaceDistributionsUseCase = + new ListMarketplaceDistributionsUseCase( + this.marketplaceRepository, + this.marketplaceDistributionRepository, + this.deploymentsServices.getPackageService(), + this.spacesPort, + this.accountsPort, + ); + + this._getMarketplaceDistributionChangesUseCase = + new GetMarketplaceDistributionChangesUseCase( + this.marketplaceRepository, + this.marketplaceDistributionRepository, + this.deploymentsServices.getPackageService(), + new PackageVersionFingerprintService( + this.recipesPort, + this.standardsPort, + this.skillsPort, + ), + this.recipesPort, + this.standardsPort, + this.skillsPort, + this.accountsPort, + ); } /** @@ -573,9 +767,19 @@ export class DeploymentsAdapter */ private async buildDelayedJobs( jobsService: JobsService, + eventEmitterService: PackmindEventEmitterService, ): Promise<IDeploymentsDelayedJobs> { this.logger.debug('Building delayed jobs for Deployments domain'); + // Shared fingerprint service: baselines a package's artifact versions at + // publish time and recomputes them on reconcile to flag "outdated". Built + // once here and shared by the publish + reconciliation factories. + const versionFingerprintService = new PackageVersionFingerprintService( + this.recipesPort!, + this.standardsPort!, + this.skillsPort!, + ); + const jobFactory = new PublishArtifactsJobFactory( this.distributionRepository, this.gitPort!, @@ -590,9 +794,193 @@ export class DeploymentsAdapter ); } + // Marketplace reconciliation queue — per-marketplace BullMQ repeatable + // jobs (default `*/30 * * * *`). Registered through the same JobsService + // so the queue is initialized alongside the rest of the worker pool. + const reconciliationFactory = new MarketplaceReconciliationJobFactory( + this.marketplaceRepository, + this.marketplaceDistributionRepository, + this.gitRepoService, + this.gitPort!, + this.marketplaceDescriptorParserRegistry, + this.deploymentsServices.getPackageService(), + versionFingerprintService, + ); + jobsService.registerJobQueue( + reconciliationFactory.getQueueName(), + reconciliationFactory, + ); + await reconciliationFactory.createQueue(); + + if (!reconciliationFactory.delayedJob) { + throw new Error( + 'DeploymentsAdapter: Failed to create delayed job for marketplace reconciliation', + ); + } + + // Marketplace plugin publish queue. The worker is intentionally + // single-concurrency — Git pushes onto the rolling `packmind/sync` branch + // must be serialized across simultaneous publish attempts. The renderer + // is provided as a lazy callable so it can reach the + // `RenderPackageAsPluginUseCase` that is constructed later in + // `initialize()`. + const publishPluginToMarketplaceFactory = + new PublishPluginToMarketplaceJobFactory( + this.marketplaceDistributionRepository, + this.marketplaceRepository, + this.deploymentsServices.getPackageService(), + this.gitRepoService, + this.gitPort!, + this.marketplaceDescriptorParserRegistry, + async (params) => this.renderPluginForPublishJob(params), + versionFingerprintService, + eventEmitterService, + ); + jobsService.registerJobQueue( + publishPluginToMarketplaceFactory.getQueueName(), + publishPluginToMarketplaceFactory, + ); + await publishPluginToMarketplaceFactory.createQueue(); + + if (!publishPluginToMarketplaceFactory.delayedJob) { + throw new Error( + 'DeploymentsAdapter: Failed to create delayed job for publish plugin to marketplace', + ); + } + + // Marketplace plugin removal queue — the inverse of the publish queue. + // Also single-concurrency: deletion commits onto `packmind/sync` must be + // serialized with publishes. No renderer is needed (we delete the + // plugin's files rather than render them). + const removePluginFromMarketplaceFactory = + new RemovePluginFromMarketplaceJobFactory( + this.marketplaceDistributionRepository, + this.marketplaceRepository, + this.gitRepoService, + this.gitPort!, + this.marketplaceDescriptorParserRegistry, + ); + jobsService.registerJobQueue( + removePluginFromMarketplaceFactory.getQueueName(), + removePluginFromMarketplaceFactory, + ); + await removePluginFromMarketplaceFactory.createQueue(); + + if (!removePluginFromMarketplaceFactory.delayedJob) { + throw new Error( + 'DeploymentsAdapter: Failed to create delayed job for remove plugin from marketplace', + ); + } + this.logger.debug('Deployments delayed jobs built successfully'); return { publishArtifactsDelayedJob: jobFactory.delayedJob, + marketplaceReconciliationDelayedJob: reconciliationFactory.delayedJob, + publishPluginToMarketplaceDelayedJob: + publishPluginToMarketplaceFactory.delayedJob, + removePluginFromMarketplaceDelayedJob: + removePluginFromMarketplaceFactory.delayedJob, + }; + } + + /** + * Exposes the marketplace plugin removal job so the cross-cutting + * `PackageDeletedDistributionsListener` (constructed in `DeploymentsHexa`) + * can enqueue deletions for the package-deletion cascade. Available only + * after `initialize()` has built the delayed jobs. + */ + public getRemovePluginFromMarketplaceJob(): RemovePluginFromMarketplaceDelayedJob { + if (!this.deploymentsDelayedJobs) { + throw new Error( + 'DeploymentsAdapter: delayed jobs not initialized — call initialize() first', + ); + } + return this.deploymentsDelayedJobs.removePluginFromMarketplaceDelayedJob; + } + + /** + * Renderer callable used by the `PublishPluginToMarketplaceDelayedJob`. + * + * Wraps `RenderPackageAsPluginUseCase` so the job spec stays decoupled from + * the rendering hexagon. The package is resolved via the existing + * `@<space-slug>/<package-slug>` selector to keep the rendering path + * consistent across surfaces. + */ + private async renderPluginForPublishJob(params: { + marketplace: Marketplace; + package: import('@packmind/types').Package; + userId: string; + organizationId: string; + }): Promise< + import('../jobs/PublishPluginToMarketplaceDelayedJob').PluginRendererResult + > { + if (!this.spacesPort) { + throw new Error( + 'DeploymentsAdapter: SpacesPort missing — renderer cannot run', + ); + } + const space = await this.spacesPort.getSpaceById(params.package.spaceId); + if (!space) { + throw new Error( + `Space ${params.package.spaceId} not found for package ${params.package.slug}`, + ); + } + const slug = `@${space.slug}/${params.package.slug}`; + const pluginRoot = `plugins/${params.package.slug}`; + + // Build install-tracking metadata when the marketplace has a tracking + // token. Tokens are generated at link time; the null guard covers rows + // created before this feature shipped and not yet republished. + let installTracking: + | { + apiBaseUrl: string; + marketplaceName: string; + pluginSlug: string; + trackingToken: string; + } + | undefined; + + if (params.marketplace.trackingToken !== null) { + const appWebUrl = await Configuration.getConfig('APP_WEB_URL'); + if (appWebUrl) { + // The API is served under the versioned global prefix `/api/v0` + // (see apps/api main.ts). Embedding only `/api` produces a 404. + const normalizedBase = appWebUrl.replace(/\/$/, ''); + installTracking = { + apiBaseUrl: `${normalizedBase}/api/v0`, + marketplaceName: params.marketplace.name, + pluginSlug: params.package.slug, + trackingToken: params.marketplace.trackingToken, + }; + } else { + // Don't bake an unreachable localhost URL into a distributed plugin; + // skip install-tracking hooks when APP_WEB_URL is not configured. + this.logger.warn( + 'APP_WEB_URL is not configured; skipping plugin install-tracking hooks for this publish', + ); + } + } + + const response = await this._renderPackageAsPluginUseCase.execute({ + userId: params.userId, + organizationId: params.organizationId, + packageSlug: slug, + mode: 'marketplace', + pluginRoot, + pluginName: params.package.name, + ...(installTracking !== undefined ? { installTracking } : {}), + }); + + const marketplacePluginDescription = formatMarketplacePluginDescription({ + spaceSlug: space.slug, + packageDescription: response.pluginDescription, + }); + + return { + files: response.files.map((f) => ({ path: f.path, content: f.content })), + pluginName: response.pluginName, + pluginVersion: response.pluginVersion, + pluginDescription: marketplacePluginDescription, }; } @@ -862,4 +1250,82 @@ export class DeploymentsAdapter ): Promise<GetLastDistributionDateByProvidersResponse> { return this._getLastDistributionDateByProvidersUseCase.execute(command); } + + async linkMarketplace( + command: LinkMarketplaceCommand, + ): Promise<LinkMarketplaceResponse> { + return this._linkMarketplaceUseCase.execute(command); + } + + async unlinkMarketplace( + command: UnlinkMarketplaceCommand, + ): Promise<UnlinkMarketplaceResponse> { + return this._unlinkMarketplaceUseCase.execute(command); + } + + async listMarketplaces( + command: ListMarketplacesCommand, + ): Promise<ListMarketplacesResponse> { + return this._listMarketplacesUseCase.execute(command); + } + + async validateMarketplaceUrl( + command: ValidateMarketplaceUrlCommand, + ): Promise<ValidateMarketplaceUrlResponse> { + return this._validateMarketplaceUrlUseCase.execute(command); + } + + async publishPackageOnMarketplace( + command: PublishPackageOnMarketplaceCommand, + ): Promise<PublishPackageOnMarketplaceResponse> { + return this._publishPackageOnMarketplaceUseCase.execute(command); + } + + async listMarketplaceDistributionsForPackage( + command: ListMarketplaceDistributionsForPackageCommand, + ): Promise<ListMarketplaceDistributionsForPackageResponse> { + return this._listMarketplaceDistributionsForPackageUseCase.execute(command); + } + + async findMarketplaceDistributionById( + command: FindMarketplaceDistributionByIdCommand, + ): Promise<FindMarketplaceDistributionByIdResponse> { + return this._findMarketplaceDistributionByIdUseCase.execute(command); + } + + async markPluginForRemoval( + command: MarkPluginForRemovalCommand, + ): Promise<MarkPluginForRemovalResponse> { + return this._markPluginForRemovalUseCase.execute(command); + } + + async syncMarketplaceNow( + command: SyncMarketplaceNowCommand, + ): Promise<SyncMarketplaceNowResponse> { + return this._syncMarketplaceNowUseCase.execute(command); + } + + async listMarketplaceDistributions( + command: ListMarketplaceDistributionsCommand, + ): Promise<ListMarketplaceDistributionsResponse> { + return this._listMarketplaceDistributionsUseCase.execute(command); + } + + async getMarketplaceDistributionChanges( + command: GetMarketplaceDistributionChangesCommand, + ): Promise<GetMarketplaceDistributionChangesResponse> { + return this._getMarketplaceDistributionChangesUseCase.execute(command); + } + + async trackPluginInstallHeartbeat( + command: TrackPluginInstallHeartbeatCommand, + ): Promise<TrackPluginInstallHeartbeatResponse> { + return this._trackPluginInstallHeartbeatUseCase.execute(command); + } + + async listMarketplacePluginInstalls( + command: ListMarketplacePluginInstallsCommand, + ): Promise<ListMarketplacePluginInstallsResponse> { + return this._listMarketplacePluginInstallsUseCase.execute(command); + } } diff --git a/packages/deployments/src/application/jobs/MarketplaceReconciliationDelayedJob.spec.ts b/packages/deployments/src/application/jobs/MarketplaceReconciliationDelayedJob.spec.ts new file mode 100644 index 000000000..b919fe6c3 --- /dev/null +++ b/packages/deployments/src/application/jobs/MarketplaceReconciliationDelayedJob.spec.ts @@ -0,0 +1,1340 @@ +import { v4 as uuidv4 } from 'uuid'; +import { stubLogger } from '@packmind/test-utils'; +import { IQueue, QueueListeners } from '@packmind/node-utils'; +import { GitRepoService } from '@packmind/git'; +import { + createGitProviderId, + createGitRepoId, + createMarketplaceDistributionId, + createMarketplaceId, + createOrganizationId, + createPackageId, + createUserId, + DistributionStatus, + GitRepo, + IGitPort, + MARKETPLACE_DESCRIPTOR_FILENAME, + Marketplace, + MarketplaceDescriptor, + MarketplaceDistribution, + MarketplaceReconciliationJobInput, + MarketplaceReconciliationJobOutput, + Package, +} from '@packmind/types'; +import { IMarketplaceDistributionRepository } from '../../domain/repositories/IMarketplaceDistributionRepository'; +import { IMarketplaceRepository } from '../../domain/repositories/IMarketplaceRepository'; +import { MarketplaceDescriptorParserRegistry } from '../services/MarketplaceDescriptorParserRegistry'; +import { PackageService } from '../services/PackageService'; +import { PackageVersionFingerprintService } from '../services/PackageVersionFingerprintService'; +import { MarketplaceReconciliationDelayedJob } from './MarketplaceReconciliationDelayedJob'; + +describe('MarketplaceReconciliationDelayedJob', () => { + const marketplaceId = createMarketplaceId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const gitRepoId = createGitRepoId(uuidv4()); + const providerId = createGitProviderId(uuidv4()); + + const baseDescriptor: MarketplaceDescriptor = { + vendor: 'anthropic', + name: 'ACME Plugins', + version: '1.0.0', + plugins: [ + { slug: 'p1', name: 'Plugin 1' }, + { slug: 'p2', name: 'Plugin 2' }, + ], + raw: { vendor: 'anthropic', name: 'ACME Plugins', plugins: [] }, + }; + + const marketplace = { + id: marketplaceId, + organizationId, + gitRepoId, + name: 'ACME Plugins', + vendor: 'anthropic', + addedBy: userId, + linkedAt: new Date('2024-01-01T00:00:00Z'), + state: 'healthy', + lastValidatedAt: null, + descriptor: baseDescriptor, + pluginCount: baseDescriptor.plugins.length, + } as unknown as Marketplace; + + const gitRepo: GitRepo = { + id: gitRepoId, + owner: 'acme', + repo: 'plugins', + branch: 'main', + providerId, + type: 'marketplace', + }; + + const input: MarketplaceReconciliationJobInput = { marketplaceId }; + + let mockMarketplaceRepository: jest.Mocked<IMarketplaceRepository>; + let mockMarketplaceDistributionRepository: jest.Mocked<IMarketplaceDistributionRepository>; + let mockGitRepoService: jest.Mocked<GitRepoService>; + let mockGitPort: jest.Mocked<IGitPort>; + let mockParserRegistry: jest.Mocked<MarketplaceDescriptorParserRegistry>; + let mockPackageService: jest.Mocked<PackageService>; + let mockVersionFingerprintService: jest.Mocked<PackageVersionFingerprintService>; + let mockQueue: jest.Mocked< + IQueue< + MarketplaceReconciliationJobInput, + MarketplaceReconciliationJobOutput + > + >; + let job: MarketplaceReconciliationDelayedJob; + + const queueFactory = async ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _listeners: Partial<QueueListeners>, + ) => mockQueue; + + beforeEach(() => { + mockMarketplaceRepository = { + findById: jest.fn().mockResolvedValue(marketplace), + updateState: jest.fn().mockResolvedValue(undefined), + findByOrganizationId: jest.fn(), + findByOrganizationAndGitRepo: jest.fn(), + findByOrganizationAndId: jest.fn(), + findAllForReconciliation: jest.fn(), + add: jest.fn(), + addMany: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + hardDeleteById: jest.fn(), + } as unknown as jest.Mocked<IMarketplaceRepository>; + + mockMarketplaceDistributionRepository = { + findSuccessfulByMarketplaceId: jest.fn().mockResolvedValue([]), + findPendingRemovalsByMarketplaceId: jest.fn().mockResolvedValue([]), + findPendingMergesByMarketplaceId: jest.fn().mockResolvedValue([]), + updateStatus: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked<IMarketplaceDistributionRepository>; + + mockGitRepoService = { + findMarketplaceGitRepoById: jest.fn().mockResolvedValue(gitRepo), + } as unknown as jest.Mocked<GitRepoService>; + + mockGitPort = { + getFileFromRepo: jest.fn(), + checkMarketplaceRepoExists: jest.fn().mockResolvedValue({ exists: true }), + findOpenSyncPullRequest: jest.fn().mockResolvedValue(null), + } as unknown as jest.Mocked<IGitPort>; + + mockParserRegistry = { + parse: jest.fn().mockReturnValue(baseDescriptor), + } as unknown as jest.Mocked<MarketplaceDescriptorParserRegistry>; + + mockPackageService = { + findById: jest.fn().mockResolvedValue(null), + } as unknown as jest.Mocked<PackageService>; + + mockVersionFingerprintService = { + compute: jest.fn().mockResolvedValue({ + recipes: {}, + standards: {}, + skills: {}, + }), + } as unknown as jest.Mocked<PackageVersionFingerprintService>; + + mockQueue = { + addJob: jest.fn().mockResolvedValue('queued-job-id'), + cancelJob: jest.fn().mockResolvedValue(undefined), + removeRepeatable: jest.fn().mockResolvedValue(undefined), + addWorker: jest.fn().mockResolvedValue(null), + } as unknown as jest.Mocked< + IQueue< + MarketplaceReconciliationJobInput, + MarketplaceReconciliationJobOutput + > + >; + + job = new MarketplaceReconciliationDelayedJob( + queueFactory, + mockMarketplaceRepository, + mockMarketplaceDistributionRepository, + mockGitRepoService, + mockGitPort, + mockParserRegistry, + mockPackageService, + mockVersionFingerprintService, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('healthy state — descriptor unchanged', () => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockResolvedValue({ + sha: 'abc', + content: JSON.stringify(baseDescriptor.raw), + }); + // Parser returns an equivalent descriptor (deep-equal modulo raw). + mockParserRegistry.parse.mockReturnValue({ + ...baseDescriptor, + raw: { reformatted: true }, + }); + + result = await job.runJob('job-1', input, new AbortController()); + }); + + it('persists state="healthy"', () => { + expect(result.state).toBe('healthy'); + }); + + it('updates state exactly once', () => { + expect(mockMarketplaceRepository.updateState).toHaveBeenCalledTimes(1); + }); + + it('updates the state for the reconciled marketplace id', () => { + const [calledId] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(calledId).toBe(marketplaceId); + }); + + it('patches the state to "healthy"', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.state).toBe('healthy'); + }); + + it('patches a fresh lastValidatedAt', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.lastValidatedAt).toBeInstanceOf(Date); + }); + + it('does not patch the descriptor', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.descriptor).toBeUndefined(); + }); + + it('does not patch the plugin count', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.pluginCount).toBeUndefined(); + }); + + it('reads the descriptor file from the marketplace-typed GitRepo', () => { + expect(mockGitPort.getFileFromRepo).toHaveBeenCalledWith( + gitRepo, + MARKETPLACE_DESCRIPTOR_FILENAME, + gitRepo.branch, + ); + }); + }); + + describe('healthy state — stored descriptor differs only by key order', () => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + // The stored baseline round-trips through a Postgres jsonb column, + // which re-sorts object keys. The parsed descriptor carries the + // parser's insertion order. The comparison must treat them as equal. + const jsonbOrderedMarketplace = { + ...marketplace, + descriptor: { + ...baseDescriptor, + plugins: [ + { name: 'Plugin 1', slug: 'p1' }, + { name: 'Plugin 2', slug: 'p2' }, + ], + }, + } as unknown as Marketplace; + mockMarketplaceRepository.findById.mockResolvedValue( + jsonbOrderedMarketplace, + ); + mockGitPort.getFileFromRepo.mockResolvedValue({ + sha: 'abc', + content: JSON.stringify(baseDescriptor.raw), + }); + mockParserRegistry.parse.mockReturnValue({ + ...baseDescriptor, + raw: { reformatted: true }, + }); + + result = await job.runJob('job-key-order', input, new AbortController()); + }); + + it('reports healthy — key order is not a descriptor change', () => { + expect(result.state).toBe('healthy'); + }); + + it('does not refresh the stored descriptor', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.descriptor).toBeUndefined(); + }); + }); + + describe('drift state — descriptor differs', () => { + const driftedDescriptor: MarketplaceDescriptor = { + vendor: 'anthropic', + name: 'ACME Plugins', + version: '2.0.0', + plugins: [ + { slug: 'p1', name: 'Plugin 1' }, + { slug: 'p2', name: 'Plugin 2' }, + { slug: 'p3', name: 'Plugin 3' }, + ], + raw: { version: '2.0.0' }, + }; + + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockResolvedValue({ + sha: 'def', + content: JSON.stringify(driftedDescriptor.raw), + }); + mockParserRegistry.parse.mockReturnValue(driftedDescriptor); + + result = await job.runJob('job-2', input, new AbortController()); + }); + + it('persists state="drift"', () => { + expect(result.state).toBe('drift'); + }); + + it('updates state exactly once', () => { + expect(mockMarketplaceRepository.updateState).toHaveBeenCalledTimes(1); + }); + + it('updates the state for the reconciled marketplace id', () => { + const [calledId] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(calledId).toBe(marketplaceId); + }); + + it('patches the state to "drift"', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.state).toBe('drift'); + }); + + it('patches the new descriptor', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + // The job spreads the descriptor with optional driftedPluginSlugs, + // so deep-equal rather than reference-equal. + expect(patch.descriptor).toMatchObject({ + vendor: driftedDescriptor.vendor, + name: driftedDescriptor.name, + version: driftedDescriptor.version, + plugins: driftedDescriptor.plugins, + }); + }); + + it('patches the new plugin count', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.pluginCount).toBe(driftedDescriptor.plugins.length); + }); + + it('patches a fresh lastValidatedAt', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.lastValidatedAt).toBeInstanceOf(Date); + }); + }); + + describe('unreachable state', () => { + describe('when getFileFromRepo throws', () => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockRejectedValue( + new Error('upstream timeout'), + ); + + result = await job.runJob('job-3', input, new AbortController()); + }); + + it('persists state="unreachable"', () => { + expect(result.state).toBe('unreachable'); + }); + + it('classifies a plain fetch failure as network_transient', () => { + expect(result.errorKind).toBe('network_transient'); + }); + + it('updates the state with a fresh lastValidatedAt', () => { + expect(mockMarketplaceRepository.updateState).toHaveBeenCalledWith( + marketplaceId, + expect.objectContaining({ + state: 'unreachable', + lastValidatedAt: expect.any(Date), + errorKind: 'network_transient', + }), + ); + }); + + it('leaves the descriptor untouched', () => { + const patch = mockMarketplaceRepository.updateState.mock.calls[0][1]; + expect(patch.descriptor).toBeUndefined(); + }); + + it('leaves the plugin count untouched', () => { + const patch = mockMarketplaceRepository.updateState.mock.calls[0][1]; + expect(patch.pluginCount).toBeUndefined(); + }); + }); + + describe('when the marketplace-typed GitRepo cannot be resolved', () => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitRepoService.findMarketplaceGitRepoById.mockResolvedValue(null); + + result = await job.runJob('job-6', input, new AbortController()); + }); + + it('persists state="unreachable"', () => { + expect(result.state).toBe('unreachable'); + }); + + it('does not fetch the descriptor file', () => { + expect(mockGitPort.getFileFromRepo).not.toHaveBeenCalled(); + }); + + it('updates the state with a fresh lastValidatedAt', () => { + expect(mockMarketplaceRepository.updateState).toHaveBeenCalledWith( + marketplaceId, + expect.objectContaining({ + state: 'unreachable', + lastValidatedAt: expect.any(Date), + errorKind: 'repo_not_found', + }), + ); + }); + }); + }); + + describe('bad_format state', () => { + describe('when getFileFromRepo returns null and the repo is reachable', () => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockResolvedValue(null); + mockGitPort.checkMarketplaceRepoExists.mockResolvedValue({ + exists: true, + }); + + result = await job.runJob('job-bf-1', input, new AbortController()); + }); + + it('persists state="bad_format"', () => { + expect(result.state).toBe('bad_format'); + }); + + it('leaves errorKind null for a broken-contract descriptor', () => { + expect(result.errorKind).toBeNull(); + }); + + it('updates the state with a fresh lastValidatedAt', () => { + expect(mockMarketplaceRepository.updateState).toHaveBeenCalledWith( + marketplaceId, + expect.objectContaining({ + state: 'bad_format', + lastValidatedAt: expect.any(Date), + errorKind: null, + }), + ); + }); + }); + + describe('when getFileFromRepo returns null and the repo is gone', () => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockResolvedValue(null); + mockGitPort.checkMarketplaceRepoExists.mockResolvedValue({ + exists: false, + reason: 'repo_not_found', + }); + + result = await job.runJob('job-bf-gone', input, new AbortController()); + }); + + it('persists state="unreachable"', () => { + expect(result.state).toBe('unreachable'); + }); + + it('classifies the failure as repo_not_found', () => { + expect(result.errorKind).toBe('repo_not_found'); + }); + + it('updates the state with errorKind=repo_not_found', () => { + expect(mockMarketplaceRepository.updateState).toHaveBeenCalledWith( + marketplaceId, + expect.objectContaining({ + state: 'unreachable', + errorKind: 'repo_not_found', + }), + ); + }); + }); + + describe('when getFileFromRepo returns null and the probe reports auth failure', () => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockResolvedValue(null); + mockGitPort.checkMarketplaceRepoExists.mockResolvedValue({ + exists: false, + reason: 'auth_failed', + }); + + result = await job.runJob('job-bf-auth', input, new AbortController()); + }); + + it('classifies the failure as auth_failed', () => { + expect(result.errorKind).toBe('auth_failed'); + }); + + it('surfaces the credential error detail', () => { + expect(result.errorDetail).toBe( + 'The marketplace credentials are invalid or expired. Reconnect the Git provider.', + ); + }); + }); + + describe('when getFileFromRepo returns null and the probe reports no reason', () => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockResolvedValue(null); + mockGitPort.checkMarketplaceRepoExists.mockResolvedValue({ + exists: false, + }); + + result = await job.runJob( + 'job-bf-noreason', + input, + new AbortController(), + ); + }); + + it('defaults the classification to network_transient', () => { + expect(result.errorKind).toBe('network_transient'); + }); + + it('surfaces the transient error detail', () => { + expect(result.errorDetail).toBe( + 'The marketplace repository is temporarily unreachable.', + ); + }); + }); + + describe('when the parser throws (descriptor unparseable)', () => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockResolvedValue({ + sha: 'abc', + content: 'not really json', + }); + mockParserRegistry.parse.mockImplementation(() => { + throw new Error('parse error'); + }); + + result = await job.runJob('job-bf-2', input, new AbortController()); + }); + + it('persists state="bad_format"', () => { + expect(result.state).toBe('bad_format'); + }); + + it('updates the state with a fresh lastValidatedAt', () => { + expect(mockMarketplaceRepository.updateState).toHaveBeenCalledWith( + marketplaceId, + expect.objectContaining({ + state: 'bad_format', + lastValidatedAt: expect.any(Date), + errorKind: null, + }), + ); + }); + }); + }); + + describe('credential failures', () => { + describe.each([401, 403])('when getFileFromRepo throws %i', (status) => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockRejectedValue({ response: { status } }); + result = await job.runJob('job-auth', input, new AbortController()); + }); + + it('classifies the failure as auth_failed', () => { + expect(result.errorKind).toBe('auth_failed'); + }); + + it('persists state="unreachable" with errorKind=auth_failed', () => { + expect(mockMarketplaceRepository.updateState).toHaveBeenCalledWith( + marketplaceId, + expect.objectContaining({ + state: 'unreachable', + errorKind: 'auth_failed', + }), + ); + }); + }); + }); + + describe('error paths carry forward last-known live-state fields', () => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockMarketplaceRepository.findById.mockResolvedValue({ + ...marketplace, + pendingPrUrl: 'https://github.com/acme/plugins/pull/5', + outdatedPluginSlugs: ['p1'], + }); + mockGitPort.getFileFromRepo.mockRejectedValue( + new Error('upstream timeout'), + ); + + result = await job.runJob('job-carry', input, new AbortController()); + }); + + it('echoes the last-known pending PR url on a fetch failure', () => { + expect(result.pendingPrUrl).toBe( + 'https://github.com/acme/plugins/pull/5', + ); + }); + + it('echoes the last-known outdated slugs on a fetch failure', () => { + expect(result.outdatedPluginSlugs).toEqual(['p1']); + }); + }); + + describe('healthy reconcile clears error fields', () => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockResolvedValue({ + sha: 'abc', + content: JSON.stringify(baseDescriptor.raw), + }); + mockParserRegistry.parse.mockReturnValue({ + ...baseDescriptor, + raw: { reformatted: true }, + }); + result = await job.runJob('job-healthy', input, new AbortController()); + }); + + it('returns a null errorKind', () => { + expect(result.errorKind).toBeNull(); + }); + + it('patches errorKind and errorDetail to null', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch).toMatchObject({ errorKind: null, errorDetail: null }); + }); + }); + + describe('pending sync PR', () => { + beforeEach(() => { + mockGitPort.getFileFromRepo.mockResolvedValue({ + sha: 'abc', + content: JSON.stringify(baseDescriptor.raw), + }); + mockParserRegistry.parse.mockReturnValue({ + ...baseDescriptor, + raw: { reformatted: true }, + }); + }); + + describe('when an open sync PR exists', () => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitPort.findOpenSyncPullRequest.mockResolvedValue({ + url: 'https://github.com/acme/market/pull/7', + number: 7, + }); + result = await job.runJob('job-pr', input, new AbortController()); + }); + + it('returns the pending PR url', () => { + expect(result.pendingPrUrl).toBe( + 'https://github.com/acme/market/pull/7', + ); + }); + + it('persists the pending PR url', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.pendingPrUrl).toBe( + 'https://github.com/acme/market/pull/7', + ); + }); + }); + + describe('when no open sync PR exists', () => { + it('clears the pending PR url to null', async () => { + mockGitPort.findOpenSyncPullRequest.mockResolvedValue(null); + const result = await job.runJob( + 'job-pr2', + input, + new AbortController(), + ); + expect(result.pendingPrUrl).toBeNull(); + }); + }); + + describe('when the sync PR lookup throws', () => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockMarketplaceRepository.findById.mockResolvedValue({ + ...marketplace, + pendingPrUrl: 'https://github.com/acme/market/pull/3', + }); + mockGitPort.findOpenSyncPullRequest.mockRejectedValue( + new Error('rate limited'), + ); + result = await job.runJob('job-pr3', input, new AbortController()); + }); + + it('does not flip the marketplace to unreachable', () => { + expect(result.state).toBe('healthy'); + }); + + it('falls back to the last known pending PR url', () => { + expect(result.pendingPrUrl).toBe( + 'https://github.com/acme/market/pull/3', + ); + }); + }); + }); + + describe('outdated plugin detection', () => { + const packageId = createPackageId(uuidv4()); + + const makeSuccessDistribution = ( + over: Partial<MarketplaceDistribution> = {}, + ): MarketplaceDistribution => + ({ + id: createMarketplaceDistributionId(uuidv4()), + organizationId, + marketplaceId, + packageId, + pluginSlug: 'p1', + authorId: userId, + status: DistributionStatus.success, + source: 'app', + createdAt: new Date('2026-01-02T00:00:00Z'), + ...over, + }) as unknown as MarketplaceDistribution; + + beforeEach(() => { + // Healthy descriptor read so the success path is exercised. + mockGitPort.getFileFromRepo.mockResolvedValue({ + sha: 'abc', + content: JSON.stringify(baseDescriptor.raw), + }); + mockParserRegistry.parse.mockReturnValue({ + ...baseDescriptor, + raw: { reformatted: true }, + }); + mockPackageService.findById.mockResolvedValue({ + id: packageId, + recipes: [], + standards: [], + skills: [], + } as unknown as Package); + }); + + describe('when the current fingerprint differs from the published one', () => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findSuccessfulByMarketplaceId.mockResolvedValue( + [ + makeSuccessDistribution({ + versionFingerprint: { + recipes: { r: 1 }, + standards: {}, + skills: {}, + }, + }), + ], + ); + mockVersionFingerprintService.compute.mockResolvedValue({ + recipes: { r: 2 }, + standards: {}, + skills: {}, + }); + result = await job.runJob('job-out', input, new AbortController()); + }); + + it('flags the plugin slug as outdated', () => { + expect(result.outdatedPluginSlugs).toEqual(['p1']); + }); + + it('persists the outdated slugs', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.outdatedPluginSlugs).toEqual(['p1']); + }); + }); + + describe('when the current fingerprint equals the published one', () => { + it('reports no outdated plugins', async () => { + mockMarketplaceDistributionRepository.findSuccessfulByMarketplaceId.mockResolvedValue( + [ + makeSuccessDistribution({ + versionFingerprint: { + recipes: { r: 1 }, + standards: {}, + skills: {}, + }, + }), + ], + ); + mockVersionFingerprintService.compute.mockResolvedValue({ + recipes: { r: 1 }, + standards: {}, + skills: {}, + }); + const result = await job.runJob( + 'job-out2', + input, + new AbortController(), + ); + expect(result.outdatedPluginSlugs).toBeNull(); + }); + }); + + describe('when the distribution predates fingerprints', () => { + it('never marks the plugin outdated', async () => { + mockMarketplaceDistributionRepository.findSuccessfulByMarketplaceId.mockResolvedValue( + [makeSuccessDistribution({ versionFingerprint: undefined })], + ); + const result = await job.runJob( + 'job-out3', + input, + new AbortController(), + ); + expect(result.outdatedPluginSlugs).toBeNull(); + }); + }); + + describe('when a package has multiple success distributions', () => { + it('compares only the most recent one', async () => { + mockMarketplaceDistributionRepository.findSuccessfulByMarketplaceId.mockResolvedValue( + [ + makeSuccessDistribution({ + createdAt: new Date('2026-02-01T00:00:00Z'), + versionFingerprint: { + recipes: { r: 2 }, + standards: {}, + skills: {}, + }, + }), + makeSuccessDistribution({ + createdAt: new Date('2026-01-01T00:00:00Z'), + versionFingerprint: { + recipes: { r: 1 }, + standards: {}, + skills: {}, + }, + }), + ], + ); + mockVersionFingerprintService.compute.mockResolvedValue({ + recipes: { r: 2 }, + standards: {}, + skills: {}, + }); + const result = await job.runJob( + 'job-out4', + input, + new AbortController(), + ); + expect(result.outdatedPluginSlugs).toBeNull(); + }); + }); + }); + + describe('soft-deleted marketplace', () => { + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockMarketplaceRepository.findById.mockResolvedValue(null); + + result = await job.runJob('job-7', input, new AbortController()); + }); + + it('returns state="unreachable"', () => { + expect(result.state).toBe('unreachable'); + }); + + it('does not resolve the marketplace-typed GitRepo', () => { + expect( + mockGitRepoService.findMarketplaceGitRepoById, + ).not.toHaveBeenCalled(); + }); + + it('does not fetch the descriptor', () => { + expect(mockGitPort.getFileFromRepo).not.toHaveBeenCalled(); + }); + + it('does not update state', () => { + expect(mockMarketplaceRepository.updateState).not.toHaveBeenCalled(); + }); + }); + + describe('distribution cross-check', () => { + const distributionPackageId = createPackageId(uuidv4()); + + const buildSuccessDistribution = (slug: string): MarketplaceDistribution => + ({ + id: createMarketplaceDistributionId(uuidv4()), + organizationId, + marketplaceId, + packageId: distributionPackageId, + pluginSlug: slug, + authorId: userId, + status: DistributionStatus.success, + source: 'app', + }) as unknown as MarketplaceDistribution; + + const buildPendingRemovalDistribution = ( + slug: string, + ): MarketplaceDistribution => + ({ + id: createMarketplaceDistributionId(uuidv4()), + organizationId, + marketplaceId, + packageId: distributionPackageId, + pluginSlug: slug, + authorId: userId, + status: DistributionStatus.to_be_removed, + source: 'app', + }) as unknown as MarketplaceDistribution; + + const buildPendingMergeDistribution = ( + slug: string, + contentHash: string, + ): MarketplaceDistribution => + ({ + id: createMarketplaceDistributionId(uuidv4()), + organizationId, + marketplaceId, + packageId: distributionPackageId, + pluginSlug: slug, + authorId: userId, + status: DistributionStatus.pending_merge, + source: 'app', + contentHash, + }) as unknown as MarketplaceDistribution; + + const buildLockContent = (plugins: Record<string, string>): string => + JSON.stringify({ + schemaVersion: 1, + plugins: Object.fromEntries( + Object.entries(plugins).map(([slug, contentHash]) => [ + slug, + { + version: '1.0.0', + contentHash, + lastPublishedAt: '2024-01-01T00:00:00Z', + lastPublishedBy: userId, + }, + ]), + ), + }); + + describe('pending_merge → success promotion', () => { + describe('when a first publish landed on the default branch (lock hash matches)', () => { + const pendingMerge = buildPendingMergeDistribution('p3', 'hash-p3'); + const mergedDescriptor: MarketplaceDescriptor = { + ...baseDescriptor, + plugins: [ + ...baseDescriptor.plugins, + { slug: 'p3', name: 'Plugin 3' }, + ], + }; + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockImplementation(async (_repo, path) => + path === 'packmind-lock.json' + ? { + sha: 'lock-sha', + content: buildLockContent({ p3: 'hash-p3' }), + } + : { + sha: 'abc', + content: JSON.stringify(mergedDescriptor.raw), + }, + ); + mockParserRegistry.parse.mockReturnValue(mergedDescriptor); + mockMarketplaceDistributionRepository.findPendingMergesByMarketplaceId.mockResolvedValue( + [pendingMerge], + ); + + result = await job.runJob('job-pm1', input, new AbortController()); + }); + + it('promotes the pending row to success with a publishConfirmedAt stamp', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith(pendingMerge.id, { + status: DistributionStatus.success, + publishConfirmedAt: expect.any(Date), + }); + }); + + it('reports healthy — the confirmed publish explains the descriptor diff', () => { + expect(result.state).toBe('healthy'); + }); + + it('persists the refreshed descriptor as the new comparison baseline', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.pluginCount).toBe(mergedDescriptor.plugins.length); + }); + }); + + describe('when an update publish landed (slug already in descriptor, lock hash matches)', () => { + const pendingMerge = buildPendingMergeDistribution('p1', 'hash-p1-v2'); + + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockImplementation(async (_repo, path) => + path === 'packmind-lock.json' + ? { + sha: 'lock-sha', + content: buildLockContent({ p1: 'hash-p1-v2' }), + } + : { + sha: 'abc', + content: JSON.stringify(baseDescriptor.raw), + }, + ); + mockParserRegistry.parse.mockReturnValue({ + ...baseDescriptor, + raw: { reformatted: true }, + }); + mockMarketplaceDistributionRepository.findPendingMergesByMarketplaceId.mockResolvedValue( + [pendingMerge], + ); + + await job.runJob('job-pm2', input, new AbortController()); + }); + + it('promotes the pending row to success', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith(pendingMerge.id, { + status: DistributionStatus.success, + publishConfirmedAt: expect.any(Date), + }); + }); + }); + + describe('when the lock still carries the previous content hash (PR not merged)', () => { + const pendingMerge = buildPendingMergeDistribution('p1', 'hash-p1-v2'); + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockImplementation(async (_repo, path) => + path === 'packmind-lock.json' + ? { + sha: 'lock-sha', + content: buildLockContent({ p1: 'hash-p1-v1' }), + } + : { + sha: 'abc', + content: JSON.stringify(baseDescriptor.raw), + }, + ); + mockParserRegistry.parse.mockReturnValue({ + ...baseDescriptor, + raw: { reformatted: true }, + }); + mockMarketplaceDistributionRepository.findPendingMergesByMarketplaceId.mockResolvedValue( + [pendingMerge], + ); + + result = await job.runJob('job-pm3', input, new AbortController()); + }); + + it('leaves the row in pending_merge', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).not.toHaveBeenCalled(); + }); + + it('keeps the marketplace healthy', () => { + expect(result.state).toBe('healthy'); + }); + }); + + describe('when the lock file cannot be read', () => { + const pendingMerge = buildPendingMergeDistribution('p1', 'hash-p1-v2'); + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockImplementation( + async (_repo, path) => { + if (path === 'packmind-lock.json') { + throw new Error('network blip'); + } + return { + sha: 'abc', + content: JSON.stringify(baseDescriptor.raw), + }; + }, + ); + mockParserRegistry.parse.mockReturnValue({ + ...baseDescriptor, + raw: { reformatted: true }, + }); + mockMarketplaceDistributionRepository.findPendingMergesByMarketplaceId.mockResolvedValue( + [pendingMerge], + ); + + result = await job.runJob('job-pm4', input, new AbortController()); + }); + + it('postpones the confirmation instead of failing the reconcile', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).not.toHaveBeenCalled(); + }); + + it('still completes the reconcile as healthy', () => { + expect(result.state).toBe('healthy'); + }); + }); + + describe('when no pending merges exist', () => { + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockResolvedValue({ + sha: 'abc', + content: JSON.stringify(baseDescriptor.raw), + }); + mockParserRegistry.parse.mockReturnValue({ + ...baseDescriptor, + raw: { reformatted: true }, + }); + + await job.runJob('job-pm5', input, new AbortController()); + }); + + it('does not fetch the lock file', () => { + expect(mockGitPort.getFileFromRepo).not.toHaveBeenCalledWith( + gitRepo, + 'packmind-lock.json', + gitRepo.branch, + ); + }); + }); + }); + + describe('to_be_removed → removed terminal transition', () => { + const pending = buildPendingRemovalDistribution('p1'); + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + // Descriptor no longer carries the slug. + const descriptorWithoutP1: MarketplaceDescriptor = { + ...baseDescriptor, + plugins: [{ slug: 'p2', name: 'Plugin 2' }], + }; + mockGitPort.getFileFromRepo.mockResolvedValue({ + sha: 'abc', + content: JSON.stringify(descriptorWithoutP1.raw), + }); + mockParserRegistry.parse.mockReturnValue(descriptorWithoutP1); + mockMarketplaceDistributionRepository.findPendingRemovalsByMarketplaceId.mockResolvedValue( + [pending], + ); + mockMarketplaceDistributionRepository.findSuccessfulByMarketplaceId.mockResolvedValue( + [], + ); + + result = await job.runJob('job-x1', input, new AbortController()); + }); + + it('flips the distribution to removed', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith(pending.id, { + status: DistributionStatus.removed, + }); + }); + + it('does not stamp driftedPluginSlugs on this signal alone', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.descriptor?.driftedPluginSlugs).toBeUndefined(); + }); + + it('reports healthy — the confirmed removal explains the descriptor diff', () => { + expect(result.state).toBe('healthy'); + }); + + it('persists the refreshed descriptor as the new comparison baseline', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.pluginCount).toBe(1); + }); + }); + + describe('drift detection on missing slug without a paired to_be_removed (AC9)', () => { + const live = buildSuccessDistribution('p1'); + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + // Descriptor is missing p1 but otherwise structurally similar. + const driftDescriptor: MarketplaceDescriptor = { + ...baseDescriptor, + plugins: [{ slug: 'p2', name: 'Plugin 2' }], + }; + mockGitPort.getFileFromRepo.mockResolvedValue({ + sha: 'abc', + content: JSON.stringify(driftDescriptor.raw), + }); + mockParserRegistry.parse.mockReturnValue(driftDescriptor); + mockMarketplaceDistributionRepository.findSuccessfulByMarketplaceId.mockResolvedValue( + [live], + ); + mockMarketplaceDistributionRepository.findPendingRemovalsByMarketplaceId.mockResolvedValue( + [], + ); + + result = await job.runJob('job-x2', input, new AbortController()); + }); + + it('marks the marketplace as drift', () => { + expect(result.state).toBe('drift'); + }); + + it('persists driftedPluginSlugs containing the missing slug', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.descriptor?.driftedPluginSlugs).toEqual(['p1']); + }); + }); + + describe('drift detection dedup across republishes of the same plugin', () => { + // Each republish writes a new `success` row for the same plugin slug. + // The drift list must collapse them so the banner / state badge shows + // one entry per plugin, not one entry per historical publish. + const liveA = buildSuccessDistribution('p1'); + const liveB = buildSuccessDistribution('p1'); + const liveC = buildSuccessDistribution('p1'); + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + const descriptorWithoutP1: MarketplaceDescriptor = { + ...baseDescriptor, + plugins: [{ slug: 'p2', name: 'Plugin 2' }], + }; + mockGitPort.getFileFromRepo.mockResolvedValue({ + sha: 'abc', + content: JSON.stringify(descriptorWithoutP1.raw), + }); + mockParserRegistry.parse.mockReturnValue(descriptorWithoutP1); + mockMarketplaceDistributionRepository.findSuccessfulByMarketplaceId.mockResolvedValue( + [liveA, liveB, liveC], + ); + mockMarketplaceDistributionRepository.findPendingRemovalsByMarketplaceId.mockResolvedValue( + [], + ); + + result = await job.runJob('job-x2b', input, new AbortController()); + }); + + it('still flags the marketplace as drift', () => { + expect(result.state).toBe('drift'); + }); + + it('reports the slug only once in driftedPluginSlugs', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.descriptor?.driftedPluginSlugs).toEqual(['p1']); + }); + }); + + describe('drift suppression when a to_be_removed exists for the slug (AC10)', () => { + const live = buildSuccessDistribution('p1'); + const pending = buildPendingRemovalDistribution('p1'); + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + const descriptorWithoutP1: MarketplaceDescriptor = { + ...baseDescriptor, + plugins: [{ slug: 'p2', name: 'Plugin 2' }], + }; + mockGitPort.getFileFromRepo.mockResolvedValue({ + sha: 'abc', + content: JSON.stringify(descriptorWithoutP1.raw), + }); + mockParserRegistry.parse.mockReturnValue(descriptorWithoutP1); + mockMarketplaceDistributionRepository.findSuccessfulByMarketplaceId.mockResolvedValue( + [live], + ); + mockMarketplaceDistributionRepository.findPendingRemovalsByMarketplaceId.mockResolvedValue( + [pending], + ); + + result = await job.runJob('job-x3', input, new AbortController()); + }); + + it('does not include p1 in driftedPluginSlugs', () => { + const [, patch] = mockMarketplaceRepository.updateState.mock.calls[0]; + expect(patch.descriptor?.driftedPluginSlugs).toBeUndefined(); + }); + + it('still transitions the pending row to removed (terminal)', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith(pending.id, { + status: DistributionStatus.removed, + }); + }); + + it('reports healthy — the confirmed removal explains the descriptor diff', () => { + expect(result.state).toBe('healthy'); + }); + }); + + describe('healthy retained when descriptor matches and all distributions accounted for', () => { + const live = buildSuccessDistribution('p1'); + let result: MarketplaceReconciliationJobOutput; + + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockResolvedValue({ + sha: 'abc', + content: JSON.stringify(baseDescriptor.raw), + }); + mockParserRegistry.parse.mockReturnValue({ + ...baseDescriptor, + raw: { reformatted: true }, + }); + mockMarketplaceDistributionRepository.findSuccessfulByMarketplaceId.mockResolvedValue( + [live], + ); + mockMarketplaceDistributionRepository.findPendingRemovalsByMarketplaceId.mockResolvedValue( + [], + ); + + result = await job.runJob('job-x4', input, new AbortController()); + }); + + it('persists state="healthy"', () => { + expect(result.state).toBe('healthy'); + }); + + it('does not write any distribution status update', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).not.toHaveBeenCalled(); + }); + }); + }); + + describe('getJobName', () => { + it('returns a stable name keyed by marketplaceId', () => { + const name = job.getJobName({ marketplaceId }); + expect(name).toBe(`marketplace-reconciliation-${marketplaceId}`); + }); + }); +}); diff --git a/packages/deployments/src/application/jobs/MarketplaceReconciliationDelayedJob.ts b/packages/deployments/src/application/jobs/MarketplaceReconciliationDelayedJob.ts new file mode 100644 index 000000000..f42c219a9 --- /dev/null +++ b/packages/deployments/src/application/jobs/MarketplaceReconciliationDelayedJob.ts @@ -0,0 +1,785 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + AbstractAIDelayedJob, + getErrorMessage, + IQueue, + QueueListeners, + WorkerListeners, +} from '@packmind/node-utils'; +import { GitRepoService } from '@packmind/git'; +import { + DistributionStatus, + IGitPort, + MARKETPLACE_DESCRIPTOR_FILENAME, + MarketplaceDescriptor, + MarketplaceErrorKind, + MarketplaceId, + MarketplaceReconciliationJobInput, + MarketplaceReconciliationJobOutput, + MarketplaceState, + PackmindMarketplaceLock, + versionFingerprintsEqual, +} from '@packmind/types'; +import { Job } from 'bullmq'; +import { IMarketplaceDistributionRepository } from '../../domain/repositories/IMarketplaceDistributionRepository'; +import { IMarketplaceRepository } from '../../domain/repositories/IMarketplaceRepository'; +import { fetchMarketplaceDescriptorFile } from '../services/fetchMarketplaceDescriptorFile'; +import { fetchPackmindMarketplaceLock } from '../services/packmindMarketplaceLock'; +import { MARKETPLACE_SYNC_BRANCH } from '../services/marketplaceSyncPullRequest'; +import { MarketplaceDescriptorParserRegistry } from '../services/MarketplaceDescriptorParserRegistry'; +import { PackageService } from '../services/PackageService'; +import { PackageVersionFingerprintService } from '../services/PackageVersionFingerprintService'; + +/** + * Default cron pattern for the marketplace reconciliation sweep. Configurable + * at runtime via the `MARKETPLACE_RECONCILIATION_CRON` env var; falls back to + * every 30 minutes to match the spec. + */ +export const DEFAULT_MARKETPLACE_RECONCILIATION_CRON = '*/30 * * * *'; + +/** + * Compute the deterministic BullMQ `jobId` used for a marketplace's + * repeatable reconciliation schedule. Exposed so `UnlinkMarketplaceUseCase` + * can target the same id without hard-coding the format inline. + */ +export function buildMarketplaceReconciliationJobId( + marketplaceId: MarketplaceId, +): string { + return `marketplace-reconciliation:${marketplaceId}`; +} + +/** + * Resolve the active reconciliation cron pattern from the environment. + */ +export function resolveMarketplaceReconciliationCron(): string { + const fromEnv = process.env['MARKETPLACE_RECONCILIATION_CRON']; + if (fromEnv && fromEnv.trim().length > 0) { + return fromEnv; + } + return DEFAULT_MARKETPLACE_RECONCILIATION_CRON; +} + +const logOrigin = 'MarketplaceReconciliationDelayedJob'; + +/** + * Periodic reconciliation job for an org-linked marketplace. + * + * For each scheduled run the worker: + * 1. Loads the marketplace row by id. If soft-deleted or missing → skip + * (return the current/known state with `lastValidatedAt = now`). + * 2. Resolves the marketplace-typed `GitRepo` via `GitRepoService`. Missing + * repo → `state='unreachable'`. + * 3. Fetches `marketplace.json` via `IGitPort.getFileFromRepo`. Any fetch + * failure (network, 404, etc.) → `state='unreachable'`, descriptor + * unchanged. + * 4. Parses the file via `MarketplaceDescriptorParserRegistry`. Parser + * failures map to `state='unreachable'` (closest meaningful state per + * spec — the descriptor is no longer parseable from the source). + * 5. Confirms pending sync transitions against the default branch: + * - `pending_merge → success` when the `packmind-lock.json` entry's + * contentHash matches the row's (the rolling sync PR merged); + * stamps `publishConfirmedAt`. + * - `to_be_removed → removed` when the slug left the descriptor. + * 6. Diff against the stored descriptor (deep-equal modulo `raw`): + * - identical → `state='healthy'` + * - changed but explained by transitions confirmed in step 5 → + * `state='healthy'`; persist new descriptor + new pluginCount + * - changed → `state='drift'`; persist new descriptor + new + * pluginCount + * 7. Persist via `IMarketplaceRepository.updateState`. + * + * PII compliance: marketplace name, owner/repo and ids are safe to log; any + * potential PII (e.g., committer email lifted from descriptor metadata) is + * never surfaced in this job's logs. See + * `standard-compliance-logging-personal-information.md`. + */ +export class MarketplaceReconciliationDelayedJob extends AbstractAIDelayedJob< + MarketplaceReconciliationJobInput, + MarketplaceReconciliationJobOutput +> { + readonly origin = logOrigin; + + constructor( + queueFactory: ( + queueListeners: Partial<QueueListeners>, + ) => Promise< + IQueue< + MarketplaceReconciliationJobInput, + MarketplaceReconciliationJobOutput + > + >, + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly marketplaceDistributionRepository: IMarketplaceDistributionRepository, + private readonly gitRepoService: GitRepoService, + private readonly gitPort: IGitPort, + private readonly parserRegistry: MarketplaceDescriptorParserRegistry, + private readonly packageService: PackageService, + private readonly versionFingerprintService: PackageVersionFingerprintService, + logger: PackmindLogger = new PackmindLogger(logOrigin), + ) { + super(queueFactory, logger); + } + + async onFail(jobId: string): Promise<void> { + this.logger.error( + `[${this.origin}] Job ${jobId} failed — marketplace state was not updated`, + ); + } + + /** + * Schedule (or re-arm) the repeatable reconciliation cron for a marketplace. + * + * The repeatable job is keyed by a deterministic `jobId` derived from the + * marketplaceId so re-running the link path is idempotent — BullMQ will + * overwrite the existing schedule rather than stacking duplicates. + */ + async scheduleRecurring( + marketplaceId: MarketplaceId, + pattern: string = resolveMarketplaceReconciliationCron(), + ): Promise<void> { + await this.initialize(); + const jobId = buildMarketplaceReconciliationJobId(marketplaceId); + await this.queue.addJob( + this.getJobName({ marketplaceId }), + { marketplaceId, timeout: this.DEFAULT_JOB_TIMEOUT }, + { + jobId, + repeat: { pattern }, + }, + ); + this.logger.info( + `[${this.origin}] Scheduled repeatable reconciliation for ${marketplaceId}`, + { marketplaceId, pattern, jobId }, + ); + } + + /** + * Cancel the repeatable reconciliation cron for a marketplace. + * + * Used by `UnlinkMarketplaceUseCase` so an unlinked marketplace stops + * accumulating background work. Safe to call when no schedule exists — the + * underlying BullMQ remove is a no-op in that case. + */ + async cancelRecurring( + marketplaceId: MarketplaceId, + pattern: string = resolveMarketplaceReconciliationCron(), + ): Promise<void> { + await this.initialize(); + const jobId = buildMarketplaceReconciliationJobId(marketplaceId); + await this.queue.removeRepeatable( + this.getJobName({ marketplaceId }), + pattern, + jobId, + ); + this.logger.info( + `[${this.origin}] Removed repeatable reconciliation for ${marketplaceId}`, + { marketplaceId, pattern, jobId }, + ); + } + + /** + * Run a reconciliation sweep synchronously, outside the BullMQ worker, and + * return the resulting state. Backs the member-triggered "Sync now" action so + * the caller gets the fresh marketplace state in-band instead of waiting for + * the next cron tick. Reuses the exact `runJob` logic — no duplication. The + * abort controller is unused by `runJob`; a fresh one keeps the signature + * satisfied. + */ + async reconcileNow( + marketplaceId: MarketplaceId, + ): Promise<MarketplaceReconciliationJobOutput> { + return this.runJob( + `manual-reconcile-${marketplaceId}`, + { marketplaceId }, + new AbortController(), + ); + } + + async runJob( + jobId: string, + input: MarketplaceReconciliationJobInput, + _controller: AbortController, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise<MarketplaceReconciliationJobOutput> { + const lastValidatedAt = new Date(); + this.logger.info( + `[${this.origin}] Reconciling marketplace ${input.marketplaceId}`, + { jobId, marketplaceId: input.marketplaceId }, + ); + + // Step 1 — Load the marketplace. + const marketplace = await this.marketplaceRepository.findById( + input.marketplaceId, + ); + if (!marketplace) { + this.logger.info( + `[${this.origin}] Marketplace ${input.marketplaceId} not found or soft-deleted — skipping`, + { marketplaceId: input.marketplaceId }, + ); + return { + state: 'unreachable', + lastValidatedAt, + errorKind: null, + errorDetail: null, + pendingPrUrl: null, + outdatedPluginSlugs: null, + }; + } + + // Step 2 — Resolve the underlying marketplace-typed GitRepo. + const gitRepo = await this.gitRepoService.findMarketplaceGitRepoById( + marketplace.gitRepoId, + ); + if (!gitRepo) { + this.logger.warn( + `[${this.origin}] Marketplace GitRepo missing for marketplace ${marketplace.id}`, + { + marketplaceId: marketplace.id, + gitRepoId: marketplace.gitRepoId, + }, + ); + await this.marketplaceRepository.updateState(marketplace.id, { + state: 'unreachable', + lastValidatedAt, + errorKind: 'repo_not_found', + errorDetail: ERROR_DETAIL_REPO_NOT_FOUND, + }); + return { + state: 'unreachable', + lastValidatedAt, + errorKind: 'repo_not_found', + errorDetail: ERROR_DETAIL_REPO_NOT_FOUND, + pendingPrUrl: marketplace.pendingPrUrl, + outdatedPluginSlugs: marketplace.outdatedPluginSlugs, + }; + } + + // Step 3 — Fetch the descriptor from git, probing the official + // `.claude-plugin/marketplace.json` path first and falling back to a bare + // root `marketplace.json`. + let descriptorFile: Awaited< + ReturnType<typeof fetchMarketplaceDescriptorFile> + >; + try { + descriptorFile = await fetchMarketplaceDescriptorFile( + this.gitPort, + gitRepo, + gitRepo.branch, + ); + } catch (error) { + this.logger.warn( + `[${this.origin}] Failed to fetch ${MARKETPLACE_DESCRIPTOR_FILENAME} for marketplace ${marketplace.id}`, + { + marketplaceId: marketplace.id, + gitRepoId: marketplace.gitRepoId, + owner: gitRepo.owner, + repo: gitRepo.repo, + error: getErrorMessage(error), + }, + ); + const { errorKind, errorDetail } = classifyFetchError(error); + await this.marketplaceRepository.updateState(marketplace.id, { + state: 'unreachable', + lastValidatedAt, + errorKind, + errorDetail, + }); + return { + state: 'unreachable', + lastValidatedAt, + errorKind, + errorDetail, + pendingPrUrl: marketplace.pendingPrUrl, + outdatedPluginSlugs: marketplace.outdatedPluginSlugs, + }; + } + + if (!descriptorFile) { + // A missing descriptor file is ambiguous: the repo may be gone/renamed + // (404 on the contents path) or genuinely reachable with a broken + // contract. Probe the repo itself to tell the two apart. + const reachability = + await this.gitPort.checkMarketplaceRepoExists(gitRepo); + if (!reachability.exists) { + const errorKind: MarketplaceErrorKind = + reachability.reason ?? 'network_transient'; + const errorDetail = + errorKind === 'repo_not_found' + ? ERROR_DETAIL_REPO_NOT_FOUND + : errorKind === 'auth_failed' + ? ERROR_DETAIL_AUTH_FAILED + : ERROR_DETAIL_NETWORK_TRANSIENT; + this.logger.warn( + `[${this.origin}] Marketplace ${marketplace.id} repo unreachable (${errorKind})`, + { + marketplaceId: marketplace.id, + owner: gitRepo.owner, + repo: gitRepo.repo, + }, + ); + await this.marketplaceRepository.updateState(marketplace.id, { + state: 'unreachable', + lastValidatedAt, + errorKind, + errorDetail, + }); + return { + state: 'unreachable', + lastValidatedAt, + errorKind, + errorDetail, + pendingPrUrl: marketplace.pendingPrUrl, + outdatedPluginSlugs: marketplace.outdatedPluginSlugs, + }; + } + + // The repository was reachable but the descriptor file itself is + // missing — this is a broken contract on the marketplace side, not a + // transient network failure. Surface it as `bad_format` so admins can + // tell the two apart from the marketplace list. + this.logger.warn( + `[${this.origin}] ${MARKETPLACE_DESCRIPTOR_FILENAME} missing for marketplace ${marketplace.id}`, + { + marketplaceId: marketplace.id, + gitRepoId: marketplace.gitRepoId, + owner: gitRepo.owner, + repo: gitRepo.repo, + }, + ); + await this.marketplaceRepository.updateState(marketplace.id, { + state: 'bad_format', + lastValidatedAt, + errorKind: null, + errorDetail: ERROR_DETAIL_BAD_FORMAT, + }); + return { + state: 'bad_format', + lastValidatedAt, + errorKind: null, + errorDetail: ERROR_DETAIL_BAD_FORMAT, + pendingPrUrl: marketplace.pendingPrUrl, + outdatedPluginSlugs: marketplace.outdatedPluginSlugs, + }; + } + + // Step 4 — Parse the descriptor. + let descriptor: MarketplaceDescriptor; + try { + descriptor = this.parserRegistry.parse(descriptorFile.content); + } catch (error) { + // The file was reachable but unparseable — same broken-contract bucket + // as a missing descriptor. Distinguishes from network/auth failures. + this.logger.warn( + `[${this.origin}] Failed to parse marketplace descriptor for ${marketplace.id}`, + { + marketplaceId: marketplace.id, + gitRepoId: marketplace.gitRepoId, + owner: gitRepo.owner, + repo: gitRepo.repo, + error: getErrorMessage(error), + }, + ); + await this.marketplaceRepository.updateState(marketplace.id, { + state: 'bad_format', + lastValidatedAt, + errorKind: null, + errorDetail: ERROR_DETAIL_BAD_FORMAT, + }); + return { + state: 'bad_format', + lastValidatedAt, + errorKind: null, + errorDetail: ERROR_DETAIL_BAD_FORMAT, + pendingPrUrl: marketplace.pendingPrUrl, + outdatedPluginSlugs: marketplace.outdatedPluginSlugs, + }; + } + + // Step 5 — Cross-check `MarketplaceDistribution` rows against the + // descriptor's slug set. Three outcomes: + // (a) `pending_merge` rows whose contentHash matches the default-branch + // `packmind-lock.json` entry are promoted to `success` — proof the + // rolling sync PR merged. Done first so the freshly confirmed rows + // participate in drift/outdated detection below. + // (b) `to_be_removed` rows whose slug is no longer in the descriptor + // transition to terminal `removed` (no domain event). + // (c) `success` rows whose slug is absent AND not covered by a + // `to_be_removed` row contribute to drift detection. + const descriptorSlugs = new Set(descriptor.plugins.map((p) => p.slug)); + + // (a) Promote `pending_merge → success` for publishes that landed on the + // default branch. Best-effort: an unreadable lock postpones confirmation + // to the next sweep and must not fail the reconcile. + const pendingMergeDistributions = + await this.marketplaceDistributionRepository.findPendingMergesByMarketplaceId( + marketplace.id, + ); + let defaultBranchLock: PackmindMarketplaceLock | null = null; + if (pendingMergeDistributions.length > 0) { + try { + defaultBranchLock = await fetchPackmindMarketplaceLock( + this.gitPort, + gitRepo, + gitRepo.branch, + ); + } catch (error) { + this.logger.warn( + `[${this.origin}] Failed to read packmind-lock.json while confirming pending publishes`, + { marketplaceId: marketplace.id, error: getErrorMessage(error) }, + ); + } + } + let confirmedPublishes = 0; + if (defaultBranchLock) { + for (const pendingMerge of pendingMergeDistributions) { + const lockEntry = defaultBranchLock.plugins[pendingMerge.pluginSlug]; + if ( + !pendingMerge.contentHash || + lockEntry?.contentHash !== pendingMerge.contentHash + ) { + continue; + } + try { + await this.marketplaceDistributionRepository.updateStatus( + pendingMerge.id, + { + status: DistributionStatus.success, + publishConfirmedAt: lastValidatedAt, + }, + ); + confirmedPublishes += 1; + this.logger.info( + `[${this.origin}] Marketplace plugin publish confirmed on default branch`, + { + distributionId: pendingMerge.id, + marketplaceId: marketplace.id, + fromStatus: DistributionStatus.pending_merge, + toStatus: DistributionStatus.success, + }, + ); + } catch (error) { + this.logger.error( + `[${this.origin}] Failed to confirm pending publish`, + { + distributionId: pendingMerge.id, + marketplaceId: marketplace.id, + error: getErrorMessage(error), + }, + ); + } + } + } + + const [successDistributions, pendingRemovalDistributions] = + await Promise.all([ + this.marketplaceDistributionRepository.findSuccessfulByMarketplaceId( + marketplace.id, + ), + this.marketplaceDistributionRepository.findPendingRemovalsByMarketplaceId( + marketplace.id, + ), + ]); + + // (b) Transition `to_be_removed → removed` for slugs that vanished. + let confirmedRemovals = 0; + const pendingRemovalSlugs = new Set<string>(); + for (const pending of pendingRemovalDistributions) { + pendingRemovalSlugs.add(pending.pluginSlug); + if (!descriptorSlugs.has(pending.pluginSlug)) { + try { + await this.marketplaceDistributionRepository.updateStatus( + pending.id, + { status: DistributionStatus.removed }, + ); + confirmedRemovals += 1; + this.logger.info( + `[${this.origin}] Marketplace plugin distribution transitioned to terminal removed`, + { + distributionId: pending.id, + marketplaceId: marketplace.id, + fromStatus: DistributionStatus.to_be_removed, + toStatus: DistributionStatus.removed, + }, + ); + } catch (error) { + this.logger.error( + `[${this.origin}] Failed to mark distribution as removed`, + { + distributionId: pending.id, + marketplaceId: marketplace.id, + error: getErrorMessage(error), + }, + ); + } + } + } + + // (c) Drift detection (AC9, AC10): a slug carried by a `success` + // distribution is missing AND is not in `pendingRemovalSlugs`. Each + // republish writes a fresh `success` row for the same plugin, so dedupe + // by slug — consumers (banner, state badge) want a flat list of plugins, + // not one entry per historical publish. + const driftedSlugSet = new Set<string>(); + for (const live of successDistributions) { + if ( + !descriptorSlugs.has(live.pluginSlug) && + !pendingRemovalSlugs.has(live.pluginSlug) + ) { + driftedSlugSet.add(live.pluginSlug); + } + } + const driftedPluginSlugs = Array.from(driftedSlugSet); + + // Step 6 — Decide final state. + // - If `success` slugs went missing without a pending-removal pair → + // `drift` (regardless of descriptor diff). + // - A descriptor diff explained by Packmind's own sync PR landing + // (this sweep confirmed pending publishes/removals) is NOT drift — + // the refreshed descriptor is persisted below so the next sweep + // compares against it. + // - Else fall back to descriptor diff (`healthy` vs `drift`). + const descriptorChanged = !descriptorsEquivalent( + marketplace.descriptor, + descriptor, + ); + const descriptorChangeExplained = + confirmedPublishes + confirmedRemovals > 0 && + driftedPluginSlugs.length === 0; + const state: MarketplaceState = + driftedPluginSlugs.length > 0 || + (descriptorChanged && !descriptorChangeExplained) + ? 'drift' + : 'healthy'; + + // Surface a pending "Packmind sync" PR (poll-on-refresh; cleaned on the + // next reconcile once the PR is merged/closed). Best-effort: a lookup + // failure must not flip the marketplace to unreachable. + let pendingPrUrl: string | null = null; + try { + const openPr = await this.gitPort.findOpenSyncPullRequest( + gitRepo, + MARKETPLACE_SYNC_BRANCH, + ); + pendingPrUrl = openPr?.url ?? null; + } catch (error) { + this.logger.warn( + `[${this.origin}] Failed to look up sync PR for marketplace ${marketplace.id}`, + { marketplaceId: marketplace.id, error: getErrorMessage(error) }, + ); + pendingPrUrl = marketplace.pendingPrUrl; // keep last known on lookup error + } + + // Outdated detection: for the latest success distribution per package, + // compare the package's CURRENT artifact-version fingerprint against the + // fingerprint captured at publish. Distributions published before + // fingerprints existed (versionFingerprint undefined) are skipped. + const latestSuccessByPackage = new Map< + string, + (typeof successDistributions)[number] + >(); + for (const dist of successDistributions) { + // successDistributions is ordered by createdAt DESC, so first wins. + if (!latestSuccessByPackage.has(dist.packageId)) { + latestSuccessByPackage.set(dist.packageId, dist); + } + } + + const outdatedSet = new Set<string>(); + await Promise.all( + Array.from(latestSuccessByPackage.values()).map(async (dist) => { + if (!dist.versionFingerprint) { + return; // cannot determine — never mark outdated + } + const pkg = await this.packageService.findById(dist.packageId); + if (!pkg) { + return; + } + const current = await this.versionFingerprintService.compute(pkg); + if (!versionFingerprintsEqual(current, dist.versionFingerprint)) { + outdatedSet.add(dist.pluginSlug); + } + }), + ); + const outdatedPluginSlugs = + outdatedSet.size > 0 ? Array.from(outdatedSet) : null; + + // Step 7 — Persist the update. + if (state === 'drift') { + const enrichedDescriptor: MarketplaceDescriptor = { + ...descriptor, + ...(driftedPluginSlugs.length > 0 ? { driftedPluginSlugs } : {}), + }; + + await this.marketplaceRepository.updateState(marketplace.id, { + state, + lastValidatedAt, + descriptor: enrichedDescriptor, + pluginCount: descriptor.plugins.length, + errorKind: null, + errorDetail: null, + pendingPrUrl, + outdatedPluginSlugs, + }); + this.logger.info( + `[${this.origin}] Marketplace ${marketplace.id} drifted — descriptor updated`, + { + marketplaceId: marketplace.id, + previousPluginCount: marketplace.pluginCount, + newPluginCount: descriptor.plugins.length, + driftedPluginCount: driftedPluginSlugs.length, + }, + ); + } else { + await this.marketplaceRepository.updateState(marketplace.id, { + state, + lastValidatedAt, + // An explained descriptor change (Packmind's own sync landing) must + // refresh the stored baseline, or the next sweep would diff against + // the pre-merge descriptor and flag a spurious drift. + ...(descriptorChanged + ? { descriptor, pluginCount: descriptor.plugins.length } + : {}), + errorKind: null, + errorDetail: null, + pendingPrUrl, + outdatedPluginSlugs, + }); + this.logger.info( + `[${this.origin}] Marketplace ${marketplace.id} is healthy`, + { marketplaceId: marketplace.id }, + ); + } + + return { + state, + lastValidatedAt, + errorKind: null, + errorDetail: null, + pendingPrUrl, + outdatedPluginSlugs, + }; + } + + getJobName(input: MarketplaceReconciliationJobInput): string { + return `marketplace-reconciliation-${input.marketplaceId}`; + } + + jobStartedInfo(input: MarketplaceReconciliationJobInput): string { + return `marketplaceId: ${input.marketplaceId}`; + } + + getWorkerListener(): Partial< + WorkerListeners< + MarketplaceReconciliationJobInput, + MarketplaceReconciliationJobOutput + > + > { + return { + completed: async ( + job: Job< + MarketplaceReconciliationJobInput, + MarketplaceReconciliationJobOutput, + string + >, + result: MarketplaceReconciliationJobOutput, + ) => { + this.logger.info( + `[${this.origin}] Job ${job.id} completed — marketplace ${job.data.marketplaceId} state=${result.state}`, + { + marketplaceId: job.data.marketplaceId, + state: result.state, + }, + ); + }, + failed: async (job, error) => { + this.logger.error( + `[${this.origin}] Job ${job.id} failed for marketplace ${job.data.marketplaceId}: ${getErrorMessage(error)}`, + { + marketplaceId: job.data.marketplaceId, + }, + ); + }, + }; + } +} + +const ERROR_DETAIL_AUTH_FAILED = + 'The marketplace credentials are invalid or expired. Reconnect the Git provider.'; +const ERROR_DETAIL_NETWORK_TRANSIENT = + 'The marketplace repository is temporarily unreachable.'; +const ERROR_DETAIL_REPO_NOT_FOUND = + 'The marketplace repository could not be found. It may have been deleted or renamed.'; +const ERROR_DETAIL_BAD_FORMAT = + 'The marketplace descriptor is missing or unparseable.'; + +/** + * Map a thrown git fetch error to a credential vs transient failure. Repo-gone + * (404) is detected separately via a repo-existence probe, because a 404 on the + * descriptor path is indistinguishable from a missing file at the throw site. + */ +function classifyFetchError(error: unknown): { + errorKind: MarketplaceErrorKind; + errorDetail: string; +} { + const status = (error as { response?: { status?: number } })?.response + ?.status; + if (status === 401 || status === 403) { + return { + errorKind: 'auth_failed', + errorDetail: ERROR_DETAIL_AUTH_FAILED, + }; + } + return { + errorKind: 'network_transient', + errorDetail: ERROR_DETAIL_NETWORK_TRANSIENT, + }; +} + +/** + * Deep equality between two descriptors ignoring the `raw` payload (which is + * a verbatim copy of the parsed JSON and so changes with formatting alone). + * + * The comparison must be key-order-insensitive: the stored baseline round- + * trips through a Postgres `jsonb` column, which re-sorts object keys, while + * the freshly parsed descriptor carries the parser's insertion order. A naive + * `JSON.stringify` equality therefore reported a change on every sweep and + * flagged a permanent spurious drift. + */ +function descriptorsEquivalent( + a: MarketplaceDescriptor, + b: MarketplaceDescriptor, +): boolean { + const stripRaw = ( + descriptor: MarketplaceDescriptor, + ): Omit<MarketplaceDescriptor, 'raw'> => ({ + vendor: descriptor.vendor, + name: descriptor.name, + version: descriptor.version, + plugins: descriptor.plugins, + }); + return stableStringify(stripRaw(a)) === stableStringify(stripRaw(b)); +} + +/** + * JSON.stringify with recursively sorted object keys, so two structurally + * equal plain values serialize identically regardless of key insertion order. + * Array order stays significant — a reordered plugin list is a real file + * change. Sufficient for `MarketplaceDescriptor`: primitives, plain objects + * and arrays only. + */ +function stableStringify(value: unknown): string { + return JSON.stringify(sortKeysDeep(value)); +} + +function sortKeysDeep(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortKeysDeep); + } + if (typeof value === 'object' && value !== null) { + return Object.fromEntries( + Object.entries(value as Record<string, unknown>) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, sortKeysDeep(entry)]), + ); + } + return value; +} diff --git a/packages/deployments/src/application/jobs/PublishPluginToMarketplaceDelayedJob.spec.ts b/packages/deployments/src/application/jobs/PublishPluginToMarketplaceDelayedJob.spec.ts new file mode 100644 index 000000000..c9aaf89e3 --- /dev/null +++ b/packages/deployments/src/application/jobs/PublishPluginToMarketplaceDelayedJob.spec.ts @@ -0,0 +1,855 @@ +import { v4 as uuidv4 } from 'uuid'; +import { stubLogger } from '@packmind/test-utils'; +import { GitRepoService } from '@packmind/git'; +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { + createGitProviderId, + createGitRepoId, + createMarketplaceDistributionId, + createMarketplaceId, + createOrganizationId, + createPackageId, + createSpaceId, + createUserId, + DistributionStatus, + GitCommit, + GitProviderVendors, + GitRepo, + IGitPort, + MarketplaceDescriptor, + Package, + PluginPublishedEvent, + PluginPublishFailedEvent, + PublishPluginToMarketplaceJobInput, +} from '@packmind/types'; +import { IMarketplaceDistributionRepository } from '../../domain/repositories/IMarketplaceDistributionRepository'; +import { IMarketplaceRepository } from '../../domain/repositories/IMarketplaceRepository'; +import { marketplaceFactory } from '../../infra/repositories/__factories__/marketplaceFactory'; +import { marketplaceDistributionFactory } from '../../infra/repositories/__factories__/marketplaceDistributionFactory'; +import { MarketplaceDescriptorParserRegistry } from '../services/MarketplaceDescriptorParserRegistry'; +import { PackageService } from '../services/PackageService'; +import { buildPluginContentHash } from '../services/buildPluginContentHash'; +import { + MARKETPLACE_SYNC_BRANCH, + MARKETPLACE_SYNC_PR_TITLE, +} from '../services/marketplaceSyncPullRequest'; +import { + PluginRenderer, + PluginRendererResult, + PublishPluginToMarketplaceDelayedJob, +} from './PublishPluginToMarketplaceDelayedJob'; +import { PackageVersionFingerprintService } from '../services/PackageVersionFingerprintService'; + +describe('PublishPluginToMarketplaceDelayedJob', () => { + const marketplaceDistributionId = createMarketplaceDistributionId(uuidv4()); + const marketplaceId = createMarketplaceId(uuidv4()); + const packageId = createPackageId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const spaceId = createSpaceId(uuidv4()); + const gitRepoId = createGitRepoId(uuidv4()); + const gitProviderId = createGitProviderId(uuidv4()); + + const input: PublishPluginToMarketplaceJobInput = { + marketplaceDistributionId, + marketplaceId, + packageId, + organizationId, + userId, + }; + + const distribution = marketplaceDistributionFactory({ + id: marketplaceDistributionId, + organizationId, + marketplaceId, + packageId, + pluginSlug: 'security', + authorId: userId, + status: DistributionStatus.in_progress, + }); + + const marketplace = marketplaceFactory({ + id: marketplaceId, + organizationId, + gitRepoId, + name: 'ACME Marketplace', + descriptor: { + vendor: 'anthropic', + name: 'ACME Marketplace', + plugins: [], + raw: { name: 'ACME Marketplace', plugins: [] }, + }, + pluginCount: 0, + }); + + const pkg: Package = { + id: packageId, + name: 'Security', + slug: 'security', + description: 'Security curated package', + spaceId, + createdBy: userId, + recipes: [], + standards: [], + skills: [], + }; + + const gitRepo: GitRepo = { + id: gitRepoId, + owner: 'acme', + repo: 'plugins', + branch: 'main', + providerId: gitProviderId, + type: 'marketplace', + }; + + const renderedFiles: PluginRendererResult = { + files: [ + { path: 'plugins/security/plugin.json', content: '{"name":"Security"}' }, + { path: 'plugins/security/commands/foo.md', content: '# foo' }, + ], + pluginName: 'Security', + pluginVersion: '0.1.0', + }; + + const descriptor: MarketplaceDescriptor = { + vendor: 'anthropic', + name: 'ACME Marketplace', + plugins: [], + raw: { name: 'ACME Marketplace', plugins: [] }, + }; + + const successfulCommit: GitCommit = { + sha: 'commit-sha-1', + } as unknown as GitCommit; + + let mockMarketplaceDistributionRepository: jest.Mocked<IMarketplaceDistributionRepository>; + let mockMarketplaceRepository: jest.Mocked<IMarketplaceRepository>; + let mockPackageService: jest.Mocked<PackageService>; + let mockGitRepoService: jest.Mocked<GitRepoService>; + let mockGitPort: jest.Mocked<IGitPort>; + let mockParserRegistry: jest.Mocked<MarketplaceDescriptorParserRegistry>; + let mockRenderer: jest.MockedFunction<PluginRenderer>; + let mockVersionFingerprintService: jest.Mocked<PackageVersionFingerprintService>; + let mockEventEmitter: jest.Mocked<PackmindEventEmitterService>; + let job: PublishPluginToMarketplaceDelayedJob; + + beforeEach(() => { + mockMarketplaceDistributionRepository = { + findById: jest.fn().mockResolvedValue(distribution), + findLatestByPackageAndMarketplace: jest.fn().mockResolvedValue(null), + updateStatus: jest.fn().mockResolvedValue(undefined), + add: jest.fn(), + findByMarketplaceId: jest.fn(), + findByPackageId: jest.fn(), + } as unknown as jest.Mocked<IMarketplaceDistributionRepository>; + + mockMarketplaceRepository = { + findById: jest.fn().mockResolvedValue(marketplace), + } as unknown as jest.Mocked<IMarketplaceRepository>; + + mockPackageService = { + findById: jest.fn().mockResolvedValue(pkg), + } as unknown as jest.Mocked<PackageService>; + + mockGitRepoService = { + findMarketplaceGitRepoById: jest.fn().mockResolvedValue(gitRepo), + } as unknown as jest.Mocked<GitRepoService>; + + mockGitPort = { + commitToGit: jest.fn().mockResolvedValue(successfulCommit), + // By default the descriptor is served as a minimal valid JSON and the + // packmind-lock.json file is reported as missing — first-publish path. + getFileFromRepo: jest.fn().mockImplementation(async (_repo, path) => { + if (path === 'packmind-lock.json') { + return null; + } + return { sha: 'sha-1', content: '{}' }; + }), + // First-publish ever: the rolling sync branch doesn't exist yet, so + // the job reads descriptor + lock from the marketplace's default + // branch and the resolver returns that branch. + checkBranchExists: jest.fn().mockResolvedValue(false), + createBranchFromBase: jest.fn().mockResolvedValue(undefined), + openOrUpdatePullRequest: jest.fn().mockResolvedValue({ + url: 'https://github.com/acme/plugins/pull/1', + number: 1, + wasCreated: true, + }), + listProviders: jest.fn().mockResolvedValue({ + providers: [ + { + id: gitProviderId, + source: GitProviderVendors.github, + organizationId, + url: 'https://api.github.com', + authMethod: 'token', + hasAuth: true, + }, + ], + }), + } as unknown as jest.Mocked<IGitPort>; + + mockParserRegistry = { + parse: jest.fn().mockReturnValue(descriptor), + } as unknown as jest.Mocked<MarketplaceDescriptorParserRegistry>; + + mockRenderer = jest.fn().mockResolvedValue(renderedFiles); + + mockVersionFingerprintService = { + compute: jest.fn().mockResolvedValue({ + recipes: {}, + standards: {}, + skills: {}, + }), + } as unknown as jest.Mocked<PackageVersionFingerprintService>; + + mockEventEmitter = { + emit: jest.fn(), + } as unknown as jest.Mocked<PackmindEventEmitterService>; + + job = new PublishPluginToMarketplaceDelayedJob( + async () => ({}) as never, + mockMarketplaceDistributionRepository, + mockMarketplaceRepository, + mockPackageService, + mockGitRepoService, + mockGitPort, + mockParserRegistry, + mockRenderer, + mockVersionFingerprintService, + mockEventEmitter, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('on success', () => { + beforeEach(async () => { + await job.runJob('job-1', input, new AbortController()); + }); + + it('pushes the rendered files + descriptor to the rolling sync branch', () => { + expect(mockGitPort.commitToGit).toHaveBeenCalledWith( + expect.objectContaining({ branch: MARKETPLACE_SYNC_BRANCH }), + expect.arrayContaining([ + expect.objectContaining({ path: 'plugins/security/plugin.json' }), + expect.objectContaining({ path: '.claude-plugin/marketplace.json' }), + ]), + MARKETPLACE_SYNC_PR_TITLE, + ); + }); + + it('includes the packmind-lock.json file at the repo root in the commit', () => { + const committedFiles = mockGitPort.commitToGit.mock.calls[0][1] as Array<{ + path: string; + content: string; + }>; + const lockFile = committedFiles.find( + (f) => f.path === 'packmind-lock.json', + ); + expect(lockFile).toBeDefined(); + }); + + it('writes the lock entry with the expected version and contentHash', () => { + const committedFiles = mockGitPort.commitToGit.mock.calls[0][1] as Array<{ + path: string; + content: string; + }>; + const lockFile = committedFiles.find( + (f) => f.path === 'packmind-lock.json', + ); + const lock = JSON.parse(lockFile?.content ?? '{}'); + expect(lock.plugins.security).toMatchObject({ + version: '0.1.0', + contentHash: expect.any(String), + lastPublishedBy: userId, + lastPublishedAt: expect.stringMatching( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/, + ), + }); + }); + + it('writes a lock containing exactly one plugin slug on first publish', () => { + const committedFiles = mockGitPort.commitToGit.mock.calls[0][1] as Array<{ + path: string; + content: string; + }>; + const lockFile = committedFiles.find( + (f) => f.path === 'packmind-lock.json', + ); + const lock = JSON.parse(lockFile?.content ?? '{}'); + expect(Object.keys(lock.plugins)).toEqual(['security']); + }); + + it('does not embed packmindLock inside the descriptor commit', () => { + const committedFiles = mockGitPort.commitToGit.mock.calls[0][1] as Array<{ + path: string; + content: string; + }>; + const descriptorCommit = committedFiles.find( + (f) => f.path === '.claude-plugin/marketplace.json', + ); + const parsed = JSON.parse(descriptorCommit?.content ?? '{}'); + expect(parsed.packmindLock).toBeUndefined(); + }); + + describe('the plugin entry written into the descriptor', () => { + let pluginEntry: { + slug?: string; + name?: string; + version?: string; + description?: string; + source?: { source?: string; url?: string; path?: string }; + }; + + beforeEach(() => { + const committedFiles = mockGitPort.commitToGit.mock + .calls[0][1] as Array<{ + path: string; + content: string; + }>; + const descriptorCommit = committedFiles.find( + (f) => f.path === '.claude-plugin/marketplace.json', + ); + const parsed = JSON.parse(descriptorCommit?.content ?? '{}') as { + plugins: Array<typeof pluginEntry>; + }; + pluginEntry = parsed.plugins.find((p) => p.slug === 'security') ?? {}; + }); + + it('carries a git-subdir source block', () => { + expect(pluginEntry.source?.source).toBe('git-subdir'); + }); + + it('points the source url at the marketplace HTTPS clone URL', () => { + expect(pluginEntry.source?.url).toBe( + 'https://github.com/acme/plugins.git', + ); + }); + + it('targets the plugins/{slug} subdirectory in the source path', () => { + expect(pluginEntry.source?.path).toBe('plugins/security'); + }); + }); + + it('ensures the rolling-PR branch exists before committing', () => { + expect(mockGitPort.createBranchFromBase).toHaveBeenCalledWith( + gitRepo, + MARKETPLACE_SYNC_BRANCH, + ); + }); + + it('ensures the branch before pushing the commit', () => { + const branchCallOrder = + mockGitPort.createBranchFromBase.mock.invocationCallOrder[0]; + const commitCallOrder = + mockGitPort.commitToGit.mock.invocationCallOrder[0]; + expect(branchCallOrder).toBeLessThan(commitCallOrder); + }); + + it('updates the distribution status to pending_merge with a content hash', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith( + marketplaceDistributionId, + expect.objectContaining({ + status: DistributionStatus.pending_merge, + gitCommit: successfulCommit.sha, + contentHash: expect.any(String), + }), + ); + }); + + it('records the artifact-version fingerprint computed by the service', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith( + marketplaceDistributionId, + expect.objectContaining({ + versionFingerprint: { recipes: {}, standards: {}, skills: {} }, + }), + ); + }); + + it('opens the rolling pull request after the commit lands', () => { + expect(mockGitPort.openOrUpdatePullRequest).toHaveBeenCalledWith( + gitRepo, + expect.objectContaining({ + head: MARKETPLACE_SYNC_BRANCH, + title: MARKETPLACE_SYNC_PR_TITLE, + body: expect.any(String), + }), + ); + }); + + it('persists the PR URL on the pending_merge distribution row', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith( + marketplaceDistributionId, + expect.objectContaining({ + prUrl: 'https://github.com/acme/plugins/pull/1', + }), + ); + }); + + describe('PluginPublishedEvent emission', () => { + let emitted: PluginPublishedEvent; + + beforeEach(() => { + emitted = mockEventEmitter.emit.mock + .calls[0][0] as PluginPublishedEvent; + }); + + it('emits a PluginPublishedEvent', () => { + expect(emitted).toBeInstanceOf(PluginPublishedEvent); + }); + + it('marks the published event with wasNoop=false', () => { + expect(emitted.payload.wasNoop).toBe(false); + }); + + it('carries the rolling PR URL on the event payload', () => { + expect(emitted.payload.prUrl).toBe( + 'https://github.com/acme/plugins/pull/1', + ); + }); + }); + }); + + describe('when the renderer supplies a plugin description', () => { + let pluginEntry: { description?: string }; + + beforeEach(async () => { + mockRenderer.mockResolvedValue({ + ...renderedFiles, + pluginDescription: + 'Packmind - space @engineering: Security curated package', + }); + + await job.runJob('job-with-description', input, new AbortController()); + + const committedFiles = mockGitPort.commitToGit.mock.calls[0][1] as Array<{ + path: string; + content: string; + }>; + const descriptorCommit = committedFiles.find( + (f) => f.path === '.claude-plugin/marketplace.json', + ); + const parsed = JSON.parse(descriptorCommit?.content ?? '{}') as { + plugins: Array<{ slug?: string; description?: string }>; + }; + pluginEntry = parsed.plugins.find((p) => p.slug === 'security') ?? {}; + }); + + it('writes the description into the descriptor entry', () => { + expect(pluginEntry.description).toBe( + 'Packmind - space @engineering: Security curated package', + ); + }); + }); + + describe('when the rolling sync branch does not yet exist', () => { + beforeEach(async () => { + mockGitPort.checkBranchExists.mockResolvedValue(false); + await job.runJob('job-no-sync', input, new AbortController()); + }); + + it('reads the descriptor from the marketplace default branch', () => { + expect(mockGitPort.getFileFromRepo).toHaveBeenCalledWith( + gitRepo, + '.claude-plugin/marketplace.json', + gitRepo.branch, + ); + }); + + it('reads the packmind-lock.json from the marketplace default branch', () => { + expect(mockGitPort.getFileFromRepo).toHaveBeenCalledWith( + gitRepo, + 'packmind-lock.json', + gitRepo.branch, + ); + }); + + it('probes the rolling sync branch before fetching the descriptor', () => { + const checkOrder = + mockGitPort.checkBranchExists.mock.invocationCallOrder[0]; + const fetchOrder = + mockGitPort.getFileFromRepo.mock.invocationCallOrder[0]; + expect(checkOrder).toBeLessThan(fetchOrder); + }); + + it('creates the sync branch only after reading descriptor + lock', () => { + const fetchOrder = Math.max( + ...mockGitPort.getFileFromRepo.mock.invocationCallOrder, + ); + const branchOrder = + mockGitPort.createBranchFromBase.mock.invocationCallOrder[0]; + expect(fetchOrder).toBeLessThan(branchOrder); + }); + }); + + describe('when the rolling sync branch already exists', () => { + beforeEach(async () => { + mockGitPort.checkBranchExists.mockResolvedValue(true); + await job.runJob('job-sync-exists', input, new AbortController()); + }); + + it('reads the descriptor from the rolling sync branch', () => { + expect(mockGitPort.getFileFromRepo).toHaveBeenCalledWith( + gitRepo, + '.claude-plugin/marketplace.json', + MARKETPLACE_SYNC_BRANCH, + ); + }); + + it('reads the packmind-lock.json from the rolling sync branch', () => { + expect(mockGitPort.getFileFromRepo).toHaveBeenCalledWith( + gitRepo, + 'packmind-lock.json', + MARKETPLACE_SYNC_BRANCH, + ); + }); + }); + + describe('on republish over an existing managed plugin entry', () => { + beforeEach(async () => { + // Simulate the descriptor already containing the managed plugin from a + // previous publish — the mutator must rewrite (not duplicate) the entry + // and keep the source block populated. + mockParserRegistry.parse.mockReturnValue({ + ...descriptor, + plugins: [ + { + slug: 'security', + name: 'Security', + version: '0.0.1', + source: { + source: 'git-subdir', + url: 'https://github.com/acme/plugins.git', + path: 'plugins/security', + }, + }, + ], + }); + + // Simulate the standalone packmind-lock.json having the slug listed + // under `plugins` so the collision check classifies the entry as + // Packmind-managed (and therefore not a name conflict). + mockGitPort.getFileFromRepo.mockImplementation(async (_repo, path) => { + if (path === 'packmind-lock.json') { + return { + sha: 'lock-sha', + content: JSON.stringify({ + schemaVersion: 1, + plugins: { + security: { + version: '0.0.1', + contentHash: 'old-hash', + lastPublishedAt: new Date().toISOString(), + lastPublishedBy: userId, + }, + }, + }), + }; + } + return { sha: 'sha-1', content: '{}' }; + }); + + await job.runJob('job-republish', input, new AbortController()); + }); + + it('still writes a complete git-subdir source block on the entry', () => { + const committedFiles = mockGitPort.commitToGit.mock.calls[0][1] as Array<{ + path: string; + content: string; + }>; + const descriptorCommit = committedFiles.find( + (f) => f.path === '.claude-plugin/marketplace.json', + ); + const parsed = JSON.parse(descriptorCommit?.content ?? '{}') as { + plugins: Array<{ + slug: string; + source?: { source?: string; url?: string; path?: string }; + }>; + }; + const entry = parsed.plugins.find((p) => p.slug === 'security'); + expect(entry?.source).toEqual({ + source: 'git-subdir', + url: 'https://github.com/acme/plugins.git', + path: 'plugins/security', + }); + }); + }); + + describe('when openOrUpdatePullRequest returns an existing PR (wasCreated=false)', () => { + beforeEach(async () => { + mockGitPort.openOrUpdatePullRequest.mockResolvedValue({ + url: 'https://github.com/acme/plugins/pull/42', + number: 42, + wasCreated: false, + }); + + await job.runJob('job-existing-pr', input, new AbortController()); + }); + + it('persists the existing PR URL on the pending_merge distribution row', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith( + marketplaceDistributionId, + expect.objectContaining({ + status: DistributionStatus.pending_merge, + prUrl: 'https://github.com/acme/plugins/pull/42', + }), + ); + }); + }); + + describe('when openOrUpdatePullRequest fails after a successful commit', () => { + beforeEach(async () => { + mockGitPort.openOrUpdatePullRequest.mockRejectedValue( + new Error('GitHub 503'), + ); + + await job.runJob('job-pr-failure', input, new AbortController()); + }); + + it('still records a pending_merge status', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith( + marketplaceDistributionId, + expect.objectContaining({ + status: DistributionStatus.pending_merge, + }), + ); + }); + + it('persists an undefined prUrl on the row', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith( + marketplaceDistributionId, + expect.objectContaining({ prUrl: undefined }), + ); + }); + + it('emits a PluginPublishedEvent (not a failure event)', () => { + const emitted = mockEventEmitter.emit.mock + .calls[0][0] as PluginPublishedEvent; + expect(emitted).toBeInstanceOf(PluginPublishedEvent); + }); + }); + + describe('when the previous success row has the same content hash', () => { + beforeEach(async () => { + // Derive the expected hash from the rendered files so we do not need to + // run a probe job (which would require resetting mocks mid-test). + const expectedHash = buildPluginContentHash( + renderedFiles.files.map((f) => ({ path: f.path, content: f.content })), + ); + + mockMarketplaceDistributionRepository.findLatestByPackageAndMarketplace.mockResolvedValue( + marketplaceDistributionFactory({ + status: DistributionStatus.success, + contentHash: expectedHash, + }), + ); + + await job.runJob('job-2', input, new AbortController()); + }); + + it('does not push any commit to git', () => { + expect(mockGitPort.commitToGit).not.toHaveBeenCalled(); + }); + + it('records no_changes status with the unchanged content hash', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith( + marketplaceDistributionId, + expect.objectContaining({ + status: DistributionStatus.no_changes, + contentHash: expect.any(String), + }), + ); + }); + + it('emits a PluginPublishedEvent with wasNoop=true', () => { + const emitted = mockEventEmitter.emit.mock + .calls[0][0] as PluginPublishedEvent; + expect(emitted.payload.wasNoop).toBe(true); + }); + }); + + describe('when the previous pending_merge row has the same content hash', () => { + beforeEach(async () => { + const expectedHash = buildPluginContentHash( + renderedFiles.files.map((f) => ({ path: f.path, content: f.content })), + ); + + mockMarketplaceDistributionRepository.findLatestByPackageAndMarketplace.mockResolvedValue( + marketplaceDistributionFactory({ + status: DistributionStatus.pending_merge, + contentHash: expectedHash, + }), + ); + + await job.runJob('job-pending-noop', input, new AbortController()); + }); + + it('does not push any commit to git', () => { + expect(mockGitPort.commitToGit).not.toHaveBeenCalled(); + }); + + it('records no_changes status since the content already awaits merge on the sync branch', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith( + marketplaceDistributionId, + expect.objectContaining({ + status: DistributionStatus.no_changes, + }), + ); + }); + }); + + describe('when the descriptor is missing at job time', () => { + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockResolvedValue(null); + await job.runJob('job-3', input, new AbortController()); + }); + + it('records failure with failureReason=descriptor_missing', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith( + marketplaceDistributionId, + expect.objectContaining({ + status: DistributionStatus.failure, + failureReason: 'descriptor_missing', + }), + ); + }); + + describe('PluginPublishFailedEvent emission', () => { + let emitted: PluginPublishFailedEvent; + + beforeEach(() => { + emitted = mockEventEmitter.emit.mock + .calls[0][0] as PluginPublishFailedEvent; + }); + + it('emits a PluginPublishFailedEvent', () => { + expect(emitted).toBeInstanceOf(PluginPublishFailedEvent); + }); + + it('carries the matching failureReason on the event', () => { + expect(emitted.payload.failureReason).toBe('descriptor_missing'); + }); + }); + }); + + describe('when commitToGit reports NO_CHANGES_DETECTED', () => { + beforeEach(async () => { + mockGitPort.commitToGit.mockRejectedValue( + new Error('NO_CHANGES_DETECTED'), + ); + await job.runJob('job-4', input, new AbortController()); + }); + + it('records no_changes status', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith( + marketplaceDistributionId, + expect.objectContaining({ + status: DistributionStatus.no_changes, + }), + ); + }); + + it('emits a PluginPublishedEvent with wasNoop=true', () => { + const emitted = mockEventEmitter.emit.mock + .calls[0][0] as PluginPublishedEvent; + expect(emitted.payload.wasNoop).toBe(true); + }); + + it('still ensures the rolling PR so a branch orphaned by a prior failed publish self-heals', () => { + expect(mockGitPort.openOrUpdatePullRequest).toHaveBeenCalledWith( + gitRepo, + expect.objectContaining({ head: MARKETPLACE_SYNC_BRANCH }), + ); + }); + }); + + describe('when an unmanaged name collision is detected at job time', () => { + beforeEach(async () => { + mockParserRegistry.parse.mockReturnValue({ + ...descriptor, + plugins: [{ slug: 'security', name: 'Security (unmanaged)' }], + }); + await job.runJob('job-5', input, new AbortController()); + }); + + it('records failure with failureReason=name_conflict_unmanaged', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith( + marketplaceDistributionId, + expect.objectContaining({ + status: DistributionStatus.failure, + failureReason: 'name_conflict_unmanaged', + }), + ); + }); + }); + + describe('on generic failure', () => { + beforeEach(async () => { + mockGitPort.commitToGit.mockRejectedValue(new Error('upstream 503')); + await job.runJob('job-6', input, new AbortController()); + }); + + it('records failure with failureReason=other', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith( + marketplaceDistributionId, + expect.objectContaining({ + status: DistributionStatus.failure, + failureReason: 'other', + }), + ); + }); + }); + + describe('when packmind-lock.json is malformed', () => { + beforeEach(async () => { + mockGitPort.getFileFromRepo.mockImplementation(async (_repo, path) => { + if (path === 'packmind-lock.json') { + return { sha: 'sha-lock', content: 'not-valid-json' }; + } + return { sha: 'sha-1', content: '{}' }; + }); + await job.runJob('job-lock-malformed', input, new AbortController()); + }); + + it('records failure with failureReason=descriptor_missing', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith( + marketplaceDistributionId, + expect.objectContaining({ + status: DistributionStatus.failure, + failureReason: 'descriptor_missing', + }), + ); + }); + }); +}); diff --git a/packages/deployments/src/application/jobs/PublishPluginToMarketplaceDelayedJob.ts b/packages/deployments/src/application/jobs/PublishPluginToMarketplaceDelayedJob.ts new file mode 100644 index 000000000..32633cee2 --- /dev/null +++ b/packages/deployments/src/application/jobs/PublishPluginToMarketplaceDelayedJob.ts @@ -0,0 +1,796 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + AbstractAIDelayedJob, + getErrorMessage, + IQueue, + PackmindEventEmitterService, + QueueListeners, + SSEEventPublisher, + WorkerListeners, +} from '@packmind/node-utils'; +import { GitRepoService } from '@packmind/git'; +import { + DistributionStatus, + FileModification, + GitCommit, + GitProviderVendor, + GitProviderVendors, + GitRepo, + IGitPort, + MARKETPLACE_DESCRIPTOR_FILENAME, + Marketplace, + MarketplaceDescriptor, + MarketplaceDistribution, + MarketplaceDistributionId, + MarketplaceId, + Package, + PackmindMarketplaceLock, + PluginPublishedEvent, + PluginPublishFailedEvent, + PluginSource, + PublishFailureReason, + PublishPluginToMarketplaceJobInput, + PublishPluginToMarketplaceJobOutput, +} from '@packmind/types'; +import { IMarketplaceDistributionRepository } from '../../domain/repositories/IMarketplaceDistributionRepository'; +import { IMarketplaceRepository } from '../../domain/repositories/IMarketplaceRepository'; +import { applyPluginDescriptorMutation } from '../services/PluginDescriptorMutator'; +import { applyPackmindMarketplaceLockMutation } from '../services/applyPackmindMarketplaceLockMutation'; +import { + fetchPackmindMarketplaceLock, + PACKMIND_MARKETPLACE_LOCK_PATH, + serializePackmindMarketplaceLock, +} from '../services/packmindMarketplaceLock'; +import { + buildPluginContentHash, + PluginContentEntry, +} from '../services/buildPluginContentHash'; +import { + MARKETPLACE_SYNC_BRANCH, + MARKETPLACE_SYNC_PR_TITLE, +} from '../services/marketplaceSyncPullRequest'; +import { fetchMarketplaceDescriptorFile } from '../services/fetchMarketplaceDescriptorFile'; +import { MarketplaceDescriptorParserRegistry } from '../services/MarketplaceDescriptorParserRegistry'; +import { PackageService } from '../services/PackageService'; +import { PackageVersionFingerprintService } from '../services/PackageVersionFingerprintService'; +import { resolveMarketplaceReadBranch } from '../services/resolveMarketplaceReadBranch'; + +const logOrigin = 'PublishPluginToMarketplaceDelayedJob'; + +const PLUGIN_VERSION_FALLBACK = '0.1.0'; + +/** + * Rendered plugin payload provided by an injected renderer callable. + * + * Injecting the renderer rather than the full use case keeps the job + * testable without dragging the entire `RenderPackageAsPluginUseCase` + * dependency graph into unit tests. + */ +export type PluginRendererResult = { + files: PluginContentEntry[]; + pluginName: string; + pluginVersion: string; + pluginDescription?: string; +}; + +export type PluginRenderer = (params: { + marketplace: Marketplace; + package: Package; + userId: string; + organizationId: string; +}) => Promise<PluginRendererResult>; + +/** + * Background job that performs the Git side effects of a marketplace publish + * attempt. + * + * Algorithm: + * 1. Load the marketplace distribution row, marketplace and package. + * 2. Render the plugin via the injected renderer (typically the + * `RenderPackageAsPluginUseCase`). + * 3. Compute the content hash and short-circuit on no-op against the previous + * `success` or `pending_merge` row's hash. + * 4. Refetch + reparse the marketplace descriptor (fresh read to surface any + * concurrent edit since the use case ran). + * 5. Re-apply the name-collision check against unmanaged plugin entries. + * 6. Mutate the descriptor through `PluginDescriptorMutator`. + * 7. Push the rendered files + updated descriptor on the rolling + * `packmind/sync` branch via `IGitPort.commitToGit`. Catches the + * `NO_CHANGES_DETECTED` signal as a no-op. + * 8. Persist the resulting status on the distribution row — `pending_merge` + * when the commit landed on the sync branch (the reconciliation sweep + * later promotes it to `success` once the rolling PR merges), + * `no_changes`/`failure` as terminal states. + * 9. Emit `PluginPublishedEvent` (success/no-op) or `PluginPublishFailedEvent`. + * + * BullMQ concurrency is intentionally constrained to a single worker — Git + * operations on the rolling PR must be serialized. + */ +export class PublishPluginToMarketplaceDelayedJob extends AbstractAIDelayedJob< + PublishPluginToMarketplaceJobInput, + PublishPluginToMarketplaceJobOutput +> { + readonly origin = logOrigin; + + constructor( + queueFactory: ( + queueListeners: Partial<QueueListeners>, + ) => Promise< + IQueue< + PublishPluginToMarketplaceJobInput, + PublishPluginToMarketplaceJobOutput + > + >, + private readonly marketplaceDistributionRepository: IMarketplaceDistributionRepository, + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly packageService: PackageService, + private readonly gitRepoService: GitRepoService, + private readonly gitPort: IGitPort, + private readonly parserRegistry: MarketplaceDescriptorParserRegistry, + private readonly renderer: PluginRenderer, + private readonly versionFingerprintService: PackageVersionFingerprintService, + private readonly eventEmitterService: PackmindEventEmitterService, + logger: PackmindLogger = new PackmindLogger(logOrigin), + ) { + super(queueFactory, logger); + } + + async onFail(jobId: string): Promise<void> { + this.logger.error( + `[${this.origin}] Job ${jobId} failed — terminal status was updated in the failed listener`, + ); + } + + async runJob( + jobId: string, + input: PublishPluginToMarketplaceJobInput, + _controller: AbortController, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise<PublishPluginToMarketplaceJobOutput> { + this.logger.info( + `[${this.origin}] Processing publish job ${jobId} for distribution ${input.marketplaceDistributionId}`, + { + marketplaceId: input.marketplaceId, + packageId: input.packageId, + }, + ); + + // Hoist marketplace/package out of the try so the failure SSE notification + // can still surface human-readable names when loadContext succeeded before + // a later step threw. + let marketplace: Marketplace | undefined; + let pkg: Package | undefined; + + try { + const context = await this.loadContext(input); + marketplace = context.marketplace; + pkg = context.pkg; + const { distribution } = context; + + // Baseline the package's current artifact versions so reconcile can later + // detect "outdated". Computed once and recorded on every terminal row — + // including no-ops — so a no-change publish still refreshes the baseline. + const versionFingerprint = + await this.versionFingerprintService.compute(pkg); + + const rendered = await this.renderer({ + marketplace, + package: pkg, + userId: input.userId, + organizationId: input.organizationId, + }); + + const pluginEntries: PluginContentEntry[] = rendered.files.map((f) => ({ + path: f.path, + content: f.content, + })); + + const contentHash = buildPluginContentHash(pluginEntries); + + // Short-circuit no-op against the previous success row's content hash. + // A pending_merge row counts too: its content already sits on the sync + // branch awaiting merge, so re-publishing identical content would only + // produce an empty commit and a duplicate pending row. + const previous = + await this.marketplaceDistributionRepository.findLatestByPackageAndMarketplace( + input.packageId, + input.marketplaceId, + ); + const wasNoopByHash = + previous && + previous.id !== distribution.id && + (previous.status === DistributionStatus.success || + previous.status === DistributionStatus.pending_merge) && + previous.contentHash === contentHash; + if (wasNoopByHash) { + this.logger.info( + `[${this.origin}] Plugin content unchanged since last publish; recording no_changes`, + { + marketplaceDistributionId: input.marketplaceDistributionId, + contentHash, + }, + ); + await this.marketplaceDistributionRepository.updateStatus( + input.marketplaceDistributionId, + { + status: DistributionStatus.no_changes, + contentHash, + versionFingerprint, + }, + ); + this.eventEmitterService.emit( + new PluginPublishedEvent({ + userId: input.userId, + organizationId: input.organizationId, + source: 'ui', + marketplaceDistributionId: input.marketplaceDistributionId, + marketplaceId: input.marketplaceId, + packageId: input.packageId, + wasNoop: true, + }), + ); + await this.publishCompletedNotification({ + input, + marketplace, + pkg, + status: 'no_changes', + }); + return; + } + + // Fresh-read the descriptor at job execution time to catch concurrent + // edits since the use case acquired its snapshot. + const marketplaceGitRepo = + await this.gitRepoService.findMarketplaceGitRepoById( + marketplace.gitRepoId, + ); + if (!marketplaceGitRepo) { + throw new PublishJobFailure( + 'descriptor_missing', + 'Marketplace git repo not found', + ); + } + + // Read descriptor + lock from the rolling `packmind/sync` branch when + // it already exists, so successive publishes accumulate plugin entries + // on top of the previous publish's unmerged state. Falls back to the + // marketplace's default branch on the first publish ever and on + // post-merge republishes (where the merged entries now live on main). + const readBranch = await resolveMarketplaceReadBranch( + this.gitPort, + marketplaceGitRepo, + ); + + const descriptorFile = await fetchMarketplaceDescriptorFile( + this.gitPort, + marketplaceGitRepo, + readBranch, + ); + if (!descriptorFile) { + throw new PublishJobFailure( + 'descriptor_missing', + `Marketplace descriptor missing at publish time on ${marketplaceGitRepo.owner}/${marketplaceGitRepo.repo}`, + ); + } + + let descriptor: MarketplaceDescriptor; + try { + descriptor = this.parserRegistry.parse(descriptorFile.content); + } catch (error) { + throw new PublishJobFailure( + 'descriptor_missing', + `Marketplace descriptor unparseable at publish time: ${getErrorMessage(error)}`, + ); + } + + // Read the standalone packmind-lock.json from the same branch as the + // descriptor — a missing file is the first-publish path and returns an + // empty lock. A malformed lock is the same failure category as a + // malformed descriptor: the marketplace is unhealthy and the publish + // cannot proceed. + let lock: PackmindMarketplaceLock; + try { + lock = await fetchPackmindMarketplaceLock( + this.gitPort, + marketplaceGitRepo, + readBranch, + ); + } catch (error) { + throw new PublishJobFailure( + 'descriptor_missing', + `packmind-lock.json is unparseable on ${marketplaceGitRepo.owner}/${marketplaceGitRepo.repo}: ${getErrorMessage(error)}`, + ); + } + + const pluginSlug = pkg.slug; + this.assertNoUnmanagedNameCollision({ + pluginSlug, + descriptor, + lock, + marketplaceName: marketplace.name, + }); + + const nextLock = applyPackmindMarketplaceLockMutation(lock, { + pluginSlug, + pluginVersion: rendered.pluginVersion || PLUGIN_VERSION_FALLBACK, + contentHash, + lastPublishedAt: new Date(), + lastPublishedBy: distribution.authorId, + }); + + const providerVendor = await this.resolveProviderVendor( + marketplaceGitRepo, + input, + ); + const pluginSource: PluginSource = { + source: 'git-subdir', + url: buildMarketplaceCloneUrl(marketplaceGitRepo, providerVendor), + path: `plugins/${pluginSlug}`, + }; + + const nextDescriptor = applyPluginDescriptorMutation(descriptor, { + pluginSlug, + pluginName: rendered.pluginName, + pluginVersion: rendered.pluginVersion || PLUGIN_VERSION_FALLBACK, + ...(rendered.pluginDescription !== undefined + ? { pluginDescription: rendered.pluginDescription } + : {}), + pluginSource, + }); + + const fileModifications: FileModification[] = [ + ...pluginEntries.map<FileModification>((entry) => ({ + path: entry.path, + content: entry.content, + })), + { + path: descriptorFile.path ?? MARKETPLACE_DESCRIPTOR_FILENAME, + content: this.serializeDescriptor(nextDescriptor), + }, + { + path: PACKMIND_MARKETPLACE_LOCK_PATH, + content: serializePackmindMarketplaceLock(nextLock), + }, + ]; + + // The rolling PR is owned by the upstream Git host — the worker pushes + // commits onto `packmind/sync` and lets the host's existing PR flow + // surface or amend the request. The commit message is the rolling PR + // title so reviewers see the Packmind-managed channel at a glance. + const commitMessage = MARKETPLACE_SYNC_PR_TITLE; + + // Ensure the rolling-PR branch exists. First publish creates it from the + // marketplace's default branch; subsequent publishes are a no-op. + try { + await this.gitPort.createBranchFromBase( + marketplaceGitRepo, + MARKETPLACE_SYNC_BRANCH, + ); + } catch (error) { + throw new PublishJobFailure( + 'other', + `Failed to ensure rolling-PR branch: ${getErrorMessage(error)}`, + ); + } + + let gitCommit: GitCommit | undefined; + try { + gitCommit = await this.gitPort.commitToGit( + { ...marketplaceGitRepo, branch: MARKETPLACE_SYNC_BRANCH }, + fileModifications, + commitMessage, + ); + } catch (error) { + if (error instanceof Error && error.message === 'NO_CHANGES_DETECTED') { + // No new commit this run, but a prior publish may have landed a + // commit on `packmind/sync` whose PR-open step transiently failed, + // leaving the branch without a PR. Still ensure the rolling PR so + // that orphaned branch self-heals instead of waiting for a manual + // PR. Opening a PR with no commits ahead of base is a host-side + // no-op and is swallowed inside the helper. + const healedPrUrl = await this.ensureRollingPullRequest( + marketplaceGitRepo, + input.marketplaceDistributionId, + ); + await this.marketplaceDistributionRepository.updateStatus( + input.marketplaceDistributionId, + { + status: DistributionStatus.no_changes, + contentHash, + prUrl: healedPrUrl, + versionFingerprint, + }, + ); + this.eventEmitterService.emit( + new PluginPublishedEvent({ + userId: input.userId, + organizationId: input.organizationId, + source: 'ui', + marketplaceDistributionId: input.marketplaceDistributionId, + marketplaceId: input.marketplaceId, + packageId: input.packageId, + wasNoop: true, + }), + ); + await this.publishCompletedNotification({ + input, + marketplace, + pkg, + status: 'no_changes', + prUrl: healedPrUrl, + }); + return; + } + throw new PublishJobFailure('other', getErrorMessage(error)); + } + + // Ensure the rolling "Packmind sync" PR exists on the marketplace + // repo (or amends the existing one). The commit already landed on + // `packmind/sync` above — failing to surface the PR is logged but does + // not roll back the publish. + const prUrl = await this.ensureRollingPullRequest( + marketplaceGitRepo, + input.marketplaceDistributionId, + ); + + // The commit only landed on the rolling sync branch — the plugin is not + // live until the PR merges. The reconciliation sweep owns the + // `pending_merge → success` promotion (matched via the lock file's + // content hash on the default branch). + await this.marketplaceDistributionRepository.updateStatus( + input.marketplaceDistributionId, + { + status: DistributionStatus.pending_merge, + contentHash, + gitCommit: gitCommit?.sha, + prUrl, + versionFingerprint, + }, + ); + + this.eventEmitterService.emit( + new PluginPublishedEvent({ + userId: input.userId, + organizationId: input.organizationId, + source: 'ui', + marketplaceDistributionId: input.marketplaceDistributionId, + marketplaceId: input.marketplaceId, + packageId: input.packageId, + prUrl, + wasNoop: false, + }), + ); + await this.publishCompletedNotification({ + input, + marketplace, + pkg, + status: 'success', + prUrl, + }); + } catch (error) { + const failureReason: PublishFailureReason = + error instanceof PublishJobFailure ? error.reason : 'other'; + const errorMessage = + error instanceof PublishJobFailure + ? error.description + : getErrorMessage(error); + this.logger.error( + `[${this.origin}] Publish job failed for distribution ${input.marketplaceDistributionId}`, + { + marketplaceDistributionId: input.marketplaceDistributionId, + failureReason, + error: errorMessage, + }, + ); + + await this.marketplaceDistributionRepository.updateStatus( + input.marketplaceDistributionId, + { + status: DistributionStatus.failure, + failureReason, + error: errorMessage, + }, + ); + + this.eventEmitterService.emit( + new PluginPublishFailedEvent({ + userId: input.userId, + organizationId: input.organizationId, + source: 'ui', + marketplaceDistributionId: input.marketplaceDistributionId, + marketplaceId: input.marketplaceId, + packageId: input.packageId, + failureReason, + }), + ); + await this.publishCompletedNotification({ + input, + marketplace, + pkg, + status: 'failure', + failureReason, + }); + } + } + + /** + * Best-effort SSE notification to the user who triggered the publish so the + * frontend can surface a terminal-state toast (with the rolling PR URL when + * available). Never throws — a failure here must not derail the job's + * terminal status which has already been persisted. + */ + private async publishCompletedNotification(params: { + input: PublishPluginToMarketplaceJobInput; + marketplace: Marketplace | undefined; + pkg: Package | undefined; + status: 'success' | 'no_changes' | 'failure'; + prUrl?: string; + failureReason?: PublishFailureReason; + }): Promise<void> { + const { input, marketplace, pkg, status, prUrl, failureReason } = params; + try { + await SSEEventPublisher.publishMarketplacePublishCompletedEvent({ + marketplaceDistributionId: input.marketplaceDistributionId, + marketplaceId: input.marketplaceId, + packageId: input.packageId, + pluginSlug: pkg?.slug ?? '', + packageName: pkg?.name ?? '', + marketplaceName: marketplace?.name ?? '', + status, + userId: input.userId, + prUrl, + failureReason, + }); + } catch (error) { + this.logger.warn( + `[${this.origin}] Failed to publish SSE completion notification for distribution ${input.marketplaceDistributionId}`, + { error: getErrorMessage(error) }, + ); + } + } + + /** + * Ensure the rolling "Packmind sync" PR exists for the marketplace repo and + * return its URL. Idempotent — amends the existing PR if one is open. A + * failure here is logged but never rethrown: the commit (if any) already + * landed on `packmind/sync`, and surfacing the PR is best-effort. Crucially + * this is reachable on the `NO_CHANGES_DETECTED` path too, so a branch left + * without a PR by a prior failed publish self-heals on the next attempt. + */ + private async ensureRollingPullRequest( + marketplaceGitRepo: GitRepo, + marketplaceDistributionId: MarketplaceDistributionId, + ): Promise<string | undefined> { + try { + const pr = await this.gitPort.openOrUpdatePullRequest( + marketplaceGitRepo, + { + head: MARKETPLACE_SYNC_BRANCH, + title: MARKETPLACE_SYNC_PR_TITLE, + body: 'Packmind-managed plugin sync. Successive publishes amend this PR.', + }, + ); + return pr.url; + } catch (error) { + this.logger.warn( + `[${this.origin}] Failed to ensure rolling PR for distribution ${marketplaceDistributionId}`, + { error: getErrorMessage(error) }, + ); + return undefined; + } + } + + private async loadContext( + input: PublishPluginToMarketplaceJobInput, + ): Promise<{ + distribution: MarketplaceDistribution; + marketplace: Marketplace; + pkg: Package; + }> { + const distribution = await this.marketplaceDistributionRepository.findById( + input.marketplaceDistributionId, + ); + if (!distribution) { + throw new PublishJobFailure( + 'other', + `Marketplace distribution ${input.marketplaceDistributionId} not found`, + ); + } + const marketplace = await this.marketplaceRepository.findById( + input.marketplaceId, + ); + if (!marketplace) { + throw new PublishJobFailure( + 'other', + `Marketplace ${input.marketplaceId} not found`, + ); + } + const pkg = await this.packageService.findById(input.packageId); + if (!pkg) { + throw new PublishJobFailure( + 'other', + `Package ${input.packageId} not found`, + ); + } + return { distribution, marketplace, pkg }; + } + + private serializeDescriptor(descriptor: MarketplaceDescriptor): string { + // Merge the normalized fields back over the original raw JSON so any + // unknown vendor-specific fields are preserved verbatim. + const rawBase = + typeof descriptor.raw === 'object' && descriptor.raw !== null + ? (descriptor.raw as Record<string, unknown>) + : {}; + const merged: Record<string, unknown> = { + ...rawBase, + name: descriptor.name, + plugins: descriptor.plugins, + }; + if (descriptor.version !== undefined) { + merged['version'] = descriptor.version; + } + // Strip the legacy embedded packmindLock so orphan fields from existing + // repos disappear on the next publish — the lock now lives at the repo + // root as a standalone packmind-lock.json file. + delete merged['packmindLock']; + return JSON.stringify(merged, null, 2); + } + + /** + * Look up the marketplace git repo's provider vendor so the plugin source + * URL uses the right hostname. Falls back to `unknown` (which yields an + * empty URL) if the provider cannot be resolved — preferable to throwing + * because the publish is otherwise complete and a missing URL only breaks + * downstream install-ability, not the upstream git operations. + */ + private async resolveProviderVendor( + gitRepo: GitRepo, + input: PublishPluginToMarketplaceJobInput, + ): Promise<GitProviderVendor> { + try { + const { providers } = await this.gitPort.listProviders({ + userId: input.userId, + organizationId: input.organizationId, + }); + const provider = providers.find((p) => p.id === gitRepo.providerId); + return provider?.source ?? GitProviderVendors.unknown; + } catch (error) { + this.logger.warn( + `[${this.origin}] Failed to resolve provider vendor for marketplace repo ${gitRepo.owner}/${gitRepo.repo}; falling back to unknown`, + { error: getErrorMessage(error) }, + ); + return GitProviderVendors.unknown; + } + } + + private assertNoUnmanagedNameCollision(params: { + pluginSlug: string; + descriptor: MarketplaceDescriptor; + lock: PackmindMarketplaceLock; + marketplaceName: string; + }): void { + const { pluginSlug, descriptor, lock, marketplaceName } = params; + const managedSlugs = new Set<string>(Object.keys(lock.plugins)); + const colliding = descriptor.plugins.find( + (p) => p.slug === pluginSlug && !managedSlugs.has(p.slug), + ); + if (colliding) { + throw new PublishJobFailure( + 'name_conflict_unmanaged', + `Cannot publish: plugin "${pluginSlug}" already exists on marketplace "${marketplaceName}" and is not managed by Packmind`, + ); + } + } + + getJobName(input: PublishPluginToMarketplaceJobInput): string { + return `publish-plugin-to-marketplace-${input.marketplaceDistributionId}`; + } + + jobStartedInfo(input: PublishPluginToMarketplaceJobInput): string { + return `marketplaceDistributionId: ${input.marketplaceDistributionId}`; + } + + getWorkerListener(): Partial< + WorkerListeners< + PublishPluginToMarketplaceJobInput, + PublishPluginToMarketplaceJobOutput + > + > { + return { + completed: async (job) => { + this.logger.info( + `[${this.origin}] Job ${job.id} completed for distribution ${job.data.marketplaceDistributionId}`, + ); + }, + failed: async (job, error) => { + this.logger.error( + `[${this.origin}] Job ${job.id} failed for distribution ${job.data.marketplaceDistributionId}: ${getErrorMessage(error)}`, + ); + // The runJob path catches its own errors and updates the row to + // `failure`. The failed listener fires only when an unhandled error + // escapes — in which case we still persist the failure terminal state + // so the row never stays `in_progress` indefinitely. + try { + await this.markFailureFromListener( + job.data.marketplaceDistributionId, + getErrorMessage(error), + ); + } catch (updateError) { + this.logger.error( + `[${this.origin}] Failed to persist failure state in listener for job ${job.id}`, + { error: getErrorMessage(updateError) }, + ); + } + }, + }; + } + + private async markFailureFromListener( + marketplaceDistributionId: MarketplaceDistributionId, + errorMessage: string, + ): Promise<void> { + const current = await this.marketplaceDistributionRepository.findById( + marketplaceDistributionId, + ); + if (current && current.status === DistributionStatus.in_progress) { + await this.marketplaceDistributionRepository.updateStatus( + marketplaceDistributionId, + { + status: DistributionStatus.failure, + failureReason: 'other', + error: errorMessage, + }, + ); + } + } +} + +/** + * Helper error type used inside the job to carry a categorical failure + * reason from any deep call site back to the terminal-status writer. + */ +class PublishJobFailure extends Error { + constructor( + public readonly reason: PublishFailureReason, + public readonly description: string, + ) { + super(description); + this.name = 'PublishJobFailure'; + } +} + +/** + * Build the HTTPS git clone URL for a marketplace repository so the + * plugin entries written by the publish flow are install-able by + * marketplace consumers (e.g. Claude Code's `git-subdir` plugin source). + * + * Mirrors the cloud-host convention used by + * `ListMarketplacesUseCase`'s `buildRepositoryWebUrl` helper, but appends + * the `.git` suffix that `git clone` accepts. GitLab `owner` may itself be + * a `group/subgroup` path — that is fine, the slashes carry through into a + * valid URL. + * + * For an `unknown` provider vendor (or any unexpected value) the helper + * returns an empty string so the upstream commit still lands; the + * marketplace publish is the source-of-truth for the `source` block and a + * later republish on a known-vendor provider corrects the entry. + */ +function buildMarketplaceCloneUrl( + gitRepo: GitRepo, + providerVendor: GitProviderVendor, +): string { + switch (providerVendor) { + case GitProviderVendors.github: + return `https://github.com/${gitRepo.owner}/${gitRepo.repo}.git`; + case GitProviderVendors.gitlab: + return `https://gitlab.com/${gitRepo.owner}/${gitRepo.repo}.git`; + default: + return ''; + } +} + +/** + * Type-helper re-export so `MarketplaceId` consumers stay in lockstep when + * the schema/types update. + */ +export type { MarketplaceId }; diff --git a/packages/deployments/src/application/jobs/RemovePluginFromMarketplaceDelayedJob.spec.ts b/packages/deployments/src/application/jobs/RemovePluginFromMarketplaceDelayedJob.spec.ts new file mode 100644 index 000000000..e20e9fa8f --- /dev/null +++ b/packages/deployments/src/application/jobs/RemovePluginFromMarketplaceDelayedJob.spec.ts @@ -0,0 +1,386 @@ +import { v4 as uuidv4 } from 'uuid'; +import { stubLogger } from '@packmind/test-utils'; +import { GitRepoService } from '@packmind/git'; +import { + createGitProviderId, + createGitRepoId, + createMarketplaceDistributionId, + createMarketplaceId, + createOrganizationId, + createPackageId, + createUserId, + DeleteItemType, + DistributionStatus, + GitCommit, + GitRepo, + IGitPort, + MarketplaceDescriptor, + RemovePluginFromMarketplaceJobInput, +} from '@packmind/types'; +import { IMarketplaceDistributionRepository } from '../../domain/repositories/IMarketplaceDistributionRepository'; +import { IMarketplaceRepository } from '../../domain/repositories/IMarketplaceRepository'; +import { marketplaceFactory } from '../../infra/repositories/__factories__/marketplaceFactory'; +import { marketplaceDistributionFactory } from '../../infra/repositories/__factories__/marketplaceDistributionFactory'; +import { MarketplaceDescriptorParserRegistry } from '../services/MarketplaceDescriptorParserRegistry'; +import { + MARKETPLACE_SYNC_BRANCH, + MARKETPLACE_SYNC_PR_TITLE, +} from '../services/marketplaceSyncPullRequest'; +import { RemovePluginFromMarketplaceDelayedJob } from './RemovePluginFromMarketplaceDelayedJob'; + +describe('RemovePluginFromMarketplaceDelayedJob', () => { + const marketplaceDistributionId = createMarketplaceDistributionId(uuidv4()); + const marketplaceId = createMarketplaceId(uuidv4()); + const packageId = createPackageId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const gitRepoId = createGitRepoId(uuidv4()); + const gitProviderId = createGitProviderId(uuidv4()); + + const input: RemovePluginFromMarketplaceJobInput = { + marketplaceDistributionId, + marketplaceId, + packageId, + organizationId, + userId, + }; + + const distribution = marketplaceDistributionFactory({ + id: marketplaceDistributionId, + organizationId, + marketplaceId, + packageId, + pluginSlug: 'security', + authorId: userId, + // The propose flow no longer pre-flips the status: the job receives a + // still-`success` row and owns the flip once the sync commit lands. The + // manual flow stamps `removalRequestedAt`, which the job uses to tell an + // in-flight removal apart from one cancelled before the commit landed. + status: DistributionStatus.success, + removalRequestedAt: new Date('2026-06-09T10:00:00.000Z'), + }); + + const marketplace = marketplaceFactory({ + id: marketplaceId, + organizationId, + gitRepoId, + name: 'ACME Marketplace', + descriptor: { + vendor: 'anthropic', + name: 'ACME Marketplace', + plugins: [{ slug: 'security', name: 'Security' }], + raw: { name: 'ACME Marketplace', plugins: [] }, + }, + pluginCount: 1, + }); + + const gitRepo: GitRepo = { + id: gitRepoId, + owner: 'acme', + repo: 'plugins', + branch: 'main', + providerId: gitProviderId, + type: 'marketplace', + }; + + const descriptor: MarketplaceDescriptor = { + vendor: 'anthropic', + name: 'ACME Marketplace', + plugins: [{ slug: 'security', name: 'Security' }], + raw: { name: 'ACME Marketplace', plugins: [{ slug: 'security' }] }, + }; + + const successfulCommit: GitCommit = { + sha: 'commit-sha-1', + } as unknown as GitCommit; + + let mockMarketplaceDistributionRepository: jest.Mocked<IMarketplaceDistributionRepository>; + let mockMarketplaceRepository: jest.Mocked<IMarketplaceRepository>; + let mockGitRepoService: jest.Mocked<GitRepoService>; + let mockGitPort: jest.Mocked<IGitPort>; + let mockParserRegistry: jest.Mocked<MarketplaceDescriptorParserRegistry>; + let job: RemovePluginFromMarketplaceDelayedJob; + + beforeEach(() => { + mockMarketplaceDistributionRepository = { + findById: jest.fn().mockResolvedValue(distribution), + updateStatus: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked<IMarketplaceDistributionRepository>; + + mockMarketplaceRepository = { + findById: jest.fn().mockResolvedValue(marketplace), + } as unknown as jest.Mocked<IMarketplaceRepository>; + + mockGitRepoService = { + findMarketplaceGitRepoById: jest.fn().mockResolvedValue(gitRepo), + } as unknown as jest.Mocked<GitRepoService>; + + mockGitPort = { + commitToGit: jest.fn().mockResolvedValue(successfulCommit), + getFileFromRepo: jest.fn().mockImplementation(async (_repo, path) => { + if (path === 'packmind-lock.json') { + return { + sha: 'lock-sha', + content: JSON.stringify({ + schemaVersion: 1, + plugins: { + security: { + version: '0.1.0', + contentHash: 'hash', + lastPublishedAt: '2026-06-01T10:00:00.000Z', + lastPublishedBy: userId, + }, + }, + }), + }; + } + return { sha: 'sha-1', content: '{}' }; + }), + // By default the rolling sync branch already exists from a prior + // publish so the removal reads from it. + checkBranchExists: jest.fn().mockResolvedValue(true), + createBranchFromBase: jest.fn().mockResolvedValue(undefined), + openOrUpdatePullRequest: jest + .fn() + .mockResolvedValue({ url: 'https://pr', number: 1, wasCreated: true }), + } as unknown as jest.Mocked<IGitPort>; + + mockParserRegistry = { + parse: jest.fn().mockReturnValue(descriptor), + } as unknown as jest.Mocked<MarketplaceDescriptorParserRegistry>; + + job = new RemovePluginFromMarketplaceDelayedJob( + async () => ({}) as never, + mockMarketplaceDistributionRepository, + mockMarketplaceRepository, + mockGitRepoService, + mockGitPort, + mockParserRegistry, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('on success', () => { + beforeEach(async () => { + await job.runJob('job-1', input, new AbortController()); + }); + + it('ensures the rolling-PR branch exists', () => { + expect(mockGitPort.createBranchFromBase).toHaveBeenCalledWith( + gitRepo, + MARKETPLACE_SYNC_BRANCH, + ); + }); + + it('reads the descriptor from the sync branch', () => { + expect(mockGitPort.getFileFromRepo).toHaveBeenCalledWith( + gitRepo, + expect.any(String), + MARKETPLACE_SYNC_BRANCH, + ); + }); + + it('commits the updated descriptor to the rolling sync branch', () => { + expect(mockGitPort.commitToGit).toHaveBeenCalledWith( + expect.objectContaining({ branch: MARKETPLACE_SYNC_BRANCH }), + expect.arrayContaining([ + expect.objectContaining({ path: '.claude-plugin/marketplace.json' }), + ]), + MARKETPLACE_SYNC_PR_TITLE, + expect.any(Array), + ); + }); + + it("deletes the plugin's directory as part of the commit", () => { + const deleteFiles = mockGitPort.commitToGit.mock.calls[0][3]; + expect(deleteFiles).toEqual([ + { path: 'plugins/security', type: DeleteItemType.Directory }, + ]); + }); + + it('ensures the branch before pushing the commit', () => { + const branchCallOrder = + mockGitPort.createBranchFromBase.mock.invocationCallOrder[0]; + const commitCallOrder = + mockGitPort.commitToGit.mock.invocationCallOrder[0]; + expect(branchCallOrder).toBeLessThan(commitCallOrder); + }); + + it('ensures the rolling Packmind sync PR after committing', () => { + expect(mockGitPort.openOrUpdatePullRequest).toHaveBeenCalledWith( + gitRepo, + expect.objectContaining({ head: MARKETPLACE_SYNC_BRANCH }), + ); + }); + + it('flips the distribution to to_be_removed once the sync commit landed', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith(marketplaceDistributionId, { + status: DistributionStatus.to_be_removed, + }); + }); + + it('flips the status only after committing to the sync branch', () => { + const commitCallOrder = + mockGitPort.commitToGit.mock.invocationCallOrder[0]; + const statusCallOrder = + mockMarketplaceDistributionRepository.updateStatus.mock + .invocationCallOrder[0]; + expect(commitCallOrder).toBeLessThan(statusCallOrder); + }); + }); + + describe('when the distribution is not in success state', () => { + beforeEach(async () => { + mockMarketplaceDistributionRepository.findById.mockResolvedValue({ + ...distribution, + status: DistributionStatus.removed, + }); + await job.runJob('job-removed', input, new AbortController()); + }); + + it('does not regress the status (leaves a terminal/pending row untouched)', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when the distribution is a pending_merge publish marked for removal', () => { + beforeEach(async () => { + mockMarketplaceDistributionRepository.findById.mockResolvedValue({ + ...distribution, + status: DistributionStatus.pending_merge, + }); + await job.runJob('job-pending-merge', input, new AbortController()); + }); + + it('commits the deletion to the rolling sync branch', () => { + expect(mockGitPort.commitToGit).toHaveBeenCalledWith( + expect.objectContaining({ branch: MARKETPLACE_SYNC_BRANCH }), + expect.any(Array), + MARKETPLACE_SYNC_PR_TITLE, + expect.any(Array), + ); + }); + + it('flips the distribution to to_be_removed once the sync commit landed', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith(marketplaceDistributionId, { + status: DistributionStatus.to_be_removed, + }); + }); + }); + + describe('when the rolling sync branch does not yet exist', () => { + beforeEach(async () => { + mockGitPort.checkBranchExists.mockResolvedValue(false); + await job.runJob('job-no-sync', input, new AbortController()); + }); + + it('reads the descriptor from the marketplace default branch', () => { + expect(mockGitPort.getFileFromRepo).toHaveBeenCalledWith( + gitRepo, + '.claude-plugin/marketplace.json', + gitRepo.branch, + ); + }); + + it('reads the packmind-lock.json from the marketplace default branch', () => { + expect(mockGitPort.getFileFromRepo).toHaveBeenCalledWith( + gitRepo, + 'packmind-lock.json', + gitRepo.branch, + ); + }); + }); + + describe('when the plugin is already absent (NO_CHANGES_DETECTED)', () => { + beforeEach(() => { + mockGitPort.commitToGit = jest + .fn() + .mockRejectedValue(new Error('NO_CHANGES_DETECTED')); + }); + + it('treats it as a no-op and does not throw', async () => { + await expect( + job.runJob('job-1', input, new AbortController()), + ).resolves.toBeUndefined(); + }); + + it('still ensures the rolling PR so a branch orphaned by a prior failed run self-heals', async () => { + await job.runJob('job-1', input, new AbortController()); + + expect(mockGitPort.openOrUpdatePullRequest).toHaveBeenCalledWith( + gitRepo, + expect.objectContaining({ head: MARKETPLACE_SYNC_BRANCH }), + ); + }); + + it('still flips the status (the deletion already lives on the sync branch)', async () => { + await job.runJob('job-1', input, new AbortController()); + + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith(marketplaceDistributionId, { + status: DistributionStatus.to_be_removed, + }); + }); + }); + + describe('when ensuring the rolling PR fails', () => { + beforeEach(() => { + mockGitPort.openOrUpdatePullRequest = jest + .fn() + .mockRejectedValue(new Error('GitHub 502')); + }); + + it('does not throw (the commit already landed on the sync branch)', async () => { + await expect( + job.runJob('job-1', input, new AbortController()), + ).resolves.toBeUndefined(); + }); + }); + + describe('when the git commit fails for another reason', () => { + beforeEach(() => { + mockGitPort.commitToGit = jest.fn().mockRejectedValue(new Error('boom')); + }); + + it('propagates the error so the job can be retried', async () => { + await expect( + job.runJob('job-1', input, new AbortController()), + ).rejects.toThrow('boom'); + }); + + it('leaves the status untouched', async () => { + await job + .runJob('job-1', input, new AbortController()) + .catch(() => undefined); + + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when the marketplace git repo is missing', () => { + beforeEach(() => { + mockGitRepoService.findMarketplaceGitRepoById = jest + .fn() + .mockResolvedValue(null); + }); + + it('throws without attempting a commit', async () => { + await expect( + job.runJob('job-1', input, new AbortController()), + ).rejects.toThrow(/git repo not found/i); + }); + }); +}); diff --git a/packages/deployments/src/application/jobs/RemovePluginFromMarketplaceDelayedJob.ts b/packages/deployments/src/application/jobs/RemovePluginFromMarketplaceDelayedJob.ts new file mode 100644 index 000000000..8e4b8a8a3 --- /dev/null +++ b/packages/deployments/src/application/jobs/RemovePluginFromMarketplaceDelayedJob.ts @@ -0,0 +1,383 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + AbstractAIDelayedJob, + getErrorMessage, + IQueue, + QueueListeners, + WorkerListeners, +} from '@packmind/node-utils'; +import { GitRepoService } from '@packmind/git'; +import { + DeleteItem, + DeleteItemType, + DistributionStatus, + FileModification, + GitRepo, + IGitPort, + MARKETPLACE_DESCRIPTOR_FILENAME, + Marketplace, + MarketplaceDescriptor, + MarketplaceDistribution, + MarketplaceDistributionId, + PackmindMarketplaceLock, + RemovePluginFromMarketplaceJobInput, + RemovePluginFromMarketplaceJobOutput, +} from '@packmind/types'; +import { IMarketplaceDistributionRepository } from '../../domain/repositories/IMarketplaceDistributionRepository'; +import { IMarketplaceRepository } from '../../domain/repositories/IMarketplaceRepository'; +import { removePluginDescriptorEntry } from '../services/PluginDescriptorMutator'; +import { removePackmindMarketplaceLockEntry } from '../services/applyPackmindMarketplaceLockMutation'; +import { + fetchPackmindMarketplaceLock, + PACKMIND_MARKETPLACE_LOCK_PATH, + serializePackmindMarketplaceLock, +} from '../services/packmindMarketplaceLock'; +import { + MARKETPLACE_SYNC_BRANCH, + MARKETPLACE_SYNC_PR_TITLE, +} from '../services/marketplaceSyncPullRequest'; +import { fetchMarketplaceDescriptorFile } from '../services/fetchMarketplaceDescriptorFile'; +import { MarketplaceDescriptorParserRegistry } from '../services/MarketplaceDescriptorParserRegistry'; +import { resolveMarketplaceReadBranch } from '../services/resolveMarketplaceReadBranch'; + +const logOrigin = 'RemovePluginFromMarketplaceDelayedJob'; + +/** + * Background job that performs the Git side effects of retiring a published + * marketplace plugin — the inverse of + * `PublishPluginToMarketplaceDelayedJob`. + * + * Algorithm: + * 1. Load the marketplace distribution row, marketplace and package. + * 2. Ensure the rolling `packmind/sync` branch exists, then fresh-read + + * reparse the descriptor *from that branch* so the removal stacks on top + * of any pending publishes already accumulated on the PR rather than + * clobbering them. + * 3. Drop the plugin entry from the descriptor via `PluginDescriptorMutator`. + * 4. Push the updated descriptor + a directory delete for the plugin's files + * onto `packmind/sync` via `IGitPort.commitToGit`. `NO_CHANGES_DETECTED` + * (plugin already gone) is treated as a no-op. + * 5. Once the deletion is on `packmind/sync`, flip the distribution + * `success`/`pending_merge` → `to_be_removed`. The status therefore tracks + * the sync branch: it only flips after the deletion PR genuinely exists, + * never on the bare request. The flip is guarded to those statuses so it + * never regresses a cascade-preflipped row or a terminal `removed` row. + * + * The terminal `removed` transition stays owned by + * `MarketplaceReconciliationDelayedJob`, which fires once the deletion PR + * merges and the slug disappears from the default-branch descriptor. + * + * BullMQ concurrency is intentionally constrained to a single worker — Git + * operations on the rolling PR must be serialized. + */ +export class RemovePluginFromMarketplaceDelayedJob extends AbstractAIDelayedJob< + RemovePluginFromMarketplaceJobInput, + RemovePluginFromMarketplaceJobOutput +> { + readonly origin = logOrigin; + + constructor( + queueFactory: ( + queueListeners: Partial<QueueListeners>, + ) => Promise< + IQueue< + RemovePluginFromMarketplaceJobInput, + RemovePluginFromMarketplaceJobOutput + > + >, + private readonly marketplaceDistributionRepository: IMarketplaceDistributionRepository, + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly gitRepoService: GitRepoService, + private readonly gitPort: IGitPort, + private readonly parserRegistry: MarketplaceDescriptorParserRegistry, + logger: PackmindLogger = new PackmindLogger(logOrigin), + ) { + super(queueFactory, logger); + } + + async onFail(jobId: string): Promise<void> { + this.logger.error( + `[${this.origin}] Job ${jobId} failed — distribution keeps its current status (stays 'success' until the sync commit lands) for retry/reconciliation`, + ); + } + + async runJob( + jobId: string, + input: RemovePluginFromMarketplaceJobInput, + _controller: AbortController, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise<RemovePluginFromMarketplaceJobOutput> { + this.logger.info( + `[${this.origin}] Processing removal job ${jobId} for distribution ${input.marketplaceDistributionId}`, + { + marketplaceId: input.marketplaceId, + packageId: input.packageId, + }, + ); + + const { distribution, marketplace } = await this.loadContext(input); + + // The distribution stores the plugin slug captured at publish time + // (`pluginSlug === package.slug`), so we never need the package itself — + // critical for the package-deletion cascade where the package is gone. + const pluginSlug = distribution.pluginSlug; + + const marketplaceGitRepo = + await this.gitRepoService.findMarketplaceGitRepoById( + marketplace.gitRepoId, + ); + if (!marketplaceGitRepo) { + throw new Error( + `[${this.origin}] Marketplace git repo not found for marketplace ${input.marketplaceId}`, + ); + } + + // Resolve which branch the descriptor + lock should be read from. When + // the rolling `packmind/sync` branch already exists from a prior + // publish/remove, that branch carries the accumulated unmerged state and + // is the canonical source. Falls back to the default branch on the very + // first sync-branch operation (the branch will be created from the + // default branch right before the commit lands) and on post-merge + // operations where the rolling branch has been deleted. + const readBranch = await resolveMarketplaceReadBranch( + this.gitPort, + marketplaceGitRepo, + ); + + const descriptorFile = await fetchMarketplaceDescriptorFile( + this.gitPort, + marketplaceGitRepo, + readBranch, + ); + if (!descriptorFile) { + throw new Error( + `[${this.origin}] Marketplace descriptor missing on ${marketplaceGitRepo.owner}/${marketplaceGitRepo.repo}`, + ); + } + + const descriptor: MarketplaceDescriptor = this.parserRegistry.parse( + descriptorFile.content, + ); + + // Read the standalone packmind-lock.json from the same branch as the + // descriptor. A missing lock yields the empty shape, which is fine — the + // descriptor entry is dropped either way. + const lock: PackmindMarketplaceLock = await fetchPackmindMarketplaceLock( + this.gitPort, + marketplaceGitRepo, + readBranch, + ); + + // Ensure the rolling-PR branch exists before committing to it. First op + // creates it from the marketplace's default branch; later ones no-op. + await this.gitPort.createBranchFromBase( + marketplaceGitRepo, + MARKETPLACE_SYNC_BRANCH, + ); + + const nextDescriptor = removePluginDescriptorEntry(descriptor, pluginSlug); + const nextLock = removePackmindMarketplaceLockEntry(lock, pluginSlug); + + const fileUpdates: FileModification[] = [ + { + path: descriptorFile.path ?? MARKETPLACE_DESCRIPTOR_FILENAME, + content: this.serializeDescriptor(nextDescriptor), + }, + { + path: PACKMIND_MARKETPLACE_LOCK_PATH, + content: serializePackmindMarketplaceLock(nextLock), + }, + ]; + + // The plugin's rendered files live under `plugins/<slug>/` — mirrors the + // `pluginRoot` the publish renderer writes to. Remove the whole directory. + const deleteFiles: DeleteItem[] = [ + { + path: `plugins/${pluginSlug}`, + type: DeleteItemType.Directory, + }, + ]; + + try { + await this.gitPort.commitToGit( + { ...marketplaceGitRepo, branch: MARKETPLACE_SYNC_BRANCH }, + fileUpdates, + MARKETPLACE_SYNC_PR_TITLE, + deleteFiles, + ); + } catch (error) { + if (error instanceof Error && error.message === 'NO_CHANGES_DETECTED') { + this.logger.info( + `[${this.origin}] Plugin "${pluginSlug}" already absent from the sync branch; nothing to commit`, + { marketplaceDistributionId: input.marketplaceDistributionId }, + ); + // No new commit this run, but the deletion already lives on + // `packmind/sync` (a prior run committed it, or it was never there). + // The sync branch reflects the removal either way, so flip the status + // just like the committing path does. + await this.transitionToToBeRemoved(distribution); + // Still ensure the rolling PR so a branch orphaned by a prior run + // whose PR-open step transiently failed self-heals instead of waiting + // for a manual PR. Opening a PR with no commits ahead of base is a + // no-op (the host rejects it) and is swallowed inside the helper. + await this.ensureRollingPullRequest( + marketplaceGitRepo, + input.marketplaceDistributionId, + ); + return; + } + throw error; + } + + // The deletion is now on `packmind/sync` — flip the status so it tracks + // the sync branch. A DB failure here rethrows so the job retries (the next + // run hits NO_CHANGES_DETECTED and re-attempts the flip). + await this.transitionToToBeRemoved(distribution); + + // Ensure the rolling "Packmind sync" PR exists (idempotent — amends the + // existing one). Mirrors the publish job. A PR-call failure after a + // successful commit is logged but not rolled back: the deletion already + // landed on `packmind/sync`. + await this.ensureRollingPullRequest( + marketplaceGitRepo, + input.marketplaceDistributionId, + ); + + this.logger.info( + `[${this.origin}] Committed removal of plugin "${pluginSlug}" to ${MARKETPLACE_SYNC_BRANCH}`, + { + marketplaceDistributionId: input.marketplaceDistributionId, + marketplaceId: input.marketplaceId, + }, + ); + } + + /** + * Ensure the rolling "Packmind sync" PR exists for the marketplace repo. + * Idempotent — amends the existing PR if one is open. A failure here is + * logged but never rolled back or rethrown: the commit (if any) already + * landed on `packmind/sync`, and surfacing the PR is best-effort. Crucially + * this is reachable on the `NO_CHANGES_DETECTED` path too, so a branch left + * without a PR by a prior failed run self-heals on the next attempt. + */ + private async ensureRollingPullRequest( + marketplaceGitRepo: GitRepo, + marketplaceDistributionId: MarketplaceDistributionId, + ): Promise<void> { + try { + await this.gitPort.openOrUpdatePullRequest(marketplaceGitRepo, { + head: MARKETPLACE_SYNC_BRANCH, + title: MARKETPLACE_SYNC_PR_TITLE, + body: 'Packmind-managed plugin sync. Successive publishes amend this PR.', + }); + } catch (error) { + this.logger.warn( + `[${this.origin}] Failed to ensure rolling PR for distribution ${marketplaceDistributionId}`, + { error: getErrorMessage(error) }, + ); + } + } + + /** + * Flip `success`/`pending_merge` → `to_be_removed` once the deletion lives + * on `packmind/sync`. Guarded to those statuses: a `to_be_removed` row + * (cascade pre-flip or a prior run) is already correct, and a terminal + * `removed` row must never be regressed. Rethrows on DB failure so the job + * retries. + */ + private async transitionToToBeRemoved( + distribution: MarketplaceDistribution, + ): Promise<void> { + if ( + distribution.status !== DistributionStatus.success && + distribution.status !== DistributionStatus.pending_merge + ) { + return; + } + await this.marketplaceDistributionRepository.updateStatus(distribution.id, { + status: DistributionStatus.to_be_removed, + }); + this.logger.info( + `[${this.origin}] Distribution flipped to to_be_removed after the sync-branch commit`, + { + marketplaceDistributionId: distribution.id, + fromStatus: distribution.status, + toStatus: DistributionStatus.to_be_removed, + }, + ); + } + + private async loadContext( + input: RemovePluginFromMarketplaceJobInput, + ): Promise<{ + distribution: MarketplaceDistribution; + marketplace: Marketplace; + }> { + const distribution = await this.marketplaceDistributionRepository.findById( + input.marketplaceDistributionId, + ); + if (!distribution) { + throw new Error( + `[${this.origin}] Marketplace distribution ${input.marketplaceDistributionId} not found`, + ); + } + const marketplace = await this.marketplaceRepository.findById( + input.marketplaceId, + ); + if (!marketplace) { + throw new Error( + `[${this.origin}] Marketplace ${input.marketplaceId} not found`, + ); + } + return { distribution, marketplace }; + } + + private serializeDescriptor(descriptor: MarketplaceDescriptor): string { + // Merge the normalized fields back over the original raw JSON so any + // unknown vendor-specific fields are preserved verbatim. + const rawBase = + typeof descriptor.raw === 'object' && descriptor.raw !== null + ? (descriptor.raw as Record<string, unknown>) + : {}; + const merged: Record<string, unknown> = { + ...rawBase, + name: descriptor.name, + plugins: descriptor.plugins, + }; + if (descriptor.version !== undefined) { + merged['version'] = descriptor.version; + } + // Strip the legacy embedded packmindLock so orphan fields from existing + // repos disappear on the next mutation — the lock now lives at the repo + // root as a standalone packmind-lock.json file. + delete merged['packmindLock']; + return JSON.stringify(merged, null, 2); + } + + getJobName(input: RemovePluginFromMarketplaceJobInput): string { + return `remove-plugin-from-marketplace-${input.marketplaceDistributionId}`; + } + + jobStartedInfo(input: RemovePluginFromMarketplaceJobInput): string { + return `marketplaceDistributionId: ${input.marketplaceDistributionId}`; + } + + getWorkerListener(): Partial< + WorkerListeners< + RemovePluginFromMarketplaceJobInput, + RemovePluginFromMarketplaceJobOutput + > + > { + return { + completed: async (job) => { + this.logger.info( + `[${this.origin}] Job ${job.id} completed for distribution ${job.data.marketplaceDistributionId}`, + ); + }, + failed: async (job, error) => { + this.logger.error( + `[${this.origin}] Job ${job.id} failed for distribution ${job.data.marketplaceDistributionId}: ${getErrorMessage(error)}`, + ); + }, + }; + } +} diff --git a/packages/deployments/src/application/listeners/PackageDeletedDistributionsListener.spec.ts b/packages/deployments/src/application/listeners/PackageDeletedDistributionsListener.spec.ts new file mode 100644 index 000000000..f6a29050f --- /dev/null +++ b/packages/deployments/src/application/listeners/PackageDeletedDistributionsListener.spec.ts @@ -0,0 +1,368 @@ +import { v4 as uuidv4 } from 'uuid'; +import { DataSource } from 'typeorm'; +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { + createMarketplaceDistributionId, + createMarketplaceId, + createOrganizationId, + createPackageId, + createSpaceId, + createUserId, + DistributionStatus, + MarketplaceDistribution, + MarketplacePluginRemovalInitiatedEvent, + Package, + PackagesDeletedEvent, +} from '@packmind/types'; +import { IMarketplaceDistributionRepository } from '../../domain/repositories/IMarketplaceDistributionRepository'; +import { PackageService } from '../services/PackageService'; +import { PackageDeletedDistributionsListener } from './PackageDeletedDistributionsListener'; + +describe('PackageDeletedDistributionsListener', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const spaceId = createSpaceId(uuidv4()); + const packageId = createPackageId(uuidv4()); + const otherPackageId = createPackageId(uuidv4()); + const marketplaceA = createMarketplaceId(uuidv4()); + const marketplaceB = createMarketplaceId(uuidv4()); + + const buildSuccessDistribution = ( + overrides: Partial<MarketplaceDistribution> = {}, + ): MarketplaceDistribution => + ({ + id: createMarketplaceDistributionId(uuidv4()), + organizationId, + marketplaceId: marketplaceA, + packageId, + pluginSlug: 'my-plugin', + authorId: userId, + status: DistributionStatus.success, + source: 'app', + ...overrides, + }) as unknown as MarketplaceDistribution; + + let eventService: PackmindEventEmitterService; + let mockMarketplaceDistributionRepository: jest.Mocked<IMarketplaceDistributionRepository>; + let mockPackageService: jest.Mocked<PackageService>; + let mockRemovalJob: { addJob: jest.Mock }; + let listener: PackageDeletedDistributionsListener; + let mockDataSource: DataSource; + let emitSpy: jest.SpyInstance; + + beforeEach(() => { + mockDataSource = { + isInitialized: true, + options: {}, + } as unknown as DataSource; + + eventService = new PackmindEventEmitterService(mockDataSource); + + mockMarketplaceDistributionRepository = { + findActiveByPackageId: jest.fn().mockResolvedValue([]), + updateStatus: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked<IMarketplaceDistributionRepository>; + + mockPackageService = { + findById: jest + .fn() + .mockResolvedValue({ id: packageId, slug: 'my-package' } as Package), + } as unknown as jest.Mocked<PackageService>; + + mockRemovalJob = { + addJob: jest.fn().mockResolvedValue('job-id'), + }; + + listener = new PackageDeletedDistributionsListener({ + marketplaceDistributionRepository: mockMarketplaceDistributionRepository, + packageService: mockPackageService, + removePluginFromMarketplaceJob: mockRemovalJob as never, + }); + listener.initialize(eventService); + + emitSpy = jest.spyOn(eventService, 'emit'); + }); + + afterEach(() => { + eventService.removeAllListeners(); + jest.clearAllMocks(); + }); + + const waitForHandlers = () => + new Promise((resolve) => setTimeout(resolve, 10)); + + describe('when a single distribution exists for the deleted package', () => { + const distribution = buildSuccessDistribution(); + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findActiveByPackageId.mockResolvedValue( + [distribution], + ); + + eventService.emit( + new PackagesDeletedEvent({ + userId, + organizationId, + packageIds: [packageId], + spaceId, + }), + ); + + await waitForHandlers(); + }); + + it('flips the distribution to to_be_removed', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith(distribution.id, { + status: DistributionStatus.to_be_removed, + }); + }); + + it('enqueues the removal job for the cascaded distribution', () => { + expect(mockRemovalJob.addJob).toHaveBeenCalledWith( + expect.objectContaining({ + marketplaceDistributionId: distribution.id, + marketplaceId: marketplaceA, + }), + ); + }); + + describe('cascade event', () => { + let cascadeEvents: MarketplacePluginRemovalInitiatedEvent[]; + + beforeEach(() => { + cascadeEvents = emitSpy.mock.calls + .map(([event]) => event) + .filter( + (event): event is MarketplacePluginRemovalInitiatedEvent => + event instanceof MarketplacePluginRemovalInitiatedEvent, + ); + }); + + it('emits exactly one MarketplacePluginRemovalInitiatedEvent', () => { + expect(cascadeEvents).toHaveLength(1); + }); + + it('uses the from_packmind_package trigger', () => { + expect(cascadeEvents[0].payload.trigger).toBe('from_packmind_package'); + }); + + it('carries the distribution id in the payload', () => { + expect(cascadeEvents[0].payload.distributionId).toBe(distribution.id); + }); + + it('carries the marketplace id in the payload', () => { + expect(cascadeEvents[0].payload.marketplaceId).toBe(marketplaceA); + }); + }); + }); + + describe('when multiple distributions exist across marketplaces', () => { + const distA = buildSuccessDistribution({ marketplaceId: marketplaceA }); + const distB = buildSuccessDistribution({ marketplaceId: marketplaceB }); + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findActiveByPackageId.mockResolvedValue( + [distA, distB], + ); + + eventService.emit( + new PackagesDeletedEvent({ + userId, + organizationId, + packageIds: [packageId], + spaceId, + }), + ); + + await waitForHandlers(); + }); + + it('calls updateStatus once per distribution', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledTimes(2); + }); + + it('flips distribution A to to_be_removed', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith(distA.id, { + status: DistributionStatus.to_be_removed, + }); + }); + + it('flips distribution B to to_be_removed', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith(distB.id, { + status: DistributionStatus.to_be_removed, + }); + }); + + it('emits one event per affected distribution', () => { + const cascadeEvents = emitSpy.mock.calls + .map(([event]) => event) + .filter( + (event) => event instanceof MarketplacePluginRemovalInitiatedEvent, + ); + expect(cascadeEvents).toHaveLength(2); + }); + }); + + describe('when no distributions exist for the deleted package', () => { + beforeEach(async () => { + mockMarketplaceDistributionRepository.findActiveByPackageId.mockResolvedValue( + [], + ); + + eventService.emit( + new PackagesDeletedEvent({ + userId, + organizationId, + packageIds: [packageId], + spaceId, + }), + ); + + await waitForHandlers(); + }); + + it('does not call updateStatus', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).not.toHaveBeenCalled(); + }); + + it('does not emit a removal event', () => { + const cascadeEvents = emitSpy.mock.calls + .map(([event]) => event) + .filter( + (event) => event instanceof MarketplacePluginRemovalInitiatedEvent, + ); + expect(cascadeEvents).toHaveLength(0); + }); + }); + + describe('status filter — already to_be_removed rows are skipped', () => { + const liveDist = buildSuccessDistribution(); + const alreadyPending = buildSuccessDistribution({ + status: DistributionStatus.to_be_removed, + }); + + beforeEach(async () => { + // The repo would normally pre-filter; we double-up to assert the + // listener's defensive guard. + mockMarketplaceDistributionRepository.findActiveByPackageId.mockResolvedValue( + [liveDist, alreadyPending], + ); + + eventService.emit( + new PackagesDeletedEvent({ + userId, + organizationId, + packageIds: [packageId], + spaceId, + }), + ); + + await waitForHandlers(); + }); + + it('calls updateStatus exactly once', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledTimes(1); + }); + + it('only flips the live success row to to_be_removed', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith(liveDist.id, { + status: DistributionStatus.to_be_removed, + }); + }); + }); + + describe('when a pending_merge distribution exists for the deleted package', () => { + const pendingMerge = buildSuccessDistribution({ + status: DistributionStatus.pending_merge, + }); + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findActiveByPackageId.mockResolvedValue( + [pendingMerge], + ); + + eventService.emit( + new PackagesDeletedEvent({ + userId, + organizationId, + packageIds: [packageId], + spaceId, + }), + ); + + await waitForHandlers(); + }); + + it('flips the pending publish to to_be_removed so the sync branch gets reverted', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledWith(pendingMerge.id, { + status: DistributionStatus.to_be_removed, + }); + }); + + it('enqueues the removal job for the cascaded pending publish', () => { + expect(mockRemovalJob.addJob).toHaveBeenCalledWith( + expect.objectContaining({ + marketplaceDistributionId: pendingMerge.id, + }), + ); + }); + }); + + describe('when the event carries multiple package ids', () => { + const distFromPkg1 = buildSuccessDistribution({ packageId }); + const distFromPkg2 = buildSuccessDistribution({ + packageId: otherPackageId, + marketplaceId: marketplaceB, + }); + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findActiveByPackageId.mockImplementation( + async (pid) => + pid === packageId + ? [distFromPkg1] + : pid === otherPackageId + ? [distFromPkg2] + : [], + ); + + eventService.emit( + new PackagesDeletedEvent({ + userId, + organizationId, + packageIds: [packageId, otherPackageId], + spaceId, + }), + ); + + await waitForHandlers(); + }); + + it('looks up active distributions once per package id', () => { + expect( + mockMarketplaceDistributionRepository.findActiveByPackageId, + ).toHaveBeenCalledTimes(2); + }); + + it('flips one distribution per package id', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/deployments/src/application/listeners/PackageDeletedDistributionsListener.ts b/packages/deployments/src/application/listeners/PackageDeletedDistributionsListener.ts new file mode 100644 index 000000000..15c7eac57 --- /dev/null +++ b/packages/deployments/src/application/listeners/PackageDeletedDistributionsListener.ts @@ -0,0 +1,174 @@ +import { PackmindLogger } from '@packmind/logger'; +import { PackmindListener } from '@packmind/node-utils'; +import { + DistributionStatus, + MarketplacePluginRemovalInitiatedEvent, + PackagesDeletedEvent, + PackageId, +} from '@packmind/types'; +import { IMarketplaceDistributionRepository } from '../../domain/repositories/IMarketplaceDistributionRepository'; +import { PackageService } from '../services/PackageService'; +import { RemovePluginFromMarketplaceDelayedJob } from '../jobs/RemovePluginFromMarketplaceDelayedJob'; + +const origin = 'PackageDeletedDistributionsListener'; + +/** + * Aggregates the dependencies needed to flip live marketplace distributions + * to `to_be_removed` when a Packmind package is deleted. + */ +export type PackageDeletedDistributionsAdapter = { + marketplaceDistributionRepository: IMarketplaceDistributionRepository; + packageService: PackageService; + removePluginFromMarketplaceJob: RemovePluginFromMarketplaceDelayedJob; +}; + +/** + * Cascades a Packmind package deletion to every linked-marketplace + * distribution of that package: + * + * - Subscribes to `PackagesDeletedEvent` (emitted by + * `DeletePackagesBatchUseCase`). + * - For each deleted package, looks up every `success` or `pending_merge` + * distribution via `findActiveByPackageId` (the status filter skips + * already `to_be_removed` rows so the listener is idempotent on retries). + * - Transitions each one to `to_be_removed`, emits + * `MarketplacePluginRemovalInitiatedEvent` with + * `trigger='from_packmind_package'`, and enqueues + * `RemovePluginFromMarketplaceDelayedJob` so the deletion is committed onto + * the rolling `packmind/sync` PR (symmetric to the manual removal flow). + */ +export class PackageDeletedDistributionsListener extends PackmindListener<PackageDeletedDistributionsAdapter> { + constructor( + adapter: PackageDeletedDistributionsAdapter, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(adapter); + } + + protected registerHandlers(): void { + this.subscribe(PackagesDeletedEvent, this.handlePackagesDeleted); + } + + private handlePackagesDeleted = async ( + event: PackagesDeletedEvent, + ): Promise<void> => { + const { packageIds, userId, organizationId, source } = event.payload; + this.logger.info('Handling PackagesDeletedEvent', { + packageCount: packageIds.length, + organizationId, + }); + + for (const packageId of packageIds) { + try { + await this.cascadePackageDeletion( + packageId, + userId, + organizationId, + source, + ); + } catch (error) { + this.logger.error( + 'Failed to cascade package deletion to marketplace distributions', + { + packageId, + error: error instanceof Error ? error.message : String(error), + }, + ); + // Re-throw so the error surfaces in tests/observability. + throw error; + } + } + }; + + private async cascadePackageDeletion( + packageId: PackageId, + userId: PackagesDeletedEvent['payload']['userId'], + organizationId: PackagesDeletedEvent['payload']['organizationId'], + source: PackagesDeletedEvent['payload']['source'], + ): Promise<void> { + const liveDistributions = + await this.adapter.marketplaceDistributionRepository.findActiveByPackageId( + packageId, + ); + + if (liveDistributions.length === 0) { + this.logger.info( + 'No live marketplace distributions for deleted package', + { + packageId, + }, + ); + return; + } + + // Best-effort: resolve the package slug once for the event payload. The + // package is already deleted in this code path, so an empty slug is the + // graceful fallback (the event consumer can still correlate via id). + const pkg = await this.adapter.packageService.findById(packageId); + const packageSlug = pkg?.slug ?? ''; + + for (const distribution of liveDistributions) { + // Defensive: findActiveByPackageId already filters on + // success/pending_merge, but the contract is explicit so we + // double-check. A pending_merge row is cascaded too — its plugin files + // already sit on the rolling sync branch and must be reverted. + if ( + distribution.status !== DistributionStatus.success && + distribution.status !== DistributionStatus.pending_merge + ) { + continue; + } + + await this.adapter.marketplaceDistributionRepository.updateStatus( + distribution.id, + { status: DistributionStatus.to_be_removed }, + ); + + this.eventEmitterService.emit( + new MarketplacePluginRemovalInitiatedEvent({ + userId, + organizationId, + source, + marketplaceId: distribution.marketplaceId, + distributionId: distribution.id, + packageId: distribution.packageId, + packageSlug, + pluginSlug: distribution.pluginSlug, + trigger: 'from_packmind_package', + }), + ); + + // Enqueue the Git side effect (commit the deletion to `packmind/sync`). + // Best-effort: a failed enqueue must not abort the cascade — the + // distribution stays `to_be_removed` for reconciliation/manual fallback. + try { + await this.adapter.removePluginFromMarketplaceJob.addJob({ + marketplaceDistributionId: distribution.id, + marketplaceId: distribution.marketplaceId, + packageId: distribution.packageId, + organizationId, + userId, + }); + } catch (error) { + this.logger.error( + 'Failed to enqueue marketplace plugin removal job during cascade', + { + distributionId: distribution.id, + marketplaceId: distribution.marketplaceId, + error: error instanceof Error ? error.message : String(error), + }, + ); + } + + this.logger.info( + 'Marketplace plugin distribution cascaded to to_be_removed', + { + distributionId: distribution.id, + marketplaceId: distribution.marketplaceId, + fromStatus: distribution.status, + toStatus: DistributionStatus.to_be_removed, + }, + ); + } + } +} diff --git a/packages/deployments/src/application/services/MarketplaceDescriptorParserRegistry.spec.ts b/packages/deployments/src/application/services/MarketplaceDescriptorParserRegistry.spec.ts new file mode 100644 index 000000000..b9e9d5b88 --- /dev/null +++ b/packages/deployments/src/application/services/MarketplaceDescriptorParserRegistry.spec.ts @@ -0,0 +1,280 @@ +import { stubLogger } from '@packmind/test-utils'; +import { + IMarketplaceDescriptorParser, + MarketplaceDescriptor, +} from '@packmind/types'; +import { + MarketplaceDescriptorParseError, + UnknownMarketplaceDescriptorError, +} from '../../domain/errors'; +import { MarketplaceDescriptorParserRegistry } from './MarketplaceDescriptorParserRegistry'; + +const sampleDescriptor: MarketplaceDescriptor = { + vendor: 'anthropic', + name: 'Test Marketplace', + plugins: [{ slug: 'plugin-a', name: 'Plugin A' }], + raw: { name: 'Test Marketplace', plugins: [{ name: 'Plugin A' }] }, +}; + +const buildParser = ( + overrides: Partial<IMarketplaceDescriptorParser> = {}, +): jest.Mocked<IMarketplaceDescriptorParser> => ({ + canParse: jest.fn().mockReturnValue(false), + parse: jest.fn().mockReturnValue(sampleDescriptor), + ...overrides, +}); + +describe('MarketplaceDescriptorParserRegistry', () => { + let logger: ReturnType<typeof stubLogger>; + + beforeEach(() => { + logger = stubLogger(); + }); + + describe('parse', () => { + describe('when the first registered parser claims the content', () => { + let claiming: jest.Mocked<IMarketplaceDescriptorParser>; + let fallback: jest.Mocked<IMarketplaceDescriptorParser>; + let result: MarketplaceDescriptor; + + beforeEach(() => { + claiming = buildParser({ + canParse: jest.fn().mockReturnValue(true), + parse: jest.fn().mockReturnValue(sampleDescriptor), + }); + fallback = buildParser(); + const registry = new MarketplaceDescriptorParserRegistry( + [claiming, fallback], + logger, + ); + + result = registry.parse('{"name":"Test Marketplace"}'); + }); + + it('returns the descriptor produced by that parser', () => { + expect(result).toBe(sampleDescriptor); + }); + + it('invokes the claiming parser once', () => { + expect(claiming.parse).toHaveBeenCalledTimes(1); + }); + + it('does not consult the fallback parser', () => { + expect(fallback.canParse).not.toHaveBeenCalled(); + }); + + it('does not parse with the fallback parser', () => { + expect(fallback.parse).not.toHaveBeenCalled(); + }); + }); + + describe('when the first parser declines but the second claims', () => { + let declining: jest.Mocked<IMarketplaceDescriptorParser>; + let claiming: jest.Mocked<IMarketplaceDescriptorParser>; + let result: MarketplaceDescriptor; + + beforeEach(() => { + declining = buildParser({ + canParse: jest.fn().mockReturnValue(false), + }); + claiming = buildParser({ + canParse: jest.fn().mockReturnValue(true), + parse: jest.fn().mockReturnValue(sampleDescriptor), + }); + const registry = new MarketplaceDescriptorParserRegistry( + [declining, claiming], + logger, + ); + + result = registry.parse('{"foo":"bar"}'); + }); + + it('falls through to the next parser', () => { + expect(result).toBe(sampleDescriptor); + }); + + it('consults the declining parser once', () => { + expect(declining.canParse).toHaveBeenCalledTimes(1); + }); + + it('does not parse with the declining parser', () => { + expect(declining.parse).not.toHaveBeenCalled(); + }); + + it('parses with the claiming parser once', () => { + expect(claiming.parse).toHaveBeenCalledTimes(1); + }); + }); + + describe('when no parser claims the content', () => { + it('throws UnknownMarketplaceDescriptorError', () => { + const registry = new MarketplaceDescriptorParserRegistry( + [buildParser(), buildParser()], + logger, + ); + + expect(() => registry.parse('{"foo":"bar"}')).toThrow( + UnknownMarketplaceDescriptorError, + ); + }); + }); + + describe('when no parsers are registered', () => { + it('throws UnknownMarketplaceDescriptorError', () => { + const registry = new MarketplaceDescriptorParserRegistry([], logger); + + expect(() => registry.parse('{}')).toThrow( + UnknownMarketplaceDescriptorError, + ); + }); + }); + + describe('when a claiming parser throws MarketplaceDescriptorParseError', () => { + it('propagates the original error untouched', () => { + const cause = { issues: ['missing name'] }; + const parseError = new MarketplaceDescriptorParseError( + 'Invalid descriptor', + cause, + ); + const claiming = buildParser({ + canParse: jest.fn().mockReturnValue(true), + parse: jest.fn().mockImplementation(() => { + throw parseError; + }), + }); + const registry = new MarketplaceDescriptorParserRegistry( + [claiming], + logger, + ); + + let thrown: unknown; + try { + registry.parse('{"foo":"bar"}'); + } catch (error) { + thrown = error; + } + + expect(thrown).toBe(parseError); + }); + }); + + describe('when a claiming parser throws an unexpected error', () => { + let cause: Error; + let thrown: unknown; + + beforeEach(() => { + cause = new Error('boom'); + const claiming = buildParser({ + canParse: jest.fn().mockReturnValue(true), + parse: jest.fn().mockImplementation(() => { + throw cause; + }), + }); + const registry = new MarketplaceDescriptorParserRegistry( + [claiming], + logger, + ); + + try { + registry.parse('{"foo":"bar"}'); + } catch (error) { + thrown = error; + } + }); + + it('wraps it in a MarketplaceDescriptorParseError', () => { + expect(thrown).toBeInstanceOf(MarketplaceDescriptorParseError); + }); + + it('carries the original error as the cause', () => { + expect((thrown as MarketplaceDescriptorParseError).cause).toBe(cause); + }); + }); + + describe('when the input is not valid JSON', () => { + describe('without consulting parsers', () => { + let parser: jest.Mocked<IMarketplaceDescriptorParser>; + let registry: MarketplaceDescriptorParserRegistry; + + beforeEach(() => { + parser = buildParser({ + canParse: jest.fn().mockReturnValue(true), + }); + registry = new MarketplaceDescriptorParserRegistry([parser], logger); + }); + + it('throws MarketplaceDescriptorParseError', () => { + expect(() => registry.parse('{ not valid json')).toThrow( + MarketplaceDescriptorParseError, + ); + }); + + it('does not consult canParse on any parser', () => { + try { + registry.parse('{ not valid json'); + } catch { + // expected + } + + expect(parser.canParse).not.toHaveBeenCalled(); + }); + + it('does not call parse on any parser', () => { + try { + registry.parse('{ not valid json'); + } catch { + // expected + } + + expect(parser.parse).not.toHaveBeenCalled(); + }); + }); + + describe('with no parsers registered', () => { + let thrown: unknown; + + beforeEach(() => { + const registry = new MarketplaceDescriptorParserRegistry([], logger); + + try { + registry.parse('not json at all'); + } catch (error) { + thrown = error; + } + }); + + it('throws a MarketplaceDescriptorParseError', () => { + expect(thrown).toBeInstanceOf(MarketplaceDescriptorParseError); + }); + + it('preserves the underlying SyntaxError on the cause property', () => { + expect( + (thrown as MarketplaceDescriptorParseError).cause, + ).toBeInstanceOf(SyntaxError); + }); + }); + }); + + describe('when a parser throws during canParse', () => { + it('skips that parser and continues with the next', () => { + const throwing = buildParser({ + canParse: jest.fn().mockImplementation(() => { + throw new Error('canParse blew up'); + }), + }); + const claiming = buildParser({ + canParse: jest.fn().mockReturnValue(true), + parse: jest.fn().mockReturnValue(sampleDescriptor), + }); + const registry = new MarketplaceDescriptorParserRegistry( + [throwing, claiming], + logger, + ); + + const result = registry.parse('{"foo":"bar"}'); + + expect(result).toBe(sampleDescriptor); + }); + }); + }); +}); diff --git a/packages/deployments/src/application/services/MarketplaceDescriptorParserRegistry.ts b/packages/deployments/src/application/services/MarketplaceDescriptorParserRegistry.ts new file mode 100644 index 000000000..97d07d091 --- /dev/null +++ b/packages/deployments/src/application/services/MarketplaceDescriptorParserRegistry.ts @@ -0,0 +1,105 @@ +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { + IMarketplaceDescriptorParser, + MarketplaceDescriptor, +} from '@packmind/types'; +import { + MarketplaceDescriptorParseError, + UnknownMarketplaceDescriptorError, +} from '../../domain/errors'; + +const origin = 'MarketplaceDescriptorParserRegistry'; + +/** + * Vendor-agnostic registry for marketplace descriptor parsers. + * + * Iterates the parsers it was constructed with in declaration order and + * dispatches the raw descriptor payload to the first parser whose `canParse` + * returns true. New parsers (e.g. Copilot, Cursor) plug in by being appended + * to the constructor's `parsers` array; consumers (link/unlink use cases, + * reconciliation job) do not branch on vendor. + * + * Error contract: + * - `MarketplaceDescriptorParseError` — raised when the input is not valid JSON + * or when a claiming parser fails to parse/validate its claimed content. + * - `UnknownMarketplaceDescriptorError` — raised when no registered parser + * recognises the descriptor shape. + */ +export class MarketplaceDescriptorParserRegistry { + private readonly parsers: ReadonlyArray<IMarketplaceDescriptorParser>; + + constructor( + parsers: IMarketplaceDescriptorParser[], + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.INFO, + ), + ) { + this.parsers = [...parsers]; + this.logger.info('MarketplaceDescriptorParserRegistry initialized', { + parserCount: this.parsers.length, + }); + } + + /** + * Parses the raw descriptor content (the body of `marketplace.json`) into a + * normalized `MarketplaceDescriptor`. + * + * @throws MarketplaceDescriptorParseError when JSON parsing or a claiming + * parser's validation fails. + * @throws UnknownMarketplaceDescriptorError when no registered parser claims + * the content. + */ + parse(content: string): MarketplaceDescriptor { + const rawJson = this.parseJson(content); + + for (const parser of this.parsers) { + if (!this.canParserClaim(parser, rawJson)) { + continue; + } + + try { + return parser.parse(rawJson); + } catch (error) { + if (error instanceof MarketplaceDescriptorParseError) { + throw error; + } + throw new MarketplaceDescriptorParseError( + 'Marketplace descriptor parser failed to validate the descriptor content', + error, + ); + } + } + + this.logger.warn( + 'No registered marketplace descriptor parser claimed the content', + ); + throw new UnknownMarketplaceDescriptorError(); + } + + private parseJson(content: string): unknown { + try { + return JSON.parse(content); + } catch (error) { + throw new MarketplaceDescriptorParseError( + 'Marketplace descriptor content is not valid JSON', + error, + ); + } + } + + private canParserClaim( + parser: IMarketplaceDescriptorParser, + rawJson: unknown, + ): boolean { + try { + return parser.canParse(rawJson); + } catch (error) { + this.logger.warn( + 'Marketplace descriptor parser threw during canParse; skipping', + { error: error instanceof Error ? error.message : String(error) }, + ); + return false; + } + } +} diff --git a/packages/deployments/src/application/services/PackageVersionFingerprintService.spec.ts b/packages/deployments/src/application/services/PackageVersionFingerprintService.spec.ts new file mode 100644 index 000000000..b9b08b730 --- /dev/null +++ b/packages/deployments/src/application/services/PackageVersionFingerprintService.spec.ts @@ -0,0 +1,93 @@ +import { stubLogger } from '@packmind/test-utils'; +import { + IRecipesPort, + ISkillsPort, + IStandardsPort, + Package, + createPackageId, + createRecipeId, + createSkillId, + createStandardId, +} from '@packmind/types'; +import { PackageVersionFingerprintService } from './PackageVersionFingerprintService'; + +describe('PackageVersionFingerprintService', () => { + const recipeIdA = createRecipeId('recipe-a'); + const recipeIdB = createRecipeId('recipe-b'); + const standardId = createStandardId('standard-a'); + const skillId = createSkillId('skill-a'); + + const pkg = { + id: createPackageId('pkg-1'), + recipes: [recipeIdA, recipeIdB], + standards: [standardId], + skills: [skillId], + } as unknown as Package; + + let mockRecipesPort: jest.Mocked<IRecipesPort>; + let mockStandardsPort: jest.Mocked<IStandardsPort>; + let mockSkillsPort: jest.Mocked<ISkillsPort>; + let service: PackageVersionFingerprintService; + + beforeEach(() => { + mockRecipesPort = { + listRecipeVersions: jest.fn().mockImplementation(async (id) => { + if (id === recipeIdA) { + return [{ version: 1 }, { version: 3 }, { version: 2 }]; + } + return [{ version: 5 }]; + }), + } as unknown as jest.Mocked<IRecipesPort>; + + mockStandardsPort = { + getLatestStandardVersion: jest.fn().mockResolvedValue({ version: 7 }), + } as unknown as jest.Mocked<IStandardsPort>; + + mockSkillsPort = { + getLatestSkillVersion: jest.fn().mockResolvedValue({ version: 4 }), + } as unknown as jest.Mocked<ISkillsPort>; + + service = new PackageVersionFingerprintService( + mockRecipesPort, + mockStandardsPort, + mockSkillsPort, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when computing the fingerprint of a package', () => { + describe('when a recipe has multiple versions', () => { + it('picks the highest version number', async () => { + const result = await service.compute(pkg); + expect(result.recipes[recipeIdA]).toBe(3); + }); + }); + + it('records each recipe latest version', async () => { + const result = await service.compute(pkg); + expect(result.recipes[recipeIdB]).toBe(5); + }); + + it('records the latest standard version', async () => { + const result = await service.compute(pkg); + expect(result.standards[standardId]).toBe(7); + }); + + it('records the latest skill version', async () => { + const result = await service.compute(pkg); + expect(result.skills[skillId]).toBe(4); + }); + }); + + describe('when an artifact has no version', () => { + it('records 0 for a standard with no latest version', async () => { + mockStandardsPort.getLatestStandardVersion.mockResolvedValue(null); + const result = await service.compute(pkg); + expect(result.standards[standardId]).toBe(0); + }); + }); +}); diff --git a/packages/deployments/src/application/services/PackageVersionFingerprintService.ts b/packages/deployments/src/application/services/PackageVersionFingerprintService.ts new file mode 100644 index 000000000..517b71c99 --- /dev/null +++ b/packages/deployments/src/application/services/PackageVersionFingerprintService.ts @@ -0,0 +1,58 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + IRecipesPort, + ISkillsPort, + IStandardsPort, + Package, + VersionFingerprint, +} from '@packmind/types'; + +const origin = 'PackageVersionFingerprintService'; + +/** + * Computes a stable fingerprint of a package's current artifact versions + * (latest version number per recipe/standard/skill). No rendering and no user + * context required — safe to call from background jobs. Used at publish time + * to baseline a distribution, and on reconcile to detect "outdated". + */ +export class PackageVersionFingerprintService { + constructor( + private readonly recipesPort: IRecipesPort, + private readonly standardsPort: IStandardsPort, + private readonly skillsPort: ISkillsPort, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async compute(pkg: Package): Promise<VersionFingerprint> { + const recipes: Record<string, number> = {}; + await Promise.all( + pkg.recipes.map(async (recipeId) => { + const versions = await this.recipesPort.listRecipeVersions(recipeId); + const latest = versions.reduce( + (max, v) => (v.version > max ? v.version : max), + 0, + ); + recipes[recipeId] = latest; + }), + ); + + const standards: Record<string, number> = {}; + await Promise.all( + pkg.standards.map(async (standardId) => { + const latest = + await this.standardsPort.getLatestStandardVersion(standardId); + standards[standardId] = latest?.version ?? 0; + }), + ); + + const skills: Record<string, number> = {}; + await Promise.all( + pkg.skills.map(async (skillId) => { + const latest = await this.skillsPort.getLatestSkillVersion(skillId); + skills[skillId] = latest?.version ?? 0; + }), + ); + + return { recipes, standards, skills }; + } +} diff --git a/packages/deployments/src/application/services/PluginDescriptorMutator.spec.ts b/packages/deployments/src/application/services/PluginDescriptorMutator.spec.ts new file mode 100644 index 000000000..49f0e1917 --- /dev/null +++ b/packages/deployments/src/application/services/PluginDescriptorMutator.spec.ts @@ -0,0 +1,285 @@ +import { MarketplaceDescriptor, PluginSource } from '@packmind/types'; +import { + applyPluginDescriptorMutation, + removePluginDescriptorEntry, +} from './PluginDescriptorMutator'; + +const sampleSource: PluginSource = { + source: 'git-subdir', + url: 'https://github.com/test-org/test-marketplace.git', + path: 'plugins/security', +}; + +const otherSource: PluginSource = { + source: 'git-subdir', + url: 'https://github.com/test-org/test-marketplace.git', + path: 'plugins/existing-unmanaged', +}; + +describe('applyPluginDescriptorMutation', () => { + const baseDescriptor: MarketplaceDescriptor = { + vendor: 'anthropic', + name: 'sample-marketplace', + version: '1.0.0', + plugins: [ + { + slug: 'existing-unmanaged', + name: 'Existing Unmanaged', + version: '2.0.0', + source: otherSource, + }, + ], + raw: { + name: 'sample-marketplace', + version: '1.0.0', + plugins: [ + { + slug: 'existing-unmanaged', + name: 'Existing Unmanaged', + version: '2.0.0', + }, + ], + }, + }; + + afterEach(() => jest.clearAllMocks()); + + describe('when the plugin slug is new (first publish)', () => { + let result: MarketplaceDescriptor; + + beforeEach(() => { + result = applyPluginDescriptorMutation(baseDescriptor, { + pluginSlug: 'security', + pluginName: 'Security', + pluginVersion: '0.1.0', + pluginSource: sampleSource, + }); + }); + + it('appends a new plugin entry to descriptor.plugins[]', () => { + expect(result.plugins).toHaveLength(2); + }); + + it('keeps the unmanaged plugin entry untouched', () => { + expect(result.plugins[0]).toEqual(baseDescriptor.plugins[0]); + }); + + it('inserts the newly managed plugin in descriptor.plugins[]', () => { + expect(result.plugins[1]).toEqual({ + slug: 'security', + name: 'Security', + version: '0.1.0', + source: sampleSource, + }); + }); + + it('attaches the supplied source block on the new entry', () => { + expect(result.plugins[1].source).toEqual(sampleSource); + }); + }); + + describe('when the plugin slug already exists (republish)', () => { + let result: MarketplaceDescriptor; + + beforeEach(() => { + const descriptorWithEntry: MarketplaceDescriptor = { + ...baseDescriptor, + plugins: [ + ...baseDescriptor.plugins, + { + slug: 'security', + name: 'Security old', + version: '0.1.0', + source: sampleSource, + }, + ], + }; + + result = applyPluginDescriptorMutation(descriptorWithEntry, { + pluginSlug: 'security', + pluginName: 'Security', + pluginVersion: '0.2.0', + pluginSource: sampleSource, + }); + }); + + it('preserves the total plugin count', () => { + expect(result.plugins).toHaveLength(2); + }); + + it('updates the existing plugin entry in place', () => { + const updated = result.plugins.find((p) => p.slug === 'security'); + expect(updated).toEqual({ + slug: 'security', + name: 'Security', + version: '0.2.0', + source: sampleSource, + }); + }); + }); + + describe('when an unmanaged plugin is already present under a different slug', () => { + let result: MarketplaceDescriptor; + + beforeEach(() => { + result = applyPluginDescriptorMutation(baseDescriptor, { + pluginSlug: 'security', + pluginName: 'Security', + pluginVersion: '0.1.0', + pluginSource: sampleSource, + }); + }); + + it('keeps the unmanaged plugin entry in place', () => { + const unmanaged = result.plugins.find( + (p) => p.slug === 'existing-unmanaged', + ); + expect(unmanaged).toEqual({ + slug: 'existing-unmanaged', + name: 'Existing Unmanaged', + version: '2.0.0', + source: otherSource, + }); + }); + + it('preserves the existing source block on the other entry', () => { + const unmanaged = result.plugins.find( + (p) => p.slug === 'existing-unmanaged', + ); + expect(unmanaged?.source).toEqual(otherSource); + }); + }); + + describe('when a plugin description is supplied', () => { + let result: MarketplaceDescriptor; + + beforeEach(() => { + result = applyPluginDescriptorMutation(baseDescriptor, { + pluginSlug: 'security', + pluginName: 'Security', + pluginVersion: '0.1.0', + pluginDescription: 'Packmind - space @engineering: Security pack', + pluginSource: sampleSource, + }); + }); + + it('writes the description onto the new plugin entry', () => { + const inserted = result.plugins.find((p) => p.slug === 'security'); + expect(inserted?.description).toBe( + 'Packmind - space @engineering: Security pack', + ); + }); + }); + + describe('when no plugin description is supplied', () => { + let result: MarketplaceDescriptor; + + beforeEach(() => { + result = applyPluginDescriptorMutation(baseDescriptor, { + pluginSlug: 'security', + pluginName: 'Security', + pluginVersion: '0.1.0', + pluginSource: sampleSource, + }); + }); + + it('leaves description undefined on the new plugin entry', () => { + const inserted = result.plugins.find((p) => p.slug === 'security'); + expect(inserted?.description).toBeUndefined(); + }); + }); + + describe('idempotency', () => { + describe('when called twice with the same input', () => { + let first: MarketplaceDescriptor; + let second: MarketplaceDescriptor; + + beforeEach(() => { + first = applyPluginDescriptorMutation(baseDescriptor, { + pluginSlug: 'security', + pluginName: 'Security', + pluginVersion: '0.1.0', + pluginSource: sampleSource, + }); + second = applyPluginDescriptorMutation(first, { + pluginSlug: 'security', + pluginName: 'Security', + pluginVersion: '0.1.0', + pluginSource: sampleSource, + }); + }); + + it('produces identical output', () => { + expect(second).toEqual(first); + }); + }); + }); + + describe('immutability', () => { + it('does not mutate the input plugins array', () => { + const snapshot = [...baseDescriptor.plugins]; + applyPluginDescriptorMutation(baseDescriptor, { + pluginSlug: 'security', + pluginName: 'Security', + pluginVersion: '0.1.0', + pluginSource: sampleSource, + }); + expect(baseDescriptor.plugins).toEqual(snapshot); + }); + }); +}); + +describe('removePluginDescriptorEntry', () => { + const descriptorWithSecurity: MarketplaceDescriptor = { + vendor: 'anthropic', + name: 'sample-marketplace', + version: '1.0.0', + plugins: [ + { + slug: 'existing-unmanaged', + name: 'Existing Unmanaged', + version: '2.0.0', + }, + { slug: 'security', name: 'Security', version: '0.1.0' }, + ], + raw: { name: 'sample-marketplace' }, + }; + + afterEach(() => jest.clearAllMocks()); + + describe('when the slug is present', () => { + let result: MarketplaceDescriptor; + + beforeEach(() => { + result = removePluginDescriptorEntry(descriptorWithSecurity, 'security'); + }); + + it('drops the matching entry from descriptor.plugins[]', () => { + expect(result.plugins.some((p) => p.slug === 'security')).toBe(false); + }); + + it('keeps unmanaged plugin entries', () => { + expect(result.plugins.some((p) => p.slug === 'existing-unmanaged')).toBe( + true, + ); + }); + }); + + describe('when the slug is already absent', () => { + it('returns the same set of plugins (idempotent)', () => { + const result = removePluginDescriptorEntry( + descriptorWithSecurity, + 'never-published', + ); + expect(result.plugins).toHaveLength(2); + }); + }); + + describe('immutability', () => { + it('does not mutate the input descriptor plugins', () => { + const snapshot = [...descriptorWithSecurity.plugins]; + removePluginDescriptorEntry(descriptorWithSecurity, 'security'); + expect(descriptorWithSecurity.plugins).toEqual(snapshot); + }); + }); +}); diff --git a/packages/deployments/src/application/services/PluginDescriptorMutator.ts b/packages/deployments/src/application/services/PluginDescriptorMutator.ts new file mode 100644 index 000000000..3a2547b37 --- /dev/null +++ b/packages/deployments/src/application/services/PluginDescriptorMutator.ts @@ -0,0 +1,110 @@ +import { + MarketplaceDescriptor, + PluginRef, + PluginSource, +} from '@packmind/types'; + +/** + * Plugin metadata supplied by the publish job when it asks the mutator to + * add or refresh a Packmind-managed plugin entry inside the marketplace + * descriptor. + */ +export type PluginDescriptorMutationInput = { + /** Slug used as the canonical identifier inside `descriptor.plugins[]`. */ + pluginSlug: string; + /** Human-readable name displayed by marketplace consumers. */ + pluginName: string; + /** Optional plugin version surfaced on `PluginRef.version`. */ + pluginVersion?: string; + /** Optional description displayed by marketplace consumers. */ + pluginDescription?: string; + /** + * Source coordinates Claude Code (and other marketplace consumers) need to + * actually install the plugin. The publish job builds this from the + * marketplace's backing Git repo + the plugin slug. + */ + pluginSource: PluginSource; +}; + +/** + * Pure descriptor mutator used by the marketplace publish pipeline. + * + * Given a parsed `MarketplaceDescriptor` and the metadata for one managed + * plugin, returns a brand-new descriptor with `descriptor.plugins[]` + * containing an up-to-date entry for the plugin slug (insert on first + * publish, refresh in place on subsequent publishes). + * + * The standalone `packmind-lock.json` is mutated by a separate concern + * (`applyPackmindMarketplaceLockMutation`) — the descriptor mutator stays + * focused on the vendor file and does not touch any Packmind metadata. + * + * The function is idempotent — calling it twice with the same input + * produces the same descriptor. Unmanaged plugin entries are left + * untouched. + * + * The function does **not** mutate the input descriptor in place. The + * returned descriptor is a structural copy suitable for serialization. + */ +export function applyPluginDescriptorMutation( + descriptor: MarketplaceDescriptor, + mutation: PluginDescriptorMutationInput, +): MarketplaceDescriptor { + const { + pluginSlug, + pluginName, + pluginVersion, + pluginDescription, + pluginSource, + } = mutation; + + const refreshedPlugins = upsertPluginRef(descriptor.plugins, { + slug: pluginSlug, + name: pluginName, + ...(pluginVersion !== undefined ? { version: pluginVersion } : {}), + ...(pluginDescription !== undefined + ? { description: pluginDescription } + : {}), + source: pluginSource, + }); + + return { + ...descriptor, + plugins: refreshedPlugins, + }; +} + +/** + * Pure descriptor mutator used by the marketplace removal pipeline — the + * inverse of {@link applyPluginDescriptorMutation}. + * + * Given a parsed `MarketplaceDescriptor` and a plugin slug, returns a + * brand-new descriptor with the matching entry dropped from + * `descriptor.plugins[]`. + * + * Idempotent — removing a slug that is already absent returns a structurally + * equivalent descriptor. Unmanaged plugin entries are left untouched. Does + * **not** mutate the input descriptor in place. + */ +export function removePluginDescriptorEntry( + descriptor: MarketplaceDescriptor, + pluginSlug: string, +): MarketplaceDescriptor { + const refreshedPlugins = descriptor.plugins.filter( + (p) => p.slug !== pluginSlug, + ); + + return { + ...descriptor, + plugins: refreshedPlugins, + }; +} + +function upsertPluginRef(current: PluginRef[], next: PluginRef): PluginRef[] { + const existingIndex = current.findIndex((p) => p.slug === next.slug); + if (existingIndex === -1) { + return [...current, next]; + } + const copy = [...current]; + copy[existingIndex] = next; + return copy; +} diff --git a/packages/deployments/src/application/services/applyPackmindMarketplaceLockMutation.spec.ts b/packages/deployments/src/application/services/applyPackmindMarketplaceLockMutation.spec.ts new file mode 100644 index 000000000..da0d9a878 --- /dev/null +++ b/packages/deployments/src/application/services/applyPackmindMarketplaceLockMutation.spec.ts @@ -0,0 +1,213 @@ +import { + PackmindMarketplaceLock, + PackmindMarketplaceLockPluginEntry, + createUserId, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; +import { + applyPackmindMarketplaceLockMutation, + removePackmindMarketplaceLockEntry, +} from './applyPackmindMarketplaceLockMutation'; + +describe('applyPackmindMarketplaceLockMutation', () => { + const userId = createUserId(uuidv4()); + const publishedAt = new Date('2026-06-02T12:00:00.000Z'); + + afterEach(() => jest.clearAllMocks()); + + describe('when the lock is previously empty (first publish)', () => { + let result: PackmindMarketplaceLock; + + beforeEach(() => { + result = applyPackmindMarketplaceLockMutation( + { schemaVersion: 1, plugins: {} }, + { + pluginSlug: 'security', + pluginVersion: '0.1.0', + contentHash: 'hash-1', + lastPublishedAt: publishedAt, + lastPublishedBy: userId, + }, + ); + }); + + it('keeps schemaVersion at 1', () => { + expect(result.schemaVersion).toBe(1); + }); + + it('writes a single entry under the published slug', () => { + expect(result.plugins).toEqual({ + security: { + version: '0.1.0', + contentHash: 'hash-1', + lastPublishedAt: '2026-06-02T12:00:00.000Z', + lastPublishedBy: userId, + }, + }); + }); + }); + + describe('when the lock already contains the same slug (republish)', () => { + let result: PackmindMarketplaceLock; + const previousEntry: PackmindMarketplaceLockPluginEntry = { + version: '0.1.0', + contentHash: 'old-hash', + lastPublishedAt: '2026-05-30T10:00:00.000Z', + lastPublishedBy: userId, + }; + + beforeEach(() => { + result = applyPackmindMarketplaceLockMutation( + { + schemaVersion: 1, + plugins: { security: previousEntry }, + }, + { + pluginSlug: 'security', + pluginVersion: '0.2.0', + contentHash: 'new-hash', + lastPublishedAt: publishedAt, + lastPublishedBy: userId, + }, + ); + }); + + it('overwrites the entry with the latest values', () => { + expect(result.plugins['security']).toEqual({ + version: '0.2.0', + contentHash: 'new-hash', + lastPublishedAt: '2026-06-02T12:00:00.000Z', + lastPublishedBy: userId, + }); + }); + }); + + describe('when the lock contains other unrelated entries', () => { + let result: PackmindMarketplaceLock; + const otherEntry: PackmindMarketplaceLockPluginEntry = { + version: '1.0.0', + contentHash: 'other-hash', + lastPublishedAt: '2026-05-20T00:00:00.000Z', + lastPublishedBy: userId, + }; + + beforeEach(() => { + result = applyPackmindMarketplaceLockMutation( + { + schemaVersion: 1, + plugins: { observability: otherEntry }, + }, + { + pluginSlug: 'security', + pluginVersion: '0.1.0', + contentHash: 'hash-1', + lastPublishedAt: publishedAt, + lastPublishedBy: userId, + }, + ); + }); + + it('preserves the unrelated entry verbatim', () => { + expect(result.plugins['observability']).toEqual(otherEntry); + }); + }); + + describe('idempotency', () => { + describe('when called twice with the same input', () => { + let first: PackmindMarketplaceLock; + let second: PackmindMarketplaceLock; + + beforeEach(() => { + const lock: PackmindMarketplaceLock = { + schemaVersion: 1, + plugins: {}, + }; + first = applyPackmindMarketplaceLockMutation(lock, { + pluginSlug: 'security', + pluginVersion: '0.1.0', + contentHash: 'hash-1', + lastPublishedAt: publishedAt, + lastPublishedBy: userId, + }); + second = applyPackmindMarketplaceLockMutation(first, { + pluginSlug: 'security', + pluginVersion: '0.1.0', + contentHash: 'hash-1', + lastPublishedAt: publishedAt, + lastPublishedBy: userId, + }); + }); + + it('produces the same lock', () => { + expect(second).toEqual(first); + }); + }); + }); + + describe('immutability', () => { + it('does not mutate the input lock', () => { + const lock: PackmindMarketplaceLock = { + schemaVersion: 1, + plugins: {}, + }; + const snapshot = JSON.stringify(lock); + applyPackmindMarketplaceLockMutation(lock, { + pluginSlug: 'security', + pluginVersion: '0.1.0', + contentHash: 'hash-1', + lastPublishedAt: publishedAt, + lastPublishedBy: userId, + }); + expect(JSON.stringify(lock)).toBe(snapshot); + }); + }); +}); + +describe('removePackmindMarketplaceLockEntry', () => { + const userId = createUserId(uuidv4()); + const entry: PackmindMarketplaceLockPluginEntry = { + version: '0.1.0', + contentHash: 'hash-1', + lastPublishedAt: '2026-06-01T10:00:00.000Z', + lastPublishedBy: userId, + }; + + afterEach(() => jest.clearAllMocks()); + + describe('when the slug is present', () => { + it('removes the matching entry', () => { + const result = removePackmindMarketplaceLockEntry( + { + schemaVersion: 1, + plugins: { security: entry, observability: entry }, + }, + 'security', + ); + expect(result.plugins['security']).toBeUndefined(); + }); + }); + + describe('when other entries remain', () => { + it('preserves them', () => { + const result = removePackmindMarketplaceLockEntry( + { + schemaVersion: 1, + plugins: { security: entry, observability: entry }, + }, + 'security', + ); + expect(result.plugins['observability']).toEqual(entry); + }); + }); + + describe('when the slug is already absent', () => { + it('returns a structurally equivalent lock', () => { + const before: PackmindMarketplaceLock = { + schemaVersion: 1, + plugins: { observability: entry }, + }; + const after = removePackmindMarketplaceLockEntry(before, 'security'); + expect(after).toEqual(before); + }); + }); +}); diff --git a/packages/deployments/src/application/services/applyPackmindMarketplaceLockMutation.ts b/packages/deployments/src/application/services/applyPackmindMarketplaceLockMutation.ts new file mode 100644 index 000000000..e271da2b7 --- /dev/null +++ b/packages/deployments/src/application/services/applyPackmindMarketplaceLockMutation.ts @@ -0,0 +1,71 @@ +import { + PackmindMarketplaceLock, + PackmindMarketplaceLockPluginEntry, + UserId, +} from '@packmind/types'; + +/** + * Input to the lock mutator — the publish job builds one of these per + * successful publish and asks the mutator to upsert the slug entry inside + * the standalone `packmind-lock.json` file. + */ +export type PackmindMarketplaceLockMutationInput = { + pluginSlug: string; + pluginVersion: string; + contentHash: string; + lastPublishedAt: Date; + lastPublishedBy: UserId; +}; + +/** + * Pure mutator for the standalone Packmind marketplace lock file. + * + * Given a parsed `PackmindMarketplaceLock` and the metadata for one managed + * plugin, returns a brand-new lock with an up-to-date entry under the + * plugin slug (insert on first publish, refresh in place on subsequent + * publishes). Entries for other slugs are preserved verbatim. + * + * The function is idempotent — calling it twice with the same input + * produces the same lock. It does NOT mutate the input lock in place. The + * returned lock is structurally suitable for serialization. + */ +export function applyPackmindMarketplaceLockMutation( + lock: PackmindMarketplaceLock, + params: PackmindMarketplaceLockMutationInput, +): PackmindMarketplaceLock { + const entry: PackmindMarketplaceLockPluginEntry = { + version: params.pluginVersion, + contentHash: params.contentHash, + lastPublishedAt: params.lastPublishedAt.toISOString(), + lastPublishedBy: params.lastPublishedBy, + }; + + return { + schemaVersion: 1, + plugins: { + ...lock.plugins, + [params.pluginSlug]: entry, + }, + }; +} + +/** + * Pure mutator that removes a plugin entry from the standalone lock. Used + * by the remove-plugin job to keep the lock in sync with the descriptor + * when retiring a managed plugin. + * + * Idempotent — removing a slug that is already absent returns a + * structurally equivalent lock. Does NOT mutate the input lock in place. + */ +export function removePackmindMarketplaceLockEntry( + lock: PackmindMarketplaceLock, + pluginSlug: string, +): PackmindMarketplaceLock { + const plugins = Object.fromEntries( + Object.entries(lock.plugins).filter(([slug]) => slug !== pluginSlug), + ); + return { + schemaVersion: 1, + plugins, + }; +} diff --git a/packages/deployments/src/application/services/buildPluginContentHash.spec.ts b/packages/deployments/src/application/services/buildPluginContentHash.spec.ts new file mode 100644 index 000000000..eadebf74e --- /dev/null +++ b/packages/deployments/src/application/services/buildPluginContentHash.spec.ts @@ -0,0 +1,105 @@ +import { + buildPluginContentHash, + PluginContentEntry, +} from './buildPluginContentHash'; + +describe('buildPluginContentHash', () => { + const entries: PluginContentEntry[] = [ + { path: 'plugin.json', content: '{"name":"sample"}' }, + { path: 'commands/foo.md', content: '# foo' }, + { path: 'skills/bar.md', content: '# bar' }, + ]; + + describe('stability', () => { + let firstHash: string; + let secondHash: string; + + beforeEach(() => { + firstHash = buildPluginContentHash(entries); + secondHash = buildPluginContentHash([...entries]); + }); + + it('returns the same hash for the same input', () => { + expect(firstHash).toBe(secondHash); + }); + + it('returns a 64-character hex sha256 digest', () => { + expect(firstHash).toMatch(/^[a-f0-9]{64}$/); + }); + }); + + describe('order-independence', () => { + let hashA: string; + let hashB: string; + + beforeEach(() => { + hashA = buildPluginContentHash(entries); + hashB = buildPluginContentHash([...entries].reverse()); + }); + + it('produces the same hash regardless of input order', () => { + expect(hashA).toBe(hashB); + }); + }); + + describe('tamper-evidence', () => { + let baseline: string; + + beforeEach(() => { + baseline = buildPluginContentHash(entries); + }); + + describe('when a content byte changes', () => { + let tamperedHash: string; + + beforeEach(() => { + const tampered: PluginContentEntry[] = entries.map((entry, idx) => + idx === 0 ? { ...entry, content: entry.content + 'X' } : entry, + ); + tamperedHash = buildPluginContentHash(tampered); + }); + + it('changes the hash', () => { + expect(tamperedHash).not.toBe(baseline); + }); + }); + + describe('when a path changes', () => { + let tamperedHash: string; + + beforeEach(() => { + const tampered: PluginContentEntry[] = entries.map((entry, idx) => + idx === 0 ? { ...entry, path: entry.path + '-renamed' } : entry, + ); + tamperedHash = buildPluginContentHash(tampered); + }); + + it('changes the hash', () => { + expect(tamperedHash).not.toBe(baseline); + }); + }); + }); + + describe('framing', () => { + it('does not collide for inputs that share concatenated bytes', () => { + const collisionAttemptA: PluginContentEntry[] = [ + { path: 'a', content: 'b' }, + ]; + const collisionAttemptB: PluginContentEntry[] = [ + { path: 'ab', content: '' }, + ]; + + expect(buildPluginContentHash(collisionAttemptA)).not.toBe( + buildPluginContentHash(collisionAttemptB), + ); + }); + }); + + describe('empty input', () => { + it('returns a stable hash for an empty entry list', () => { + const firstHash = buildPluginContentHash([]); + const secondHash = buildPluginContentHash([]); + expect(firstHash).toBe(secondHash); + }); + }); +}); diff --git a/packages/deployments/src/application/services/buildPluginContentHash.ts b/packages/deployments/src/application/services/buildPluginContentHash.ts new file mode 100644 index 000000000..4a6d7881f Binary files /dev/null and b/packages/deployments/src/application/services/buildPluginContentHash.ts differ diff --git a/packages/deployments/src/application/services/fetchMarketplaceDescriptorFile.spec.ts b/packages/deployments/src/application/services/fetchMarketplaceDescriptorFile.spec.ts new file mode 100644 index 000000000..dcd8b8903 --- /dev/null +++ b/packages/deployments/src/application/services/fetchMarketplaceDescriptorFile.spec.ts @@ -0,0 +1,144 @@ +import { GitRepo, IGitPort } from '@packmind/types'; +import { fetchMarketplaceDescriptorFile } from './fetchMarketplaceDescriptorFile'; + +describe('fetchMarketplaceDescriptorFile', () => { + const gitRepo = { + id: 'repo-1', + owner: 'acme', + repo: 'marketplace', + branch: 'main', + providerId: 'gp-1', + type: 'marketplace', + } as unknown as GitRepo; + + function makeGitPort(getFileFromRepo: jest.Mock): IGitPort { + return { getFileFromRepo } as unknown as IGitPort; + } + + describe('when the official .claude-plugin path is present', () => { + let getFileFromRepo: jest.Mock; + let result: Awaited<ReturnType<typeof fetchMarketplaceDescriptorFile>>; + + beforeEach(async () => { + getFileFromRepo = jest + .fn() + .mockResolvedValue({ sha: 'sha-1', content: '{"plugins":[]}' }); + const gitPort = makeGitPort(getFileFromRepo); + + result = await fetchMarketplaceDescriptorFile(gitPort, gitRepo, 'main'); + }); + + it('returns the descriptor from the official .claude-plugin path', () => { + expect(result).toEqual({ + path: '.claude-plugin/marketplace.json', + sha: 'sha-1', + content: '{"plugins":[]}', + }); + }); + + it('probes a single path', () => { + expect(getFileFromRepo).toHaveBeenCalledTimes(1); + }); + + it('probes the official .claude-plugin path', () => { + expect(getFileFromRepo).toHaveBeenCalledWith( + gitRepo, + '.claude-plugin/marketplace.json', + 'main', + ); + }); + }); + + describe('when the official path is missing', () => { + let getFileFromRepo: jest.Mock; + let result: Awaited<ReturnType<typeof fetchMarketplaceDescriptorFile>>; + + beforeEach(async () => { + getFileFromRepo = jest + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ sha: 'sha-2', content: '{"plugins":[]}' }); + const gitPort = makeGitPort(getFileFromRepo); + + result = await fetchMarketplaceDescriptorFile(gitPort, gitRepo, 'main'); + }); + + it('falls back to the root marketplace.json', () => { + expect(result).toEqual({ + path: 'marketplace.json', + sha: 'sha-2', + content: '{"plugins":[]}', + }); + }); + + it('probes both candidate paths', () => { + expect(getFileFromRepo).toHaveBeenCalledTimes(2); + }); + + it('probes the root marketplace.json on the second attempt', () => { + expect(getFileFromRepo).toHaveBeenNthCalledWith( + 2, + gitRepo, + 'marketplace.json', + 'main', + ); + }); + }); + + describe('when no candidate path exists', () => { + let getFileFromRepo: jest.Mock; + let result: Awaited<ReturnType<typeof fetchMarketplaceDescriptorFile>>; + + beforeEach(async () => { + getFileFromRepo = jest.fn().mockResolvedValue(null); + const gitPort = makeGitPort(getFileFromRepo); + + result = await fetchMarketplaceDescriptorFile(gitPort, gitRepo, 'main'); + }); + + it('returns null', () => { + expect(result).toBeNull(); + }); + + it('probes both candidate paths', () => { + expect(getFileFromRepo).toHaveBeenCalledTimes(2); + }); + }); + + describe('when the transport throws', () => { + let getFileFromRepo: jest.Mock; + let act: () => Promise<unknown>; + + beforeEach(async () => { + getFileFromRepo = jest.fn().mockRejectedValue(new Error('network down')); + const gitPort = makeGitPort(getFileFromRepo); + + act = () => fetchMarketplaceDescriptorFile(gitPort, gitRepo, 'main'); + + await act().catch(() => undefined); + }); + + it('propagates the transport error', async () => { + await expect(act()).rejects.toThrow('network down'); + }); + + it('does not probe further', () => { + expect(getFileFromRepo).toHaveBeenCalledTimes(1); + }); + }); + + it('forwards an undefined branch so the port uses the default branch', async () => { + const getFileFromRepo = jest + .fn() + .mockResolvedValue({ sha: 'sha-1', content: '{"plugins":[]}' }); + const gitPort = makeGitPort(getFileFromRepo); + + await fetchMarketplaceDescriptorFile(gitPort, gitRepo); + + expect(getFileFromRepo).toHaveBeenCalledWith( + gitRepo, + '.claude-plugin/marketplace.json', + undefined, + ); + }); +}); diff --git a/packages/deployments/src/application/services/fetchMarketplaceDescriptorFile.ts b/packages/deployments/src/application/services/fetchMarketplaceDescriptorFile.ts new file mode 100644 index 000000000..f7fdb825a --- /dev/null +++ b/packages/deployments/src/application/services/fetchMarketplaceDescriptorFile.ts @@ -0,0 +1,46 @@ +import { + GitRepo, + IGitPort, + MARKETPLACE_DESCRIPTOR_PATHS, +} from '@packmind/types'; + +/** + * A marketplace descriptor file located on a git repository, plus the path it + * was found at (so callers can log which candidate matched). + */ +export interface MarketplaceDescriptorFile { + path: string; + sha: string; + content: string; +} + +/** + * Probes `MARKETPLACE_DESCRIPTOR_PATHS` in order and returns the first + * descriptor file that exists on the given branch, or `null` if none match. + * + * The official Claude Code layout is `.claude-plugin/marketplace.json`; a bare + * root `marketplace.json` is accepted as a fallback. Probing stops at the first + * hit so a present descriptor costs a single fetch. + * + * `IGitPort.getFileFromRepo` returns `null` for a missing file, which lets us + * try the next candidate. Transport errors are thrown by the port and + * propagate to the caller, so a network failure is never silently mistaken for + * a missing descriptor. + * + * When `branch` is omitted the git port falls back to the repository's default + * branch. + */ +export async function fetchMarketplaceDescriptorFile( + gitPort: IGitPort, + gitRepo: GitRepo, + branch?: string, +): Promise<MarketplaceDescriptorFile | null> { + for (const path of MARKETPLACE_DESCRIPTOR_PATHS) { + const file = await gitPort.getFileFromRepo(gitRepo, path, branch); + if (file) { + return { path, sha: file.sha, content: file.content }; + } + } + + return null; +} diff --git a/packages/deployments/src/application/services/formatMarketplacePluginDescription.spec.ts b/packages/deployments/src/application/services/formatMarketplacePluginDescription.spec.ts new file mode 100644 index 000000000..c09702ee2 --- /dev/null +++ b/packages/deployments/src/application/services/formatMarketplacePluginDescription.spec.ts @@ -0,0 +1,102 @@ +import { + formatMarketplacePluginDescription, + MARKETPLACE_PLUGIN_DESCRIPTION_MAX_LENGTH, +} from './formatMarketplacePluginDescription'; + +describe('formatMarketplacePluginDescription', () => { + describe('when the package description is non-empty', () => { + it('prefixes with Packmind attribution and the space slug', () => { + expect( + formatMarketplacePluginDescription({ + spaceSlug: 'engineering', + packageDescription: 'Security curated package', + }), + ).toBe('Packmind - space @engineering: Security curated package'); + }); + }); + + describe('when the package description is undefined', () => { + it('omits the colon and trailing payload', () => { + expect( + formatMarketplacePluginDescription({ + spaceSlug: 'engineering', + }), + ).toBe('Packmind - space @engineering'); + }); + }); + + describe('when the package description is whitespace-only', () => { + it('omits the colon and trailing payload', () => { + expect( + formatMarketplacePluginDescription({ + spaceSlug: 'engineering', + packageDescription: ' ', + }), + ).toBe('Packmind - space @engineering'); + }); + }); + + describe('when the package description carries leading or trailing whitespace', () => { + it('trims the description before composing the output', () => { + expect( + formatMarketplacePluginDescription({ + spaceSlug: 'engineering', + packageDescription: ' Security curated package ', + }), + ).toBe('Packmind - space @engineering: Security curated package'); + }); + }); + + describe('when the formatted output is at the cap', () => { + it('keeps the output verbatim', () => { + const prefix = 'Packmind - space @engineering: '; + const tailLength = + MARKETPLACE_PLUGIN_DESCRIPTION_MAX_LENGTH - prefix.length; + const tail = 'A'.repeat(tailLength); + + const result = formatMarketplacePluginDescription({ + spaceSlug: 'engineering', + packageDescription: tail, + }); + + expect(result).toBe(`${prefix}${tail}`); + }); + }); + + describe('when the formatted output exceeds the cap', () => { + let result: string; + + beforeEach(() => { + result = formatMarketplacePluginDescription({ + spaceSlug: 'engineering', + packageDescription: 'A'.repeat(500), + }); + }); + + it('caps the total length at the configured maximum', () => { + expect(result.length).toBe(MARKETPLACE_PLUGIN_DESCRIPTION_MAX_LENGTH); + }); + + it('ends with a single ellipsis character', () => { + expect(result.endsWith('…')).toBe(true); + }); + + it('preserves the Packmind attribution prefix', () => { + expect(result.startsWith('Packmind - space @engineering: ')).toBe(true); + }); + }); + + describe('when the cut point lands on whitespace', () => { + it('trims trailing whitespace before appending the ellipsis', () => { + const padding = ' '.repeat(MARKETPLACE_PLUGIN_DESCRIPTION_MAX_LENGTH); + const description = `Short text${padding}more`; + + const result = formatMarketplacePluginDescription({ + spaceSlug: 'engineering', + packageDescription: description, + }); + + expect(result.endsWith(' …')).toBe(false); + }); + }); +}); diff --git a/packages/deployments/src/application/services/formatMarketplacePluginDescription.ts b/packages/deployments/src/application/services/formatMarketplacePluginDescription.ts new file mode 100644 index 000000000..db01f5e73 --- /dev/null +++ b/packages/deployments/src/application/services/formatMarketplacePluginDescription.ts @@ -0,0 +1,41 @@ +/** + * Hard cap on the marketplace plugin description length. The Anthropic + * marketplace spec does not document an upper bound, but `Package.description` + * is an unbounded Postgres `text` column — without a defensive limit a + * multi-paragraph package description would land verbatim in `marketplace.json` + * and render poorly in any consumer UI. 200 characters keeps listings on par + * with conventional short-description budgets (GitHub, npm, VS Code). + */ +export const MARKETPLACE_PLUGIN_DESCRIPTION_MAX_LENGTH = 200; + +const ELLIPSIS = '…'; + +/** + * Build the description string displayed by marketplace consumers for a + * Packmind-published plugin entry. + * + * The prefix attributes the plugin to Packmind and identifies the originating + * space so reviewers browsing the marketplace can trace it back to its source. + * When the package itself has no description, the colon and trailing payload + * are dropped so the entry never reads as a half-finished sentence. When the + * full string exceeds {@link MARKETPLACE_PLUGIN_DESCRIPTION_MAX_LENGTH}, the + * tail is trimmed and a single Unicode ellipsis is appended so the total + * length still respects the cap. + */ +export function formatMarketplacePluginDescription(params: { + spaceSlug: string; + packageDescription?: string; +}): string { + const trimmed = params.packageDescription?.trim(); + const prefix = `Packmind - space @${params.spaceSlug}`; + const full = trimmed ? `${prefix}: ${trimmed}` : prefix; + + if (full.length <= MARKETPLACE_PLUGIN_DESCRIPTION_MAX_LENGTH) { + return full; + } + + const sliced = full + .slice(0, MARKETPLACE_PLUGIN_DESCRIPTION_MAX_LENGTH - ELLIPSIS.length) + .trimEnd(); + return `${sliced}${ELLIPSIS}`; +} diff --git a/packages/deployments/src/application/services/index.ts b/packages/deployments/src/application/services/index.ts index 705067445..380bd0a5b 100644 --- a/packages/deployments/src/application/services/index.ts +++ b/packages/deployments/src/application/services/index.ts @@ -1,4 +1,8 @@ export * from './DeploymentsServices'; +export * from './fetchMarketplaceDescriptorFile'; +export * from './resolveMarketplaceReadBranch'; +export * from './MarketplaceDescriptorParserRegistry'; +export * from './parsers'; export * from './PackageService'; export * from './packageSlugHelpers'; export * from './PackmindConfigService'; diff --git a/packages/deployments/src/application/services/marketplaceSyncPullRequest.spec.ts b/packages/deployments/src/application/services/marketplaceSyncPullRequest.spec.ts new file mode 100644 index 000000000..745f021ed --- /dev/null +++ b/packages/deployments/src/application/services/marketplaceSyncPullRequest.spec.ts @@ -0,0 +1,18 @@ +import { + MARKETPLACE_SYNC_BRANCH, + MARKETPLACE_SYNC_PR_TITLE, +} from './marketplaceSyncPullRequest'; + +describe('marketplaceSyncPullRequest constants', () => { + describe('MARKETPLACE_SYNC_PR_TITLE', () => { + it('exposes the verbatim title "Packmind sync"', () => { + expect(MARKETPLACE_SYNC_PR_TITLE).toBe('Packmind sync'); + }); + }); + + describe('MARKETPLACE_SYNC_BRANCH', () => { + it('exposes the verbatim branch "packmind/sync"', () => { + expect(MARKETPLACE_SYNC_BRANCH).toBe('packmind/sync'); + }); + }); +}); diff --git a/packages/deployments/src/application/services/marketplaceSyncPullRequest.ts b/packages/deployments/src/application/services/marketplaceSyncPullRequest.ts new file mode 100644 index 000000000..aaf016158 --- /dev/null +++ b/packages/deployments/src/application/services/marketplaceSyncPullRequest.ts @@ -0,0 +1,10 @@ +/** + * Shared constants for the rolling "Packmind sync" pull request used by the + * marketplace publish pipeline. + * + * Both the publish job (writer) and any read path that needs to detect a + * Packmind-managed PR must reference these values rather than hard-coding the + * literal strings — keeping title and branch in lockstep across surfaces. + */ +export const MARKETPLACE_SYNC_PR_TITLE = 'Packmind sync'; +export const MARKETPLACE_SYNC_BRANCH = 'packmind/sync'; diff --git a/packages/deployments/src/application/services/packmindMarketplaceLock.spec.ts b/packages/deployments/src/application/services/packmindMarketplaceLock.spec.ts new file mode 100644 index 000000000..6bcdb3e54 --- /dev/null +++ b/packages/deployments/src/application/services/packmindMarketplaceLock.spec.ts @@ -0,0 +1,210 @@ +import { + GitRepo, + IGitPort, + createGitProviderId, + createGitRepoId, + createUserId, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; +import { + emptyPackmindMarketplaceLock, + fetchPackmindMarketplaceLock, + PACKMIND_MARKETPLACE_LOCK_PATH, + parsePackmindMarketplaceLock, + serializePackmindMarketplaceLock, +} from './packmindMarketplaceLock'; + +describe('packmindMarketplaceLock', () => { + const gitRepo: GitRepo = { + id: createGitRepoId(uuidv4()), + owner: 'acme', + repo: 'marketplace', + branch: 'main', + providerId: createGitProviderId(uuidv4()), + type: 'marketplace', + }; + const userId = createUserId(uuidv4()); + + afterEach(() => jest.clearAllMocks()); + + describe('emptyPackmindMarketplaceLock', () => { + it('returns a lock with schemaVersion 1 and no plugins', () => { + expect(emptyPackmindMarketplaceLock()).toEqual({ + schemaVersion: 1, + plugins: {}, + }); + }); + }); + + describe('serializePackmindMarketplaceLock', () => { + it('produces pretty-printed JSON with two-space indent', () => { + const lock = { + schemaVersion: 1 as const, + plugins: { + security: { + version: '0.1.0', + contentHash: 'hash-1', + lastPublishedAt: '2026-06-01T10:00:00.000Z', + lastPublishedBy: userId, + }, + }, + }; + expect(serializePackmindMarketplaceLock(lock)).toBe( + JSON.stringify(lock, null, 2), + ); + }); + }); + + describe('parsePackmindMarketplaceLock', () => { + describe('when the content is a valid v1 lock', () => { + it('returns the parsed lock with all plugin entries', () => { + const content = JSON.stringify({ + schemaVersion: 1, + plugins: { + security: { + version: '0.1.0', + contentHash: 'hash-1', + lastPublishedAt: '2026-06-01T10:00:00.000Z', + lastPublishedBy: userId, + }, + }, + }); + expect(parsePackmindMarketplaceLock(content)).toEqual({ + schemaVersion: 1, + plugins: { + security: { + version: '0.1.0', + contentHash: 'hash-1', + lastPublishedAt: '2026-06-01T10:00:00.000Z', + lastPublishedBy: userId, + }, + }, + }); + }); + }); + + describe('when the content is not valid JSON', () => { + it('throws a descriptive error', () => { + expect(() => parsePackmindMarketplaceLock('not-json')).toThrow( + /packmind-lock\.json is not valid JSON/, + ); + }); + }); + + describe('when the top-level value is not an object', () => { + it('throws a descriptive error', () => { + expect(() => parsePackmindMarketplaceLock('[]')).toThrow( + /packmind-lock\.json must be a JSON object/, + ); + }); + }); + + describe('when schemaVersion is not 1', () => { + it('throws a descriptive error', () => { + const content = JSON.stringify({ schemaVersion: 2, plugins: {} }); + expect(() => parsePackmindMarketplaceLock(content)).toThrow( + /unsupported schemaVersion/, + ); + }); + }); + + describe('when plugins is missing or malformed', () => { + describe('when plugins is absent', () => { + it('throws', () => { + const content = JSON.stringify({ schemaVersion: 1 }); + expect(() => parsePackmindMarketplaceLock(content)).toThrow( + /must have a "plugins" object/, + ); + }); + }); + }); + + describe('when a plugin entry is missing a required field', () => { + it('throws a descriptive error mentioning the slug', () => { + const content = JSON.stringify({ + schemaVersion: 1, + plugins: { security: { contentHash: 'h' } }, + }); + expect(() => parsePackmindMarketplaceLock(content)).toThrow( + /security.*version/, + ); + }); + }); + }); + + describe('fetchPackmindMarketplaceLock', () => { + describe('when the file is missing', () => { + it('returns an empty lock', async () => { + const mockGitPort = { + getFileFromRepo: jest.fn().mockResolvedValue(null), + } as unknown as IGitPort; + const lock = await fetchPackmindMarketplaceLock( + mockGitPort, + gitRepo, + 'main', + ); + expect(lock).toEqual(emptyPackmindMarketplaceLock()); + }); + }); + + describe('when the file is present and valid', () => { + it('returns the parsed lock', async () => { + const content = JSON.stringify({ + schemaVersion: 1, + plugins: { + security: { + version: '0.1.0', + contentHash: 'hash-1', + lastPublishedAt: '2026-06-01T10:00:00.000Z', + lastPublishedBy: userId, + }, + }, + }); + const mockGitPort = { + getFileFromRepo: jest + .fn() + .mockResolvedValue({ sha: 'sha-1', content }), + } as unknown as IGitPort; + const lock = await fetchPackmindMarketplaceLock( + mockGitPort, + gitRepo, + 'main', + ); + expect(lock.plugins['security']?.contentHash).toBe('hash-1'); + }); + }); + + describe('when the file is present but unparseable', () => { + it('rethrows the parse error', async () => { + const mockGitPort = { + getFileFromRepo: jest + .fn() + .mockResolvedValue({ sha: 'sha-1', content: 'not-json' }), + } as unknown as IGitPort; + await expect( + fetchPackmindMarketplaceLock(mockGitPort, gitRepo, 'main'), + ).rejects.toThrow(/packmind-lock\.json is not valid JSON/); + }); + }); + + describe('when the file has an unsupported schemaVersion', () => { + it('rethrows the validation error', async () => { + const content = JSON.stringify({ schemaVersion: 99, plugins: {} }); + const mockGitPort = { + getFileFromRepo: jest + .fn() + .mockResolvedValue({ sha: 'sha-1', content }), + } as unknown as IGitPort; + await expect( + fetchPackmindMarketplaceLock(mockGitPort, gitRepo, 'main'), + ).rejects.toThrow(/unsupported schemaVersion/); + }); + }); + + describe('PACKMIND_MARKETPLACE_LOCK_PATH', () => { + it('targets the marketplace repo root', () => { + expect(PACKMIND_MARKETPLACE_LOCK_PATH).toBe('packmind-lock.json'); + }); + }); + }); +}); diff --git a/packages/deployments/src/application/services/packmindMarketplaceLock.ts b/packages/deployments/src/application/services/packmindMarketplaceLock.ts new file mode 100644 index 000000000..891e80165 --- /dev/null +++ b/packages/deployments/src/application/services/packmindMarketplaceLock.ts @@ -0,0 +1,156 @@ +import { + GitRepo, + IGitPort, + PACKMIND_MARKETPLACE_LOCK_FILENAME, + PackmindMarketplaceLock, + PackmindMarketplaceLockPluginEntry, +} from '@packmind/types'; + +/** + * Path of the standalone Packmind marketplace lock file. The file always + * sits at the marketplace repository root, regardless of where the vendor + * descriptor (e.g. `marketplace.json`) lives. + * + * Re-export of `PACKMIND_MARKETPLACE_LOCK_FILENAME` so callers in the + * deployments package can stay free of `@packmind/types` for this concern. + */ +export const PACKMIND_MARKETPLACE_LOCK_PATH = + PACKMIND_MARKETPLACE_LOCK_FILENAME; + +/** + * Parses the raw text content of `packmind-lock.json` into a typed + * `PackmindMarketplaceLock`. + * + * Throws a descriptive `Error` if the JSON is malformed, the top-level + * shape is not an object, `schemaVersion` is not exactly `1`, or `plugins` + * is not a record of properly shaped entries. The error message is + * intentionally compact so the caller can surface it inside a + * `MarketplaceDescriptorBadFormatError`. + */ +export function parsePackmindMarketplaceLock( + content: string, +): PackmindMarketplaceLock { + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`packmind-lock.json is not valid JSON: ${message}`); + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('packmind-lock.json must be a JSON object'); + } + + const candidate = parsed as Record<string, unknown>; + if (candidate['schemaVersion'] !== 1) { + throw new Error( + `packmind-lock.json has unsupported schemaVersion: expected 1, got ${String( + candidate['schemaVersion'], + )}`, + ); + } + + const pluginsRaw = candidate['plugins']; + if ( + typeof pluginsRaw !== 'object' || + pluginsRaw === null || + Array.isArray(pluginsRaw) + ) { + throw new Error('packmind-lock.json must have a "plugins" object'); + } + + const plugins: Record<string, PackmindMarketplaceLockPluginEntry> = {}; + for (const [slug, entryRaw] of Object.entries( + pluginsRaw as Record<string, unknown>, + )) { + if (typeof entryRaw !== 'object' || entryRaw === null) { + throw new Error( + `packmind-lock.json plugin entry "${slug}" must be an object`, + ); + } + const entry = entryRaw as Record<string, unknown>; + if (typeof entry['version'] !== 'string') { + throw new Error( + `packmind-lock.json plugin entry "${slug}" is missing a "version" string`, + ); + } + if (typeof entry['contentHash'] !== 'string') { + throw new Error( + `packmind-lock.json plugin entry "${slug}" is missing a "contentHash" string`, + ); + } + if (typeof entry['lastPublishedAt'] !== 'string') { + throw new Error( + `packmind-lock.json plugin entry "${slug}" is missing a "lastPublishedAt" string`, + ); + } + if (typeof entry['lastPublishedBy'] !== 'string') { + throw new Error( + `packmind-lock.json plugin entry "${slug}" is missing a "lastPublishedBy" string`, + ); + } + plugins[slug] = { + version: entry['version'], + contentHash: entry['contentHash'], + lastPublishedAt: entry['lastPublishedAt'], + lastPublishedBy: entry[ + 'lastPublishedBy' + ] as PackmindMarketplaceLockPluginEntry['lastPublishedBy'], + }; + } + + return { + schemaVersion: 1, + plugins, + }; +} + +/** + * Canonical JSON serialization of the Packmind marketplace lock — pretty + * printed with two-space indent so the file is readable on the rolling-PR + * diff. + */ +export function serializePackmindMarketplaceLock( + lock: PackmindMarketplaceLock, +): string { + return JSON.stringify(lock, null, 2); +} + +/** + * Empty lock instance — convenience constructor for the first-publish path + * where no `packmind-lock.json` exists yet on the marketplace repo. + */ +export function emptyPackmindMarketplaceLock(): PackmindMarketplaceLock { + return { + schemaVersion: 1, + plugins: {}, + }; +} + +/** + * Fetches and parses the standalone `packmind-lock.json` file from the + * marketplace repo at the given branch (defaults to the repo's default + * branch when omitted). + * + * A `null` from `gitPort.getFileFromRepo` is treated as the first-publish + * path — the function returns an empty lock so callers don't have to fork + * their algorithm. Any other failure (transport error, malformed content) + * propagates so the caller can surface it as + * `MarketplaceDescriptorBadFormatError`. + */ +export async function fetchPackmindMarketplaceLock( + gitPort: IGitPort, + gitRepo: GitRepo, + branch?: string, +): Promise<PackmindMarketplaceLock> { + const file = await gitPort.getFileFromRepo( + gitRepo, + PACKMIND_MARKETPLACE_LOCK_PATH, + branch, + ); + if (!file) { + return emptyPackmindMarketplaceLock(); + } + return parsePackmindMarketplaceLock(file.content); +} diff --git a/packages/deployments/src/application/services/parsers/AnthropicMarketplaceDescriptorParser.spec.ts b/packages/deployments/src/application/services/parsers/AnthropicMarketplaceDescriptorParser.spec.ts new file mode 100644 index 000000000..edc06c497 --- /dev/null +++ b/packages/deployments/src/application/services/parsers/AnthropicMarketplaceDescriptorParser.spec.ts @@ -0,0 +1,274 @@ +import { MarketplaceDescriptorParseError } from '../../../domain/errors'; +import { AnthropicMarketplaceDescriptorParser } from './AnthropicMarketplaceDescriptorParser'; + +describe('AnthropicMarketplaceDescriptorParser', () => { + let parser: AnthropicMarketplaceDescriptorParser; + + beforeEach(() => { + parser = new AnthropicMarketplaceDescriptorParser(); + }); + + describe('canParse', () => { + describe('when the raw object has the anthropic-shaped plugins array', () => { + describe('when no vendor field is present', () => { + it('returns true', () => { + const raw = { name: 'demo', plugins: [{ name: 'plugin-a' }] }; + + expect(parser.canParse(raw)).toBe(true); + }); + }); + + describe('when vendor is explicitly anthropic', () => { + it('returns true', () => { + const raw = { + vendor: 'anthropic', + name: 'demo', + plugins: [{ name: 'plugin-a' }], + }; + + expect(parser.canParse(raw)).toBe(true); + }); + }); + }); + + describe('when a foreign vendor is declared', () => { + it('declines to claim the descriptor', () => { + const raw = { + vendor: 'copilot', + name: 'demo', + plugins: [{ name: 'plugin-a' }], + }; + + expect(parser.canParse(raw)).toBe(false); + }); + }); + + describe('when the shape is structurally foreign', () => { + describe('when plugins is missing', () => { + it('returns false', () => { + expect(parser.canParse({ name: 'demo' })).toBe(false); + }); + }); + + describe('when plugins is not an array', () => { + it('returns false', () => { + expect(parser.canParse({ name: 'demo', plugins: {} })).toBe(false); + }); + }); + + it('returns false for null', () => { + expect(parser.canParse(null)).toBe(false); + }); + + describe('for a non-object primitive', () => { + it('returns false for a string', () => { + expect(parser.canParse('a string')).toBe(false); + }); + + it('returns false for a number', () => { + expect(parser.canParse(42)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(parser.canParse(undefined)).toBe(false); + }); + }); + + it('returns false for a top-level array', () => { + expect(parser.canParse([{ name: 'demo', plugins: [] }])).toBe(false); + }); + }); + }); + + describe('parse', () => { + describe('with a valid descriptor', () => { + describe('returns a normalized MarketplaceDescriptor with vendor=anthropic', () => { + const raw = { + name: 'Acme Marketplace', + version: '1.2.0', + plugins: [ + { name: 'Plugin A' }, + { name: 'Plugin B', version: '2.0.0' }, + ], + }; + + let result: ReturnType<AnthropicMarketplaceDescriptorParser['parse']>; + + beforeEach(() => { + result = parser.parse(raw); + }); + + it('sets the vendor to anthropic', () => { + expect(result.vendor).toBe('anthropic'); + }); + + it('preserves the name', () => { + expect(result.name).toBe('Acme Marketplace'); + }); + + it('preserves the version', () => { + expect(result.version).toBe('1.2.0'); + }); + + it('normalizes the plugins with slugs', () => { + expect(result.plugins).toEqual([ + { slug: 'plugin-a', name: 'Plugin A' }, + { slug: 'plugin-b', name: 'Plugin B', version: '2.0.0' }, + ]); + }); + + it('exposes the original raw object', () => { + expect(result.raw).toBe(raw); + }); + }); + + it('preserves unknown top-level + plugin fields on raw', () => { + const raw = { + name: 'demo', + plugins: [{ name: 'plugin-a', description: 'Does things' }], + metadata: { author: 'Acme' }, + }; + + const result = parser.parse(raw); + + expect(result.raw).toBe(raw); + }); + + describe('when the descriptor does not declare a version', () => { + it('omits version', () => { + const raw = { name: 'demo', plugins: [{ name: 'plugin-a' }] }; + + const result = parser.parse(raw); + + expect(result.version).toBeUndefined(); + }); + }); + + describe('when a plugin entry declares a source block', () => { + const raw = { + name: 'demo', + plugins: [ + { + name: 'plugin-a', + version: '0.1.0', + source: { + source: 'git-subdir', + url: 'https://github.com/test-org/test-marketplace.git', + path: 'plugins/plugin-a', + }, + }, + ], + }; + + it('reads the source block back into the normalized PluginRef', () => { + const result = parser.parse(raw); + + expect(result.plugins[0]).toEqual({ + slug: 'plugin-a', + name: 'plugin-a', + version: '0.1.0', + source: { + source: 'git-subdir', + url: 'https://github.com/test-org/test-marketplace.git', + path: 'plugins/plugin-a', + }, + }); + }); + }); + + describe('when a plugin entry declares a description', () => { + it('reads the description back into the normalized PluginRef', () => { + const raw = { + name: 'demo', + plugins: [{ name: 'plugin-a', description: 'Does things' }], + }; + + const result = parser.parse(raw); + + expect(result.plugins[0].description).toBe('Does things'); + }); + }); + + describe('when a plugin entry has no description', () => { + it('leaves description undefined in the normalized PluginRef', () => { + const raw = { + name: 'demo', + plugins: [{ name: 'plugin-a' }], + }; + + const result = parser.parse(raw); + + expect(result.plugins[0].description).toBeUndefined(); + }); + }); + + describe('when a plugin entry has no source block (legacy)', () => { + it('parses it with source undefined', () => { + const raw = { + name: 'demo', + plugins: [{ name: 'plugin-a', version: '0.1.0' }], + }; + + const result = parser.parse(raw); + + expect(result.plugins[0].source).toBeUndefined(); + }); + }); + }); + + describe('when a required field is missing', () => { + describe('when name is missing', () => { + const raw = { plugins: [{ name: 'plugin-a' }] }; + + let thrown: unknown; + + beforeEach(() => { + thrown = undefined; + try { + parser.parse(raw); + } catch (error) { + thrown = error; + } + }); + + it('throws MarketplaceDescriptorParseError', () => { + expect(thrown).toBeInstanceOf(MarketplaceDescriptorParseError); + }); + + it('defines a cause on the error', () => { + expect( + (thrown as MarketplaceDescriptorParseError).cause, + ).toBeDefined(); + }); + + it('exposes the cause as an array', () => { + expect( + Array.isArray((thrown as MarketplaceDescriptorParseError).cause), + ).toBe(true); + }); + }); + + describe('when a plugin has no name', () => { + it('throws MarketplaceDescriptorParseError', () => { + const raw = { name: 'demo', plugins: [{ version: '1.0.0' }] }; + + expect(() => parser.parse(raw)).toThrow( + MarketplaceDescriptorParseError, + ); + }); + }); + }); + + describe('when the descriptor was passed as a JSON string (malformed input)', () => { + it('throws MarketplaceDescriptorParseError because parse expects an object', () => { + // The registry is responsible for JSON.parse — passing a raw string + // here represents a misuse / malformed-input scenario. + const raw = '{"name":"demo","plugins":[]}'; + + expect(() => parser.parse(raw)).toThrow( + MarketplaceDescriptorParseError, + ); + }); + }); + }); +}); diff --git a/packages/deployments/src/application/services/parsers/AnthropicMarketplaceDescriptorParser.ts b/packages/deployments/src/application/services/parsers/AnthropicMarketplaceDescriptorParser.ts new file mode 100644 index 000000000..88801ee78 --- /dev/null +++ b/packages/deployments/src/application/services/parsers/AnthropicMarketplaceDescriptorParser.ts @@ -0,0 +1,138 @@ +import { z } from 'zod'; +import { + IMarketplaceDescriptorParser, + MarketplaceDescriptor, + PluginRef, + PluginSource, +} from '@packmind/types'; +import { MarketplaceDescriptorParseError } from '../../../domain/errors'; + +/** + * Zod schema describing the minimum Anthropic marketplace descriptor shape. + * + * The descriptor is intentionally permissive — fields beyond the required + * `name` + `plugins[]` (e.g. `metadata`, `owner`) are preserved unparsed on + * `MarketplaceDescriptor.raw` so the reconciliation job can deep-diff against + * future fetches. The `source` block is parsed when present so the + * normalized `PluginRef.source` round-trips through the mutator on + * subsequent publishes. + */ +const anthropicPluginSourceSchema = z + .object({ + source: z.literal('git-subdir'), + url: z.string().min(1, 'plugin.source.url is required'), + path: z.string().min(1, 'plugin.source.path is required'), + }) + .passthrough(); + +const anthropicPluginSchema = z + .object({ + name: z.string().min(1, 'plugin.name is required'), + version: z.string().optional(), + description: z.string().optional(), + source: anthropicPluginSourceSchema.optional(), + }) + .passthrough(); + +const anthropicDescriptorSchema = z + .object({ + name: z.string().min(1, 'name is required'), + version: z.string().optional(), + vendor: z.literal('anthropic').optional(), + plugins: z.array(anthropicPluginSchema), + }) + .passthrough(); + +type AnthropicDescriptorShape = z.infer<typeof anthropicDescriptorSchema>; + +/** + * Marketplace descriptor parser for the Anthropic Claude Code marketplace + * format. + * + * Claim heuristic: the raw JSON must be a plain object containing a `plugins` + * array (the structural backbone of the format). Either: + * - `vendor === 'anthropic'` is explicitly set, OR + * - no `vendor` field is set (treated as anthropic-by-default in v1). + * + * If a different vendor is declared, the parser declines (`canParse` returns + * false) so the next parser in the registry can claim it. + */ +export class AnthropicMarketplaceDescriptorParser implements IMarketplaceDescriptorParser { + canParse(rawJson: unknown): boolean { + if (!this.isPlainObject(rawJson)) { + return false; + } + if (!Array.isArray((rawJson as { plugins?: unknown }).plugins)) { + return false; + } + + const vendor = (rawJson as { vendor?: unknown }).vendor; + if (vendor !== undefined && vendor !== 'anthropic') { + return false; + } + + return true; + } + + parse(rawJson: unknown): MarketplaceDescriptor { + const result = anthropicDescriptorSchema.safeParse(rawJson); + if (!result.success) { + throw new MarketplaceDescriptorParseError( + 'Anthropic marketplace descriptor failed schema validation', + result.error.issues, + ); + } + + const parsed: AnthropicDescriptorShape = result.data; + + const plugins: PluginRef[] = parsed.plugins.map((plugin) => { + const ref: PluginRef = { + slug: this.toSlug(plugin.name), + name: plugin.name, + }; + if (plugin.version !== undefined) { + ref.version = plugin.version; + } + if (plugin.description !== undefined) { + ref.description = plugin.description; + } + if (plugin.source !== undefined) { + const pluginSource: PluginSource = { + source: plugin.source.source, + url: plugin.source.url, + path: plugin.source.path, + }; + ref.source = pluginSource; + } + return ref; + }); + + const descriptor: MarketplaceDescriptor = { + vendor: 'anthropic', + name: parsed.name, + plugins, + raw: rawJson, + }; + if (parsed.version !== undefined) { + descriptor.version = parsed.version; + } + return descriptor; + } + + private isPlainObject(value: unknown): value is Record<string, unknown> { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); + } + + private toSlug(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + } +} diff --git a/packages/deployments/src/application/services/parsers/index.ts b/packages/deployments/src/application/services/parsers/index.ts new file mode 100644 index 000000000..ba1b67c3c --- /dev/null +++ b/packages/deployments/src/application/services/parsers/index.ts @@ -0,0 +1 @@ +export * from './AnthropicMarketplaceDescriptorParser'; diff --git a/packages/deployments/src/application/services/resolveMarketplaceReadBranch.ts b/packages/deployments/src/application/services/resolveMarketplaceReadBranch.ts new file mode 100644 index 000000000..df790fca4 --- /dev/null +++ b/packages/deployments/src/application/services/resolveMarketplaceReadBranch.ts @@ -0,0 +1,46 @@ +import { GitRepo, IGitPort } from '@packmind/types'; +import { MARKETPLACE_SYNC_BRANCH } from './marketplaceSyncPullRequest'; + +/** + * Resolves which branch should be used as the source of truth when reading + * the marketplace descriptor (`marketplace.json`) and the standalone + * `packmind-lock.json` during publish / unpublish operations. + * + * Rationale: the publish + remove jobs always write to the rolling + * `MARKETPLACE_SYNC_BRANCH` (`packmind/sync`), but historically read from the + * marketplace's default branch. After the first publish, the rolling branch + * carries the new plugin listing while the default branch is still untouched. + * Reading from the default branch on the second publish would therefore drop + * the previous plugin's descriptor + lock entries — even though the plugin + * folder itself stays intact via GitHub's `base_tree` inheritance. + * + * Algorithm: + * - Probe whether `packmind/sync` exists on the marketplace repo. + * - If it does, return `packmind/sync` so successive publishes accumulate + * descriptor + lock entries on top of the unmerged state. + * - Otherwise fall back to the marketplace's default branch — covers the + * first publish ever (the branch will be created by + * `createBranchFromBase` just before the commit) and the post-merge + * case (GitHub's typical flow deletes the head branch after merge, so + * the merged plugins now live on the default branch). + * + * The "first publish ever" case and the "N-th publish after merge" case + * follow the exact same code path: `checkBranchExists` returns `false`, we + * read from the default branch, and downstream code creates the rolling + * branch from that same base. Accumulation continues seamlessly from + * whatever the default branch already contains. + */ +export async function resolveMarketplaceReadBranch( + gitPort: IGitPort, + marketplaceGitRepo: GitRepo, +): Promise<string> { + const rollingBranchExists = await gitPort.checkBranchExists( + marketplaceGitRepo.providerId, + marketplaceGitRepo.owner, + marketplaceGitRepo.repo, + MARKETPLACE_SYNC_BRANCH, + ); + return rollingBranchExists + ? MARKETPLACE_SYNC_BRANCH + : marketplaceGitRepo.branch; +} diff --git a/packages/deployments/src/application/useCases/InstallPackagesUseCase.spec.ts b/packages/deployments/src/application/useCases/InstallPackagesUseCase.spec.ts index 539e75a4a..5cefb2814 100644 --- a/packages/deployments/src/application/useCases/InstallPackagesUseCase.spec.ts +++ b/packages/deployments/src/application/useCases/InstallPackagesUseCase.spec.ts @@ -69,6 +69,7 @@ const createSpaceMembership = ( userId: createUserId(userId), spaceId: createSpaceId(spaceId), role: UserSpaceRole.MEMBER, + pinned: false, createdBy: createUserId(userId), updatedBy: createUserId(userId), }); diff --git a/packages/deployments/src/application/useCases/ListDriftedPackagesByOrgUseCase.spec.ts b/packages/deployments/src/application/useCases/ListDriftedPackagesByOrgUseCase.spec.ts index 0bce210ae..471484d34 100644 --- a/packages/deployments/src/application/useCases/ListDriftedPackagesByOrgUseCase.spec.ts +++ b/packages/deployments/src/application/useCases/ListDriftedPackagesByOrgUseCase.spec.ts @@ -9,13 +9,17 @@ import { createUserId, DistributionStatus, IAccountsPort, + IRecipesPort, + ISkillsPort, ISpacesPort, + IStandardsPort, ListDriftedPackagesByOrgCommand, Organization, Package, PackageId, Space, SpaceType, + Standard, StandardId, TargetId, User, @@ -32,6 +36,11 @@ import { ListDriftedPackagesByOrgUseCase } from './ListDriftedPackagesByOrgUseCa const orgId = createOrganizationId('org-1'); const userId = createUserId('user-1'); +// The use case only reads `id` and `version` from each artifact to resolve +// drift, so minimal objects are enough. +const standardVersion = (id: StandardId, version: number): Standard => + ({ id, version }) as unknown as Standard; + const space = (id: string, slug: string, name: string): Space => ({ id: createSpaceId(id), name, @@ -94,6 +103,9 @@ describe('ListDriftedPackagesByOrgUseCase', () => { let mockAccountsPort: jest.Mocked<IAccountsPort>; let mockDistributionRepository: jest.Mocked<IDistributionRepository>; let mockPackageRepository: jest.Mocked<IPackageRepository>; + let mockStandardsPort: jest.Mocked<IStandardsPort>; + let mockRecipesPort: jest.Mocked<IRecipesPort>; + let mockSkillsPort: jest.Mocked<ISkillsPort>; const ctx: MemberContext = { user: { id: userId } as User, @@ -123,11 +135,26 @@ describe('ListDriftedPackagesByOrgUseCase', () => { findBySpaceId: jest.fn(), } as unknown as jest.Mocked<IPackageRepository>; + mockStandardsPort = { + listAllStandardsByOrganization: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked<IStandardsPort>; + + mockRecipesPort = { + listAllRecipesByOrganization: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked<IRecipesPort>; + + mockSkillsPort = { + listAllSkillsByOrganization: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked<ISkillsPort>; + useCase = new ListDriftedPackagesByOrgUseCase( mockSpacesPort, mockAccountsPort, mockDistributionRepository, mockPackageRepository, + mockStandardsPort, + mockRecipesPort, + mockSkillsPort, stubLogger(), ); }); @@ -198,6 +225,9 @@ describe('ListDriftedPackagesByOrgUseCase', () => { mockPackageRepository.findBySpaceId.mockResolvedValue([ pkg('pkg-1', 'packmind-ui', spaceId, [standardId]), ]); + mockStandardsPort.listAllStandardsByOrganization.mockResolvedValue([ + standardVersion(standardId, 2), + ]); }); it('counts two behind distributions and reports the latest lastUpdatedAt', async () => { @@ -253,6 +283,10 @@ describe('ListDriftedPackagesByOrgUseCase', () => { } return [pkg('pkg-b', 'alpha', spaceB, [stdB])]; }); + mockStandardsPort.listAllStandardsByOrganization.mockResolvedValue([ + standardVersion(stdA, 2), + standardVersion(stdB, 2), + ]); }); it('sorts by behindDistributions desc then name asc and tags each row with its space', async () => { @@ -286,6 +320,10 @@ describe('ListDriftedPackagesByOrgUseCase', () => { mockPackageRepository.findBySpaceId.mockResolvedValue([ pkg('pkg-1', 'pkg one', spaceId, [ownedStd]), ]); + mockStandardsPort.listAllStandardsByOrganization.mockResolvedValue([ + standardVersion(otherStd, 2), + standardVersion(ownedStd, 1), + ]); }); it('does not attribute drift to that package', async () => { @@ -293,5 +331,63 @@ describe('ListDriftedPackagesByOrgUseCase', () => { expect(result).toEqual([]); }); }); + + describe('when a deployed artifact is on its latest version', () => { + const spaceId = createSpaceId('space-1'); + const packageId = createPackageId('pkg-1'); + const standardId = createStandardId('std-1'); + const target = createTargetId('target-1'); + + beforeEach(() => { + mockSpacesPort.listSpacesByOrganization.mockResolvedValue([ + space('space-1', 'space-one', 'Space One'), + ]); + mockDistributionRepository.findOutdatedDeploymentsBySpace.mockResolvedValue( + [outdatedOnTarget(target, [standardId])], + ); + mockDistributionRepository.findActivePackageOperationsBySpace.mockResolvedValue( + [activeOp(packageId, target)], + ); + mockPackageRepository.findBySpaceId.mockResolvedValue([ + pkg('pkg-1', 'pkg one', spaceId, [standardId]), + ]); + mockStandardsPort.listAllStandardsByOrganization.mockResolvedValue([ + standardVersion(standardId, 1), + ]); + }); + + it('does not report the package as drifted', async () => { + const result = await useCase['executeForMembers'](baseCommand); + expect(result).toEqual([]); + }); + }); + + describe('when a deployed artifact has been deleted from Packmind', () => { + const spaceId = createSpaceId('space-1'); + const packageId = createPackageId('pkg-1'); + const standardId = createStandardId('std-1'); + const target = createTargetId('target-1'); + + beforeEach(() => { + mockSpacesPort.listSpacesByOrganization.mockResolvedValue([ + space('space-1', 'space-one', 'Space One'), + ]); + mockDistributionRepository.findOutdatedDeploymentsBySpace.mockResolvedValue( + [outdatedOnTarget(target, [standardId])], + ); + mockDistributionRepository.findActivePackageOperationsBySpace.mockResolvedValue( + [activeOp(packageId, target)], + ); + mockPackageRepository.findBySpaceId.mockResolvedValue([ + pkg('pkg-1', 'pkg one', spaceId, [standardId]), + ]); + mockStandardsPort.listAllStandardsByOrganization.mockResolvedValue([]); + }); + + it('reports the package as drifted', async () => { + const result = await useCase['executeForMembers'](baseCommand); + expect(result.map((r) => r.packageId)).toEqual([packageId]); + }); + }); }); }); diff --git a/packages/deployments/src/application/useCases/ListDriftedPackagesByOrgUseCase.ts b/packages/deployments/src/application/useCases/ListDriftedPackagesByOrgUseCase.ts index 9258640e7..60ccff584 100644 --- a/packages/deployments/src/application/useCases/ListDriftedPackagesByOrgUseCase.ts +++ b/packages/deployments/src/application/useCases/ListDriftedPackagesByOrgUseCase.ts @@ -4,7 +4,10 @@ import { DriftedPackageInfo, IAccountsPort, IListDriftedPackagesByOrgUseCase, + IRecipesPort, + ISkillsPort, ISpacesPort, + IStandardsPort, ListDriftedPackagesByOrgCommand, ListDriftedPackagesByOrgResponse, OrganizationId, @@ -22,6 +25,18 @@ import { IPackageRepository } from '../../domain/repositories/IPackageRepository const origin = 'ListDriftedPackagesByOrgUseCase'; +/** + * Latest version number of each artifact (standard / recipe / skill) of the + * organization, keyed by artifact id. Mirrors how the space overview resolves + * drift: an artifact missing from the map is considered deleted, and a deployed + * version lower than the latest is considered behind. + */ +type LatestArtifactVersions = { + standards: Map<string, number>; + recipes: Map<string, number>; + skills: Map<string, number>; +}; + export class ListDriftedPackagesByOrgUseCase extends AbstractMemberUseCase< ListDriftedPackagesByOrgCommand, @@ -34,6 +49,9 @@ export class ListDriftedPackagesByOrgUseCase accountsPort: IAccountsPort, private readonly distributionRepository: IDistributionRepository, private readonly packageRepository: IPackageRepository, + private readonly standardsPort: IStandardsPort, + private readonly recipesPort: IRecipesPort, + private readonly skillsPort: ISkillsPort, logger: PackmindLogger = new PackmindLogger(origin, LogLevel.INFO), ) { super(accountsPort, logger); @@ -51,8 +69,13 @@ export class ListDriftedPackagesByOrgUseCase return []; } + const latestVersions = + await this.loadLatestArtifactVersions(organizationId); + const perSpace = await Promise.all( - spaces.map((space) => this.collectSpaceDrift(space, organizationId)), + spaces.map((space) => + this.collectSpaceDrift(space, organizationId, latestVersions), + ), ); return perSpace.flat().sort((a, b) => { @@ -63,9 +86,26 @@ export class ListDriftedPackagesByOrgUseCase }); } + private async loadLatestArtifactVersions( + organizationId: OrganizationId, + ): Promise<LatestArtifactVersions> { + const [standards, recipes, skills] = await Promise.all([ + this.standardsPort.listAllStandardsByOrganization(organizationId), + this.recipesPort.listAllRecipesByOrganization(organizationId), + this.skillsPort.listAllSkillsByOrganization(organizationId), + ]); + + return { + standards: new Map(standards.map((s) => [s.id as string, s.version])), + recipes: new Map(recipes.map((r) => [r.id as string, r.version])), + skills: new Map(skills.map((s) => [s.id as string, s.version])), + }; + } + private async collectSpaceDrift( space: Space, organizationId: OrganizationId, + latestVersions: LatestArtifactVersions, ): Promise<DriftedPackageInfo[]> { const [outdatedByTarget, activeOps, packages] = await Promise.all([ this.distributionRepository.findOutdatedDeploymentsBySpace( @@ -88,6 +128,7 @@ export class ListDriftedPackagesByOrgUseCase outdatedByTarget, activeOps, packagesById, + latestVersions, ); const results: DriftedPackageInfo[] = []; @@ -122,10 +163,25 @@ function computeLastDistributedAt( return lastByPackage; } +/** + * A deployed artifact is in drift when it has been deleted from Packmind + * (no current version, i.e. it still needs to be removed from the target) or + * when its deployed version is behind the latest version. This matches the + * drift determination used by the space overview. + */ +function isDeployedArtifactDrifted( + deployedVersion: number, + latestVersion: number | undefined, +): boolean { + if (latestVersion === undefined) return true; + return deployedVersion < latestVersion; +} + function computeDriftedTargets( outdatedByTarget: OutdatedDeploymentsByTarget[], activeOps: ReadonlyArray<ActivePackageOperationRow>, packagesById: Map<PackageId, Package>, + latestVersions: LatestArtifactVersions, ): Map<PackageId, Set<TargetId>> { const opsByTarget = new Map<TargetId, PackageId[]>(); for (const op of activeOps) { @@ -142,23 +198,44 @@ function computeDriftedTargets( const packageIdsOnTarget = opsByTarget.get(outdated.targetId); if (!packageIdsOnTarget?.length) continue; - const outdatedStandardIds = new Set( - outdated.standards.map((s) => s.artifactId as string), + const driftedStandardIds = new Set( + outdated.standards + .filter((s) => + isDeployedArtifactDrifted( + s.deployedVersion, + latestVersions.standards.get(s.artifactId as string), + ), + ) + .map((s) => s.artifactId as string), ); - const outdatedRecipeIds = new Set( - outdated.recipes.map((r) => r.artifactId as string), + const driftedRecipeIds = new Set( + outdated.recipes + .filter((r) => + isDeployedArtifactDrifted( + r.deployedVersion, + latestVersions.recipes.get(r.artifactId as string), + ), + ) + .map((r) => r.artifactId as string), ); - const outdatedSkillIds = new Set( - outdated.skills.map((s) => s.artifactId as string), + const driftedSkillIds = new Set( + outdated.skills + .filter((s) => + isDeployedArtifactDrifted( + s.deployedVersion, + latestVersions.skills.get(s.artifactId as string), + ), + ) + .map((s) => s.artifactId as string), ); for (const packageId of packageIdsOnTarget) { const pkg = packagesById.get(packageId); if (!pkg) continue; const hasDrift = - pkg.standards.some((id) => outdatedStandardIds.has(id)) || - pkg.recipes.some((id) => outdatedRecipeIds.has(id)) || - pkg.skills.some((id) => outdatedSkillIds.has(id)); + pkg.standards.some((id) => driftedStandardIds.has(id)) || + pkg.recipes.some((id) => driftedRecipeIds.has(id)) || + pkg.skills.some((id) => driftedSkillIds.has(id)); if (!hasDrift) continue; let bucket = driftedTargetsByPackage.get(packageId); diff --git a/packages/deployments/src/application/useCases/deletePackage/DeletePackagesBatchUseCase.test.ts b/packages/deployments/src/application/useCases/deletePackage/DeletePackagesBatchUseCase.test.ts index a668c980f..74d8bd785 100644 --- a/packages/deployments/src/application/useCases/deletePackage/DeletePackagesBatchUseCase.test.ts +++ b/packages/deployments/src/application/useCases/deletePackage/DeletePackagesBatchUseCase.test.ts @@ -10,6 +10,7 @@ import { createOrganizationId, PackagesDeletedEvent, DeletePackagesBatchCommand, + PackagesDeletedEvent, } from '@packmind/types'; describe('DeletePackagesBatchUseCase', () => { diff --git a/packages/deployments/src/application/useCases/findMarketplaceDistributionById/FindMarketplaceDistributionByIdUseCase.spec.ts b/packages/deployments/src/application/useCases/findMarketplaceDistributionById/FindMarketplaceDistributionByIdUseCase.spec.ts new file mode 100644 index 000000000..99069b992 --- /dev/null +++ b/packages/deployments/src/application/useCases/findMarketplaceDistributionById/FindMarketplaceDistributionByIdUseCase.spec.ts @@ -0,0 +1,136 @@ +import { v4 as uuidv4 } from 'uuid'; +import { stubLogger } from '@packmind/test-utils'; +import { + createMarketplaceDistributionId, + createMarketplaceId, + createOrganizationId, + createPackageId, + createUserId, + DistributionStatus, + IAccountsPort, + MarketplaceDistribution, + Organization, + User, +} from '@packmind/types'; +import { IMarketplaceDistributionRepository } from '../../../domain/repositories/IMarketplaceDistributionRepository'; +import { FindMarketplaceDistributionByIdUseCase } from './FindMarketplaceDistributionByIdUseCase'; + +describe('FindMarketplaceDistributionByIdUseCase', () => { + const organizationId = createOrganizationId(uuidv4()); + const otherOrganizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const marketplaceId = createMarketplaceId(uuidv4()); + const packageId = createPackageId(uuidv4()); + const distributionId = createMarketplaceDistributionId(uuidv4()); + + const memberUser = { + id: userId, + email: 'member@example.com', + displayName: 'Member User', + passwordHash: null, + active: true, + memberships: [{ userId, organizationId, role: 'member' as const }], + trial: false, + } as unknown as User; + + const organization: Organization = { + id: organizationId, + name: 'Test Org', + slug: 'test-org', + }; + + const distribution = { + id: distributionId, + organizationId, + marketplaceId, + packageId, + pluginSlug: 'my-plugin', + authorId: userId, + status: DistributionStatus.success, + source: 'app', + } as unknown as MarketplaceDistribution; + + let mockMarketplaceDistributionRepository: jest.Mocked<IMarketplaceDistributionRepository>; + let mockAccountsPort: jest.Mocked<IAccountsPort>; + let useCase: FindMarketplaceDistributionByIdUseCase; + + beforeEach(() => { + mockMarketplaceDistributionRepository = { + findById: jest.fn().mockResolvedValue(distribution), + } as unknown as jest.Mocked<IMarketplaceDistributionRepository>; + + mockAccountsPort = { + getUserById: jest.fn().mockResolvedValue(memberUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + useCase = new FindMarketplaceDistributionByIdUseCase( + mockMarketplaceDistributionRepository, + mockAccountsPort, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('happy path', () => { + let response: Awaited< + ReturnType<FindMarketplaceDistributionByIdUseCase['execute']> + >; + + beforeEach(async () => { + response = await useCase.execute({ + userId, + organizationId, + marketplaceDistributionId: distributionId, + }); + }); + + it('returns the distribution row', () => { + expect(response.marketplaceDistribution).toEqual(distribution); + }); + }); + + describe('when the row is missing', () => { + let response: Awaited< + ReturnType<FindMarketplaceDistributionByIdUseCase['execute']> + >; + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findById.mockResolvedValue(null); + response = await useCase.execute({ + userId, + organizationId, + marketplaceDistributionId: distributionId, + }); + }); + + it('returns a null distribution', () => { + expect(response.marketplaceDistribution).toBeNull(); + }); + }); + + describe('when the row belongs to another organization', () => { + let response: Awaited< + ReturnType<FindMarketplaceDistributionByIdUseCase['execute']> + >; + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findById.mockResolvedValue({ + ...distribution, + organizationId: otherOrganizationId, + } as MarketplaceDistribution); + response = await useCase.execute({ + userId, + organizationId, + marketplaceDistributionId: distributionId, + }); + }); + + it('returns a null distribution', () => { + expect(response.marketplaceDistribution).toBeNull(); + }); + }); +}); diff --git a/packages/deployments/src/application/useCases/findMarketplaceDistributionById/FindMarketplaceDistributionByIdUseCase.ts b/packages/deployments/src/application/useCases/findMarketplaceDistributionById/FindMarketplaceDistributionByIdUseCase.ts new file mode 100644 index 000000000..4aa43d9ce --- /dev/null +++ b/packages/deployments/src/application/useCases/findMarketplaceDistributionById/FindMarketplaceDistributionByIdUseCase.ts @@ -0,0 +1,45 @@ +import { PackmindLogger } from '@packmind/logger'; +import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; +import { + FindMarketplaceDistributionByIdCommand, + FindMarketplaceDistributionByIdResponse, + IAccountsPort, + IFindMarketplaceDistributionByIdUseCase, +} from '@packmind/types'; +import { IMarketplaceDistributionRepository } from '../../../domain/repositories/IMarketplaceDistributionRepository'; + +const origin = 'FindMarketplaceDistributionByIdUseCase'; + +/** + * Looks up a single `MarketplaceDistribution` row by id, scoped to the + * caller's organization. Returns `{ marketplaceDistribution: null }` when + * the row is missing or belongs to another organization so the controller + * can map that to HTTP 404 without leaking cross-org existence. + */ +export class FindMarketplaceDistributionByIdUseCase + extends AbstractMemberUseCase< + FindMarketplaceDistributionByIdCommand, + FindMarketplaceDistributionByIdResponse + > + implements IFindMarketplaceDistributionByIdUseCase +{ + constructor( + private readonly marketplaceDistributionRepository: IMarketplaceDistributionRepository, + accountsPort: IAccountsPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForMembers( + command: FindMarketplaceDistributionByIdCommand & MemberContext, + ): Promise<FindMarketplaceDistributionByIdResponse> { + const row = await this.marketplaceDistributionRepository.findById( + command.marketplaceDistributionId, + ); + if (!row || row.organizationId !== command.organization.id) { + return { marketplaceDistribution: null }; + } + return { marketplaceDistribution: row }; + } +} diff --git a/packages/deployments/src/application/useCases/findMarketplaceDistributionById/index.ts b/packages/deployments/src/application/useCases/findMarketplaceDistributionById/index.ts new file mode 100644 index 000000000..2e95c6dec --- /dev/null +++ b/packages/deployments/src/application/useCases/findMarketplaceDistributionById/index.ts @@ -0,0 +1 @@ +export { FindMarketplaceDistributionByIdUseCase } from './FindMarketplaceDistributionByIdUseCase'; diff --git a/packages/deployments/src/application/useCases/getMarketplaceDistributionChanges/GetMarketplaceDistributionChangesUseCase.spec.ts b/packages/deployments/src/application/useCases/getMarketplaceDistributionChanges/GetMarketplaceDistributionChangesUseCase.spec.ts new file mode 100644 index 000000000..d9e5c6a5d --- /dev/null +++ b/packages/deployments/src/application/useCases/getMarketplaceDistributionChanges/GetMarketplaceDistributionChangesUseCase.spec.ts @@ -0,0 +1,593 @@ +import { v4 as uuidv4 } from 'uuid'; +import { stubLogger } from '@packmind/test-utils'; +import { + createMarketplaceDistributionId, + createMarketplaceId, + createOrganizationId, + createPackageId, + createRecipeId, + createSkillId, + createStandardId, + createUserId, + DistributionStatus, + IAccountsPort, + IRecipesPort, + ISkillsPort, + IStandardsPort, + Marketplace, + MarketplaceDistribution, + MarketplaceNotFoundError, + Organization, + Package, + Recipe, + Skill, + Standard, + User, + VersionFingerprint, +} from '@packmind/types'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { IMarketplaceDistributionRepository } from '../../../domain/repositories/IMarketplaceDistributionRepository'; +import { PackageService } from '../../services/PackageService'; +import { PackageVersionFingerprintService } from '../../services/PackageVersionFingerprintService'; +import { GetMarketplaceDistributionChangesUseCase } from './GetMarketplaceDistributionChangesUseCase'; + +describe('GetMarketplaceDistributionChangesUseCase', () => { + const organizationId = createOrganizationId(uuidv4()); + const otherOrganizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const marketplaceId = createMarketplaceId(uuidv4()); + const otherMarketplaceId = createMarketplaceId(uuidv4()); + const distributionId = createMarketplaceDistributionId(uuidv4()); + const packageId = createPackageId(uuidv4()); + + const recipeStableId = createRecipeId(uuidv4()); + const recipeUpdatedId = createRecipeId(uuidv4()); + const recipeAddedId = createRecipeId(uuidv4()); + const recipeRemovedId = createRecipeId(uuidv4()); + const standardUpdatedId = createStandardId(uuidv4()); + const skillRemovedId = createSkillId(uuidv4()); + + const existingMarketplace = { + id: marketplaceId, + organizationId, + name: 'ACME Plugins', + } as unknown as Marketplace; + + const memberUser = { + id: userId, + email: 'admin@example.com', + displayName: 'Author Person', + passwordHash: null, + active: true, + memberships: [{ userId, organizationId, role: 'member' as const }], + trial: false, + } as unknown as User; + + const organization: Organization = { + id: organizationId, + name: 'Test Org', + slug: 'test-org', + }; + + const publishedFingerprint: VersionFingerprint = { + recipes: { + [recipeStableId]: 3, + [recipeUpdatedId]: 1, + [recipeRemovedId]: 2, + }, + standards: { + [standardUpdatedId]: 4, + }, + skills: { + [skillRemovedId]: 1, + }, + }; + + const currentFingerprint: VersionFingerprint = { + recipes: { + [recipeStableId]: 3, + [recipeUpdatedId]: 2, + [recipeAddedId]: 1, + }, + standards: { + [standardUpdatedId]: 5, + }, + skills: {}, + }; + + const baseDistribution = { + id: distributionId, + organizationId, + marketplaceId, + packageId, + pluginSlug: 'my-plugin', + authorId: userId, + status: DistributionStatus.success, + source: 'app', + versionFingerprint: publishedFingerprint, + } as unknown as MarketplaceDistribution; + + const sourcePackage = { + id: packageId, + name: 'My Package', + slug: 'my-package', + } as unknown as Package; + + let mockMarketplaceRepository: jest.Mocked<IMarketplaceRepository>; + let mockMarketplaceDistributionRepository: jest.Mocked<IMarketplaceDistributionRepository>; + let mockPackageService: jest.Mocked<PackageService>; + let mockFingerprintService: jest.Mocked<PackageVersionFingerprintService>; + let mockRecipesPort: jest.Mocked<IRecipesPort>; + let mockStandardsPort: jest.Mocked<IStandardsPort>; + let mockSkillsPort: jest.Mocked<ISkillsPort>; + let mockAccountsPort: jest.Mocked<IAccountsPort>; + let useCase: GetMarketplaceDistributionChangesUseCase; + + beforeEach(() => { + mockMarketplaceRepository = { + findByOrganizationAndId: jest.fn().mockResolvedValue(existingMarketplace), + } as unknown as jest.Mocked<IMarketplaceRepository>; + + mockMarketplaceDistributionRepository = { + findById: jest.fn().mockResolvedValue(baseDistribution), + findSuccessfulByMarketplaceId: jest + .fn() + .mockResolvedValue([baseDistribution]), + findPendingMergesByMarketplaceId: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked<IMarketplaceDistributionRepository>; + + mockPackageService = { + findById: jest.fn().mockResolvedValue(sourcePackage), + } as unknown as jest.Mocked<PackageService>; + + mockFingerprintService = { + compute: jest.fn().mockResolvedValue(currentFingerprint), + } as unknown as jest.Mocked<PackageVersionFingerprintService>; + + mockRecipesPort = { + getRecipeByIdInternal: jest.fn(async (id) => { + const map: Record<string, Recipe> = { + [recipeUpdatedId]: { + name: 'Format Code', + slug: 'format-code', + } as unknown as Recipe, + [recipeAddedId]: { + name: 'Lint Strict', + slug: 'lint-strict', + } as unknown as Recipe, + [recipeRemovedId]: { + name: 'Legacy Recipe', + slug: 'legacy-recipe', + } as unknown as Recipe, + }; + return map[id as string] ?? null; + }), + } as unknown as jest.Mocked<IRecipesPort>; + + mockStandardsPort = { + getStandard: jest.fn(async (id) => { + return id === standardUpdatedId + ? ({ + name: 'TypeScript Style', + slug: 'typescript-style', + } as unknown as Standard) + : null; + }), + } as unknown as jest.Mocked<IStandardsPort>; + + mockSkillsPort = { + getSkill: jest.fn(async (id) => { + return id === skillRemovedId + ? ({ + name: 'Onboarding Skill', + slug: 'onboarding-skill', + } as unknown as Skill) + : null; + }), + } as unknown as jest.Mocked<ISkillsPort>; + + mockAccountsPort = { + getUserById: jest.fn().mockResolvedValue(memberUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + useCase = new GetMarketplaceDistributionChangesUseCase( + mockMarketplaceRepository, + mockMarketplaceDistributionRepository, + mockPackageService, + mockFingerprintService, + mockRecipesPort, + mockStandardsPort, + mockSkillsPort, + mockAccountsPort, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('happy path with drift', () => { + let result: Awaited< + ReturnType<GetMarketplaceDistributionChangesUseCase['execute']> + >; + + beforeEach(async () => { + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + }); + + it('flags the package as outdated against the main branch', () => { + expect(result.state).toBe('outdated_main'); + }); + + it('returns one entry per added/updated/removed artifact', () => { + expect(result.changes).toHaveLength(5); + }); + + it('returns a null PR url', () => { + expect(result.prUrl).toBeNull(); + }); + + it('orders the changes added → updated → removed, then by artifact kind, then by name', () => { + expect( + result.changes.map((c) => ({ kind: c.kind, name: c.name })), + ).toEqual([ + { kind: 'added', name: 'Lint Strict' }, + { kind: 'updated', name: 'Format Code' }, + { kind: 'updated', name: 'TypeScript Style' }, + { kind: 'removed', name: 'Legacy Recipe' }, + { kind: 'removed', name: 'Onboarding Skill' }, + ]); + }); + + it('attaches null publishedVersion to added artifacts', () => { + const added = result.changes.find((c) => c.kind === 'added'); + expect(added?.publishedVersion).toBeNull(); + }); + + it('attaches null currentVersion to removed artifacts', () => { + const removed = result.changes.find((c) => c.kind === 'removed'); + expect(removed?.currentVersion).toBeNull(); + }); + + it('exposes both versions for an updated artifact', () => { + const updated = result.changes.find((c) => c.name === 'Format Code'); + expect(updated).toMatchObject({ + kind: 'updated', + publishedVersion: 1, + currentVersion: 2, + }); + }); + + it('labels recipes with the marketplace "command" artifact kind', () => { + const updated = result.changes.find((c) => c.name === 'Format Code'); + expect(updated?.artifactKind).toBe('command'); + }); + }); + + describe('when a removed artifact has been hard-deleted', () => { + let result: Awaited< + ReturnType<GetMarketplaceDistributionChangesUseCase['execute']> + >; + + beforeEach(async () => { + mockSkillsPort.getSkill = jest.fn().mockResolvedValue(null); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + }); + + it('falls back to a placeholder name rather than crashing', () => { + const removedSkill = result.changes.find( + (c) => c.artifactKind === 'skill', + ); + expect(removedSkill?.name).toBe('(deleted)'); + }); + }); + + describe('when published and current fingerprints match', () => { + let result: Awaited< + ReturnType<GetMarketplaceDistributionChangesUseCase['execute']> + >; + + beforeEach(async () => { + mockFingerprintService.compute.mockResolvedValue(publishedFingerprint); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + }); + + it('reports in_sync_main', () => { + expect(result.state).toBe('in_sync_main'); + }); + + it('returns no changes', () => { + expect(result.changes).toEqual([]); + }); + }); + + describe('when no success distribution exists for the package', () => { + let result: Awaited< + ReturnType<GetMarketplaceDistributionChangesUseCase['execute']> + >; + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findSuccessfulByMarketplaceId.mockResolvedValue( + [], + ); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + }); + + it('reports in_sync_main rather than guessing', () => { + expect(result.state).toBe('in_sync_main'); + }); + + it('returns no changes', () => { + expect(result.changes).toEqual([]); + }); + }); + + describe('when the latest success row has no captured fingerprint', () => { + let result: Awaited< + ReturnType<GetMarketplaceDistributionChangesUseCase['execute']> + >; + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findSuccessfulByMarketplaceId.mockResolvedValue( + [ + { + ...baseDistribution, + versionFingerprint: undefined, + } as unknown as MarketplaceDistribution, + ], + ); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + }); + + it('returns no changes rather than guessing', () => { + expect(result.changes).toEqual([]); + }); + }); + + describe('when the latest non-removed row is a no_changes republish that already captured the post-edit fingerprint', () => { + let result: Awaited< + ReturnType<GetMarketplaceDistributionChangesUseCase['execute']> + >; + + beforeEach(async () => { + // The frontend hands us a no_changes row that has already absorbed the + // post-edit fingerprint. The latest success row, however, still carries + // the pre-edit fingerprint — that is what the reconciler diffs against + // when it flips the slug to outdated. Our use case must follow suit. + const noChangesRow = { + ...baseDistribution, + id: createMarketplaceDistributionId(uuidv4()), + status: DistributionStatus.no_changes, + versionFingerprint: currentFingerprint, + } as unknown as MarketplaceDistribution; + mockMarketplaceDistributionRepository.findById.mockResolvedValue( + noChangesRow, + ); + mockMarketplaceDistributionRepository.findSuccessfulByMarketplaceId.mockResolvedValue( + [baseDistribution], + ); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + }); + + it('still surfaces the drift from the latest success row', () => { + expect(result.changes.length).toBeGreaterThan(0); + }); + }); + + describe('when the source package has been hard-deleted', () => { + let result: Awaited< + ReturnType<GetMarketplaceDistributionChangesUseCase['execute']> + >; + + beforeEach(async () => { + mockPackageService.findById.mockResolvedValue(null); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + }); + + it('returns no changes', () => { + expect(result.changes).toEqual([]); + }); + }); + + describe('when an open sync PR carries this package', () => { + const pendingMergePrUrl = 'https://example.com/pr/42'; + const pendingMergeRow = { + ...baseDistribution, + id: createMarketplaceDistributionId(uuidv4()), + status: DistributionStatus.pending_merge, + versionFingerprint: currentFingerprint, + prUrl: pendingMergePrUrl, + } as unknown as MarketplaceDistribution; + + describe('and the source still matches the PR contents', () => { + let result: Awaited< + ReturnType<GetMarketplaceDistributionChangesUseCase['execute']> + >; + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findPendingMergesByMarketplaceId.mockResolvedValue( + [pendingMergeRow], + ); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + }); + + it('reports in_sync_pr', () => { + expect(result.state).toBe('in_sync_pr'); + }); + + it('exposes the PR url so the UI can render the View link', () => { + expect(result.prUrl).toBe(pendingMergePrUrl); + }); + }); + + describe('and the source has drifted further since the PR was opened', () => { + let result: Awaited< + ReturnType<GetMarketplaceDistributionChangesUseCase['execute']> + >; + + beforeEach(async () => { + // Stale pending_merge fingerprint != current → "outdated_pr". + const drift: VersionFingerprint = { + ...currentFingerprint, + recipes: { [recipeStableId]: 7 }, + }; + mockMarketplaceDistributionRepository.findPendingMergesByMarketplaceId.mockResolvedValue( + [ + { + ...pendingMergeRow, + versionFingerprint: drift, + } as unknown as MarketplaceDistribution, + ], + ); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + }); + + it('reports outdated_pr so the footer shows Publish', () => { + expect(result.state).toBe('outdated_pr'); + }); + + it('still carries the PR url so callers can link out if they want', () => { + expect(result.prUrl).toBe(pendingMergePrUrl); + }); + }); + + describe('and no success row has been recorded yet (first publish)', () => { + let result: Awaited< + ReturnType<GetMarketplaceDistributionChangesUseCase['execute']> + >; + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findSuccessfulByMarketplaceId.mockResolvedValue( + [], + ); + mockMarketplaceDistributionRepository.findPendingMergesByMarketplaceId.mockResolvedValue( + [pendingMergeRow], + ); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + }); + + it('reports in_sync_pr because the PR is the only baseline we have', () => { + expect(result.state).toBe('in_sync_pr'); + }); + + it('exposes the PR url', () => { + expect(result.prUrl).toBe(pendingMergePrUrl); + }); + }); + }); + + describe('when the distribution belongs to another marketplace', () => { + let result: Awaited< + ReturnType<GetMarketplaceDistributionChangesUseCase['execute']> + >; + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findById.mockResolvedValue({ + ...baseDistribution, + marketplaceId: otherMarketplaceId, + } as unknown as MarketplaceDistribution); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + }); + + it('refuses to leak data across marketplaces', () => { + expect(result.changes).toEqual([]); + }); + }); + + describe('when the distribution belongs to another organization', () => { + let result: Awaited< + ReturnType<GetMarketplaceDistributionChangesUseCase['execute']> + >; + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findById.mockResolvedValue({ + ...baseDistribution, + organizationId: otherOrganizationId, + } as unknown as MarketplaceDistribution); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + }); + + it('refuses to leak data across organizations', () => { + expect(result.changes).toEqual([]); + }); + }); + + describe('when the marketplace does not belong to the caller', () => { + beforeEach(() => { + mockMarketplaceRepository.findByOrganizationAndId.mockResolvedValue(null); + }); + + it('throws MarketplaceNotFoundError', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }), + ).rejects.toThrow(MarketplaceNotFoundError); + }); + }); +}); diff --git a/packages/deployments/src/application/useCases/getMarketplaceDistributionChanges/GetMarketplaceDistributionChangesUseCase.ts b/packages/deployments/src/application/useCases/getMarketplaceDistributionChanges/GetMarketplaceDistributionChangesUseCase.ts new file mode 100644 index 000000000..ba7e4ce2f --- /dev/null +++ b/packages/deployments/src/application/useCases/getMarketplaceDistributionChanges/GetMarketplaceDistributionChangesUseCase.ts @@ -0,0 +1,340 @@ +import { PackmindLogger } from '@packmind/logger'; +import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; +import { + GetMarketplaceDistributionChangesCommand, + GetMarketplaceDistributionChangesResponse, + IAccountsPort, + IGetMarketplaceDistributionChangesUseCase, + IRecipesPort, + ISkillsPort, + IStandardsPort, + MarketplaceArtifactKind, + MarketplaceNotFoundError, + RecipeId, + SkillId, + SourcePackageChange, + SourcePackagePublishState, + StandardId, + VersionFingerprint, + versionFingerprintsEqual, +} from '@packmind/types'; +import { IMarketplaceDistributionRepository } from '../../../domain/repositories/IMarketplaceDistributionRepository'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { PackageService } from '../../services/PackageService'; +import { PackageVersionFingerprintService } from '../../services/PackageVersionFingerprintService'; + +const origin = 'GetMarketplaceDistributionChangesUseCase'; + +const FALLBACK_NAME = '(deleted)'; + +const KIND_ORDER: Record<SourcePackageChange['kind'], number> = { + added: 0, + updated: 1, + removed: 2, +}; + +const ARTIFACT_KIND_ORDER: Record<MarketplaceArtifactKind, number> = { + command: 0, + standard: 1, + skill: 2, +}; + +/** + * Diffs the `VersionFingerprint` a distribution captured at publish time + * against the source package's current state, returning a flat list of + * artifact-level changes (added / updated / removed). Drives the marketplace + * plugin detail "Changes" tab. + * + * Returns `[]` when: + * - the distribution has no captured fingerprint (legacy row published + * before fingerprinting existed), + * - the source package has been hard-deleted, + * - or the fingerprints are identical (nothing to publish). + * + * Open to any organization member — admin permissions are not required for + * reads. + */ +export class GetMarketplaceDistributionChangesUseCase + extends AbstractMemberUseCase< + GetMarketplaceDistributionChangesCommand, + GetMarketplaceDistributionChangesResponse + > + implements IGetMarketplaceDistributionChangesUseCase +{ + constructor( + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly marketplaceDistributionRepository: IMarketplaceDistributionRepository, + private readonly packageService: PackageService, + private readonly packageVersionFingerprintService: PackageVersionFingerprintService, + private readonly recipesPort: IRecipesPort, + private readonly standardsPort: IStandardsPort, + private readonly skillsPort: ISkillsPort, + accountsPort: IAccountsPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForMembers( + command: GetMarketplaceDistributionChangesCommand & MemberContext, + ): Promise<GetMarketplaceDistributionChangesResponse> { + const { marketplaceId, distributionId, organization } = command; + + const marketplace = + await this.marketplaceRepository.findByOrganizationAndId( + organization.id, + marketplaceId, + ); + if (!marketplace) { + throw new MarketplaceNotFoundError(marketplaceId); + } + + const distribution = + await this.marketplaceDistributionRepository.findById(distributionId); + if ( + !distribution || + distribution.marketplaceId !== marketplaceId || + distribution.organizationId !== organization.id + ) { + return emptyResponse('in_sync_main'); + } + + // The drift signal driving the UI's "Changes ready" pill is computed by the + // reconciler against the *latest success* distribution for the package + // (see `MarketplaceReconciliationDelayedJob`). The frontend, by contrast, + // hands us the latest *non-removed* row — which can be a `no_changes` + // republish that already captured the post-edit fingerprint. Diffing + // against that would mask the drift the reconciler just flagged. + // + // We also look up the latest pending_merge row for the same package — if + // one exists, this package is part of an open sync PR, and the action + // button needs to switch to "View pull request" when the source matches + // that PR's content. + const [successDistributions, pendingMergeDistributions] = await Promise.all( + [ + this.marketplaceDistributionRepository.findSuccessfulByMarketplaceId( + marketplaceId, + ), + this.marketplaceDistributionRepository.findPendingMergesByMarketplaceId( + marketplaceId, + ), + ], + ); + const latestSuccess = successDistributions.find( + (d) => d.packageId === distribution.packageId, + ); + const latestPendingMerge = pendingMergeDistributions.find( + (d) => d.packageId === distribution.packageId, + ); + + const publishedFingerprint = latestSuccess?.versionFingerprint; + if (!publishedFingerprint) { + this.logger.info( + 'No success distribution with captured fingerprint; skipping diff', + { distributionId, packageId: distribution.packageId }, + ); + // We still surface a usable state even without a `success` baseline. + // First-time publishes spend their lifecycle in `pending_merge` until + // the reconciler promotes them, so the View PR button must still appear. + if (latestPendingMerge?.prUrl) { + return { + state: 'in_sync_pr', + changes: [], + prUrl: latestPendingMerge.prUrl, + }; + } + return emptyResponse('in_sync_main'); + } + + const pkg = await this.packageService.findById(distribution.packageId); + if (!pkg) { + this.logger.info('Source package not found; skipping diff', { + packageId: distribution.packageId, + distributionId, + }); + return emptyResponse('in_sync_main'); + } + + const currentFingerprint = + await this.packageVersionFingerprintService.compute(pkg); + + const recipeChanges = await this.diffRecipes( + publishedFingerprint, + currentFingerprint, + ); + const standardChanges = await this.diffStandards( + publishedFingerprint, + currentFingerprint, + ); + const skillChanges = await this.diffSkills( + publishedFingerprint, + currentFingerprint, + ); + + const changes = [ + ...recipeChanges, + ...standardChanges, + ...skillChanges, + ].sort(compareChanges); + + const state = resolvePublishState({ + currentFingerprint, + successFingerprint: publishedFingerprint, + pendingMergeFingerprint: latestPendingMerge?.versionFingerprint, + }); + const prUrl = + state === 'in_sync_pr' || state === 'outdated_pr' + ? (latestPendingMerge?.prUrl ?? null) + : null; + + return { state, changes, prUrl }; + } + + private async diffRecipes( + published: VersionFingerprint, + current: VersionFingerprint, + ): Promise<SourcePackageChange[]> { + const diffs = diffMaps(published.recipes, current.recipes); + return Promise.all( + diffs.map(async (diff) => { + const recipe = await this.recipesPort.getRecipeByIdInternal( + diff.id as RecipeId, + ); + return toChange(diff, 'command', recipe?.name, recipe?.slug); + }), + ); + } + + private async diffStandards( + published: VersionFingerprint, + current: VersionFingerprint, + ): Promise<SourcePackageChange[]> { + const diffs = diffMaps(published.standards, current.standards); + return Promise.all( + diffs.map(async (diff) => { + const standard = await this.standardsPort.getStandard( + diff.id as StandardId, + ); + return toChange(diff, 'standard', standard?.name, standard?.slug); + }), + ); + } + + private async diffSkills( + published: VersionFingerprint, + current: VersionFingerprint, + ): Promise<SourcePackageChange[]> { + const diffs = diffMaps(published.skills, current.skills); + return Promise.all( + diffs.map(async (diff) => { + const skill = await this.skillsPort.getSkill(diff.id as SkillId); + return toChange(diff, 'skill', skill?.name, skill?.slug); + }), + ); + } +} + +function emptyResponse( + state: SourcePackagePublishState, +): GetMarketplaceDistributionChangesResponse { + return { state, changes: [], prUrl: null }; +} + +/** + * Bucket the source package into one of the four publish-state cases the + * Changes-tab footer cares about. + * + * in sync vs success → in_sync_main (no button) + * in sync vs pending_merge → in_sync_pr (View pull request) + * drifted vs pending_merge → outdated_pr (Publish, amends PR) + * drifted vs both, no PR row → outdated_main (Publish, opens PR) + */ +function resolvePublishState(params: { + currentFingerprint: VersionFingerprint; + successFingerprint: VersionFingerprint; + pendingMergeFingerprint: VersionFingerprint | undefined; +}): SourcePackagePublishState { + const { currentFingerprint, successFingerprint, pendingMergeFingerprint } = + params; + if (versionFingerprintsEqual(currentFingerprint, successFingerprint)) { + return 'in_sync_main'; + } + if ( + pendingMergeFingerprint && + versionFingerprintsEqual(currentFingerprint, pendingMergeFingerprint) + ) { + return 'in_sync_pr'; + } + if (pendingMergeFingerprint) { + return 'outdated_pr'; + } + return 'outdated_main'; +} + +type RawDiff = { + id: string; + kind: SourcePackageChange['kind']; + publishedVersion: number | null; + currentVersion: number | null; +}; + +function diffMaps( + published: Record<string, number>, + current: Record<string, number>, +): RawDiff[] { + const diffs: RawDiff[] = []; + for (const [id, currentVersion] of Object.entries(current)) { + if (!(id in published)) { + diffs.push({ id, kind: 'added', publishedVersion: null, currentVersion }); + continue; + } + if (published[id] !== currentVersion) { + diffs.push({ + id, + kind: 'updated', + publishedVersion: published[id], + currentVersion, + }); + } + } + for (const [id, publishedVersion] of Object.entries(published)) { + if (!(id in current)) { + diffs.push({ + id, + kind: 'removed', + publishedVersion, + currentVersion: null, + }); + } + } + return diffs; +} + +function toChange( + diff: RawDiff, + artifactKind: MarketplaceArtifactKind, + name: string | undefined, + slug: string | undefined, +): SourcePackageChange { + return { + kind: diff.kind, + artifactKind, + name: name ?? FALLBACK_NAME, + slug: slug ?? diff.id, + publishedVersion: diff.publishedVersion, + currentVersion: diff.currentVersion, + }; +} + +function compareChanges( + a: SourcePackageChange, + b: SourcePackageChange, +): number { + if (a.kind !== b.kind) return KIND_ORDER[a.kind] - KIND_ORDER[b.kind]; + if (a.artifactKind !== b.artifactKind) { + return ( + ARTIFACT_KIND_ORDER[a.artifactKind] - ARTIFACT_KIND_ORDER[b.artifactKind] + ); + } + return a.name.localeCompare(b.name); +} diff --git a/packages/deployments/src/application/useCases/getMarketplaceDistributionChanges/index.ts b/packages/deployments/src/application/useCases/getMarketplaceDistributionChanges/index.ts new file mode 100644 index 000000000..61feb6c7f --- /dev/null +++ b/packages/deployments/src/application/useCases/getMarketplaceDistributionChanges/index.ts @@ -0,0 +1 @@ +export { GetMarketplaceDistributionChangesUseCase } from './GetMarketplaceDistributionChangesUseCase'; diff --git a/packages/deployments/src/application/useCases/linkMarketplace/LinkMarketplaceUseCase.spec.ts b/packages/deployments/src/application/useCases/linkMarketplace/LinkMarketplaceUseCase.spec.ts new file mode 100644 index 000000000..24c9c2cb9 --- /dev/null +++ b/packages/deployments/src/application/useCases/linkMarketplace/LinkMarketplaceUseCase.spec.ts @@ -0,0 +1,492 @@ +import { v4 as uuidv4 } from 'uuid'; +import { stubLogger } from '@packmind/test-utils'; +import { GitRepoService } from '@packmind/git'; +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { + createGitProviderId, + createGitRepoId, + createOrganizationId, + createUserId, + GitProviderMissingTokenError, + GitProviderNotFoundError, + GitProviderOrganizationMismatchError, + GitProviderWithoutToken, + GitRepo, + GitRepoAlreadyLinkedAsStandardError, + IAccountsPort, + IGitPort, + LinkMarketplaceCommand, + MARKETPLACE_DESCRIPTOR_FILENAME, + Marketplace, + MarketplaceAlreadyLinkedError, + MarketplaceDescriptor, + MarketplaceDescriptorNotFoundError, + MarketplaceDescriptorParseError, + MarketplaceLinkedEvent, + Organization, + OrganizationId, + UnknownMarketplaceDescriptorError, + User, + UserId, +} from '@packmind/types'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { MarketplaceReconciliationDelayedJob } from '../../jobs/MarketplaceReconciliationDelayedJob'; +import { MarketplaceDescriptorParserRegistry } from '../../services/MarketplaceDescriptorParserRegistry'; +import { LinkMarketplaceUseCase } from './LinkMarketplaceUseCase'; + +describe('LinkMarketplaceUseCase', () => { + const organizationId: OrganizationId = createOrganizationId(uuidv4()); + const userId: UserId = createUserId(uuidv4()); + const gitProviderId = createGitProviderId(uuidv4()); + + const baseCommand: LinkMarketplaceCommand = { + userId, + organizationId, + gitProviderId, + owner: 'acme', + repo: 'plugins', + branch: 'main', + name: 'ACME Plugins', + }; + + const adminUser = { + id: userId, + email: 'admin@example.com', + displayName: 'Admin User', + passwordHash: null, + active: true, + memberships: [{ userId, organizationId, role: 'admin' as const }], + trial: false, + } as unknown as User; + + const organization: Organization = { + id: organizationId, + name: 'Test Org', + slug: 'test-org', + }; + + const providerWithToken: GitProviderWithoutToken = { + id: gitProviderId, + source: 'github', + organizationId, + url: 'https://github.com', + hasAuth: true, + authMethod: 'token', + }; + + const providerWithoutToken: GitProviderWithoutToken = { + ...providerWithToken, + hasAuth: false, + }; + + const descriptor: MarketplaceDescriptor = { + vendor: 'anthropic', + name: 'ACME Plugins', + version: '1.0.0', + plugins: [ + { slug: 'p1', name: 'Plugin 1' }, + { slug: 'p2', name: 'Plugin 2' }, + ], + raw: { vendor: 'anthropic', plugins: [] }, + }; + + let mockMarketplaceRepository: jest.Mocked<IMarketplaceRepository>; + let mockGitRepoService: jest.Mocked<GitRepoService>; + let mockGitPort: jest.Mocked<IGitPort>; + let mockParserRegistry: jest.Mocked<MarketplaceDescriptorParserRegistry>; + let mockEventEmitterService: jest.Mocked<PackmindEventEmitterService>; + let mockAccountsPort: jest.Mocked<IAccountsPort>; + let mockReconciliationJob: jest.Mocked<MarketplaceReconciliationDelayedJob>; + let useCase: LinkMarketplaceUseCase; + + const createdGitRepo: GitRepo = { + id: createGitRepoId(uuidv4()), + owner: baseCommand.owner, + repo: baseCommand.repo, + branch: baseCommand.branch, + providerId: gitProviderId, + type: 'marketplace', + }; + + beforeEach(() => { + mockMarketplaceRepository = { + add: jest.fn(), + findByOrganizationId: jest.fn(), + findByOrganizationAndGitRepo: jest.fn(), + findByOrganizationAndId: jest.fn(), + findAllForReconciliation: jest.fn(), + updateState: jest.fn(), + findById: jest.fn(), + addMany: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + hardDeleteById: jest.fn(), + } as unknown as jest.Mocked<IMarketplaceRepository>; + + mockGitRepoService = { + findGitRepoIgnoringType: jest.fn().mockResolvedValue(null), + addGitRepo: jest.fn().mockResolvedValue(createdGitRepo), + } as unknown as jest.Mocked<GitRepoService>; + + mockGitPort = { + listProviders: jest.fn().mockResolvedValue({ + providers: [providerWithToken], + }), + getFileFromRepo: jest.fn().mockResolvedValue({ + sha: 'abc', + content: JSON.stringify({ vendor: 'anthropic', plugins: [] }), + }), + } as unknown as jest.Mocked<IGitPort>; + + mockParserRegistry = { + parse: jest.fn().mockReturnValue(descriptor), + } as unknown as jest.Mocked<MarketplaceDescriptorParserRegistry>; + + mockEventEmitterService = { + emit: jest.fn(), + } as unknown as jest.Mocked<PackmindEventEmitterService>; + + mockAccountsPort = { + getUserById: jest.fn().mockResolvedValue(adminUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + mockReconciliationJob = { + scheduleRecurring: jest.fn().mockResolvedValue(undefined), + cancelRecurring: jest.fn().mockResolvedValue(undefined), + addJob: jest.fn().mockResolvedValue('job-id'), + } as unknown as jest.Mocked<MarketplaceReconciliationDelayedJob>; + + mockMarketplaceRepository.add.mockImplementation( + async (m) => m as Marketplace, + ); + + useCase = new LinkMarketplaceUseCase( + mockMarketplaceRepository, + mockGitRepoService, + mockGitPort, + mockParserRegistry, + mockEventEmitterService, + mockReconciliationJob, + mockAccountsPort, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('admin happy path — private (token-bearing) provider', () => { + describe('marketplace row enriched with addedByUserName', () => { + let response: Awaited<ReturnType<LinkMarketplaceUseCase['execute']>>; + + beforeEach(async () => { + response = await useCase.execute(baseCommand); + }); + + it('creates exactly one marketplace row', () => { + expect(mockMarketplaceRepository.add).toHaveBeenCalledTimes(1); + }); + + it('sets the organizationId on the inserted row', () => { + const inserted = mockMarketplaceRepository.add.mock.calls[0][0]; + expect(inserted.organizationId).toBe(organizationId); + }); + + it('sets the gitRepoId on the inserted row', () => { + const inserted = mockMarketplaceRepository.add.mock.calls[0][0]; + expect(inserted.gitRepoId).toBe(createdGitRepo.id); + }); + + it('sets the name on the inserted row', () => { + const inserted = mockMarketplaceRepository.add.mock.calls[0][0]; + expect(inserted.name).toBe('ACME Plugins'); + }); + + it('sets the vendor on the inserted row', () => { + const inserted = mockMarketplaceRepository.add.mock.calls[0][0]; + expect(inserted.vendor).toBe('anthropic'); + }); + + it('sets the descriptor on the inserted row', () => { + const inserted = mockMarketplaceRepository.add.mock.calls[0][0]; + expect(inserted.descriptor).toBe(descriptor); + }); + + it('sets the pluginCount on the inserted row', () => { + const inserted = mockMarketplaceRepository.add.mock.calls[0][0]; + expect(inserted.pluginCount).toBe(descriptor.plugins.length); + }); + + it('enriches the response with addedByUserName', () => { + expect(response.addedByUserName).toBe(adminUser.displayName); + }); + }); + + describe('fetching marketplace.json from the requested repo', () => { + beforeEach(async () => { + await useCase.execute(baseCommand); + }); + + it('fetches the file exactly once', () => { + expect(mockGitPort.getFileFromRepo).toHaveBeenCalledTimes(1); + }); + + it('targets the requested owner', () => { + const [gitRepoArg] = mockGitPort.getFileFromRepo.mock.calls[0]; + expect(gitRepoArg.owner).toBe('acme'); + }); + + it('targets the requested repo', () => { + const [gitRepoArg] = mockGitPort.getFileFromRepo.mock.calls[0]; + expect(gitRepoArg.repo).toBe('plugins'); + }); + + it('uses a marketplace-typed git repo', () => { + const [gitRepoArg] = mockGitPort.getFileFromRepo.mock.calls[0]; + expect(gitRepoArg.type).toBe('marketplace'); + }); + + it('reads the marketplace descriptor filename', () => { + const [, pathArg] = mockGitPort.getFileFromRepo.mock.calls[0]; + expect(pathArg).toBe(MARKETPLACE_DESCRIPTOR_FILENAME); + }); + + it('reads from the requested branch', () => { + const [, , branchArg] = mockGitPort.getFileFromRepo.mock.calls[0]; + expect(branchArg).toBe('main'); + }); + }); + + it('persists a marketplace-typed GitRepo via GitRepoService.addGitRepo', async () => { + await useCase.execute(baseCommand); + + expect(mockGitRepoService.addGitRepo).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'acme', + repo: 'plugins', + branch: 'main', + providerId: gitProviderId, + type: 'marketplace', + }), + ); + }); + + describe('emitting MarketplaceLinkedEvent', () => { + beforeEach(async () => { + await useCase.execute(baseCommand); + }); + + it('emits exactly one event', () => { + expect(mockEventEmitterService.emit).toHaveBeenCalledTimes(1); + }); + + it('emits a MarketplaceLinkedEvent instance', () => { + const emitted = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emitted).toBeInstanceOf(MarketplaceLinkedEvent); + }); + + it('sets the organizationId on the event payload', () => { + const emitted = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emitted.payload.organizationId).toBe(organizationId); + }); + + it('sets the gitRepoId on the event payload', () => { + const emitted = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emitted.payload.gitRepoId).toBe(createdGitRepo.id); + }); + + it('sets the addedBy on the event payload', () => { + const emitted = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emitted.payload.addedBy).toBe(userId); + }); + }); + + describe('scheduling reconciliation', () => { + beforeEach(async () => { + await useCase.execute(baseCommand); + }); + + it('schedules the repeatable reconciliation cron', () => { + const insertedId = mockMarketplaceRepository.add.mock.calls[0][0].id; + expect(mockReconciliationJob.scheduleRecurring).toHaveBeenCalledWith( + insertedId, + ); + }); + + it('seeds an immediate reconciliation run', () => { + const insertedId = mockMarketplaceRepository.add.mock.calls[0][0].id; + expect(mockReconciliationJob.addJob).toHaveBeenCalledWith({ + marketplaceId: insertedId, + }); + }); + }); + }); + + describe('admin happy path — public (tokenless) provider', () => { + beforeEach(() => { + mockGitPort.listProviders = jest.fn().mockResolvedValue({ + providers: [providerWithoutToken], + }); + }); + + describe('when the provider has no token', () => { + it('still rejects the private link path', async () => { + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + GitProviderMissingTokenError, + ); + }); + }); + }); + + describe('non-admin denial', () => { + beforeEach(() => { + const memberUser = { + ...adminUser, + memberships: [{ userId, organizationId, role: 'member' as const }], + } as unknown as User; + mockAccountsPort.getUserById = jest.fn().mockResolvedValue(memberUser); + }); + + it('throws OrganizationAdminRequiredError', async () => { + await expect(useCase.execute(baseCommand)).rejects.toMatchObject({ + name: 'OrganizationAdminRequiredError', + }); + }); + + describe('does not call any downstream service', () => { + beforeEach(async () => { + try { + await useCase.execute(baseCommand); + } catch { + // expected + } + }); + + it('does not persist a marketplace row', () => { + expect(mockMarketplaceRepository.add).not.toHaveBeenCalled(); + }); + + it('does not persist a git repo', () => { + expect(mockGitRepoService.addGitRepo).not.toHaveBeenCalled(); + }); + + it('does not emit any event', () => { + expect(mockEventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + }); + + describe('provider validation errors', () => { + describe('when the provider is missing', () => { + it('throws GitProviderNotFoundError', async () => { + mockGitPort.listProviders = jest + .fn() + .mockResolvedValue({ providers: [] }); + + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + GitProviderNotFoundError, + ); + }); + }); + + it('throws GitProviderOrganizationMismatchError on cross-org provider', async () => { + const otherOrgProvider: GitProviderWithoutToken = { + ...providerWithToken, + organizationId: createOrganizationId(uuidv4()), + }; + mockGitPort.listProviders = jest + .fn() + .mockResolvedValue({ providers: [otherOrgProvider] }); + + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + GitProviderOrganizationMismatchError, + ); + }); + }); + + describe('cross-type collisions', () => { + describe('when a marketplace-typed repo exists', () => { + it('throws MarketplaceAlreadyLinkedError', async () => { + mockGitRepoService.findGitRepoIgnoringType.mockResolvedValue({ + id: createGitRepoId(uuidv4()), + owner: 'acme', + repo: 'plugins', + branch: 'main', + providerId: gitProviderId, + type: 'marketplace', + } as GitRepo); + + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + MarketplaceAlreadyLinkedError, + ); + }); + }); + + describe('when a standard-typed repo exists', () => { + it('throws GitRepoAlreadyLinkedAsStandardError', async () => { + mockGitRepoService.findGitRepoIgnoringType.mockResolvedValue({ + id: createGitRepoId(uuidv4()), + owner: 'acme', + repo: 'plugins', + branch: 'main', + providerId: gitProviderId, + type: 'standard', + } as GitRepo); + + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + GitRepoAlreadyLinkedAsStandardError, + ); + }); + }); + + describe('provider scoping', () => { + it('scopes the collision check to the target provider', async () => { + await useCase.execute(baseCommand); + + expect(mockGitRepoService.findGitRepoIgnoringType).toHaveBeenCalledWith( + organizationId, + baseCommand.owner, + baseCommand.repo, + { providerId: gitProviderId }, + ); + }); + }); + }); + + describe('descriptor errors', () => { + describe('when marketplace.json is missing', () => { + it('throws MarketplaceDescriptorNotFoundError', async () => { + mockGitPort.getFileFromRepo = jest.fn().mockResolvedValue(null); + + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + MarketplaceDescriptorNotFoundError, + ); + }); + }); + + it('propagates UnknownMarketplaceDescriptorError from the registry', async () => { + mockParserRegistry.parse.mockImplementation(() => { + throw new UnknownMarketplaceDescriptorError(); + }); + + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + UnknownMarketplaceDescriptorError, + ); + }); + + it('propagates MarketplaceDescriptorParseError from the registry', async () => { + mockParserRegistry.parse.mockImplementation(() => { + throw new MarketplaceDescriptorParseError( + 'malformed', + new Error('boom'), + ); + }); + + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + MarketplaceDescriptorParseError, + ); + }); + }); +}); diff --git a/packages/deployments/src/application/useCases/linkMarketplace/LinkMarketplaceUseCase.ts b/packages/deployments/src/application/useCases/linkMarketplace/LinkMarketplaceUseCase.ts new file mode 100644 index 000000000..d1cb71823 --- /dev/null +++ b/packages/deployments/src/application/useCases/linkMarketplace/LinkMarketplaceUseCase.ts @@ -0,0 +1,287 @@ +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { + AbstractAdminUseCase, + AdminContext, + PackmindEventEmitterService, +} from '@packmind/node-utils'; +import { + createGitRepoId, + createMarketplaceId, + createUserId, + GitProviderMissingTokenError, + GitProviderNotFoundError, + GitProviderOrganizationMismatchError, + GitRepo, + GitRepoAlreadyLinkedAsStandardError, + IAccountsPort, + IGitPort, + ILinkMarketplaceUseCase, + LinkMarketplaceCommand, + LinkMarketplaceResponse, + Marketplace, + MarketplaceAlreadyLinkedError, + MarketplaceDescriptorNotFoundError, + MarketplaceLinkedEvent, + UserId, +} from '@packmind/types'; +import { GitRepoService } from '@packmind/git'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { MarketplaceReconciliationDelayedJob } from '../../jobs/MarketplaceReconciliationDelayedJob'; +import { fetchMarketplaceDescriptorFile } from '../../services/fetchMarketplaceDescriptorFile'; +import { MarketplaceDescriptorParserRegistry } from '../../services/MarketplaceDescriptorParserRegistry'; + +const origin = 'LinkMarketplaceUseCase'; + +/** + * Links a Git repository as a marketplace for the caller's organization. + * + * Admin-only; enforced at the `AbstractAdminUseCase` boundary so denial + * surfaces as `OrganizationAdminRequiredError` (HTTP 403) regardless of the + * controller layer. + * + * Flow: + * 1. Resolve the `GitProvider` and validate it belongs to the org. + * 2. Pre-flight collision check — a `type='standard'` row for the same + * coordinates rejects with `GitRepoAlreadyLinkedAsStandardError`; a + * non-soft-deleted `type='marketplace'` row rejects with + * `MarketplaceAlreadyLinkedError(owner, repo)`. + * 3. Fetch `marketplace.json` via `IGitPort.getFileFromRepo`. + * 4. Parse it through `MarketplaceDescriptorParserRegistry`. + * 5. Create a `GitRepo` with `type='marketplace'`. + * 6. Insert a `Marketplace` row carrying the descriptor + plugin count. + * 7. Emit `MarketplaceLinkedEvent`. + * 8. (Group K, task 11.3) Enqueue the reconciliation job. + * 9. Return the marketplace enriched with `addedByUserName`. + */ +export class LinkMarketplaceUseCase + extends AbstractAdminUseCase<LinkMarketplaceCommand, LinkMarketplaceResponse> + implements ILinkMarketplaceUseCase +{ + constructor( + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly gitRepoService: GitRepoService, + private readonly gitPort: IGitPort, + private readonly parserRegistry: MarketplaceDescriptorParserRegistry, + private readonly eventEmitterService: PackmindEventEmitterService, + private readonly reconciliationJob: MarketplaceReconciliationDelayedJob, + accountsPort: IAccountsPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForAdmins( + command: LinkMarketplaceCommand & AdminContext, + ): Promise<LinkMarketplaceResponse> { + const { + gitProviderId, + owner, + repo, + branch, + name, + userId, + organization, + source, + } = command; + + if (!gitProviderId) { + throw new Error('Git provider ID is required'); + } + if (!owner || !repo || !branch) { + throw new Error('Owner, repository name, and branch are all required'); + } + if (!name || name.trim().length === 0) { + throw new Error('Marketplace name is required'); + } + + // 1. Resolve the provider and validate org match. + const providersResponse = await this.gitPort.listProviders({ + userId, + organizationId: organization.id, + }); + const gitProvider = providersResponse.providers.find( + (p) => p.id === gitProviderId, + ); + if (!gitProvider) { + this.logger.error('Git provider not found', { + gitProviderId, + organizationId: organization.id, + userId, + }); + throw new GitProviderNotFoundError(gitProviderId); + } + if (gitProvider.organizationId !== organization.id) { + this.logger.error('Git provider does not belong to organization', { + gitProviderId, + providerOrganizationId: gitProvider.organizationId, + requestedOrganizationId: organization.id, + userId, + }); + throw new GitProviderOrganizationMismatchError( + gitProviderId, + organization.id, + ); + } + if (!gitProvider.hasAuth) { + this.logger.error('Git provider has no token configured', { + gitProviderId, + organizationId: organization.id, + userId, + }); + throw new GitProviderMissingTokenError(gitProviderId); + } + + // 2. Cross-type collision check (standard ↔ marketplace), scoped to the + // target provider so the same owner/repo on a different provider in the + // org (e.g. a GitHub vs GitLab `acme/plugins`) is not a false collision. + const existingRepo = await this.gitRepoService.findGitRepoIgnoringType( + organization.id, + owner, + repo, + { providerId: gitProviderId }, + ); + if (existingRepo) { + if (existingRepo.type === 'standard') { + this.logger.error( + 'Cannot link marketplace — repo already linked as standard', + { + owner, + repo, + organizationId: organization.id, + gitRepoId: existingRepo.id, + }, + ); + throw new GitRepoAlreadyLinkedAsStandardError(owner, repo); + } + if (existingRepo.type === 'marketplace') { + // Active (non-soft-deleted) marketplace row exists for these + // coordinates. The GitRepoService default finder excludes + // soft-deleted rows, so reaching here means there is a live one. + this.logger.error('Marketplace already linked', { + owner, + repo, + organizationId: organization.id, + gitRepoId: existingRepo.id, + }); + throw new MarketplaceAlreadyLinkedError(owner, repo); + } + } + + // Build the GitRepo we're about to fetch from (not yet persisted). + const provisionalGitRepo: GitRepo = { + id: createGitRepoId(uuidv4()), + owner, + repo, + branch, + providerId: gitProviderId, + type: 'marketplace', + }; + + // 3. Fetch the marketplace descriptor, probing the official + // `.claude-plugin/marketplace.json` path first and falling back to a + // bare root `marketplace.json`. + const file = await fetchMarketplaceDescriptorFile( + this.gitPort, + provisionalGitRepo, + branch, + ); + if (!file) { + this.logger.warn('Marketplace descriptor not found', { + owner, + repo, + branch, + }); + throw new MarketplaceDescriptorNotFoundError(owner, repo); + } + + // 4. Parse descriptor via the vendor-agnostic registry. + const descriptor = this.parserRegistry.parse(file.content); + + // 5. Persist the marketplace-typed GitRepo. + const createdGitRepo = await this.gitRepoService.addGitRepo({ + owner, + repo, + branch, + providerId: gitProviderId, + type: 'marketplace', + }); + + // 6. Insert the Marketplace row carrying the descriptor + plugin count. + // `createdAt`/`updatedAt`/`deletedAt`/`deletedBy` are populated by the + // DB via column defaults/triggers — TypeORM hydrates them on the + // returned row. + const addedBy = createUserId(userId); + const linkedAt = new Date(); + const marketplaceEntity = { + id: createMarketplaceId(uuidv4()), + organizationId: organization.id, + gitRepoId: createdGitRepo.id, + name: name.trim(), + vendor: descriptor.vendor, + addedBy, + linkedAt, + state: 'healthy' as const, + lastValidatedAt: null, + descriptor, + pluginCount: descriptor.plugins.length, + errorKind: null, + errorDetail: null, + pendingPrUrl: null, + outdatedPluginSlugs: null, + // Generate a fresh tracking token so new marketplaces always have a + // non-null token from creation time (spec §7.2). + trackingToken: uuidv4(), + } as Marketplace; + const insertedMarketplace = + await this.marketplaceRepository.add(marketplaceEntity); + + // 7. Emit MarketplaceLinkedEvent. + this.eventEmitterService.emit( + new MarketplaceLinkedEvent({ + userId: addedBy, + organizationId: organization.id, + marketplaceId: insertedMarketplace.id, + gitRepoId: createdGitRepo.id, + addedBy, + source: source ?? 'ui', + }), + ); + + // 8. Schedule the BullMQ reconciliation cron + seed an immediate + // health-check so the marketplace's state column reflects the world as + // early as possible. Schedule failures are swallowed and logged — they + // must not break the link transaction. + try { + await this.reconciliationJob.scheduleRecurring(insertedMarketplace.id); + await this.reconciliationJob.addJob({ + marketplaceId: insertedMarketplace.id, + }); + } catch (error) { + this.logger.error( + 'Failed to enqueue marketplace reconciliation job — marketplace state will not auto-refresh until the next manual trigger', + { + marketplaceId: insertedMarketplace.id, + organizationId: organization.id, + error: error instanceof Error ? error.message : String(error), + }, + ); + } + + // 9. Hydrate addedByUserName for the presentation DTO. + const addedByUserName = await this.resolveAddedByUserName(addedBy); + + return { + ...insertedMarketplace, + addedByUserName, + }; + } + + private async resolveAddedByUserName(userId: UserId): Promise<string> { + const user = await this.accountsPort.getUserById(userId); + if (!user) { + return ''; + } + return user.displayName ?? user.email; + } +} diff --git a/packages/deployments/src/application/useCases/linkMarketplace/index.ts b/packages/deployments/src/application/useCases/linkMarketplace/index.ts new file mode 100644 index 000000000..c4f71e4f4 --- /dev/null +++ b/packages/deployments/src/application/useCases/linkMarketplace/index.ts @@ -0,0 +1 @@ +export { LinkMarketplaceUseCase } from './LinkMarketplaceUseCase'; diff --git a/packages/deployments/src/application/useCases/listMarketplaceDistributions/ListMarketplaceDistributionsUseCase.spec.ts b/packages/deployments/src/application/useCases/listMarketplaceDistributions/ListMarketplaceDistributionsUseCase.spec.ts new file mode 100644 index 000000000..966162c24 --- /dev/null +++ b/packages/deployments/src/application/useCases/listMarketplaceDistributions/ListMarketplaceDistributionsUseCase.spec.ts @@ -0,0 +1,534 @@ +import { v4 as uuidv4 } from 'uuid'; +import { stubLogger } from '@packmind/test-utils'; +import { + createMarketplaceDistributionId, + createMarketplaceId, + createOrganizationId, + createPackageId, + createUserId, + DistributionStatus, + IAccountsPort, + ISpacesPort, + Marketplace, + MarketplaceDistribution, + MarketplaceNotFoundError, + Organization, + Package, + Space, + SpaceId, + SpaceType, + User, + createSpaceId, +} from '@packmind/types'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { IMarketplaceDistributionRepository } from '../../../domain/repositories/IMarketplaceDistributionRepository'; +import { PackageService } from '../../services/PackageService'; +import { ListMarketplaceDistributionsUseCase } from './ListMarketplaceDistributionsUseCase'; + +describe('ListMarketplaceDistributionsUseCase', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const marketplaceId = createMarketplaceId(uuidv4()); + const packageId = createPackageId(uuidv4()); + const spaceId: SpaceId = createSpaceId(uuidv4()); + + const existingMarketplace = { + id: marketplaceId, + organizationId, + name: 'ACME Plugins', + } as unknown as Marketplace; + + const memberSpace: Space = { + id: spaceId, + name: 'Platform', + slug: 'platform', + type: SpaceType.open, + organizationId, + isDefaultSpace: false, + color: 'teal', + }; + + const memberUser = { + id: userId, + email: 'admin@example.com', + displayName: 'Author Person', + passwordHash: null, + active: true, + memberships: [{ userId, organizationId, role: 'member' as const }], + trial: false, + } as unknown as User; + + const organization: Organization = { + id: organizationId, + name: 'Test Org', + slug: 'test-org', + }; + + const distributionA = { + id: createMarketplaceDistributionId(uuidv4()), + organizationId, + marketplaceId, + packageId, + pluginSlug: 'my-plugin', + authorId: userId, + status: DistributionStatus.success, + source: 'app', + } as unknown as MarketplaceDistribution; + + const removedDistribution = { + id: createMarketplaceDistributionId(uuidv4()), + organizationId, + marketplaceId, + packageId, + pluginSlug: 'retired-plugin', + authorId: userId, + status: DistributionStatus.removed, + source: 'app', + } as unknown as MarketplaceDistribution; + + let mockMarketplaceRepository: jest.Mocked<IMarketplaceRepository>; + let mockMarketplaceDistributionRepository: jest.Mocked<IMarketplaceDistributionRepository>; + let mockPackageService: jest.Mocked<PackageService>; + let mockAccountsPort: jest.Mocked<IAccountsPort>; + let mockSpacesPort: jest.Mocked<ISpacesPort>; + let useCase: ListMarketplaceDistributionsUseCase; + + beforeEach(() => { + mockMarketplaceRepository = { + findByOrganizationAndId: jest.fn().mockResolvedValue(existingMarketplace), + } as unknown as jest.Mocked<IMarketplaceRepository>; + + mockMarketplaceDistributionRepository = { + findByMarketplaceId: jest.fn().mockResolvedValue([distributionA]), + } as unknown as jest.Mocked<IMarketplaceDistributionRepository>; + + mockPackageService = { + findById: jest.fn().mockResolvedValue({ + id: packageId, + name: 'My Package', + slug: 'my-package', + spaceId, + } as Package), + } as unknown as jest.Mocked<PackageService>; + + mockAccountsPort = { + getUserById: jest.fn().mockResolvedValue(memberUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + mockSpacesPort = { + getSpaceById: jest.fn().mockResolvedValue(memberSpace), + } as unknown as jest.Mocked<ISpacesPort>; + + useCase = new ListMarketplaceDistributionsUseCase( + mockMarketplaceRepository, + mockMarketplaceDistributionRepository, + mockPackageService, + mockSpacesPort, + mockAccountsPort, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('happy path', () => { + let result: Awaited< + ReturnType<ListMarketplaceDistributionsUseCase['execute']> + >; + + beforeEach(async () => { + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + }); + }); + + it('returns one item per distribution', () => { + expect(result).toHaveLength(1); + }); + + it('enriches the row with the package name', () => { + expect(result[0].packageName).toBe('My Package'); + }); + + it('enriches the row with the package slug', () => { + expect(result[0].packageSlug).toBe('my-package'); + }); + + it('enriches the row with the author display name', () => { + expect(result[0].authorName).toBe('Author Person'); + }); + + it('preserves the distribution id', () => { + expect(result[0].id).toEqual(distributionA.id); + }); + + it('preserves the plugin slug', () => { + expect(result[0].pluginSlug).toBe('my-plugin'); + }); + + it('preserves the distribution status', () => { + expect(result[0].status).toBe(DistributionStatus.success); + }); + + it('enriches the row with the owning space', () => { + expect(result[0].space).toEqual({ + id: spaceId, + name: 'Platform', + color: 'teal', + }); + }); + }); + + describe('when the owning space has been removed', () => { + let result: Awaited< + ReturnType<ListMarketplaceDistributionsUseCase['execute']> + >; + + beforeEach(async () => { + mockSpacesPort.getSpaceById = jest.fn().mockResolvedValue(null); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + }); + }); + + it('returns a null space rather than guessing', () => { + expect(result[0].space).toBeNull(); + }); + }); + + describe('when a package has multiple distributions', () => { + const earlierFailure = { + id: createMarketplaceDistributionId(uuidv4()), + organizationId, + marketplaceId, + packageId, + pluginSlug: 'my-plugin', + authorId: userId, + status: DistributionStatus.failed, + source: 'app', + } as unknown as MarketplaceDistribution; + + let result: Awaited< + ReturnType<ListMarketplaceDistributionsUseCase['execute']> + >; + + beforeEach(async () => { + // Repository contract: rows are returned ordered by `createdAt DESC`, + // so the latest attempt comes first and the earlier failure second. + mockMarketplaceDistributionRepository.findByMarketplaceId.mockResolvedValue( + [distributionA, earlierFailure], + ); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + }); + }); + + it('returns a single row for the package', () => { + expect(result).toHaveLength(1); + }); + + it('keeps only the latest distribution', () => { + expect(result[0].id).toEqual(distributionA.id); + }); + + it('drops the earlier failed attempt', () => { + expect(result.map((item) => item.id)).toEqual([distributionA.id]); + }); + }); + + describe('when two packages each have multiple distributions', () => { + const otherPackageId = createPackageId(uuidv4()); + + const latestForOtherPackage = { + id: createMarketplaceDistributionId(uuidv4()), + organizationId, + marketplaceId, + packageId: otherPackageId, + pluginSlug: 'other-plugin', + authorId: userId, + status: DistributionStatus.success, + source: 'app', + } as unknown as MarketplaceDistribution; + + const earlierFailureForOtherPackage = { + id: createMarketplaceDistributionId(uuidv4()), + organizationId, + marketplaceId, + packageId: otherPackageId, + pluginSlug: 'other-plugin', + authorId: userId, + status: DistributionStatus.failed, + source: 'app', + } as unknown as MarketplaceDistribution; + + const earlierFailureForFirstPackage = { + id: createMarketplaceDistributionId(uuidv4()), + organizationId, + marketplaceId, + packageId, + pluginSlug: 'my-plugin', + authorId: userId, + status: DistributionStatus.failed, + source: 'app', + } as unknown as MarketplaceDistribution; + + let result: Awaited< + ReturnType<ListMarketplaceDistributionsUseCase['execute']> + >; + + beforeEach(async () => { + mockPackageService.findById = jest.fn().mockImplementation((pid) => { + if (pid === packageId) { + return Promise.resolve({ + id: packageId, + name: 'My Package', + slug: 'my-package', + spaceId, + } as Package); + } + if (pid === otherPackageId) { + return Promise.resolve({ + id: otherPackageId, + name: 'Other Package', + slug: 'other-package', + spaceId, + } as Package); + } + return Promise.resolve(null); + }); + + mockMarketplaceDistributionRepository.findByMarketplaceId.mockResolvedValue( + [ + distributionA, + latestForOtherPackage, + earlierFailureForFirstPackage, + earlierFailureForOtherPackage, + ], + ); + + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + }); + }); + + it('returns one row per package', () => { + expect(result.map((item) => item.id)).toEqual([ + distributionA.id, + latestForOtherPackage.id, + ]); + }); + }); + + describe('lastPublishedOnMainAt enrichment', () => { + describe('when the latest row is a successful publish', () => { + const confirmedAt = new Date('2026-06-15T10:00:00.000Z'); + const successWithConfirmedAt = { + ...distributionA, + publishConfirmedAt: confirmedAt, + } as MarketplaceDistribution; + + let result: Awaited< + ReturnType<ListMarketplaceDistributionsUseCase['execute']> + >; + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findByMarketplaceId.mockResolvedValue( + [successWithConfirmedAt], + ); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + }); + }); + + it("uses the row's own publishConfirmedAt", () => { + expect(result[0].lastPublishedOnMainAt).toEqual(confirmedAt); + }); + }); + + describe('when the latest row is pending_merge with no prior success', () => { + const pendingMerge = { + id: createMarketplaceDistributionId(uuidv4()), + organizationId, + marketplaceId, + packageId, + pluginSlug: 'my-plugin', + authorId: userId, + status: DistributionStatus.pending_merge, + source: 'app', + } as unknown as MarketplaceDistribution; + + let result: Awaited< + ReturnType<ListMarketplaceDistributionsUseCase['execute']> + >; + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findByMarketplaceId.mockResolvedValue( + [pendingMerge], + ); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + }); + }); + + it('falls back to null', () => { + expect(result[0].lastPublishedOnMainAt).toBeNull(); + }); + }); + + describe('when the latest row is pending_merge over a prior success', () => { + const confirmedAt = new Date('2026-06-01T10:00:00.000Z'); + const priorSuccess = { + ...distributionA, + id: createMarketplaceDistributionId(uuidv4()), + publishConfirmedAt: confirmedAt, + } as MarketplaceDistribution; + const latestPendingMerge = { + id: createMarketplaceDistributionId(uuidv4()), + organizationId, + marketplaceId, + packageId, + pluginSlug: 'my-plugin', + authorId: userId, + status: DistributionStatus.pending_merge, + source: 'app', + } as unknown as MarketplaceDistribution; + + let result: Awaited< + ReturnType<ListMarketplaceDistributionsUseCase['execute']> + >; + + beforeEach(async () => { + // Repository contract: rows ordered DESC by createdAt — the new + // pending_merge row comes first, the older success row second. + mockMarketplaceDistributionRepository.findByMarketplaceId.mockResolvedValue( + [latestPendingMerge, priorSuccess], + ); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + }); + }); + + it('keeps the latest pending_merge row as the visible distribution', () => { + expect(result[0].id).toEqual(latestPendingMerge.id); + }); + + it("surfaces the prior success's publishConfirmedAt on the row", () => { + expect(result[0].lastPublishedOnMainAt).toEqual(confirmedAt); + }); + }); + }); + + describe('when a distribution has been removed', () => { + let result: Awaited< + ReturnType<ListMarketplaceDistributionsUseCase['execute']> + >; + + beforeEach(async () => { + mockMarketplaceDistributionRepository.findByMarketplaceId.mockResolvedValue( + [distributionA, removedDistribution], + ); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + }); + }); + + it('excludes the removed distribution from the list', () => { + expect(result.map((item) => item.id)).toEqual([distributionA.id]); + }); + }); + + describe('wrong-org isolation', () => { + beforeEach(() => { + mockMarketplaceRepository.findByOrganizationAndId.mockResolvedValue(null); + }); + + it('throws MarketplaceNotFoundError', async () => { + await expect( + useCase.execute({ userId, organizationId, marketplaceId }), + ).rejects.toBeInstanceOf(MarketplaceNotFoundError); + }); + + it('does not load any distribution', async () => { + await useCase + .execute({ userId, organizationId, marketplaceId }) + .catch(() => { + /* expected */ + }); + expect( + mockMarketplaceDistributionRepository.findByMarketplaceId, + ).not.toHaveBeenCalled(); + }); + }); + + describe('empty marketplace', () => { + beforeEach(() => { + mockMarketplaceDistributionRepository.findByMarketplaceId.mockResolvedValue( + [], + ); + }); + + it('returns an empty array', async () => { + const result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + }); + expect(result).toEqual([]); + }); + + it('does not call the package service', async () => { + await useCase.execute({ userId, organizationId, marketplaceId }); + expect(mockPackageService.findById).not.toHaveBeenCalled(); + }); + }); + + describe('when the source package has been removed', () => { + let result: Awaited< + ReturnType<ListMarketplaceDistributionsUseCase['execute']> + >; + + beforeEach(async () => { + mockPackageService.findById = jest.fn().mockResolvedValue(null); + result = await useCase.execute({ + userId, + organizationId, + marketplaceId, + }); + }); + + it('still returns the distribution row', () => { + expect(result).toHaveLength(1); + }); + + it('falls back to an empty package name without throwing', () => { + expect(result[0].packageName).toBe(''); + }); + + it('falls back to a null space rather than guessing', () => { + expect(result[0].space).toBeNull(); + }); + + it('does not call the spaces port', () => { + expect(mockSpacesPort.getSpaceById).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/deployments/src/application/useCases/listMarketplaceDistributions/ListMarketplaceDistributionsUseCase.ts b/packages/deployments/src/application/useCases/listMarketplaceDistributions/ListMarketplaceDistributionsUseCase.ts new file mode 100644 index 000000000..718a3625f --- /dev/null +++ b/packages/deployments/src/application/useCases/listMarketplaceDistributions/ListMarketplaceDistributionsUseCase.ts @@ -0,0 +1,204 @@ +import { PackmindLogger } from '@packmind/logger'; +import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; +import { + DistributionStatus, + IAccountsPort, + IListMarketplaceDistributionsUseCase, + ISpacesPort, + ListMarketplaceDistributionsCommand, + ListMarketplaceDistributionsResponse, + MarketplaceDistributionListItem, + MarketplaceNotFoundError, + PackageId, + SpaceColor, + SpaceId, + UserId, +} from '@packmind/types'; +import { IMarketplaceDistributionRepository } from '../../../domain/repositories/IMarketplaceDistributionRepository'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { PackageService } from '../../services/PackageService'; + +const origin = 'ListMarketplaceDistributionsUseCase'; + +/** + * Lists every marketplace plugin distribution for a marketplace owned by the + * caller's organization, enriched with the Packmind package name and the + * display name of the author who originally published the plugin. + * + * Open to any organization member — admin permissions are not required for + * reads (admin-only mutations live in `MarkPluginForRemovalUseCase`). + * + * Flow: + * 1. Validate the marketplace belongs to the caller's organization. + * Miss → `MarketplaceNotFoundError`. + * 2. Load every (non-soft-deleted) distribution targeting the marketplace, + * excluding terminal `removed` rows — once a plugin's deletion has landed + * in the repo it is no longer "on" the marketplace, so it drops out of the + * list. + * 3. Collapse to one row per `packageId`, keeping only the latest distribution + * (the repository returns rows ordered by `createdAt DESC`, so the first + * occurrence of each `packageId` wins). Previous attempts — failures + * superseded by a later success, or any earlier publish — are dropped. + * 4. Hydrate `packageName` per row via `PackageService.findById`. Calls are + * de-duplicated per `packageId`. Each loaded package also yields the + * `spaceId` used to resolve the owning Space (step 6). + * 5. Hydrate `authorName` per row via `IAccountsPort.getUserById`. Calls are + * de-duplicated per `authorId`. + * 6. Hydrate the owning `space` (`id`, `name`, `color`) per row via + * `ISpacesPort.getSpaceById`. Calls are de-duplicated per `spaceId`. Rows + * whose source package has been hard-deleted (or whose space has) carry a + * `null` space so the UI can either fall back gracefully or hide the chip. + */ +export class ListMarketplaceDistributionsUseCase + extends AbstractMemberUseCase< + ListMarketplaceDistributionsCommand, + ListMarketplaceDistributionsResponse + > + implements IListMarketplaceDistributionsUseCase +{ + constructor( + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly marketplaceDistributionRepository: IMarketplaceDistributionRepository, + private readonly packageService: PackageService, + private readonly spacesPort: ISpacesPort, + accountsPort: IAccountsPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForMembers( + command: ListMarketplaceDistributionsCommand & MemberContext, + ): Promise<ListMarketplaceDistributionsResponse> { + const { marketplaceId, organization } = command; + + const marketplace = + await this.marketplaceRepository.findByOrganizationAndId( + organization.id, + marketplaceId, + ); + if (!marketplace) { + this.logger.warn('Marketplace not found for listDistributions', { + marketplaceId, + organizationId: organization.id, + }); + throw new MarketplaceNotFoundError(marketplaceId); + } + + // Exclude terminal `removed` rows: a plugin whose deletion has landed in + // the repo is no longer part of the marketplace and must not appear in the + // list (it lingers as `to_be_removed` only until reconciliation confirms + // the merge). + const rawDistributions = ( + await this.marketplaceDistributionRepository.findByMarketplaceId( + marketplaceId, + ) + ).filter( + (distribution) => distribution.status !== DistributionStatus.removed, + ); + + // Walk the rows ordered DESC by `createdAt` to compute, per package, the + // most recent `success` distribution's `publishConfirmedAt`. We keep this + // even when the latest row is `pending_merge` so the UI can still show + // "last published Xd ago" while a new PR is in flight, and distinguish a + // never-landed-on-main first publish from a re-publish. + const lastPublishedOnMainByPackageId = new Map<PackageId, Date>(); + for (const distribution of rawDistributions) { + if ( + distribution.status === DistributionStatus.success && + distribution.publishConfirmedAt && + !lastPublishedOnMainByPackageId.has(distribution.packageId) + ) { + lastPublishedOnMainByPackageId.set( + distribution.packageId, + distribution.publishConfirmedAt, + ); + } + } + + // One row per package — only the latest distribution is meaningful. The + // repository returns rows ordered by `createdAt DESC`, so the first + // occurrence of each `packageId` is the most recent attempt and we drop + // every prior one (superseded failures, earlier publishes, etc.). + const seenPackageIds = new Set<PackageId>(); + const distributions = rawDistributions.filter((distribution) => { + if (seenPackageIds.has(distribution.packageId)) { + return false; + } + seenPackageIds.add(distribution.packageId); + return true; + }); + + if (distributions.length === 0) { + return []; + } + + const uniquePackageIds = Array.from( + new Set<PackageId>(distributions.map((d) => d.packageId)), + ); + const packageNameById = new Map<PackageId, string>(); + const packageSlugById = new Map<PackageId, string>(); + const packageSpaceIdById = new Map<PackageId, SpaceId>(); + await Promise.all( + uniquePackageIds.map(async (pid) => { + const pkg = await this.packageService.findById(pid); + if (pkg) { + packageNameById.set(pid, pkg.name); + packageSlugById.set(pid, pkg.slug); + packageSpaceIdById.set(pid, pkg.spaceId); + } + }), + ); + + const uniqueAuthorIds = Array.from( + new Set<UserId>(distributions.map((d) => d.authorId)), + ); + const authorNameById = new Map<UserId, string>(); + await Promise.all( + uniqueAuthorIds.map(async (uid) => { + const user = await this.accountsPort.getUserById(uid); + if (user) { + authorNameById.set(uid, user.displayName ?? user.email); + } + }), + ); + + const uniqueSpaceIds = Array.from( + new Set<SpaceId>(packageSpaceIdById.values()), + ); + const spaceById = new Map< + SpaceId, + { id: SpaceId; name: string; color: SpaceColor } + >(); + await Promise.all( + uniqueSpaceIds.map(async (sid) => { + const space = await this.spacesPort.getSpaceById(sid); + if (space) { + spaceById.set(sid, { + id: space.id, + name: space.name, + color: space.color, + }); + } + }), + ); + + const items: MarketplaceDistributionListItem[] = distributions.map( + (distribution) => { + const sid = packageSpaceIdById.get(distribution.packageId); + const space = sid ? (spaceById.get(sid) ?? null) : null; + return { + ...distribution, + packageName: packageNameById.get(distribution.packageId) ?? '', + packageSlug: packageSlugById.get(distribution.packageId) ?? '', + authorName: authorNameById.get(distribution.authorId) ?? '', + space, + lastPublishedOnMainAt: + lastPublishedOnMainByPackageId.get(distribution.packageId) ?? null, + }; + }, + ); + + return items; + } +} diff --git a/packages/deployments/src/application/useCases/listMarketplaceDistributions/index.ts b/packages/deployments/src/application/useCases/listMarketplaceDistributions/index.ts new file mode 100644 index 000000000..c3c48bd82 --- /dev/null +++ b/packages/deployments/src/application/useCases/listMarketplaceDistributions/index.ts @@ -0,0 +1 @@ +export { ListMarketplaceDistributionsUseCase } from './ListMarketplaceDistributionsUseCase'; diff --git a/packages/deployments/src/application/useCases/listMarketplaceDistributionsForPackage/ListMarketplaceDistributionsForPackageUseCase.spec.ts b/packages/deployments/src/application/useCases/listMarketplaceDistributionsForPackage/ListMarketplaceDistributionsForPackageUseCase.spec.ts new file mode 100644 index 000000000..89e627d6a --- /dev/null +++ b/packages/deployments/src/application/useCases/listMarketplaceDistributionsForPackage/ListMarketplaceDistributionsForPackageUseCase.spec.ts @@ -0,0 +1,157 @@ +import { v4 as uuidv4 } from 'uuid'; +import { stubLogger } from '@packmind/test-utils'; +import { + createOrganizationId, + createPackageId, + createSpaceId, + createUserId, + IAccountsPort, + ISpacesPort, + ListMarketplaceDistributionsForPackageCommand, + Organization, + Package, + Space, + SpaceType, + User, +} from '@packmind/types'; +import { PackageNotFoundError } from '../../../domain/errors/PackageNotFoundError'; +import { IMarketplaceDistributionRepository } from '../../../domain/repositories/IMarketplaceDistributionRepository'; +import { PackageService } from '../../services/PackageService'; +import { marketplaceDistributionFactory } from '../../../infra/repositories/__factories__/marketplaceDistributionFactory'; +import { ListMarketplaceDistributionsForPackageUseCase } from './ListMarketplaceDistributionsForPackageUseCase'; + +describe('ListMarketplaceDistributionsForPackageUseCase', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const packageId = createPackageId(uuidv4()); + const spaceId = createSpaceId(uuidv4()); + + const command: ListMarketplaceDistributionsForPackageCommand = { + userId, + organizationId, + packageId, + }; + + const memberUser = { + id: userId, + email: 'member@example.com', + displayName: 'Member', + passwordHash: null, + active: true, + memberships: [{ userId, organizationId, role: 'member' as const }], + trial: false, + } as unknown as User; + + const organization: Organization = { + id: organizationId, + name: 'Test Org', + slug: 'test-org', + }; + + const pkg: Package = { + id: packageId, + name: 'Security', + slug: 'security', + description: 'Security curated package', + spaceId, + createdBy: userId, + recipes: [], + standards: [], + skills: [], + }; + + const space: Space = { + id: spaceId, + name: 'Default', + slug: 'default', + type: SpaceType.open, + organizationId, + isDefaultSpace: true, + color: 'blue' as Space['color'], + }; + + const olderRow = marketplaceDistributionFactory({ + packageId, + organizationId, + }); + const newerRow = marketplaceDistributionFactory({ + packageId, + organizationId, + }); + + let mockRepository: jest.Mocked<IMarketplaceDistributionRepository>; + let mockPackageService: jest.Mocked<PackageService>; + let mockSpacesPort: jest.Mocked<ISpacesPort>; + let mockAccountsPort: jest.Mocked<IAccountsPort>; + let useCase: ListMarketplaceDistributionsForPackageUseCase; + + beforeEach(() => { + mockRepository = { + findByPackageId: jest.fn().mockResolvedValue([newerRow, olderRow]), + } as unknown as jest.Mocked<IMarketplaceDistributionRepository>; + + mockPackageService = { + findById: jest.fn().mockResolvedValue(pkg), + } as unknown as jest.Mocked<PackageService>; + + mockSpacesPort = { + getSpaceById: jest.fn().mockResolvedValue(space), + } as unknown as jest.Mocked<ISpacesPort>; + + mockAccountsPort = { + getUserById: jest.fn().mockResolvedValue(memberUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + useCase = new ListMarketplaceDistributionsForPackageUseCase( + mockRepository, + mockPackageService, + mockSpacesPort, + mockAccountsPort, + stubLogger(), + ); + }); + + describe('happy path', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + result = await useCase.execute(command); + }); + + it('returns the rows in repository order (descending by createdAt)', () => { + expect(result).toEqual([newerRow, olderRow]); + }); + + it('scopes the lookup by packageId', () => { + expect(mockRepository.findByPackageId).toHaveBeenCalledWith(packageId); + }); + }); + + describe('when the package is unknown', () => { + beforeEach(() => { + mockPackageService.findById.mockResolvedValue(null); + }); + + it('throws PackageNotFoundError', async () => { + await expect(useCase.execute(command)).rejects.toBeInstanceOf( + PackageNotFoundError, + ); + }); + }); + + describe('when the package belongs to a different organization', () => { + beforeEach(() => { + mockSpacesPort.getSpaceById.mockResolvedValue({ + ...space, + organizationId: createOrganizationId(uuidv4()), + } as Space); + }); + + it('throws PackageNotFoundError to avoid leaking cross-org existence', async () => { + await expect(useCase.execute(command)).rejects.toBeInstanceOf( + PackageNotFoundError, + ); + }); + }); +}); diff --git a/packages/deployments/src/application/useCases/listMarketplaceDistributionsForPackage/ListMarketplaceDistributionsForPackageUseCase.ts b/packages/deployments/src/application/useCases/listMarketplaceDistributionsForPackage/ListMarketplaceDistributionsForPackageUseCase.ts new file mode 100644 index 000000000..37eb74ea0 --- /dev/null +++ b/packages/deployments/src/application/useCases/listMarketplaceDistributionsForPackage/ListMarketplaceDistributionsForPackageUseCase.ts @@ -0,0 +1,61 @@ +import { PackmindLogger } from '@packmind/logger'; +import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; +import { + IAccountsPort, + IListMarketplaceDistributionsForPackageUseCase, + ISpacesPort, + ListMarketplaceDistributionsForPackageCommand, + ListMarketplaceDistributionsForPackageResponse, +} from '@packmind/types'; +import { PackageNotFoundError } from '../../../domain/errors/PackageNotFoundError'; +import { IMarketplaceDistributionRepository } from '../../../domain/repositories/IMarketplaceDistributionRepository'; +import { PackageService } from '../../services/PackageService'; + +const origin = 'ListMarketplaceDistributionsForPackageUseCase'; + +/** + * Lists every marketplace distribution row attached to a package, newest + * first. Member-scoped. + * + * Used by the frontend's status helper to poll the publish lifecycle for a + * given package (in_progress → success | failure | no_changes). + */ +export class ListMarketplaceDistributionsForPackageUseCase + extends AbstractMemberUseCase< + ListMarketplaceDistributionsForPackageCommand, + ListMarketplaceDistributionsForPackageResponse + > + implements IListMarketplaceDistributionsForPackageUseCase +{ + constructor( + private readonly marketplaceDistributionRepository: IMarketplaceDistributionRepository, + private readonly packageService: PackageService, + private readonly spacesPort: ISpacesPort, + accountsPort: IAccountsPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForMembers( + command: ListMarketplaceDistributionsForPackageCommand & MemberContext, + ): Promise<ListMarketplaceDistributionsForPackageResponse> { + const { packageId, organization } = command; + + this.logger.info('Listing marketplace distributions for package', { + packageId, + organizationId: organization.id, + }); + + const pkg = await this.packageService.findById(packageId); + if (!pkg) { + throw new PackageNotFoundError(packageId); + } + const space = await this.spacesPort.getSpaceById(pkg.spaceId); + if (!space || space.organizationId !== organization.id) { + throw new PackageNotFoundError(packageId); + } + + return this.marketplaceDistributionRepository.findByPackageId(packageId); + } +} diff --git a/packages/deployments/src/application/useCases/listMarketplaceDistributionsForPackage/index.ts b/packages/deployments/src/application/useCases/listMarketplaceDistributionsForPackage/index.ts new file mode 100644 index 000000000..a9277cc26 --- /dev/null +++ b/packages/deployments/src/application/useCases/listMarketplaceDistributionsForPackage/index.ts @@ -0,0 +1 @@ +export { ListMarketplaceDistributionsForPackageUseCase } from './ListMarketplaceDistributionsForPackageUseCase'; diff --git a/packages/deployments/src/application/useCases/listMarketplacePluginInstalls/ListMarketplacePluginInstallsUseCase.spec.ts b/packages/deployments/src/application/useCases/listMarketplacePluginInstalls/ListMarketplacePluginInstallsUseCase.spec.ts new file mode 100644 index 000000000..76369f64d --- /dev/null +++ b/packages/deployments/src/application/useCases/listMarketplacePluginInstalls/ListMarketplacePluginInstallsUseCase.spec.ts @@ -0,0 +1,122 @@ +import { stubLogger } from '@packmind/test-utils'; +import { + IAccountsPort, + Marketplace, + Organization, + OrganizationId, + User, + UserOrganizationMembership, + createMarketplaceId, + createOrganizationId, + createUserId, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { IPluginInstallationRepository } from '../../../domain/repositories/IPluginInstallationRepository'; +import { ListMarketplacePluginInstallsUseCase } from './ListMarketplacePluginInstallsUseCase'; + +const createUserWithMembership = ( + userId: string, + organization: Organization, + role: UserOrganizationMembership['role'], +): User => ({ + id: createUserId(userId), + email: `${userId}@packmind.test`, + passwordHash: null, + active: true, + memberships: [ + { + userId: createUserId(userId), + organizationId: organization.id, + role, + }, + ], + trial: false, +}); + +describe('ListMarketplacePluginInstallsUseCase', () => { + let pluginInstallationRepository: jest.Mocked<IPluginInstallationRepository>; + let marketplaceRepository: jest.Mocked<IMarketplaceRepository>; + let accountsPort: jest.Mocked<IAccountsPort>; + let useCase: ListMarketplacePluginInstallsUseCase; + let organizationId: OrganizationId; + let organization: Organization; + let userId: string; + let marketplaceId: ReturnType<typeof createMarketplaceId>; + + const buildCommand = () => ({ + userId, + organizationId: organizationId as unknown as string, + marketplaceId, + }); + + beforeEach(() => { + userId = uuidv4(); + organizationId = createOrganizationId(uuidv4()); + marketplaceId = createMarketplaceId(uuidv4()); + organization = { + id: organizationId, + name: 'Test Org', + slug: 'test-org', + }; + + pluginInstallationRepository = { + upsertHeartbeat: jest.fn(), + listByMarketplace: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked<IPluginInstallationRepository>; + + marketplaceRepository = { + findByOrganizationAndId: jest.fn().mockResolvedValue({ + id: marketplaceId, + organizationId, + } as Marketplace), + } as unknown as jest.Mocked<IMarketplaceRepository>; + + accountsPort = { + getUserById: jest + .fn() + .mockResolvedValue( + createUserWithMembership(userId, organization, 'member'), + ), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + useCase = new ListMarketplacePluginInstallsUseCase( + pluginInstallationRepository, + marketplaceRepository, + accountsPort, + stubLogger(), + ); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('when the caller is not a member of the organization', () => { + beforeEach(() => { + accountsPort.getUserById.mockResolvedValue(null); + }); + + it('rejects with an authorization error', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow(); + }); + }); + + describe('when the marketplace does not belong to the caller organization', () => { + beforeEach(() => { + marketplaceRepository.findByOrganizationAndId.mockResolvedValue(null); + }); + + it('throws a MarketplaceNotFoundError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toMatchObject({ + name: 'MarketplaceNotFoundError', + }); + }); + + it('does not call the installation repository', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + expect( + pluginInstallationRepository.listByMarketplace, + ).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/deployments/src/application/useCases/listMarketplacePluginInstalls/ListMarketplacePluginInstallsUseCase.ts b/packages/deployments/src/application/useCases/listMarketplacePluginInstalls/ListMarketplacePluginInstallsUseCase.ts new file mode 100644 index 000000000..10d8676d7 --- /dev/null +++ b/packages/deployments/src/application/useCases/listMarketplacePluginInstalls/ListMarketplacePluginInstallsUseCase.ts @@ -0,0 +1,102 @@ +import { PackmindLogger } from '@packmind/logger'; +import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; +import { + IAccountsPort, + IListMarketplacePluginInstallsUseCase, + ListMarketplacePluginInstallsCommand, + ListMarketplacePluginInstallsResponse, + MarketplaceNotFoundError, + PluginInstallationListItem, + UserId, +} from '@packmind/types'; +import { + IMarketplaceRepository, + IPluginInstallationRepository, +} from '../../../domain/repositories'; + +const origin = 'ListMarketplacePluginInstallsUseCase'; + +/** + * Lists all tracked plugin installations for a marketplace. + * + * Open to any org member (read-only). Verifies the marketplace belongs to the + * caller's organization before returning data (IDOR guard — spec §7.4, §10). + * Enriches each row with a display name when the installation is attributed to + * a verified user. + * + * The frontend groups and counts client-side — no server-side aggregation. + */ +export class ListMarketplacePluginInstallsUseCase + extends AbstractMemberUseCase< + ListMarketplacePluginInstallsCommand, + ListMarketplacePluginInstallsResponse + > + implements IListMarketplacePluginInstallsUseCase +{ + constructor( + private readonly pluginInstallationRepository: IPluginInstallationRepository, + private readonly marketplaceRepository: IMarketplaceRepository, + accountsPort: IAccountsPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForMembers( + command: ListMarketplacePluginInstallsCommand & MemberContext, + ): Promise<ListMarketplacePluginInstallsResponse> { + const { marketplaceId, organization } = command; + + // Verify the marketplace belongs to the caller's organization (spec §7.4, §10). + // Mirrors the pattern used in ListMarketplaceDistributionsUseCase. + const marketplace = + await this.marketplaceRepository.findByOrganizationAndId( + organization.id, + marketplaceId, + ); + if (!marketplace) { + this.logger.warn('Marketplace not found for listPluginInstalls', { + marketplaceId, + organizationId: organization.id, + }); + throw new MarketplaceNotFoundError(marketplaceId); + } + + const installations = + await this.pluginInstallationRepository.listByMarketplace(marketplaceId); + + if (installations.length === 0) { + return []; + } + + // De-duplicate user lookups + const uniqueUserIds = Array.from( + new Set<UserId>( + installations + .map((i) => i.userId) + .filter((uid): uid is UserId => uid !== null), + ), + ); + + const displayNameByUserId = new Map<string, string>(); + await Promise.all( + uniqueUserIds.map(async (uid) => { + const user = await this.accountsPort.getUserById(uid); + if (user) { + displayNameByUserId.set(uid, user.displayName ?? user.email); + } + }), + ); + + const items: PluginInstallationListItem[] = installations.map( + (installation) => ({ + ...installation, + userDisplayName: installation.userId + ? (displayNameByUserId.get(installation.userId as string) ?? null) + : null, + }), + ); + + return items; + } +} diff --git a/packages/deployments/src/application/useCases/listMarketplaces/ListMarketplacesUseCase.spec.ts b/packages/deployments/src/application/useCases/listMarketplaces/ListMarketplacesUseCase.spec.ts new file mode 100644 index 000000000..b2db61320 --- /dev/null +++ b/packages/deployments/src/application/useCases/listMarketplaces/ListMarketplacesUseCase.spec.ts @@ -0,0 +1,315 @@ +import { v4 as uuidv4 } from 'uuid'; +import { stubLogger } from '@packmind/test-utils'; +import { + createGitProviderId, + createGitRepoId, + createMarketplaceId, + createOrganizationId, + createUserId, + GitProviderId, + GitRepo, + IAccountsPort, + IGitPort, + ListMarketplacesCommand, + Marketplace, + Organization, + OrganizationId, + User, + UserId, +} from '@packmind/types'; +import { GitRepoService } from '@packmind/git'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { ListMarketplacesUseCase } from './ListMarketplacesUseCase'; + +describe('ListMarketplacesUseCase', () => { + const organizationId: OrganizationId = createOrganizationId(uuidv4()); + const userId: UserId = createUserId(uuidv4()); + const otherUserId: UserId = createUserId(uuidv4()); + + const command: ListMarketplacesCommand = { + userId, + organizationId, + }; + + const memberUser = { + id: userId, + email: 'member@example.com', + displayName: 'Member User', + passwordHash: null, + active: true, + memberships: [{ userId, organizationId, role: 'member' as const }], + trial: false, + } as unknown as User; + + const otherAdmin = { + id: otherUserId, + email: 'other-admin@example.com', + displayName: 'Other Admin', + passwordHash: null, + active: true, + memberships: [ + { userId: otherUserId, organizationId, role: 'admin' as const }, + ], + trial: false, + } as unknown as User; + + const organization: Organization = { + id: organizationId, + name: 'Test Org', + slug: 'test-org', + }; + + const m1 = { + id: createMarketplaceId(uuidv4()), + organizationId, + gitRepoId: createGitRepoId(uuidv4()), + name: 'ACME Plugins', + vendor: 'anthropic', + addedBy: otherUserId, + linkedAt: new Date('2026-01-15'), + state: 'healthy', + lastValidatedAt: new Date(), + descriptor: { + vendor: 'anthropic', + name: 'ACME Plugins', + plugins: [{ slug: 'p1', name: 'P1' }], + raw: {}, + }, + pluginCount: 1, + } as unknown as Marketplace; + + const m2 = { + ...m1, + id: createMarketplaceId(uuidv4()), + gitRepoId: createGitRepoId(uuidv4()), + name: 'Other Plugins', + state: 'drift', + pluginCount: 4, + } as unknown as Marketplace; + + const providerId: GitProviderId = createGitProviderId(uuidv4()); + + const gitRepo1: GitRepo = { + id: m1.gitRepoId, + owner: 'acme', + repo: 'plugins', + branch: 'main', + providerId, + type: 'marketplace', + }; + + const gitRepo2: GitRepo = { + id: m2.gitRepoId, + owner: 'beta-group/sub', + repo: 'others', + branch: 'main', + providerId, + type: 'marketplace', + }; + + let mockMarketplaceRepository: jest.Mocked<IMarketplaceRepository>; + let mockGitRepoService: jest.Mocked<GitRepoService>; + let mockGitPort: jest.Mocked<IGitPort>; + let mockAccountsPort: jest.Mocked<IAccountsPort>; + let useCase: ListMarketplacesUseCase; + + beforeEach(() => { + mockMarketplaceRepository = { + findByOrganizationId: jest.fn().mockResolvedValue([m1, m2]), + add: jest.fn(), + addMany: jest.fn(), + findById: jest.fn(), + findByOrganizationAndGitRepo: jest.fn(), + findByOrganizationAndId: jest.fn(), + findAllForReconciliation: jest.fn(), + updateState: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + hardDeleteById: jest.fn(), + } as unknown as jest.Mocked<IMarketplaceRepository>; + + mockAccountsPort = { + getUserById: jest.fn().mockImplementation(async (id: UserId) => { + if (id === userId) return memberUser; + if (id === otherUserId) return otherAdmin; + return null; + }), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + mockGitRepoService = { + findMarketplaceGitRepoById: jest + .fn() + .mockImplementation(async (id: GitRepo['id']) => { + if (id === m1.gitRepoId) return gitRepo1; + if (id === m2.gitRepoId) return gitRepo2; + return null; + }), + } as unknown as jest.Mocked<GitRepoService>; + + mockGitPort = { + listProviders: jest.fn().mockResolvedValue({ + providers: [ + { + id: providerId, + source: 'github', + organizationId, + url: 'https://github.com', + hasAuth: true, + authMethod: 'token', + }, + ], + }), + } as unknown as jest.Mocked<IGitPort>; + + useCase = new ListMarketplacesUseCase( + mockMarketplaceRepository, + mockGitRepoService, + mockGitPort, + mockAccountsPort, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('member happy path', () => { + describe('hydrated marketplace list items', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + result = await useCase.execute(command); + }); + + it('returns one item per linked marketplace', () => { + expect(result).toHaveLength(2); + }); + + it('hydrates the first marketplace', () => { + expect(result[0]).toMatchObject({ + id: m1.id, + name: 'ACME Plugins', + state: 'healthy', + pluginCount: 1, + addedByUserName: otherAdmin.displayName, + }); + }); + + it('hydrates the second marketplace', () => { + expect(result[1]).toMatchObject({ + id: m2.id, + state: 'drift', + pluginCount: 4, + addedByUserName: otherAdmin.displayName, + }); + }); + }); + + describe('repository enrichment', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + result = await useCase.execute(command); + }); + + it('resolves the repository coordinates and provider for the first marketplace', () => { + expect(result[0].repository).toMatchObject({ + owner: 'acme', + repo: 'plugins', + branch: 'main', + providerSource: 'github', + }); + }); + + it('exposes the backing gitProviderId on the first marketplace', () => { + expect(result[0].repository?.gitProviderId).toBe(providerId); + }); + + it('builds the repository web url for the first marketplace', () => { + expect(result[0].repository?.url).toBe( + 'https://github.com/acme/plugins', + ); + }); + + it('preserves a group/subgroup owner path in the second marketplace url', () => { + expect(result[1].repository?.url).toBe( + 'https://github.com/beta-group/sub/others', + ); + }); + }); + + describe('when the backing git repo can no longer be resolved', () => { + it('sets repository to null', async () => { + mockGitRepoService.findMarketplaceGitRepoById.mockResolvedValue(null); + + const result = await useCase.execute(command); + + expect(result[0].repository).toBeNull(); + }); + }); + + describe('denormalized pluginCount from the row', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + result = await useCase.execute(command); + }); + + it('preserves the pluginCount of the first marketplace', () => { + expect(result[0].pluginCount).toBe(1); + }); + + it('preserves the pluginCount of the second marketplace', () => { + expect(result[1].pluginCount).toBe(4); + }); + }); + + it('de-duplicates user lookups for marketplaces added by the same user', async () => { + await useCase.execute(command); + + // Both m1 and m2 share `addedBy = otherUserId`. The member's own user + // is fetched once by AbstractMemberUseCase, and the addedBy user is + // fetched once by the use case body — so two getUserById calls total. + expect(mockAccountsPort.getUserById).toHaveBeenCalledTimes(2); + }); + + describe('when no marketplaces are linked', () => { + it('returns an empty list', async () => { + mockMarketplaceRepository.findByOrganizationId.mockResolvedValue([]); + + const result = await useCase.execute(command); + + expect(result).toEqual([]); + }); + }); + }); + + describe('non-member denial', () => { + beforeEach(() => { + const outsider = { + ...memberUser, + memberships: [], + } as unknown as User; + mockAccountsPort.getUserById = jest.fn().mockResolvedValue(outsider); + }); + + it('throws UserNotInOrganizationError', async () => { + await expect(useCase.execute(command)).rejects.toMatchObject({ + name: 'UserNotInOrganizationError', + }); + }); + + it('does not query the marketplace repository', async () => { + try { + await useCase.execute(command); + } catch { + // expected + } + expect( + mockMarketplaceRepository.findByOrganizationId, + ).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/deployments/src/application/useCases/listMarketplaces/ListMarketplacesUseCase.ts b/packages/deployments/src/application/useCases/listMarketplaces/ListMarketplacesUseCase.ts new file mode 100644 index 000000000..3d13980e1 --- /dev/null +++ b/packages/deployments/src/application/useCases/listMarketplaces/ListMarketplacesUseCase.ts @@ -0,0 +1,156 @@ +import { PackmindLogger } from '@packmind/logger'; +import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; +import { + GitProviderId, + GitProviderVendor, + GitProviderVendors, + IAccountsPort, + IGitPort, + IListMarketplacesUseCase, + ListMarketplacesCommand, + ListMarketplacesResponse, + MarketplaceListItem, + MarketplaceRepositoryInfo, + UserId, +} from '@packmind/types'; +import { GitRepoService } from '@packmind/git'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; + +const origin = 'ListMarketplacesUseCase'; + +/** + * Lists marketplaces linked to the caller's organization. Open to any org + * member — admins are not required for reads. + * + * Flow: + * 1. Load marketplaces via `IMarketplaceRepository.findByOrganizationId`. + * 2. Hydrate `addedByUserName` for each row by looking up the linking user. + * Calls are de-duplicated per `addedBy` so a marketplace list with many + * entries from the same admin makes a single lookup. + * 3. Resolve each marketplace's backing `GitRepo` and its provider so the + * list can show provider info and a link to the repository's web URL. + * 4. Return the presentation DTOs — `pluginCount` is already denormalized + * on the row by the link use case / reconciliation job. + */ +export class ListMarketplacesUseCase + extends AbstractMemberUseCase< + ListMarketplacesCommand, + ListMarketplacesResponse + > + implements IListMarketplacesUseCase +{ + constructor( + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly gitRepoService: GitRepoService, + private readonly gitPort: IGitPort, + accountsPort: IAccountsPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForMembers( + command: ListMarketplacesCommand & MemberContext, + ): Promise<ListMarketplacesResponse> { + const { organization, userId } = command; + + const marketplaces = await this.marketplaceRepository.findByOrganizationId( + organization.id, + ); + + if (marketplaces.length === 0) { + return []; + } + + // De-duplicate user lookups per addedBy (typical: same admin links most + // marketplaces for the org). + const uniqueAddedBy = Array.from( + new Set<UserId>(marketplaces.map((m) => m.addedBy)), + ); + const userNameByUserId = new Map<UserId, string>(); + await Promise.all( + uniqueAddedBy.map(async (uid) => { + const user = await this.accountsPort.getUserById(uid); + if (user) { + userNameByUserId.set(uid, user.displayName ?? user.email); + } + }), + ); + + // Resolve provider vendor per providerId once for the whole org so each + // marketplace can report which provider backs it. + const providerSourceById = new Map<GitProviderId, GitProviderVendor>(); + const providersResponse = await this.gitPort.listProviders({ + userId, + organizationId: organization.id, + }); + for (const provider of providersResponse.providers) { + providerSourceById.set(provider.id, provider.source); + } + + // Resolve each marketplace's backing GitRepo (coordinates live on the + // related row, not on the denormalized marketplace). + const repositoryByMarketplaceId = new Map< + string, + MarketplaceRepositoryInfo | null + >(); + await Promise.all( + marketplaces.map(async (marketplace) => { + const gitRepo = await this.gitRepoService.findMarketplaceGitRepoById( + marketplace.gitRepoId, + ); + if (!gitRepo) { + repositoryByMarketplaceId.set(marketplace.id, null); + return; + } + const providerSource = + providerSourceById.get(gitRepo.providerId) ?? + GitProviderVendors.unknown; + repositoryByMarketplaceId.set(marketplace.id, { + gitProviderId: gitRepo.providerId, + owner: gitRepo.owner, + repo: gitRepo.repo, + branch: gitRepo.branch, + providerSource, + url: buildRepositoryWebUrl( + providerSource, + gitRepo.owner, + gitRepo.repo, + ), + }); + }), + ); + + const items: MarketplaceListItem[] = marketplaces.map((marketplace) => ({ + ...marketplace, + addedByUserName: userNameByUserId.get(marketplace.addedBy) ?? '', + repository: repositoryByMarketplaceId.get(marketplace.id) ?? null, + })); + + return items; + } +} + +/** + * Build the browser-facing repository URL from the provider vendor and the + * repo coordinates. Mirrors the cloud-host convention used elsewhere in the + * codebase; returns an empty string for unknown vendors so the UI can suppress + * the link. + * + * GitLab `owner` may itself be a `group/subgroup` path — that is fine, the + * slashes carry through into a valid URL. + */ +function buildRepositoryWebUrl( + source: GitProviderVendor, + owner: string, + repo: string, +): string { + switch (source) { + case GitProviderVendors.github: + return `https://github.com/${owner}/${repo}`; + case GitProviderVendors.gitlab: + return `https://gitlab.com/${owner}/${repo}`; + default: + return ''; + } +} diff --git a/packages/deployments/src/application/useCases/listMarketplaces/index.ts b/packages/deployments/src/application/useCases/listMarketplaces/index.ts new file mode 100644 index 000000000..5def807ad --- /dev/null +++ b/packages/deployments/src/application/useCases/listMarketplaces/index.ts @@ -0,0 +1 @@ +export { ListMarketplacesUseCase } from './ListMarketplacesUseCase'; diff --git a/packages/deployments/src/application/useCases/markPluginForRemoval/MarkPluginForRemovalUseCase.spec.ts b/packages/deployments/src/application/useCases/markPluginForRemoval/MarkPluginForRemovalUseCase.spec.ts new file mode 100644 index 000000000..c1d5271d4 --- /dev/null +++ b/packages/deployments/src/application/useCases/markPluginForRemoval/MarkPluginForRemovalUseCase.spec.ts @@ -0,0 +1,503 @@ +import { v4 as uuidv4 } from 'uuid'; +import { stubLogger } from '@packmind/test-utils'; +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { + createMarketplaceDistributionId, + createMarketplaceId, + createOrganizationId, + createPackageId, + createUserId, + DistributionStatus, + IAccountsPort, + Marketplace, + MarketplaceDistribution, + MarketplaceNotFoundError, + MarketplacePluginRemovalInitiatedEvent, + Organization, + PluginDistributionInvalidStateError, + PluginDistributionNotFoundError, + User, +} from '@packmind/types'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { IMarketplaceDistributionRepository } from '../../../domain/repositories/IMarketplaceDistributionRepository'; +import { PackageService } from '../../services/PackageService'; +import { MarkPluginForRemovalUseCase } from './MarkPluginForRemovalUseCase'; + +describe('MarkPluginForRemovalUseCase', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const marketplaceId = createMarketplaceId(uuidv4()); + const distributionId = createMarketplaceDistributionId(uuidv4()); + const packageId = createPackageId(uuidv4()); + + const existingMarketplace = { + id: marketplaceId, + organizationId, + name: 'ACME Plugins', + } as unknown as Marketplace; + + const adminUser = { + id: userId, + email: 'admin@example.com', + displayName: 'Admin User', + passwordHash: null, + active: true, + memberships: [{ userId, organizationId, role: 'admin' as const }], + trial: false, + } as unknown as User; + + const memberUser = { + ...adminUser, + memberships: [{ userId, organizationId, role: 'member' as const }], + } as unknown as User; + + const organization: Organization = { + id: organizationId, + name: 'Test Org', + slug: 'test-org', + }; + + const successDistribution = { + id: distributionId, + organizationId, + marketplaceId, + packageId, + pluginSlug: 'my-plugin', + authorId: userId, + status: DistributionStatus.success, + source: 'app', + } as unknown as MarketplaceDistribution; + + let mockMarketplaceRepository: jest.Mocked<IMarketplaceRepository>; + let mockMarketplaceDistributionRepository: jest.Mocked<IMarketplaceDistributionRepository>; + let mockPackageService: jest.Mocked<PackageService>; + let mockEventEmitterService: jest.Mocked<PackmindEventEmitterService>; + let mockRemovalJob: { addJob: jest.Mock }; + let mockAccountsPort: jest.Mocked<IAccountsPort>; + let useCase: MarkPluginForRemovalUseCase; + + beforeEach(() => { + mockMarketplaceRepository = { + findByOrganizationAndId: jest.fn().mockResolvedValue(existingMarketplace), + } as unknown as jest.Mocked<IMarketplaceRepository>; + + mockMarketplaceDistributionRepository = { + findById: jest.fn().mockResolvedValue(successDistribution), + findLatestActiveByPackageAndMarketplace: jest + .fn() + .mockResolvedValue(successDistribution), + updateStatus: jest.fn().mockResolvedValue(undefined), + updateRemovalRequestedAt: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked<IMarketplaceDistributionRepository>; + + mockPackageService = { + findById: jest + .fn() + .mockResolvedValue({ id: packageId, slug: 'my-package' }), + } as unknown as jest.Mocked<PackageService>; + + mockEventEmitterService = { + emit: jest.fn(), + } as unknown as jest.Mocked<PackmindEventEmitterService>; + + mockRemovalJob = { + addJob: jest.fn().mockResolvedValue('job-id'), + }; + + mockAccountsPort = { + getUserById: jest.fn().mockResolvedValue(adminUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + useCase = new MarkPluginForRemovalUseCase( + mockMarketplaceRepository, + mockMarketplaceDistributionRepository, + mockPackageService, + mockEventEmitterService, + mockRemovalJob as never, + mockAccountsPort, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('admin happy path — by distributionId', () => { + beforeEach(async () => { + await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + }); + + it('looks up the marketplace by org', () => { + expect( + mockMarketplaceRepository.findByOrganizationAndId, + ).toHaveBeenCalledWith(organizationId, marketplaceId); + }); + + it('looks up the distribution by id', () => { + expect( + mockMarketplaceDistributionRepository.findById, + ).toHaveBeenCalledWith(distributionId); + }); + + it('does not call the by-package finder', () => { + expect( + mockMarketplaceDistributionRepository.findLatestActiveByPackageAndMarketplace, + ).not.toHaveBeenCalled(); + }); + + it('does not flip the status (the removal job owns the flip after the sync commit)', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).not.toHaveBeenCalled(); + }); + + it('returns the distribution still in success state', async () => { + const { distribution } = await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + expect(distribution.status).toBe(DistributionStatus.success); + }); + + it('stamps the removalRequestedAt marker', () => { + expect( + mockMarketplaceDistributionRepository.updateRemovalRequestedAt, + ).toHaveBeenCalledWith(distributionId, expect.any(Date)); + }); + + it('returns the distribution carrying the removalRequestedAt marker', async () => { + const { distribution } = await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + expect(distribution.removalRequestedAt).toBeInstanceOf(Date); + }); + + it('emits one event', () => { + expect(mockEventEmitterService.emit).toHaveBeenCalledTimes(1); + }); + + it('emits a MarketplacePluginRemovalInitiatedEvent', () => { + const emitted = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emitted).toBeInstanceOf(MarketplacePluginRemovalInitiatedEvent); + }); + + it('emits with trigger="from_marketplace"', () => { + const emitted = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emitted.payload.trigger).toBe('from_marketplace'); + }); + + it('emits the package slug fetched from the package service', () => { + const emitted = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emitted.payload.packageSlug).toBe('my-package'); + }); + + it('enqueues the removal job for the distribution', () => { + expect(mockRemovalJob.addJob).toHaveBeenCalledWith( + expect.objectContaining({ + marketplaceDistributionId: distributionId, + marketplaceId, + }), + ); + }); + }); + + describe('admin happy path — by packageId', () => { + beforeEach(async () => { + await useCase.execute({ + userId, + organizationId, + marketplaceId, + packageId, + }); + }); + + it('looks up the latest active distribution by (package, marketplace)', () => { + expect( + mockMarketplaceDistributionRepository.findLatestActiveByPackageAndMarketplace, + ).toHaveBeenCalledWith(packageId, marketplaceId); + }); + + it('does not call findById', () => { + expect( + mockMarketplaceDistributionRepository.findById, + ).not.toHaveBeenCalled(); + }); + + it('does not flip the status (the removal job owns the flip after the sync commit)', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).not.toHaveBeenCalled(); + }); + + it('emits with trigger="from_marketplace"', () => { + const emitted = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emitted.payload.trigger).toBe('from_marketplace'); + }); + }); + + describe('when the target distribution is a pending_merge publish', () => { + beforeEach(() => { + mockMarketplaceDistributionRepository.findById.mockResolvedValue({ + ...successDistribution, + status: DistributionStatus.pending_merge, + } as MarketplaceDistribution); + }); + + it('stamps the removalRequestedAt marker', async () => { + await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + expect( + mockMarketplaceDistributionRepository.updateRemovalRequestedAt, + ).toHaveBeenCalledWith(distributionId, expect.any(Date)); + }); + + it('does not flip the status (the removal job owns the flip after the sync commit)', async () => { + await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).not.toHaveBeenCalled(); + }); + + it('enqueues the removal job for the distribution', async () => { + await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + expect(mockRemovalJob.addJob).toHaveBeenCalledWith( + expect.objectContaining({ + marketplaceDistributionId: distributionId, + marketplaceId, + }), + ); + }); + + it('returns the distribution still in pending_merge state', async () => { + const { distribution } = await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + expect(distribution.status).toBe(DistributionStatus.pending_merge); + }); + }); + + describe('idempotency — already to_be_removed', () => { + beforeEach(() => { + mockMarketplaceDistributionRepository.findById.mockResolvedValue({ + ...successDistribution, + status: DistributionStatus.to_be_removed, + } as MarketplaceDistribution); + }); + + it('throws PluginDistributionInvalidStateError', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }), + ).rejects.toBeInstanceOf(PluginDistributionInvalidStateError); + }); + + it('does not write the status', async () => { + await useCase + .execute({ userId, organizationId, marketplaceId, distributionId }) + .catch(() => { + /* expected */ + }); + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).not.toHaveBeenCalled(); + }); + + it('does not emit any event', async () => { + await useCase + .execute({ userId, organizationId, marketplaceId, distributionId }) + .catch(() => { + /* expected */ + }); + expect(mockEventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + + describe('idempotency — removal already requested', () => { + beforeEach(() => { + mockMarketplaceDistributionRepository.findById.mockResolvedValue({ + ...successDistribution, + removalRequestedAt: new Date('2026-06-10T12:00:00.000Z'), + } as MarketplaceDistribution); + }); + + it('resolves without throwing', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }), + ).resolves.toMatchObject({ + distribution: { id: distributionId }, + }); + }); + + it('does not re-stamp the removalRequestedAt marker', async () => { + await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + expect( + mockMarketplaceDistributionRepository.updateRemovalRequestedAt, + ).not.toHaveBeenCalled(); + }); + + it('does not emit a duplicate event', async () => { + await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + expect(mockEventEmitterService.emit).not.toHaveBeenCalled(); + }); + + it('does not enqueue a duplicate removal job', async () => { + await useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }); + expect(mockRemovalJob.addJob).not.toHaveBeenCalled(); + }); + }); + + describe('marketplace not found', () => { + beforeEach(() => { + mockMarketplaceRepository.findByOrganizationAndId.mockResolvedValue(null); + }); + + it('throws MarketplaceNotFoundError', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }), + ).rejects.toBeInstanceOf(MarketplaceNotFoundError); + }); + }); + + describe('no active distribution exists for the package', () => { + beforeEach(() => { + mockMarketplaceDistributionRepository.findLatestActiveByPackageAndMarketplace.mockResolvedValue( + null, + ); + }); + + it('throws PluginDistributionNotFoundError', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + marketplaceId, + packageId, + }), + ).rejects.toBeInstanceOf(PluginDistributionNotFoundError); + }); + + it('does not emit any event', async () => { + await useCase + .execute({ userId, organizationId, marketplaceId, packageId }) + .catch(() => { + /* expected */ + }); + expect(mockEventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + + describe('distribution belongs to a different marketplace', () => { + beforeEach(() => { + mockMarketplaceDistributionRepository.findById.mockResolvedValue({ + ...successDistribution, + marketplaceId: createMarketplaceId(uuidv4()), + } as MarketplaceDistribution); + }); + + it('throws PluginDistributionNotFoundError', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }), + ).rejects.toBeInstanceOf(PluginDistributionNotFoundError); + }); + }); + + describe('non-admin denial', () => { + beforeEach(() => { + mockAccountsPort.getUserById = jest.fn().mockResolvedValue(memberUser); + }); + + it('throws OrganizationAdminRequiredError', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + marketplaceId, + distributionId, + }), + ).rejects.toMatchObject({ name: 'OrganizationAdminRequiredError' }); + }); + + describe('after the rejected execution', () => { + beforeEach(async () => { + await useCase + .execute({ userId, organizationId, marketplaceId, distributionId }) + .catch(() => { + /* expected */ + }); + }); + + it('does not update any distribution status', () => { + expect( + mockMarketplaceDistributionRepository.updateStatus, + ).not.toHaveBeenCalled(); + }); + + it('does not emit any cascade event', () => { + expect(mockEventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/deployments/src/application/useCases/markPluginForRemoval/MarkPluginForRemovalUseCase.ts b/packages/deployments/src/application/useCases/markPluginForRemoval/MarkPluginForRemovalUseCase.ts new file mode 100644 index 000000000..b880a0b47 --- /dev/null +++ b/packages/deployments/src/application/useCases/markPluginForRemoval/MarkPluginForRemovalUseCase.ts @@ -0,0 +1,263 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + AbstractAdminUseCase, + AdminContext, + PackmindEventEmitterService, +} from '@packmind/node-utils'; +import { + createUserId, + DistributionStatus, + IAccountsPort, + IMarkPluginForRemovalUseCase, + MarkPluginForRemovalCommand, + MarkPluginForRemovalResponse, + MarketplaceDistribution, + MarketplaceNotFoundError, + MarketplacePluginRemovalInitiatedEvent, + PluginDistributionInvalidStateError, + PluginDistributionNotFoundError, +} from '@packmind/types'; +import { IMarketplaceDistributionRepository } from '../../../domain/repositories/IMarketplaceDistributionRepository'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { PackageService } from '../../services/PackageService'; +import { RemovePluginFromMarketplaceDelayedJob } from '../../jobs/RemovePluginFromMarketplaceDelayedJob'; + +const origin = 'MarkPluginForRemovalUseCase'; + +/** + * Requests removal of a published marketplace plugin distribution. + * + * Admin-only. Resolves the target distribution either directly by + * `distributionId` or by `packageId` (latest active — `success` or + * `pending_merge` — distribution for the `(package, marketplace)` pair). Emits + * `MarketplacePluginRemovalInitiatedEvent` with `trigger='from_marketplace'` + * so the Amplitude listener and downstream consumers can observe the manual + * trigger. + * + * The status is intentionally left unchanged here (`success` or + * `pending_merge`). The `RemovePluginFromMarketplaceDelayedJob` flips it to + * `to_be_removed` only once the deletion lands on the rolling `packmind/sync` + * branch, so the status tracks the sync branch rather than the bare request. + * The reconciliation job owns the terminal `removed` transition once the + * deletion PR merges. + * + * Because the status keeps its current value during that window, the request + * is recorded out-of-band via `removalRequestedAt`: it is stamped synchronously so + * the UI can surface a "removal pending" state immediately, and a repeated + * request becomes a no-op rather than a duplicate event/job (or a confusing + * `success`-already-flipped error). + * + * Flow: + * 1. Resolve the marketplace by `(organizationId, marketplaceId)`. Miss → + * `MarketplaceNotFoundError`. + * 2. Resolve the target distribution. By `distributionId`: fetch by id + + * ensure it belongs to the marketplace. By `packageId`: call + * `findLatestActiveByPackageAndMarketplace`. Miss → + * `PluginDistributionNotFoundError`. + * 3. If `removalRequestedAt` is already set, return the row unchanged + * (idempotent — no duplicate event/job). Placed before the status guard so + * a row the job has already flipped to `to_be_removed` re-resolves here + * instead of erroring. + * 4. Validate the current status is `success` or `pending_merge` (a publish + * still awaiting its sync-PR merge is removable — the removal job simply + * reverts it off the sync branch). Other state → + * `PluginDistributionInvalidStateError`. + * 5. Stamp `removalRequestedAt = now` so the request is observable before the + * async job runs. + * 6. Emit `MarketplacePluginRemovalInitiatedEvent` with + * `trigger='from_marketplace'`. + * 7. Enqueue `RemovePluginFromMarketplaceDelayedJob` so the deletion is + * committed onto the rolling `packmind/sync` PR (symmetric to publish); the + * job flips the status to `to_be_removed` once the commit lands. + * 8. Return the distribution row (still `success`, now carrying + * `removalRequestedAt`). + */ +export class MarkPluginForRemovalUseCase + extends AbstractAdminUseCase< + MarkPluginForRemovalCommand, + MarkPluginForRemovalResponse + > + implements IMarkPluginForRemovalUseCase +{ + constructor( + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly marketplaceDistributionRepository: IMarketplaceDistributionRepository, + private readonly packageService: PackageService, + private readonly eventEmitterService: PackmindEventEmitterService, + private readonly removalJob: RemovePluginFromMarketplaceDelayedJob, + accountsPort: IAccountsPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForAdmins( + command: MarkPluginForRemovalCommand & AdminContext, + ): Promise<MarkPluginForRemovalResponse> { + const { marketplaceId, organization, userId } = command; + + // Mask UUIDs in user-facing logs per + // standard-compliance-logging-personal-information.md. + this.logger.info('Marking marketplace plugin distribution for removal', { + marketplaceId, + organizationId: organization.id, + hasDistributionId: + 'distributionId' in command && !!command.distributionId, + hasPackageId: 'packageId' in command && !!command.packageId, + }); + + const marketplace = + await this.marketplaceRepository.findByOrganizationAndId( + organization.id, + marketplaceId, + ); + if (!marketplace) { + this.logger.warn('Marketplace not found for plugin removal', { + marketplaceId, + organizationId: organization.id, + }); + throw new MarketplaceNotFoundError(marketplaceId); + } + + const distribution = await this.resolveDistribution(command); + if (!distribution) { + throw 'distributionId' in command && command.distributionId + ? new PluginDistributionNotFoundError({ + distributionId: command.distributionId, + }) + : new PluginDistributionNotFoundError({ + packageId: (command as { packageId: string }).packageId, + marketplaceId, + }); + } + + if (distribution.marketplaceId !== marketplaceId) { + this.logger.warn( + 'Marketplace plugin distribution does not belong to the requested marketplace', + { + distributionId: distribution.id, + requestedMarketplaceId: marketplaceId, + actualMarketplaceId: distribution.marketplaceId, + }, + ); + throw new PluginDistributionNotFoundError({ + distributionId: distribution.id, + }); + } + + // Idempotent: a removal already requested (marker set) must not enqueue a + // second job or emit a duplicate event. Return the row as-is so the caller + // (and the UI) still observe the pending state. Placed before the status + // guard so a row the job has already flipped to `to_be_removed` resolves + // here rather than tripping the `success`-only guard below. + if (distribution.removalRequestedAt) { + this.logger.info( + 'Marketplace plugin distribution already has a pending removal request — returning idempotently', + { + distributionId: distribution.id, + marketplaceId, + }, + ); + return { distribution }; + } + + if ( + distribution.status !== DistributionStatus.success && + distribution.status !== DistributionStatus.pending_merge + ) { + this.logger.warn( + 'Cannot mark distribution for removal — invalid current status', + { + distributionId: distribution.id, + status: distribution.status, + }, + ); + throw new PluginDistributionInvalidStateError( + distribution.id, + distribution.status, + [DistributionStatus.success, DistributionStatus.pending_merge], + ); + } + + const pkg = await this.packageService.findById(distribution.packageId); + const packageSlug = pkg?.slug ?? ''; + + // Stamp the request synchronously while the status keeps its value. This is + // the signal the UI keys off to switch the row to a "removal pending" state + // and the guard above keys off to stay idempotent. Persisted before the + // event/job so a write failure aborts the request instead of leaving a + // dangling event with no recorded request. + const requestedAt = new Date(); + await this.marketplaceDistributionRepository.updateRemovalRequestedAt( + distribution.id, + requestedAt, + ); + + this.eventEmitterService.emit( + new MarketplacePluginRemovalInitiatedEvent({ + userId: createUserId(userId), + organizationId: organization.id, + source: command.source ?? 'ui', + marketplaceId, + distributionId: distribution.id, + packageId: distribution.packageId, + packageSlug, + pluginSlug: distribution.pluginSlug, + trigger: 'from_marketplace', + }), + ); + + // Enqueue the Git side effect: commit the plugin deletion onto the rolling + // `packmind/sync` PR (symmetric to publish). The status stays unchanged + // here on purpose — the job flips it to `to_be_removed` only once the + // deletion lands on the sync branch, and the reconciliation job owns the + // terminal `removed` transition once the PR merges. A failed enqueue must + // not fail the request — reconciliation and a manual CLI deletion remain as + // fallbacks. + try { + await this.removalJob.addJob({ + marketplaceDistributionId: distribution.id, + marketplaceId, + packageId: distribution.packageId, + organizationId: organization.id, + userId: createUserId(userId), + }); + } catch (error) { + this.logger.error( + 'Failed to enqueue marketplace plugin removal job; distribution stays to_be_removed', + { + distributionId: distribution.id, + marketplaceId, + error: error instanceof Error ? error.message : String(error), + }, + ); + } + + this.logger.info('Marketplace plugin removal requested', { + distributionId: distribution.id, + marketplaceId, + status: distribution.status, + }); + + return { + distribution: { ...distribution, removalRequestedAt: requestedAt }, + }; + } + + private async resolveDistribution( + command: MarkPluginForRemovalCommand, + ): Promise<MarketplaceDistribution | null> { + if ('distributionId' in command && command.distributionId) { + return this.marketplaceDistributionRepository.findById( + command.distributionId, + ); + } + if ('packageId' in command && command.packageId) { + return this.marketplaceDistributionRepository.findLatestActiveByPackageAndMarketplace( + command.packageId, + command.marketplaceId, + ); + } + return null; + } +} diff --git a/packages/deployments/src/application/useCases/markPluginForRemoval/index.ts b/packages/deployments/src/application/useCases/markPluginForRemoval/index.ts new file mode 100644 index 000000000..2af995e15 --- /dev/null +++ b/packages/deployments/src/application/useCases/markPluginForRemoval/index.ts @@ -0,0 +1 @@ +export { MarkPluginForRemovalUseCase } from './MarkPluginForRemovalUseCase'; diff --git a/packages/deployments/src/application/useCases/publishPackageOnMarketplace/PublishPackageOnMarketplaceUseCase.spec.ts b/packages/deployments/src/application/useCases/publishPackageOnMarketplace/PublishPackageOnMarketplaceUseCase.spec.ts new file mode 100644 index 000000000..8aed76902 --- /dev/null +++ b/packages/deployments/src/application/useCases/publishPackageOnMarketplace/PublishPackageOnMarketplaceUseCase.spec.ts @@ -0,0 +1,570 @@ +import { v4 as uuidv4 } from 'uuid'; +import { stubLogger } from '@packmind/test-utils'; +import { GitRepoService } from '@packmind/git'; +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { + createGitProviderId, + createGitRepoId, + createMarketplaceId, + createOrganizationId, + createPackageId, + createSpaceId, + createUserId, + DistributionStatus, + GitProviderTokenInvalidError, + GitProviderWithoutToken, + GitRepo, + IAccountsPort, + IGitPort, + ISpacesPort, + MarketplaceDescriptor, + MarketplaceDescriptorBadFormatError, + MarketplaceDescriptorNotFoundError, + MarketplaceNotFoundError, + MarketplacePluginNameConflictError, + Organization, + Package, + PluginPublishAttemptedEvent, + PublishPackageOnMarketplaceCommand, + Space, + SpaceType, + User, + UserId, +} from '@packmind/types'; +import { PackageNotFoundError } from '../../../domain/errors/PackageNotFoundError'; +import { IMarketplaceDistributionRepository } from '../../../domain/repositories/IMarketplaceDistributionRepository'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { PublishPluginToMarketplaceDelayedJob } from '../../jobs/PublishPluginToMarketplaceDelayedJob'; +import { MarketplaceDescriptorParserRegistry } from '../../services/MarketplaceDescriptorParserRegistry'; +import { PackageService } from '../../services/PackageService'; +import { marketplaceFactory } from '../../../infra/repositories/__factories__/marketplaceFactory'; +import { PublishPackageOnMarketplaceUseCase } from './PublishPackageOnMarketplaceUseCase'; + +describe('PublishPackageOnMarketplaceUseCase', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const marketplaceId = createMarketplaceId(uuidv4()); + const packageId = createPackageId(uuidv4()); + const spaceId = createSpaceId(uuidv4()); + const gitRepoId = createGitRepoId(uuidv4()); + const gitProviderId = createGitProviderId(uuidv4()); + + const organization: Organization = { + id: organizationId, + name: 'Test Org', + slug: 'test-org', + }; + + const memberUser = { + id: userId, + email: 'member@example.com', + displayName: 'Member', + passwordHash: null, + active: true, + memberships: [{ userId, organizationId, role: 'member' as const }], + trial: false, + } as unknown as User; + + const baseCommand: PublishPackageOnMarketplaceCommand = { + userId, + organizationId, + marketplaceId, + packageId, + }; + + const pkg: Package = { + id: packageId, + name: 'Security Package', + slug: 'security', + description: 'Security curated package', + spaceId, + createdBy: userId, + recipes: [], + standards: [], + skills: [], + }; + + const space: Space = { + id: spaceId, + name: 'Default', + slug: 'default', + type: SpaceType.open, + organizationId, + isDefaultSpace: true, + color: 'blue' as Space['color'], + }; + + const marketplace = marketplaceFactory({ + id: marketplaceId, + organizationId, + gitRepoId, + name: 'ACME Marketplace', + descriptor: { + vendor: 'anthropic', + name: 'ACME Marketplace', + plugins: [], + raw: { name: 'ACME Marketplace', plugins: [] }, + }, + pluginCount: 0, + }); + + const gitRepo: GitRepo = { + id: gitRepoId, + owner: 'acme', + repo: 'plugins', + branch: 'main', + providerId: gitProviderId, + type: 'marketplace', + }; + + const providerWithToken: GitProviderWithoutToken = { + id: gitProviderId, + source: 'github', + organizationId, + url: 'https://github.com', + hasAuth: true, + authMethod: 'token', + }; + + const providerWithoutToken: GitProviderWithoutToken = { + ...providerWithToken, + hasAuth: false, + }; + + const parsedDescriptor: MarketplaceDescriptor = { + vendor: 'anthropic', + name: 'ACME Marketplace', + plugins: [], + raw: { name: 'ACME Marketplace', plugins: [] }, + }; + + let mockMarketplaceRepository: jest.Mocked<IMarketplaceRepository>; + let mockMarketplaceDistributionRepository: jest.Mocked<IMarketplaceDistributionRepository>; + let mockPackageService: jest.Mocked<PackageService>; + let mockSpacesPort: jest.Mocked<ISpacesPort>; + let mockGitPort: jest.Mocked<IGitPort>; + let mockGitRepoService: jest.Mocked<GitRepoService>; + let mockParserRegistry: jest.Mocked<MarketplaceDescriptorParserRegistry>; + let mockEventEmitter: jest.Mocked<PackmindEventEmitterService>; + let mockAccountsPort: jest.Mocked<IAccountsPort>; + let mockPublishJob: jest.Mocked<PublishPluginToMarketplaceDelayedJob>; + let useCase: PublishPackageOnMarketplaceUseCase; + + beforeEach(() => { + mockMarketplaceRepository = { + findByOrganizationAndId: jest.fn().mockResolvedValue(marketplace), + updateState: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked<IMarketplaceRepository>; + + mockMarketplaceDistributionRepository = { + findLatestByPackageAndMarketplace: jest.fn().mockResolvedValue(null), + add: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked<IMarketplaceDistributionRepository>; + + mockPackageService = { + findById: jest.fn().mockResolvedValue(pkg), + } as unknown as jest.Mocked<PackageService>; + + mockSpacesPort = { + getSpaceById: jest.fn().mockResolvedValue(space), + } as unknown as jest.Mocked<ISpacesPort>; + + mockGitPort = { + listProviders: jest + .fn() + .mockResolvedValue({ providers: [providerWithToken] }), + // Default mock: descriptor file present (raw JSON ignored by the + // parser mock), packmind-lock.json absent (first-publish path). + getFileFromRepo: jest.fn().mockImplementation(async (_repo, path) => { + if (path === 'packmind-lock.json') { + return null; + } + return { sha: 'sha-1', content: '{}' }; + }), + // First-publish ever: the rolling sync branch doesn't exist yet, so + // the use case reads descriptor + lock from the marketplace's default + // branch. + checkBranchExists: jest.fn().mockResolvedValue(false), + } as unknown as jest.Mocked<IGitPort>; + + mockGitRepoService = { + findMarketplaceGitRepoById: jest.fn().mockResolvedValue(gitRepo), + } as unknown as jest.Mocked<GitRepoService>; + + mockParserRegistry = { + parse: jest.fn().mockReturnValue(parsedDescriptor), + } as unknown as jest.Mocked<MarketplaceDescriptorParserRegistry>; + + mockEventEmitter = { + emit: jest.fn(), + } as unknown as jest.Mocked<PackmindEventEmitterService>; + + mockAccountsPort = { + getUserById: jest.fn().mockResolvedValue(memberUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + mockPublishJob = { + addJob: jest.fn().mockResolvedValue('bullmq-job-1'), + } as unknown as jest.Mocked<PublishPluginToMarketplaceDelayedJob>; + + useCase = new PublishPackageOnMarketplaceUseCase( + mockMarketplaceRepository, + mockMarketplaceDistributionRepository, + mockPackageService, + mockSpacesPort, + mockGitPort, + mockGitRepoService, + mockParserRegistry, + mockEventEmitter, + mockPublishJob, + mockAccountsPort, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('happy path', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + result = await useCase.execute(baseCommand); + }); + + it('persists an in-progress marketplace distribution row', () => { + expect(mockMarketplaceDistributionRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + status: DistributionStatus.in_progress, + marketplaceId, + packageId, + pluginSlug: 'security', + source: 'app', + }), + ); + }); + + it('enqueues the publish-plugin-to-marketplace job', () => { + expect(mockPublishJob.addJob).toHaveBeenCalledWith( + expect.objectContaining({ + marketplaceId, + packageId, + organizationId, + userId, + }), + ); + }); + + describe('event emission', () => { + let emitted: PluginPublishAttemptedEvent; + + beforeEach(() => { + emitted = mockEventEmitter.emit.mock + .calls[0][0] as PluginPublishAttemptedEvent; + }); + + it('emits a PluginPublishAttemptedEvent', () => { + expect(emitted).toBeInstanceOf(PluginPublishAttemptedEvent); + }); + + it('flags the event with isFirstPublishForPackage=true', () => { + expect(emitted.payload.isFirstPublishForPackage).toBe(true); + }); + }); + + describe('returned response', () => { + it('returns the in-progress response with the marketplace context', () => { + expect(result).toMatchObject({ + status: 'in_progress', + marketplaceId, + packageId, + pluginSlug: 'security', + }); + }); + + it('returns a defined marketplace distribution id', () => { + expect(result.marketplaceDistributionId).toBeDefined(); + }); + }); + }); + + describe('when a previous distribution exists', () => { + beforeEach(async () => { + mockMarketplaceDistributionRepository.findLatestByPackageAndMarketplace.mockResolvedValue( + { + id: createMarketplaceId(uuidv4()) as unknown as never, + } as unknown as never, + ); + await useCase.execute(baseCommand); + }); + + it('emits attempted event with isFirstPublishForPackage=false', () => { + const emitted = mockEventEmitter.emit.mock + .calls[0][0] as PluginPublishAttemptedEvent; + expect(emitted.payload.isFirstPublishForPackage).toBe(false); + }); + }); + + describe('when the marketplace does not belong to the organization', () => { + beforeEach(() => { + mockMarketplaceRepository.findByOrganizationAndId.mockResolvedValue(null); + }); + + it('throws MarketplaceNotFoundError', async () => { + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + MarketplaceNotFoundError, + ); + }); + + it('does not enqueue any job', async () => { + try { + await useCase.execute(baseCommand); + } catch { + // expected + } + expect(mockPublishJob.addJob).not.toHaveBeenCalled(); + }); + }); + + describe('when the package is unknown', () => { + beforeEach(() => { + mockPackageService.findById.mockResolvedValue(null); + }); + + it('throws PackageNotFoundError', async () => { + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + PackageNotFoundError, + ); + }); + }); + + describe('when the package belongs to a different organization', () => { + beforeEach(() => { + mockSpacesPort.getSpaceById.mockResolvedValue({ + ...space, + organizationId: createOrganizationId(uuidv4()), + } as Space); + }); + + it('throws PackageNotFoundError to avoid leaking cross-org existence', async () => { + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + PackageNotFoundError, + ); + }); + }); + + describe('when the git provider has no token', () => { + beforeEach(() => { + mockGitPort.listProviders.mockResolvedValue({ + providers: [providerWithoutToken], + }); + }); + + it('throws GitProviderTokenInvalidError', async () => { + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + GitProviderTokenInvalidError, + ); + }); + + it('exposes the verbatim user-facing message on the error', async () => { + await expect(useCase.execute(baseCommand)).rejects.toMatchObject({ + message: GitProviderTokenInvalidError.USER_FACING_MESSAGE, + }); + }); + + it('does not enqueue the job', async () => { + try { + await useCase.execute(baseCommand); + } catch { + // expected + } + expect(mockPublishJob.addJob).not.toHaveBeenCalled(); + }); + }); + + describe('when the marketplace descriptor file is missing', () => { + beforeEach(() => { + mockGitPort.getFileFromRepo.mockResolvedValue(null); + }); + + it('throws MarketplaceDescriptorNotFoundError', async () => { + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + MarketplaceDescriptorNotFoundError, + ); + }); + + it('transitions the marketplace state to bad_format', async () => { + try { + await useCase.execute(baseCommand); + } catch { + // expected + } + expect(mockMarketplaceRepository.updateState).toHaveBeenCalledWith( + marketplaceId, + expect.objectContaining({ state: 'bad_format' }), + ); + }); + }); + + describe('when the marketplace descriptor is unparseable', () => { + beforeEach(() => { + mockParserRegistry.parse.mockImplementation(() => { + throw new Error('invalid JSON'); + }); + }); + + it('throws MarketplaceDescriptorBadFormatError', async () => { + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + MarketplaceDescriptorBadFormatError, + ); + }); + + it('transitions the marketplace state to bad_format', async () => { + try { + await useCase.execute(baseCommand); + } catch { + // expected + } + expect(mockMarketplaceRepository.updateState).toHaveBeenCalledWith( + marketplaceId, + expect.objectContaining({ state: 'bad_format' }), + ); + }); + }); + + describe('when an unmanaged plugin already uses the same slug', () => { + beforeEach(() => { + mockParserRegistry.parse.mockReturnValue({ + ...parsedDescriptor, + plugins: [{ slug: 'security', name: 'Security (unmanaged)' }], + }); + }); + + it('throws MarketplacePluginNameConflictError', async () => { + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + MarketplacePluginNameConflictError, + ); + }); + + it('does not enqueue the job', async () => { + try { + await useCase.execute(baseCommand); + } catch { + // expected + } + expect(mockPublishJob.addJob).not.toHaveBeenCalled(); + }); + }); + + describe('when the plugin slug already exists but is managed by Packmind', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + mockParserRegistry.parse.mockReturnValue({ + ...parsedDescriptor, + plugins: [{ slug: 'security', name: 'Security' }], + }); + mockGitPort.getFileFromRepo.mockImplementation(async (_repo, path) => { + if (path === 'packmind-lock.json') { + return { + sha: 'lock-sha', + content: JSON.stringify({ + schemaVersion: 1, + plugins: { + security: { + version: '0.1.0', + contentHash: 'h', + lastPublishedAt: '2026-06-01T00:00:00.000Z', + lastPublishedBy: userId as UserId, + }, + }, + }), + }; + } + return { sha: 'sha-1', content: '{}' }; + }); + result = await useCase.execute(baseCommand); + }); + + it('proceeds with the publish (republish path)', () => { + expect(result.status).toBe('in_progress'); + }); + }); + + describe('when the rolling sync branch does not yet exist', () => { + beforeEach(async () => { + mockGitPort.checkBranchExists.mockResolvedValue(false); + await useCase.execute(baseCommand); + }); + + it('reads the descriptor from the marketplace default branch', () => { + expect(mockGitPort.getFileFromRepo).toHaveBeenCalledWith( + gitRepo, + '.claude-plugin/marketplace.json', + gitRepo.branch, + ); + }); + + it('reads the packmind-lock.json from the marketplace default branch', () => { + expect(mockGitPort.getFileFromRepo).toHaveBeenCalledWith( + gitRepo, + 'packmind-lock.json', + gitRepo.branch, + ); + }); + }); + + describe('when the rolling sync branch already exists', () => { + beforeEach(async () => { + mockGitPort.checkBranchExists.mockResolvedValue(true); + await useCase.execute(baseCommand); + }); + + it('reads the descriptor from the rolling sync branch', () => { + expect(mockGitPort.getFileFromRepo).toHaveBeenCalledWith( + gitRepo, + '.claude-plugin/marketplace.json', + 'packmind/sync', + ); + }); + + it('reads the packmind-lock.json from the rolling sync branch', () => { + expect(mockGitPort.getFileFromRepo).toHaveBeenCalledWith( + gitRepo, + 'packmind-lock.json', + 'packmind/sync', + ); + }); + }); + + describe('when the packmind-lock.json file is unparseable', () => { + beforeEach(() => { + mockGitPort.getFileFromRepo.mockImplementation(async (_repo, path) => { + if (path === 'packmind-lock.json') { + return { sha: 'lock-sha', content: 'not-valid-json' }; + } + return { sha: 'sha-1', content: '{}' }; + }); + }); + + it('throws MarketplaceDescriptorBadFormatError', async () => { + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + MarketplaceDescriptorBadFormatError, + ); + }); + + it('transitions the marketplace state to bad_format', async () => { + try { + await useCase.execute(baseCommand); + } catch { + // expected + } + expect(mockMarketplaceRepository.updateState).toHaveBeenCalledWith( + marketplaceId, + expect.objectContaining({ state: 'bad_format' }), + ); + }); + }); +}); diff --git a/packages/deployments/src/application/useCases/publishPackageOnMarketplace/PublishPackageOnMarketplaceUseCase.ts b/packages/deployments/src/application/useCases/publishPackageOnMarketplace/PublishPackageOnMarketplaceUseCase.ts new file mode 100644 index 000000000..9a9b014fa --- /dev/null +++ b/packages/deployments/src/application/useCases/publishPackageOnMarketplace/PublishPackageOnMarketplaceUseCase.ts @@ -0,0 +1,391 @@ +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { + AbstractMemberUseCase, + MemberContext, + PackmindEventEmitterService, +} from '@packmind/node-utils'; +import { GitRepoService } from '@packmind/git'; +import { + createMarketplaceDistributionId, + DistributionStatus, + GitProviderTokenInvalidError, + GitProviderWithoutToken, + GitRepo, + IAccountsPort, + IGitPort, + IPublishPackageOnMarketplaceUseCase, + ISpacesPort, + MARKETPLACE_DESCRIPTOR_FILENAME, + Marketplace, + MarketplaceDescriptor, + MarketplaceDescriptorBadFormatError, + MarketplaceDescriptorNotFoundError, + MarketplaceDistribution, + MarketplaceNotFoundError, + MarketplacePluginNameConflictError, + OrganizationId, + PackmindMarketplaceLock, + PluginPublishAttemptedEvent, + PluginRef, + PublishPackageOnMarketplaceCommand, + PublishPackageOnMarketplaceResponse, + UserId, +} from '@packmind/types'; +import { PackageNotFoundError } from '../../../domain/errors/PackageNotFoundError'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { IMarketplaceDistributionRepository } from '../../../domain/repositories/IMarketplaceDistributionRepository'; +import { PackageService } from '../../services/PackageService'; +import { MarketplaceDescriptorParserRegistry } from '../../services/MarketplaceDescriptorParserRegistry'; +import { fetchMarketplaceDescriptorFile } from '../../services/fetchMarketplaceDescriptorFile'; +import { fetchPackmindMarketplaceLock } from '../../services/packmindMarketplaceLock'; +import { resolveMarketplaceReadBranch } from '../../services/resolveMarketplaceReadBranch'; +import { PublishPluginToMarketplaceDelayedJob } from '../../jobs/PublishPluginToMarketplaceDelayedJob'; + +const origin = 'PublishPackageOnMarketplaceUseCase'; + +/** + * Publishes a Packmind package as a managed plugin on a linked marketplace. + * + * Member-scoped: any org member can trigger a publish. The use case is the + * synchronous slice of the publish pipeline — it validates the request, + * persists an `in_progress` `MarketplaceDistribution` row, enqueues the BullMQ + * job that owns the Git side effects, and emits + * `PluginPublishAttemptedEvent`. + * + * Failure surfaces synchronously to the caller: + * - `MarketplaceNotFoundError` — unknown marketplace or wrong org. + * - `PackageNotFoundError` — unknown package or wrong org. + * - `GitProviderTokenInvalidError` — preflight rejected by the git provider. + * - `MarketplaceDescriptorNotFoundError` / `MarketplaceDescriptorBadFormatError` + * — descriptor missing or unparseable; the marketplace state is moved to + * `bad_format` for admin visibility. + * - `MarketplacePluginNameConflictError` — unmanaged plugin on the marketplace + * already exposes the same slug. + */ +export class PublishPackageOnMarketplaceUseCase + extends AbstractMemberUseCase< + PublishPackageOnMarketplaceCommand, + PublishPackageOnMarketplaceResponse + > + implements IPublishPackageOnMarketplaceUseCase +{ + constructor( + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly marketplaceDistributionRepository: IMarketplaceDistributionRepository, + private readonly packageService: PackageService, + private readonly spacesPort: ISpacesPort, + private readonly gitPort: IGitPort, + private readonly gitRepoService: GitRepoService, + private readonly parserRegistry: MarketplaceDescriptorParserRegistry, + private readonly eventEmitterService: PackmindEventEmitterService, + private readonly publishJob: PublishPluginToMarketplaceDelayedJob, + accountsPort: IAccountsPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForMembers( + command: PublishPackageOnMarketplaceCommand & MemberContext, + ): Promise<PublishPackageOnMarketplaceResponse> { + const { marketplaceId, packageId, organization } = command; + + this.logger.info('Publish package on marketplace requested', { + organizationId: organization.id, + marketplaceId, + packageId, + }); + + // 1. Resolve the marketplace (org-scoped). + const marketplace = + await this.marketplaceRepository.findByOrganizationAndId( + organization.id, + marketplaceId, + ); + if (!marketplace) { + throw new MarketplaceNotFoundError(marketplaceId); + } + + // 2. Resolve the package and confirm it belongs to the org via its space. + const pkg = await this.packageService.findById(packageId); + if (!pkg) { + throw new PackageNotFoundError(packageId); + } + const pkgSpace = await this.spacesPort.getSpaceById(pkg.spaceId); + if (!pkgSpace || pkgSpace.organizationId !== organization.id) { + throw new PackageNotFoundError(packageId); + } + + // 3. Resolve the marketplace's git repo + git provider; preflight the token. + const marketplaceGitRepo = + await this.gitRepoService.findMarketplaceGitRepoById( + marketplace.gitRepoId, + ); + if (!marketplaceGitRepo) { + this.logger.error('Marketplace git repo missing on publish', { + marketplaceId, + gitRepoId: marketplace.gitRepoId, + }); + throw new GitProviderTokenInvalidError(); + } + await this.preflightGitToken({ + provisionalGitRepo: marketplaceGitRepo, + userId: command.user.id, + organizationId: organization.id, + }); + + // 4. Resolve which branch to read descriptor + lock from. When the + // rolling `packmind/sync` branch already exists from an earlier + // publish, that branch is the canonical Packmind-managed state and + // must be the source for the name-collision preflight; otherwise we + // fall back to the marketplace's default branch. + const readBranch = await resolveMarketplaceReadBranch( + this.gitPort, + marketplaceGitRepo, + ); + + // 4a. Fetch the descriptor and parse it. + const descriptor = await this.loadDescriptor({ + marketplace, + marketplaceGitRepo, + readBranch, + }); + + // 4b. Fetch the standalone Packmind lock file. A missing file is the + // normal first-publish path (empty lock); a malformed file means + // the marketplace is unhealthy and is treated as bad_format. + const lock = await this.loadLock({ + marketplace, + marketplaceGitRepo, + readBranch, + }); + + // 5. Reject name collisions against unmanaged plugin entries. + const pluginSlug = pkg.slug; + this.assertNoUnmanagedNameCollision({ + pluginSlug, + marketplace, + descriptor, + lock, + }); + + // 6. Compute the "first publish" flag for analytics. + const previousDistribution = + await this.marketplaceDistributionRepository.findLatestByPackageAndMarketplace( + packageId, + marketplaceId, + ); + const isFirstPublishForPackage = previousDistribution === null; + + // 7. Create the in-progress distribution row. + const marketplaceDistributionId = createMarketplaceDistributionId(uuidv4()); + const distribution: MarketplaceDistribution = { + id: marketplaceDistributionId, + organizationId: organization.id, + marketplaceId, + packageId, + pluginSlug, + authorId: command.user.id, + status: DistributionStatus.in_progress, + source: command.distributionSource ?? 'app', + } as MarketplaceDistribution; + await this.marketplaceDistributionRepository.add(distribution); + + // 8. Enqueue the BullMQ job that performs the Git side effects. + try { + await this.publishJob.addJob({ + marketplaceDistributionId, + marketplaceId, + packageId, + organizationId: organization.id, + userId: command.user.id, + }); + } catch (error) { + this.logger.error( + 'Failed to enqueue publish-plugin-to-marketplace job — marking distribution as failure', + { + marketplaceDistributionId, + marketplaceId, + packageId, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + + // 9. Emit the attempted event. + this.eventEmitterService.emit( + new PluginPublishAttemptedEvent({ + userId: command.user.id, + organizationId: organization.id, + source: command.source ?? 'ui', + marketplaceDistributionId, + marketplaceId, + packageId, + isFirstPublishForPackage, + }), + ); + + return { + marketplaceDistributionId, + status: 'in_progress', + marketplaceId, + packageId, + pluginSlug, + }; + } + + private async preflightGitToken(params: { + provisionalGitRepo: GitRepo; + userId: UserId; + organizationId: OrganizationId; + }): Promise<void> { + const { provisionalGitRepo, userId, organizationId } = params; + + try { + const providersResponse = await this.gitPort.listProviders({ + userId, + organizationId, + }); + const provider: GitProviderWithoutToken | undefined = + providersResponse.providers.find( + (p) => p.id === provisionalGitRepo.providerId, + ); + if (!provider || !provider.hasAuth) { + this.logger.warn( + 'Git provider token preflight failed — provider missing or no auth', + { + providerId: provisionalGitRepo.providerId, + hasProvider: !!provider, + hasAuth: provider?.hasAuth ?? false, + }, + ); + throw new GitProviderTokenInvalidError(); + } + } catch (error) { + if (error instanceof GitProviderTokenInvalidError) { + throw error; + } + this.logger.warn( + 'Git provider token preflight failed with an underlying error', + { + providerId: provisionalGitRepo.providerId, + // Intentionally not echoing token bytes — only the failure category. + error: error instanceof Error ? error.name : 'unknown', + }, + ); + throw new GitProviderTokenInvalidError(); + } + } + + private async loadDescriptor(params: { + marketplace: Marketplace; + marketplaceGitRepo: GitRepo; + readBranch: string; + }): Promise<MarketplaceDescriptor> { + const { marketplace, marketplaceGitRepo, readBranch } = params; + let descriptorFile: Awaited< + ReturnType<typeof fetchMarketplaceDescriptorFile> + >; + try { + descriptorFile = await fetchMarketplaceDescriptorFile( + this.gitPort, + marketplaceGitRepo, + readBranch, + ); + } catch (error) { + await this.markBadFormat(marketplace.id); + throw new MarketplaceDescriptorBadFormatError( + marketplaceGitRepo.owner, + marketplaceGitRepo.repo, + error instanceof Error ? error.message : String(error), + ); + } + + if (!descriptorFile) { + await this.markBadFormat(marketplace.id); + throw new MarketplaceDescriptorNotFoundError( + marketplaceGitRepo.owner, + marketplaceGitRepo.repo, + ); + } + + try { + return this.parserRegistry.parse(descriptorFile.content); + } catch (error) { + await this.markBadFormat(marketplace.id); + throw new MarketplaceDescriptorBadFormatError( + marketplaceGitRepo.owner, + marketplaceGitRepo.repo, + error instanceof Error ? error.message : String(error), + ); + } + } + + private async loadLock(params: { + marketplace: Marketplace; + marketplaceGitRepo: GitRepo; + readBranch: string; + }): Promise<PackmindMarketplaceLock> { + const { marketplace, marketplaceGitRepo, readBranch } = params; + try { + return await fetchPackmindMarketplaceLock( + this.gitPort, + marketplaceGitRepo, + readBranch, + ); + } catch (error) { + await this.markBadFormat(marketplace.id); + throw new MarketplaceDescriptorBadFormatError( + marketplaceGitRepo.owner, + marketplaceGitRepo.repo, + `packmind-lock.json is unparseable on ${marketplaceGitRepo.owner}/${marketplaceGitRepo.repo}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + private async markBadFormat(marketplaceId: Marketplace['id']): Promise<void> { + try { + await this.marketplaceRepository.updateState(marketplaceId, { + state: 'bad_format', + lastValidatedAt: new Date(), + }); + } catch (error) { + this.logger.warn( + `Failed to transition marketplace ${marketplaceId} to bad_format state`, + { + marketplaceId, + error: error instanceof Error ? error.message : String(error), + }, + ); + } + } + + private assertNoUnmanagedNameCollision(params: { + pluginSlug: string; + marketplace: Marketplace; + descriptor: MarketplaceDescriptor; + lock: PackmindMarketplaceLock; + }): void { + const { pluginSlug, marketplace, descriptor, lock } = params; + const managedSlugs = new Set<string>(Object.keys(lock.plugins)); + const collidingUnmanaged = descriptor.plugins.find( + (p: PluginRef) => p.slug === pluginSlug && !managedSlugs.has(p.slug), + ); + if (collidingUnmanaged) { + // Filename is referenced in the broader error context so admins can + // locate the conflicting entry inside `marketplace.json`. + this.logger.warn( + `Plugin "${pluginSlug}" collides with an unmanaged entry in ${MARKETPLACE_DESCRIPTOR_FILENAME}`, + { marketplaceId: marketplace.id, pluginSlug }, + ); + throw new MarketplacePluginNameConflictError( + pluginSlug, + marketplace.name, + ); + } + } +} diff --git a/packages/deployments/src/application/useCases/publishPackageOnMarketplace/index.ts b/packages/deployments/src/application/useCases/publishPackageOnMarketplace/index.ts new file mode 100644 index 000000000..7252b718e --- /dev/null +++ b/packages/deployments/src/application/useCases/publishPackageOnMarketplace/index.ts @@ -0,0 +1 @@ +export { PublishPackageOnMarketplaceUseCase } from './PublishPackageOnMarketplaceUseCase'; diff --git a/packages/deployments/src/application/useCases/renderPackageAsPlugin/RenderPackageAsPluginUseCase.spec.ts b/packages/deployments/src/application/useCases/renderPackageAsPlugin/RenderPackageAsPluginUseCase.spec.ts index 2a30857ee..6639e6033 100644 --- a/packages/deployments/src/application/useCases/renderPackageAsPlugin/RenderPackageAsPluginUseCase.spec.ts +++ b/packages/deployments/src/application/useCases/renderPackageAsPlugin/RenderPackageAsPluginUseCase.spec.ts @@ -310,6 +310,96 @@ describe('RenderPackageAsPluginUseCase', () => { }); }); + describe('install tracking hook files', () => { + const installTracking: RenderPackageAsPluginCommand['installTracking'] = { + apiBaseUrl: 'https://app.packmind.io/api', + marketplaceName: 'acme-marketplace', + pluginSlug: 'security', + trackingToken: 'tok_abc123', + }; + + beforeEach(() => { + packageService.getPackagesBySlugsAndSpaceWithArtefacts.mockResolvedValue([ + buildPackage(), + ]); + }); + + describe('when installTracking is absent', () => { + it('emits no hook files', async () => { + const result = await useCase.execute( + buildCommand({ installTracking: undefined }), + ); + const hookPaths = result.files.filter((f) => f.path.includes('hooks/')); + expect(hookPaths).toEqual([]); + }); + }); + + describe('when mode is standalone and installTracking is present', () => { + it('emits no hook files', async () => { + const result = await useCase.execute( + buildCommand({ mode: 'standalone', installTracking }), + ); + const hookPaths = result.files.filter((f) => f.path.includes('hooks/')); + expect(hookPaths).toEqual([]); + }); + }); + + describe('when mode is marketplace and installTracking is present', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + result = await useCase.execute(buildCommand({ installTracking })); + }); + + it('emits exactly four hook files', () => { + const hookPaths = result.files.filter((f) => f.path.includes('hooks/')); + expect(hookPaths).toHaveLength(4); + }); + + it('emits hooks/hooks.json', () => { + expect( + result.files.some((f) => f.path.endsWith('hooks/hooks.json')), + ).toBe(true); + }); + + it('emits hooks/track-install.sh', () => { + expect( + result.files.some((f) => f.path.endsWith('hooks/track-install.sh')), + ).toBe(true); + }); + + it('emits hooks/track-install.ps1', () => { + expect( + result.files.some((f) => f.path.endsWith('hooks/track-install.ps1')), + ).toBe(true); + }); + + it('emits hooks/packmind-tracking.env', () => { + expect( + result.files.some((f) => + f.path.endsWith('hooks/packmind-tracking.env'), + ), + ).toBe(true); + }); + + it('injects the tracking token into the sidecar', () => { + const env = result.files.find((f) => + f.path.endsWith('hooks/packmind-tracking.env'), + ); + expect(env?.content).toContain('PACKMIND_TRACKING_TOKEN=tok_abc123'); + }); + + it('injects the marketplace name into the sidecar', () => { + const env = result.files.find((f) => + f.path.endsWith('hooks/packmind-tracking.env'), + ); + expect(env?.content).toContain( + 'PACKMIND_MARKETPLACE_NAME=acme-marketplace', + ); + }); + }); + }); + describe('when the package has skills and commands', () => { beforeEach(() => { const recipes = [buildRecipe('r1', 'audit'), buildRecipe('r2', 'review')]; diff --git a/packages/deployments/src/application/useCases/renderPackageAsPlugin/RenderPackageAsPluginUseCase.ts b/packages/deployments/src/application/useCases/renderPackageAsPlugin/RenderPackageAsPluginUseCase.ts index 5725190b8..b7c0f8ad2 100644 --- a/packages/deployments/src/application/useCases/renderPackageAsPlugin/RenderPackageAsPluginUseCase.ts +++ b/packages/deployments/src/application/useCases/renderPackageAsPlugin/RenderPackageAsPluginUseCase.ts @@ -33,6 +33,8 @@ import { createGitRepoId, createTargetId, } from '@packmind/types'; + +const EMPTY_UPDATES: FileUpdates = { createOrUpdate: [], delete: [] }; import { v4 as uuidv4 } from 'uuid'; import { parsePackageSlug } from '../../services/packageSlugHelpers'; import { PackageService } from '../../services/PackageService'; @@ -119,10 +121,16 @@ export class RenderPackageAsPluginUseCase extends AbstractMemberUseCase< ); await deployer.deployStandards(standardVersions, gitRepo, target); + const trackingUpdate = + command.mode === 'marketplace' && command.installTracking + ? deployer.deployTrackingHooks(command.installTracking, target) + : EMPTY_UPDATES; + const files = this.toRenderedFiles([ manifestUpdate, commandsUpdate, skillsUpdate, + trackingUpdate, ]); this.logger.info('Rendered package as Claude plugin', { diff --git a/packages/deployments/src/application/useCases/syncMarketplaceNow/SyncMarketplaceNowUseCase.spec.ts b/packages/deployments/src/application/useCases/syncMarketplaceNow/SyncMarketplaceNowUseCase.spec.ts new file mode 100644 index 000000000..0a0ef8e5b --- /dev/null +++ b/packages/deployments/src/application/useCases/syncMarketplaceNow/SyncMarketplaceNowUseCase.spec.ts @@ -0,0 +1,182 @@ +import { v4 as uuidv4 } from 'uuid'; +import { stubLogger } from '@packmind/test-utils'; +import { + createMarketplaceId, + createOrganizationId, + createUserId, + IAccountsPort, + Marketplace, + MarketplaceNotFoundError, + Organization, + User, +} from '@packmind/types'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { MarketplaceReconciliationDelayedJob } from '../../jobs/MarketplaceReconciliationDelayedJob'; +import { SyncMarketplaceNowUseCase } from './SyncMarketplaceNowUseCase'; + +describe('SyncMarketplaceNowUseCase', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const marketplaceId = createMarketplaceId(uuidv4()); + const lastValidatedAt = new Date('2026-06-04T10:00:00.000Z'); + + const existingMarketplace = { + id: marketplaceId, + organizationId, + name: 'ACME Plugins', + } as unknown as Marketplace; + + const memberUser = { + id: userId, + email: 'member@example.com', + displayName: 'Member User', + passwordHash: null, + active: true, + memberships: [{ userId, organizationId, role: 'member' as const }], + trial: false, + } as unknown as User; + + const organization: Organization = { + id: organizationId, + name: 'Test Org', + slug: 'test-org', + }; + + let mockMarketplaceRepository: jest.Mocked<IMarketplaceRepository>; + let mockReconciliationJob: jest.Mocked< + Pick<MarketplaceReconciliationDelayedJob, 'reconcileNow'> + >; + let mockAccountsPort: jest.Mocked<IAccountsPort>; + let useCase: SyncMarketplaceNowUseCase; + + beforeEach(() => { + mockMarketplaceRepository = { + findByOrganizationAndId: jest.fn().mockResolvedValue(existingMarketplace), + } as unknown as jest.Mocked<IMarketplaceRepository>; + + mockReconciliationJob = { + reconcileNow: jest.fn().mockResolvedValue({ + state: 'healthy', + lastValidatedAt, + errorKind: null, + errorDetail: null, + pendingPrUrl: null, + outdatedPluginSlugs: null, + }), + }; + + mockAccountsPort = { + getUserById: jest.fn().mockResolvedValue(memberUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + useCase = new SyncMarketplaceNowUseCase( + mockMarketplaceRepository, + mockReconciliationJob as unknown as MarketplaceReconciliationDelayedJob, + mockAccountsPort, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('member happy path', () => { + let response: Awaited<ReturnType<SyncMarketplaceNowUseCase['execute']>>; + + beforeEach(async () => { + response = await useCase.execute({ + userId, + organizationId, + marketplaceId, + }); + }); + + it('runs reconciliation for the requested marketplace', () => { + expect(mockReconciliationJob.reconcileNow).toHaveBeenCalledWith( + marketplaceId, + ); + }); + + it('returns the reconciled state', () => { + expect(response.state).toBe('healthy'); + }); + + it('returns the validation timestamp', () => { + expect(response.lastValidatedAt).toEqual(lastValidatedAt); + }); + }); + + describe('freshness window', () => { + describe('when the marketplace was validated within the window', () => { + let response: Awaited<ReturnType<SyncMarketplaceNowUseCase['execute']>>; + + beforeEach(async () => { + mockMarketplaceRepository.findByOrganizationAndId.mockResolvedValue({ + ...existingMarketplace, + state: 'unreachable', + lastValidatedAt: new Date(), + errorKind: 'auth_failed', + errorDetail: 'creds expired', + pendingPrUrl: null, + outdatedPluginSlugs: ['x'], + } as unknown as Marketplace); + response = await useCase.execute({ + userId, + organizationId, + marketplaceId, + }); + }); + + it('does not run reconciliation', () => { + expect(mockReconciliationJob.reconcileNow).not.toHaveBeenCalled(); + }); + + it('returns the row state without re-fetching', () => { + expect(response.state).toBe('unreachable'); + }); + + it('returns the row errorKind without re-fetching', () => { + expect(response.errorKind).toBe('auth_failed'); + }); + }); + + describe('when the marketplace validation is stale', () => { + beforeEach(async () => { + mockMarketplaceRepository.findByOrganizationAndId.mockResolvedValue({ + ...existingMarketplace, + lastValidatedAt: new Date('2020-01-01T00:00:00.000Z'), + } as unknown as Marketplace); + await useCase.execute({ userId, organizationId, marketplaceId }); + }); + + it('runs reconciliation', () => { + expect(mockReconciliationJob.reconcileNow).toHaveBeenCalledWith( + marketplaceId, + ); + }); + }); + }); + + describe('when the marketplace is not found', () => { + beforeEach(() => { + mockMarketplaceRepository.findByOrganizationAndId.mockResolvedValue(null); + }); + + it('throws MarketplaceNotFoundError', async () => { + await expect( + useCase.execute({ userId, organizationId, marketplaceId }), + ).rejects.toBeInstanceOf(MarketplaceNotFoundError); + }); + + it('does not run reconciliation', async () => { + await useCase + .execute({ userId, organizationId, marketplaceId }) + .catch(() => { + /* expected */ + }); + expect(mockReconciliationJob.reconcileNow).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/deployments/src/application/useCases/syncMarketplaceNow/SyncMarketplaceNowUseCase.ts b/packages/deployments/src/application/useCases/syncMarketplaceNow/SyncMarketplaceNowUseCase.ts new file mode 100644 index 000000000..21e6c26a8 --- /dev/null +++ b/packages/deployments/src/application/useCases/syncMarketplaceNow/SyncMarketplaceNowUseCase.ts @@ -0,0 +1,108 @@ +import { PackmindLogger } from '@packmind/logger'; +import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; +import { + IAccountsPort, + ISyncMarketplaceNowUseCase, + MarketplaceNotFoundError, + SyncMarketplaceNowCommand, + SyncMarketplaceNowResponse, +} from '@packmind/types'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { MarketplaceReconciliationDelayedJob } from '../../jobs/MarketplaceReconciliationDelayedJob'; + +const origin = 'SyncMarketplaceNowUseCase'; + +/** Skip a re-fetch when the row was validated within this window (debounces rapid page reopens). */ +const RECONCILE_FRESHNESS_WINDOW_MS = 10_000; + +/** + * Runs an immediate reconciliation of a single marketplace and returns the + * resulting state. Member-scoped stop-gap that lets an org member refresh a + * marketplace on demand instead of waiting up to ~30 min for the next cron + * sweep — most visibly, so a merged deletion PR flips `to_be_removed` + * distributions to `removed` right away. + * + * The reconciliation is non-destructive: it only syncs Packmind's view to the + * repo's reality (drift detection + terminal `removed` transitions), so it + * needs no admin privilege. + * + * Flow: + * 1. Resolve the marketplace by `(organizationId, marketplaceId)`. Miss → + * `MarketplaceNotFoundError`. + * 2. Run the reconciliation sweep synchronously via the reconciliation job's + * `reconcileNow`. + * 3. Return the resulting `{ state, lastValidatedAt }`. + */ +export class SyncMarketplaceNowUseCase + extends AbstractMemberUseCase< + SyncMarketplaceNowCommand, + SyncMarketplaceNowResponse + > + implements ISyncMarketplaceNowUseCase +{ + constructor( + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly reconciliationJob: MarketplaceReconciliationDelayedJob, + accountsPort: IAccountsPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForMembers( + command: SyncMarketplaceNowCommand & MemberContext, + ): Promise<SyncMarketplaceNowResponse> { + const { marketplaceId, organization } = command; + + this.logger.info('Manual marketplace reconciliation requested', { + marketplaceId, + organizationId: organization.id, + actorId: command.user.id, + }); + + const marketplace = + await this.marketplaceRepository.findByOrganizationAndId( + organization.id, + marketplaceId, + ); + if (!marketplace) { + this.logger.warn('Marketplace not found for manual reconciliation', { + marketplaceId, + organizationId: organization.id, + actorId: command.user.id, + }); + throw new MarketplaceNotFoundError(marketplaceId); + } + + const isFresh = + marketplace.lastValidatedAt != null && + Date.now() - new Date(marketplace.lastValidatedAt).getTime() < + RECONCILE_FRESHNESS_WINDOW_MS; + + if (isFresh) { + this.logger.info('Marketplace recently validated — skipping reconcile', { + marketplaceId, + organizationId: organization.id, + }); + return { + state: marketplace.state, + lastValidatedAt: marketplace.lastValidatedAt as Date, + errorKind: marketplace.errorKind, + errorDetail: marketplace.errorDetail, + pendingPrUrl: marketplace.pendingPrUrl, + outdatedPluginSlugs: marketplace.outdatedPluginSlugs, + }; + } + + const result = await this.reconciliationJob.reconcileNow(marketplaceId); + + this.logger.info('Manual marketplace reconciliation completed', { + marketplaceId, + organizationId: organization.id, + actorId: command.user.id, + state: result.state, + }); + + return result; + } +} diff --git a/packages/deployments/src/application/useCases/syncMarketplaceNow/index.ts b/packages/deployments/src/application/useCases/syncMarketplaceNow/index.ts new file mode 100644 index 000000000..748b951b0 --- /dev/null +++ b/packages/deployments/src/application/useCases/syncMarketplaceNow/index.ts @@ -0,0 +1 @@ +export { SyncMarketplaceNowUseCase } from './SyncMarketplaceNowUseCase'; diff --git a/packages/deployments/src/application/useCases/trackPluginInstallHeartbeat/TrackPluginInstallHeartbeatUseCase.spec.ts b/packages/deployments/src/application/useCases/trackPluginInstallHeartbeat/TrackPluginInstallHeartbeatUseCase.spec.ts new file mode 100644 index 000000000..b2116b597 --- /dev/null +++ b/packages/deployments/src/application/useCases/trackPluginInstallHeartbeat/TrackPluginInstallHeartbeatUseCase.spec.ts @@ -0,0 +1,441 @@ +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { stubLogger } from '@packmind/test-utils'; +import { + Marketplace, + PluginInstallTrackedEvent, + PluginInstallation, + TrackPluginInstallHeartbeatCommand, + createMarketplaceId, + createOrganizationId, + createPackageId, + createPluginInstallationId, + createUserId, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; +import { + IPluginInstallationRepository, + UpsertHeartbeatResult, +} from '../../../domain/repositories/IPluginInstallationRepository'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { PackageService } from '../../services/PackageService'; +import { TrackPluginInstallHeartbeatUseCase } from './TrackPluginInstallHeartbeatUseCase'; + +const buildMarketplace = ( + overrides: Partial<Marketplace> = {}, +): Marketplace => ({ + id: createMarketplaceId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + name: 'acme-marketplace', + trackingToken: 'test-token-123', + vendor: 'anthropic', + gitRepoId: uuidv4() as Marketplace['gitRepoId'], + addedBy: createUserId(uuidv4()), + linkedAt: new Date(), + state: 'healthy', + lastValidatedAt: null, + descriptor: { + name: 'acme-marketplace', + plugins: [], + vendor: 'anthropic', + raw: {}, + }, + pluginCount: 0, + errorKind: null, + errorDetail: null, + pendingPrUrl: null, + outdatedPluginSlugs: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + deletedBy: null, + ...overrides, +}); + +const buildInstallation = ( + overrides: Partial<PluginInstallation> = {}, +): PluginInstallation => + ({ + id: createPluginInstallationId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + marketplaceId: createMarketplaceId(uuidv4()), + pluginSlug: 'my-plugin', + packageId: null, + installedVersion: null, + scope: 'user', + userId: null, + anonymousIdHash: null, + anonymousEmailMasked: null, + identityKey: '', + repoRemoteUrl: null, + repoKey: '', + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + deletedBy: null, + ...overrides, + }) as PluginInstallation; + +const buildCommand = ( + overrides: Partial<TrackPluginInstallHeartbeatCommand> = {}, +): TrackPluginInstallHeartbeatCommand => ({ + trackingToken: 'test-token-123', + pluginSlug: 'my-plugin', + marketplaceName: 'acme-marketplace', + scope: 'user', + ...overrides, +}); + +describe('TrackPluginInstallHeartbeatUseCase', () => { + let pluginInstallationRepository: jest.Mocked<IPluginInstallationRepository>; + let marketplaceRepository: jest.Mocked<IMarketplaceRepository>; + let packageService: jest.Mocked<PackageService>; + let eventEmitterService: jest.Mocked<PackmindEventEmitterService>; + let useCase: TrackPluginInstallHeartbeatUseCase; + let marketplace: Marketplace; + + beforeEach(() => { + marketplace = buildMarketplace(); + + marketplaceRepository = { + findByTrackingToken: jest.fn().mockResolvedValue(marketplace), + } as unknown as jest.Mocked<IMarketplaceRepository>; + + pluginInstallationRepository = { + upsertHeartbeat: jest.fn(), + listByMarketplace: jest.fn(), + } as unknown as jest.Mocked<IPluginInstallationRepository>; + + packageService = { + getPackagesBySlugsWithArtefacts: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked<PackageService>; + + eventEmitterService = { + emit: jest.fn(), + } as unknown as jest.Mocked<PackmindEventEmitterService>; + + useCase = new TrackPluginInstallHeartbeatUseCase( + pluginInstallationRepository, + marketplaceRepository, + packageService, + eventEmitterService, + stubLogger(), + ); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('when the tracking token is invalid', () => { + beforeEach(() => { + marketplaceRepository.findByTrackingToken.mockResolvedValue(null); + }); + + it('throws an UnauthorizedError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toMatchObject({ + name: 'UnauthorizedError', + }); + }); + + it('does not upsert any row', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + expect( + pluginInstallationRepository.upsertHeartbeat, + ).not.toHaveBeenCalled(); + }); + + it('does not emit any event', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + expect(eventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + + describe('when creating a new anonymous installation', () => { + let installation: PluginInstallation; + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + installation = buildInstallation({ + marketplaceId: marketplace.id, + organizationId: marketplace.organizationId, + anonymousIdHash: 'abc123hash', + identityKey: 'abc123hash', + }); + const upsertResult: UpsertHeartbeatResult = { + created: true, + installation, + }; + pluginInstallationRepository.upsertHeartbeat.mockResolvedValue( + upsertResult, + ); + + result = await useCase.execute( + buildCommand({ anonymousIdHash: 'abc123hash' }), + ); + }); + + it('returns created = true', () => { + expect(result.created).toBe(true); + }); + + it('returns the correct marketplaceId', () => { + expect(result.marketplaceId).toBe(marketplace.id); + }); + + it('emits a PluginInstallTrackedEvent', () => { + const emitted = eventEmitterService.emit.mock + .calls[0][0] as PluginInstallTrackedEvent; + expect(emitted).toBeInstanceOf(PluginInstallTrackedEvent); + }); + + it('emits the event with the anonymous id hash', () => { + const emitted = eventEmitterService.emit.mock + .calls[0][0] as PluginInstallTrackedEvent; + expect(emitted.payload.anonymousIdHash).toBe('abc123hash'); + }); + + it('emits the event with a null userId', () => { + const emitted = eventEmitterService.emit.mock + .calls[0][0] as PluginInstallTrackedEvent; + expect(emitted.payload.userId).toBeNull(); + }); + }); + + describe('when creating a new attributed installation', () => { + let installation: PluginInstallation; + let result: Awaited<ReturnType<typeof useCase.execute>>; + const verifiedUserId = uuidv4(); + + beforeEach(async () => { + installation = buildInstallation({ + marketplaceId: marketplace.id, + organizationId: marketplace.organizationId, + userId: createUserId(verifiedUserId), + identityKey: verifiedUserId, + }); + const upsertResult: UpsertHeartbeatResult = { + created: true, + installation, + }; + pluginInstallationRepository.upsertHeartbeat.mockResolvedValue( + upsertResult, + ); + + result = await useCase.execute( + buildCommand({ + verifiedUserId, + verifiedUserOrgId: marketplace.organizationId as string, + }), + ); + }); + + it('returns created = true', () => { + expect(result.created).toBe(true); + }); + + it('passes the verified userId to the repository', () => { + expect(pluginInstallationRepository.upsertHeartbeat).toHaveBeenCalledWith( + expect.objectContaining({ userId: verifiedUserId }), + ); + }); + + it('emits a PluginInstallTrackedEvent with the userId', () => { + const emitted = eventEmitterService.emit.mock + .calls[0][0] as PluginInstallTrackedEvent; + expect(emitted.payload.userId).toBe(createUserId(verifiedUserId)); + }); + }); + + describe('when the API key belongs to a different org (cross-org)', () => { + const crossOrgUserId = uuidv4(); + const crossOrgId = uuidv4(); // different from marketplace.organizationId + + beforeEach(() => { + const installation = buildInstallation({ + marketplaceId: marketplace.id, + organizationId: marketplace.organizationId, + // userId should remain null — cross-org key must not attribute + userId: null, + identityKey: '', + }); + pluginInstallationRepository.upsertHeartbeat.mockResolvedValue({ + created: true, + installation, + }); + }); + + it('falls back to anonymous and does not set userId', async () => { + await useCase.execute( + buildCommand({ + verifiedUserId: crossOrgUserId, + verifiedUserOrgId: crossOrgId, + }), + ); + expect(pluginInstallationRepository.upsertHeartbeat).toHaveBeenCalledWith( + expect.objectContaining({ userId: null }), + ); + }); + + it('still upserts the heartbeat row anonymously', async () => { + await useCase.execute( + buildCommand({ + verifiedUserId: crossOrgUserId, + verifiedUserOrgId: crossOrgId, + anonymousIdHash: 'anon-hash-123', + }), + ); + expect(pluginInstallationRepository.upsertHeartbeat).toHaveBeenCalledWith( + expect.objectContaining({ + userId: null, + anonymousIdHash: 'anon-hash-123', + }), + ); + }); + }); + + describe('when upgrading anonymous to attributed', () => { + const verifiedUserId = uuidv4(); + + beforeEach(() => { + const installation = buildInstallation({ + userId: createUserId(verifiedUserId), + identityKey: verifiedUserId, + }); + // Upgrade returns created = false (not a new INSERT) + const upsertResult: UpsertHeartbeatResult = { + created: false, + installation, + }; + pluginInstallationRepository.upsertHeartbeat.mockResolvedValue( + upsertResult, + ); + }); + + describe('when upgrading anonymous to attributed', () => { + it('does not emit an event', async () => { + await useCase.execute( + buildCommand({ + verifiedUserId, + verifiedUserOrgId: marketplace.organizationId as string, + anonymousIdHash: 'abc123hash', + }), + ); + expect(eventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when bumping updatedAt without creation', () => { + beforeEach(() => { + const installation = buildInstallation({ + anonymousIdHash: 'abc123hash', + identityKey: 'abc123hash', + updatedAt: new Date(), + }); + const upsertResult: UpsertHeartbeatResult = { + created: false, + installation, + }; + pluginInstallationRepository.upsertHeartbeat.mockResolvedValue( + upsertResult, + ); + }); + + it('returns created = false', async () => { + const result = await useCase.execute( + buildCommand({ anonymousIdHash: 'abc123hash' }), + ); + expect(result.created).toBe(false); + }); + + it('does not emit an event', async () => { + await useCase.execute(buildCommand({ anonymousIdHash: 'abc123hash' })); + expect(eventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + + describe('when the packageId can be resolved from pluginSlug', () => { + const packageId = createPackageId(uuidv4()); + + beforeEach(async () => { + packageService.getPackagesBySlugsWithArtefacts.mockResolvedValue([ + { + id: packageId, + slug: 'my-plugin', + } as import('@packmind/types').PackageWithArtefacts, + ]); + const installation = buildInstallation({ packageId }); + pluginInstallationRepository.upsertHeartbeat.mockResolvedValue({ + created: true, + installation, + }); + await useCase.execute(buildCommand()); + }); + + it('passes the packageId to the repository', () => { + expect(pluginInstallationRepository.upsertHeartbeat).toHaveBeenCalledWith( + expect.objectContaining({ packageId: packageId as string }), + ); + }); + }); + + describe('when the heartbeat reports an installed version', () => { + beforeEach(async () => { + const installation = buildInstallation({ installedVersion: '0.1.0' }); + pluginInstallationRepository.upsertHeartbeat.mockResolvedValue({ + created: true, + installation, + }); + await useCase.execute(buildCommand({ installedVersion: '0.1.0' })); + }); + + it('passes the installedVersion to the repository', () => { + expect(pluginInstallationRepository.upsertHeartbeat).toHaveBeenCalledWith( + expect.objectContaining({ installedVersion: '0.1.0' }), + ); + }); + }); + + describe('when the heartbeat omits an installed version', () => { + beforeEach(async () => { + const installation = buildInstallation(); + pluginInstallationRepository.upsertHeartbeat.mockResolvedValue({ + created: true, + installation, + }); + await useCase.execute(buildCommand()); + }); + + it('passes installedVersion as null to the repository', () => { + expect(pluginInstallationRepository.upsertHeartbeat).toHaveBeenCalledWith( + expect.objectContaining({ installedVersion: null }), + ); + }); + }); + + describe('when the scope is project with a repo URL', () => { + beforeEach(async () => { + const installation = buildInstallation({ + scope: 'project', + repoRemoteUrl: 'https://github.com/acme/frontend.git', + repoKey: 'acme/frontend', + }); + pluginInstallationRepository.upsertHeartbeat.mockResolvedValue({ + created: true, + installation, + }); + await useCase.execute( + buildCommand({ + scope: 'project', + repoRemoteUrl: 'https://github.com/acme/frontend.git', + }), + ); + }); + + it('passes the raw repoRemoteUrl to the repository', () => { + expect(pluginInstallationRepository.upsertHeartbeat).toHaveBeenCalledWith( + expect.objectContaining({ + repoRemoteUrl: 'https://github.com/acme/frontend.git', + }), + ); + }); + }); +}); diff --git a/packages/deployments/src/application/useCases/trackPluginInstallHeartbeat/TrackPluginInstallHeartbeatUseCase.ts b/packages/deployments/src/application/useCases/trackPluginInstallHeartbeat/TrackPluginInstallHeartbeatUseCase.ts new file mode 100644 index 000000000..251de46cd --- /dev/null +++ b/packages/deployments/src/application/useCases/trackPluginInstallHeartbeat/TrackPluginInstallHeartbeatUseCase.ts @@ -0,0 +1,166 @@ +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { + MarketplaceId, + OrganizationId, + PluginInstallTrackedEvent, + TrackPluginInstallHeartbeatCommand, + TrackPluginInstallHeartbeatResponse, + UserId, + createPluginInstallationId, + createUserId, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; +import { + IPluginInstallationRepository, + IMarketplaceRepository, +} from '../../../domain/repositories'; +import { parsePackageSlug } from '../../services/packageSlugHelpers'; +import { PackageService } from '../../services/PackageService'; + +const origin = 'TrackPluginInstallHeartbeatUseCase'; + +/** + * Resolves the packageId for a given pluginSlug by looking up packages across + * the org. Returns `null` when no matching package is found — this is an + * accepted outcome (spec §9: unknown slugs are accepted with packageId = null). + */ +async function resolvePackageId( + pluginSlug: string, + organizationId: OrganizationId, + packageService: PackageService, +): Promise<string | null> { + try { + const { packageSlug } = parsePackageSlug(pluginSlug); + const packages = await packageService.getPackagesBySlugsWithArtefacts( + [packageSlug], + organizationId, + ); + const found = packages.find((p) => p.slug === packageSlug); + return found ? (found.id as string) : null; + } catch { + return null; + } +} + +/** + * Processes a SessionStart heartbeat from a published Packmind plugin. + * + * Authorization: the `trackingToken` is the sole credential — it identifies the + * marketplace without requiring a user session. The API layer resolves the + * verified userId before calling this use case. + * + * Spec §7.3 flow: + * 1. Resolve marketplace + org from trackingToken. + * 2. Resolve packageId best-effort from pluginSlug. + * 3. Upsert the heartbeat row. + * 4. Emit `PluginInstallTrackedEvent` ONLY when a row was CREATED (first-seen). + */ +export class TrackPluginInstallHeartbeatUseCase { + private readonly logger: PackmindLogger; + + constructor( + private readonly pluginInstallationRepository: IPluginInstallationRepository, + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly packageService: PackageService, + private readonly eventEmitterService: PackmindEventEmitterService, + logger: PackmindLogger = new PackmindLogger(origin, LogLevel.INFO), + ) { + this.logger = logger; + this.logger.info('TrackPluginInstallHeartbeatUseCase initialized'); + } + + async execute( + command: TrackPluginInstallHeartbeatCommand, + ): Promise<TrackPluginInstallHeartbeatResponse> { + this.logger.info('Processing plugin install heartbeat', { + pluginSlug: command.pluginSlug, + scope: command.scope, + marketplaceName: command.marketplaceName, + }); + + // 1. Resolve marketplace + org from tracking token + const marketplace = await this.marketplaceRepository.findByTrackingToken( + command.trackingToken, + ); + if (!marketplace) { + this.logger.warn('Invalid tracking token — no marketplace found', { + tokenPrefix: command.trackingToken.substring(0, 6) + '*', + }); + const err = new Error('Invalid tracking token'); + err.name = 'UnauthorizedError'; + throw err; + } + + const marketplaceId = marketplace.id; + const organizationId = marketplace.organizationId; + + // 2. Resolve verifiedUserId with cross-org guard (spec §7.3, §9): + // "Cross-org API key → Ignore key, fall back to anonymous." + // The API layer forwards both userId and orgId from the JWT; we only + // attribute the heartbeat to the user when the API key belongs to the + // same org as the marketplace's tracking token. + const isVerifiedForOrg = + command.verifiedUserId && + command.verifiedUserOrgId && + command.verifiedUserOrgId === (organizationId as string); + const userId: UserId | null = isVerifiedForOrg + ? createUserId(command.verifiedUserId!) + : null; + + // 3. Resolve packageId best-effort from pluginSlug + const packageId = await resolvePackageId( + command.pluginSlug, + organizationId, + this.packageService, + ); + + // 4. Upsert heartbeat + const { created, installation } = + await this.pluginInstallationRepository.upsertHeartbeat({ + id: createPluginInstallationId(uuidv4()), + organizationId: organizationId as string, + marketplaceId, + pluginSlug: command.pluginSlug, + packageId, + installedVersion: command.installedVersion ?? null, + scope: command.scope, + userId: userId ? (userId as string) : null, + anonymousIdHash: command.anonymousIdHash ?? null, + anonymousEmailMasked: command.anonymousEmailMasked ?? null, + repoRemoteUrl: command.repoRemoteUrl ?? null, + now: new Date(), + }); + + // 5. Emit event ONLY on first-seen (INSERT) + if (created) { + try { + this.eventEmitterService.emit( + new PluginInstallTrackedEvent({ + organizationId, + marketplaceId, + packageId, + pluginSlug: command.pluginSlug, + scope: command.scope, + userId, + anonymousIdHash: command.anonymousIdHash ?? null, + }), + ); + } catch (error) { + this.logger.warn( + 'Failed to emit PluginInstallTrackedEvent; continuing', + { error }, + ); + } + } + + this.logger.info('Plugin install heartbeat processed', { + pluginSlug: command.pluginSlug, + marketplaceId, + created, + installationId: installation.id, + }); + + return { created, marketplaceId: marketplaceId as MarketplaceId }; + } +} diff --git a/packages/deployments/src/application/useCases/unlinkMarketplace/UnlinkMarketplaceUseCase.spec.ts b/packages/deployments/src/application/useCases/unlinkMarketplace/UnlinkMarketplaceUseCase.spec.ts new file mode 100644 index 000000000..d87fdb113 --- /dev/null +++ b/packages/deployments/src/application/useCases/unlinkMarketplace/UnlinkMarketplaceUseCase.spec.ts @@ -0,0 +1,265 @@ +import { v4 as uuidv4 } from 'uuid'; +import { stubLogger } from '@packmind/test-utils'; +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { + createGitRepoId, + createMarketplaceId, + createOrganizationId, + createUserId, + IAccountsPort, + IGitPort, + Marketplace, + MarketplaceId, + MarketplaceNotFoundError, + MarketplaceUnlinkedEvent, + Organization, + OrganizationId, + UnlinkMarketplaceCommand, + User, + UserId, +} from '@packmind/types'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { MarketplaceReconciliationDelayedJob } from '../../jobs/MarketplaceReconciliationDelayedJob'; +import { UnlinkMarketplaceUseCase } from './UnlinkMarketplaceUseCase'; + +describe('UnlinkMarketplaceUseCase', () => { + const organizationId: OrganizationId = createOrganizationId(uuidv4()); + const userId: UserId = createUserId(uuidv4()); + const marketplaceId: MarketplaceId = createMarketplaceId(uuidv4()); + const gitRepoId = createGitRepoId(uuidv4()); + + const command: UnlinkMarketplaceCommand = { + userId, + organizationId, + marketplaceId, + }; + + const existingMarketplace = { + id: marketplaceId, + organizationId, + gitRepoId, + name: 'ACME Plugins', + vendor: 'anthropic', + addedBy: userId, + linkedAt: new Date(), + state: 'healthy', + lastValidatedAt: null, + descriptor: { + vendor: 'anthropic', + name: 'ACME Plugins', + plugins: [], + raw: {}, + }, + pluginCount: 0, + } as unknown as Marketplace; + + const adminUser = { + id: userId, + email: 'admin@example.com', + displayName: 'Admin User', + passwordHash: null, + active: true, + memberships: [{ userId, organizationId, role: 'admin' as const }], + trial: false, + } as unknown as User; + + const organization: Organization = { + id: organizationId, + name: 'Test Org', + slug: 'test-org', + }; + + let mockMarketplaceRepository: jest.Mocked<IMarketplaceRepository>; + let mockGitPort: jest.Mocked<IGitPort>; + let mockEventEmitterService: jest.Mocked<PackmindEventEmitterService>; + let mockAccountsPort: jest.Mocked<IAccountsPort>; + let mockReconciliationJob: jest.Mocked<MarketplaceReconciliationDelayedJob>; + let useCase: UnlinkMarketplaceUseCase; + + beforeEach(() => { + mockMarketplaceRepository = { + findByOrganizationAndId: jest.fn().mockResolvedValue(existingMarketplace), + deleteById: jest.fn().mockResolvedValue(undefined), + add: jest.fn(), + addMany: jest.fn(), + findByOrganizationId: jest.fn(), + findByOrganizationAndGitRepo: jest.fn(), + findAllForReconciliation: jest.fn(), + updateState: jest.fn(), + findById: jest.fn(), + restoreById: jest.fn(), + hardDeleteById: jest.fn(), + } as unknown as jest.Mocked<IMarketplaceRepository>; + + mockGitPort = { + deleteGitRepo: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked<IGitPort>; + + mockEventEmitterService = { + emit: jest.fn(), + } as unknown as jest.Mocked<PackmindEventEmitterService>; + + mockAccountsPort = { + getUserById: jest.fn().mockResolvedValue(adminUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + mockReconciliationJob = { + scheduleRecurring: jest.fn().mockResolvedValue(undefined), + cancelRecurring: jest.fn().mockResolvedValue(undefined), + addJob: jest.fn().mockResolvedValue('job-id'), + } as unknown as jest.Mocked<MarketplaceReconciliationDelayedJob>; + + useCase = new UnlinkMarketplaceUseCase( + mockMarketplaceRepository, + mockGitPort, + mockEventEmitterService, + mockReconciliationJob, + mockAccountsPort, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('admin happy path', () => { + describe('soft-deletion of the marketplace and underlying git repo', () => { + let response: Awaited<ReturnType<UnlinkMarketplaceUseCase['execute']>>; + + beforeEach(async () => { + response = await useCase.execute(command); + }); + + it('soft-deletes the marketplace', () => { + expect(mockMarketplaceRepository.deleteById).toHaveBeenCalledWith( + marketplaceId, + userId, + ); + }); + + it('deletes the underlying git repo', () => { + expect(mockGitPort.deleteGitRepo).toHaveBeenCalledWith( + gitRepoId, + userId, + organizationId, + ); + }); + + it('returns the marketplace id', () => { + expect(response.marketplaceId).toBe(marketplaceId); + }); + }); + + describe('emitting MarketplaceUnlinkedEvent', () => { + beforeEach(async () => { + await useCase.execute(command); + }); + + it('emits exactly one event', () => { + expect(mockEventEmitterService.emit).toHaveBeenCalledTimes(1); + }); + + it('emits a MarketplaceUnlinkedEvent instance', () => { + const emitted = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emitted).toBeInstanceOf(MarketplaceUnlinkedEvent); + }); + + it('emits the marketplace id in the payload', () => { + const emitted = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emitted.payload.marketplaceId).toBe(marketplaceId); + }); + + it('emits the git repo id in the payload', () => { + const emitted = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emitted.payload.gitRepoId).toBe(gitRepoId); + }); + + it('emits the organization id in the payload', () => { + const emitted = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emitted.payload.organizationId).toBe(organizationId); + }); + }); + + it('cancels the repeatable reconciliation cron via the job manager', async () => { + await useCase.execute(command); + + expect(mockReconciliationJob.cancelRecurring).toHaveBeenCalledWith( + marketplaceId, + ); + }); + }); + + describe('non-admin denial', () => { + beforeEach(() => { + const memberUser = { + ...adminUser, + memberships: [{ userId, organizationId, role: 'member' as const }], + } as unknown as User; + mockAccountsPort.getUserById = jest.fn().mockResolvedValue(memberUser); + }); + + it('throws OrganizationAdminRequiredError', async () => { + await expect(useCase.execute(command)).rejects.toMatchObject({ + name: 'OrganizationAdminRequiredError', + }); + }); + + describe('does not delete anything', () => { + beforeEach(async () => { + try { + await useCase.execute(command); + } catch { + // expected + } + }); + + it('does not delete the marketplace', () => { + expect(mockMarketplaceRepository.deleteById).not.toHaveBeenCalled(); + }); + + it('does not delete the git repo', () => { + expect(mockGitPort.deleteGitRepo).not.toHaveBeenCalled(); + }); + + it('does not emit any event', () => { + expect(mockEventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + }); + + describe('marketplace not found', () => { + beforeEach(() => { + mockMarketplaceRepository.findByOrganizationAndId.mockResolvedValue(null); + }); + + it('throws MarketplaceNotFoundError', async () => { + await expect(useCase.execute(command)).rejects.toBeInstanceOf( + MarketplaceNotFoundError, + ); + }); + + describe('does not delete or emit', () => { + beforeEach(async () => { + try { + await useCase.execute(command); + } catch { + // expected + } + }); + + it('does not delete the marketplace', () => { + expect(mockMarketplaceRepository.deleteById).not.toHaveBeenCalled(); + }); + + it('does not delete the git repo', () => { + expect(mockGitPort.deleteGitRepo).not.toHaveBeenCalled(); + }); + + it('does not emit any event', () => { + expect(mockEventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/deployments/src/application/useCases/unlinkMarketplace/UnlinkMarketplaceUseCase.ts b/packages/deployments/src/application/useCases/unlinkMarketplace/UnlinkMarketplaceUseCase.ts new file mode 100644 index 000000000..bacfec918 --- /dev/null +++ b/packages/deployments/src/application/useCases/unlinkMarketplace/UnlinkMarketplaceUseCase.ts @@ -0,0 +1,122 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + AbstractAdminUseCase, + AdminContext, + PackmindEventEmitterService, +} from '@packmind/node-utils'; +import { + createUserId, + IAccountsPort, + IGitPort, + IUnlinkMarketplaceUseCase, + MarketplaceNotFoundError, + MarketplaceUnlinkedEvent, + UnlinkMarketplaceCommand, + UnlinkMarketplaceResponse, +} from '@packmind/types'; +import { IMarketplaceRepository } from '../../../domain/repositories/IMarketplaceRepository'; +import { MarketplaceReconciliationDelayedJob } from '../../jobs/MarketplaceReconciliationDelayedJob'; + +const origin = 'UnlinkMarketplaceUseCase'; + +/** + * Unlinks a marketplace from the caller's organization. + * + * Admin-only. The underlying Git repository is **never** touched — only the + * `Marketplace` row and its supporting `type='marketplace'` `GitRepo` row are + * soft-deleted. In-flight PRs are unaffected. + * + * Flow: + * 1. Find the marketplace by `(organizationId, marketplaceId)`. + * Miss → `MarketplaceNotFoundError`. + * 2. Soft-delete the `Marketplace` row. + * 3. Soft-delete the underlying marketplace-typed `GitRepo` via `IGitPort`. + * 4. (Group K, task 11.3) Remove the repeatable reconciliation job. + * 5. Emit `MarketplaceUnlinkedEvent`. + * 6. Return `{ marketplaceId }`. + */ +export class UnlinkMarketplaceUseCase + extends AbstractAdminUseCase< + UnlinkMarketplaceCommand, + UnlinkMarketplaceResponse + > + implements IUnlinkMarketplaceUseCase +{ + constructor( + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly gitPort: IGitPort, + private readonly eventEmitterService: PackmindEventEmitterService, + private readonly reconciliationJob: MarketplaceReconciliationDelayedJob, + accountsPort: IAccountsPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForAdmins( + command: UnlinkMarketplaceCommand & AdminContext, + ): Promise<UnlinkMarketplaceResponse> { + const { marketplaceId, organization, userId, source } = command; + + if (!marketplaceId) { + throw new Error('Marketplace ID is required'); + } + + // 1. Find by (orgId, marketplaceId) — soft-deleted rows are excluded. + const marketplace = + await this.marketplaceRepository.findByOrganizationAndId( + organization.id, + marketplaceId, + ); + if (!marketplace) { + this.logger.warn('Marketplace not found for unlink', { + marketplaceId, + organizationId: organization.id, + }); + throw new MarketplaceNotFoundError(marketplaceId); + } + + // 2. Soft-delete the Marketplace row. + await this.marketplaceRepository.deleteById(marketplaceId, userId); + + // 3. Soft-delete the underlying marketplace-typed GitRepo. Packmind never + // touches the Git repo itself — this only marks the row as deleted so it + // stops appearing in marketplace listings and so the coords can be + // re-linked later. + await this.gitPort.deleteGitRepo( + marketplace.gitRepoId, + createUserId(userId), + organization.id, + ); + + // 4. Cancel the BullMQ repeatable reconciliation cron for this + // marketplace so the worker stops polling once the row is gone. The + // underlying removeRepeatable is a no-op if no schedule exists, and + // we swallow errors so a scheduler hiccup never blocks the unlink. + try { + await this.reconciliationJob.cancelRecurring(marketplace.id); + } catch (error) { + this.logger.error( + 'Failed to cancel marketplace reconciliation cron — the schedule may continue running until the row is hard-deleted', + { + marketplaceId: marketplace.id, + organizationId: organization.id, + error: error instanceof Error ? error.message : String(error), + }, + ); + } + + // 5. Emit MarketplaceUnlinkedEvent. + this.eventEmitterService.emit( + new MarketplaceUnlinkedEvent({ + userId: createUserId(userId), + organizationId: organization.id, + marketplaceId: marketplace.id, + gitRepoId: marketplace.gitRepoId, + source: source ?? 'ui', + }), + ); + + return { marketplaceId: marketplace.id }; + } +} diff --git a/packages/deployments/src/application/useCases/unlinkMarketplace/index.ts b/packages/deployments/src/application/useCases/unlinkMarketplace/index.ts new file mode 100644 index 000000000..9460397de --- /dev/null +++ b/packages/deployments/src/application/useCases/unlinkMarketplace/index.ts @@ -0,0 +1 @@ +export { UnlinkMarketplaceUseCase } from './UnlinkMarketplaceUseCase'; diff --git a/packages/deployments/src/application/useCases/validateMarketplaceUrl/ValidateMarketplaceUrlUseCase.spec.ts b/packages/deployments/src/application/useCases/validateMarketplaceUrl/ValidateMarketplaceUrlUseCase.spec.ts new file mode 100644 index 000000000..77afc356f --- /dev/null +++ b/packages/deployments/src/application/useCases/validateMarketplaceUrl/ValidateMarketplaceUrlUseCase.spec.ts @@ -0,0 +1,232 @@ +import { v4 as uuidv4 } from 'uuid'; +import { stubLogger } from '@packmind/test-utils'; +import { + createGitProviderId, + createOrganizationId, + createUserId, + GitProviderWithoutToken, + IAccountsPort, + IGitPort, + MarketplaceDescriptor, + MarketplaceDescriptorNotFoundError, + MarketplaceDescriptorParseError, + MarketplaceUrlNotReachableError, + Organization, + OrganizationId, + UnknownMarketplaceDescriptorError, + User, + UserId, + ValidateMarketplaceUrlCommand, +} from '@packmind/types'; +import { MarketplaceDescriptorParserRegistry } from '../../services/MarketplaceDescriptorParserRegistry'; +import { ValidateMarketplaceUrlUseCase } from './ValidateMarketplaceUrlUseCase'; + +describe('ValidateMarketplaceUrlUseCase', () => { + const organizationId: OrganizationId = createOrganizationId(uuidv4()); + const userId: UserId = createUserId(uuidv4()); + const providerId = createGitProviderId(uuidv4()); + + const baseCommand: ValidateMarketplaceUrlCommand = { + userId, + organizationId, + url: 'https://github.com/acme/plugins', + }; + + const adminUser = { + id: userId, + email: 'admin@example.com', + displayName: 'Admin User', + passwordHash: null, + active: true, + memberships: [{ userId, organizationId, role: 'admin' as const }], + trial: false, + } as unknown as User; + + const organization: Organization = { + id: organizationId, + name: 'Test Org', + slug: 'test-org', + }; + + const tokenlessProvider: GitProviderWithoutToken = { + id: providerId, + source: 'github', + organizationId, + url: 'https://github.com', + hasAuth: false, + authMethod: 'token', + }; + + const descriptor: MarketplaceDescriptor = { + vendor: 'anthropic', + name: 'ACME Plugins', + plugins: [ + { slug: 'p1', name: 'P1' }, + { slug: 'p2', name: 'P2' }, + { slug: 'p3', name: 'P3' }, + ], + raw: {}, + }; + + let mockGitPort: jest.Mocked<IGitPort>; + let mockParserRegistry: jest.Mocked<MarketplaceDescriptorParserRegistry>; + let mockAccountsPort: jest.Mocked<IAccountsPort>; + let useCase: ValidateMarketplaceUrlUseCase; + + beforeEach(() => { + mockGitPort = { + listProviders: jest + .fn() + .mockResolvedValue({ providers: [tokenlessProvider] }), + getFileFromRepo: jest.fn().mockResolvedValue({ + sha: 'sha', + content: JSON.stringify({ vendor: 'anthropic', plugins: [] }), + }), + } as unknown as jest.Mocked<IGitPort>; + + mockParserRegistry = { + parse: jest.fn().mockReturnValue(descriptor), + } as unknown as jest.Mocked<MarketplaceDescriptorParserRegistry>; + + mockAccountsPort = { + getUserById: jest.fn().mockResolvedValue(adminUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + useCase = new ValidateMarketplaceUrlUseCase( + mockGitPort, + mockParserRegistry, + mockAccountsPort, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('verified happy path', () => { + it('returns verified response with repoPath, branch, and plugin count', async () => { + const result = await useCase.execute(baseCommand); + + expect(result).toEqual({ + kind: 'verified', + repoPath: 'acme/plugins', + defaultBranch: 'main', + pluginCount: 3, + }); + }); + + it('parses tree/<branch> segments from the URL', async () => { + const result = await useCase.execute({ + ...baseCommand, + url: 'https://github.com/acme/plugins/tree/develop', + }); + + expect(result.defaultBranch).toBe('develop'); + }); + }); + + describe('not-public — no tokenless provider matches the host', () => { + describe('when no providers exist', () => { + it('throws MarketplaceUrlNotReachableError', async () => { + mockGitPort.listProviders = jest + .fn() + .mockResolvedValue({ providers: [] }); + + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + MarketplaceUrlNotReachableError, + ); + }); + }); + + describe('when only token-bearing providers match', () => { + it('throws MarketplaceUrlNotReachableError', async () => { + mockGitPort.listProviders = jest.fn().mockResolvedValue({ + providers: [{ ...tokenlessProvider, hasAuth: true }], + }); + + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + MarketplaceUrlNotReachableError, + ); + }); + }); + + it('throws MarketplaceUrlNotReachableError on a host mismatch', async () => { + await expect( + useCase.execute({ + ...baseCommand, + url: 'https://gitlab.com/acme/plugins', + }), + ).rejects.toBeInstanceOf(MarketplaceUrlNotReachableError); + }); + }); + + describe('malformed URL', () => { + it('throws MarketplaceUrlNotReachableError on an unparseable URL', async () => { + await expect( + useCase.execute({ + ...baseCommand, + url: 'not a url', + }), + ).rejects.toBeInstanceOf(MarketplaceUrlNotReachableError); + }); + + describe('when the URL has no repo segment', () => { + it('throws MarketplaceUrlNotReachableError', async () => { + await expect( + useCase.execute({ + ...baseCommand, + url: 'https://github.com/acme', + }), + ).rejects.toBeInstanceOf(MarketplaceUrlNotReachableError); + }); + }); + }); + + describe('descriptor not-found', () => { + describe('when marketplace.json is missing', () => { + it('throws MarketplaceDescriptorNotFoundError', async () => { + mockGitPort.getFileFromRepo = jest.fn().mockResolvedValue(null); + + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + MarketplaceDescriptorNotFoundError, + ); + }); + }); + }); + + describe('unreachable fetch', () => { + it('wraps low-level fetch errors as MarketplaceUrlNotReachableError', async () => { + mockGitPort.getFileFromRepo = jest + .fn() + .mockRejectedValue(new Error('network down')); + + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + MarketplaceUrlNotReachableError, + ); + }); + }); + + describe('descriptor parse errors', () => { + it('propagates UnknownMarketplaceDescriptorError', async () => { + mockParserRegistry.parse.mockImplementation(() => { + throw new UnknownMarketplaceDescriptorError(); + }); + + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + UnknownMarketplaceDescriptorError, + ); + }); + + it('propagates MarketplaceDescriptorParseError', async () => { + mockParserRegistry.parse.mockImplementation(() => { + throw new MarketplaceDescriptorParseError('bad', new Error('boom')); + }); + + await expect(useCase.execute(baseCommand)).rejects.toBeInstanceOf( + MarketplaceDescriptorParseError, + ); + }); + }); +}); diff --git a/packages/deployments/src/application/useCases/validateMarketplaceUrl/ValidateMarketplaceUrlUseCase.ts b/packages/deployments/src/application/useCases/validateMarketplaceUrl/ValidateMarketplaceUrlUseCase.ts new file mode 100644 index 000000000..714cf9bcc --- /dev/null +++ b/packages/deployments/src/application/useCases/validateMarketplaceUrl/ValidateMarketplaceUrlUseCase.ts @@ -0,0 +1,197 @@ +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { AbstractAdminUseCase, AdminContext } from '@packmind/node-utils'; +import { + createGitProviderId, + createGitRepoId, + GitRepo, + IAccountsPort, + IGitPort, + IValidateMarketplaceUrlUseCase, + MarketplaceDescriptorNotFoundError, + MarketplaceUrlNotReachableError, + ValidateMarketplaceUrlCommand, + ValidateMarketplaceUrlResponse, +} from '@packmind/types'; +import { fetchMarketplaceDescriptorFile } from '../../services/fetchMarketplaceDescriptorFile'; +import { MarketplaceDescriptorParserRegistry } from '../../services/MarketplaceDescriptorParserRegistry'; + +const origin = 'ValidateMarketplaceUrlUseCase'; + +type ParsedMarketplaceUrl = { + host: string; + owner: string; + repo: string; + branch?: string; +}; + +/** + * Pre-flight validation for the public link path. + * + * Given a publicly reachable Git URL, this use case: + * 1. Parses the URL into `{ host, owner, repo, branch? }`. + * 2. Resolves a tokenless `GitProvider` for the URL host (existing + * `allowTokenlessProvider` flag — providers with `hasAuth=false` are + * considered public-only). + * 3. Fetches `marketplace.json` via `IGitPort.getFileFromRepo`. + * 4. Parses the descriptor via the vendor-agnostic registry. + * + * Error mapping (re-thrown verbatim where possible so callers map them to + * the playground prototype UX categories): + * - URL malformed or no provider reachable → `MarketplaceUrlNotReachableError` + * - Descriptor missing → `MarketplaceDescriptorNotFoundError` + * - Descriptor unknown / malformed → `UnknownMarketplaceDescriptorError` + * or `MarketplaceDescriptorParseError` (raised by the registry) + */ +export class ValidateMarketplaceUrlUseCase + extends AbstractAdminUseCase< + ValidateMarketplaceUrlCommand, + ValidateMarketplaceUrlResponse + > + implements IValidateMarketplaceUrlUseCase +{ + constructor( + private readonly gitPort: IGitPort, + private readonly parserRegistry: MarketplaceDescriptorParserRegistry, + accountsPort: IAccountsPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForAdmins( + command: ValidateMarketplaceUrlCommand & AdminContext, + ): Promise<ValidateMarketplaceUrlResponse> { + const { url, userId, organization } = command; + + if (!url || url.trim().length === 0) { + throw new MarketplaceUrlNotReachableError(url ?? ''); + } + + // 1. Parse the URL into coordinates. + const parsed = this.parseUrl(url); + if (!parsed) { + this.logger.warn('Failed to parse public marketplace URL', { url }); + throw new MarketplaceUrlNotReachableError(url); + } + + // 2. Locate a tokenless provider for the host. We rely on the public + // listProviders surface (returns providers with their `hasAuth` + // flag); any provider with `hasAuth=false` whose URL host matches is + // eligible for the public path. + const providersResponse = await this.gitPort.listProviders({ + userId, + organizationId: organization.id, + }); + const tokenlessProvider = providersResponse.providers.find((provider) => { + if (provider.hasAuth) { + return false; + } + if (!provider.url) { + return false; + } + const providerHost = this.safeHost(provider.url); + return providerHost === parsed.host; + }); + if (!tokenlessProvider) { + this.logger.warn( + 'No tokenless provider matches the requested marketplace URL host', + { host: parsed.host, url }, + ); + throw new MarketplaceUrlNotReachableError(url); + } + + // 3. Fetch the descriptor through the tokenless provider. Build a + // provisional GitRepo — nothing is persisted at this stage. + const provisionalGitRepo: GitRepo = { + id: createGitRepoId(uuidv4()), + owner: parsed.owner, + repo: parsed.repo, + branch: parsed.branch ?? 'main', + providerId: createGitProviderId(tokenlessProvider.id), + type: 'marketplace', + }; + + let file: Awaited<ReturnType<typeof fetchMarketplaceDescriptorFile>>; + try { + file = await fetchMarketplaceDescriptorFile( + this.gitPort, + provisionalGitRepo, + parsed.branch, + ); + } catch (error) { + this.logger.warn( + 'Failed to fetch marketplace descriptor for public URL', + { + url, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw new MarketplaceUrlNotReachableError(url); + } + + if (!file) { + throw new MarketplaceDescriptorNotFoundError(parsed.owner, parsed.repo); + } + + // 4. Parse via the registry. Descriptor errors propagate as-is so the + // API layer can map them to `not-found | malformed | unknown-vendor`. + const descriptor = this.parserRegistry.parse(file.content); + + return { + kind: 'verified', + repoPath: `${parsed.owner}/${parsed.repo}`, + defaultBranch: parsed.branch ?? 'main', + pluginCount: descriptor.plugins.length, + }; + } + + /** + * Parse a Git web URL into `{ host, owner, repo, branch? }`. + * + * Accepts conventional `https://<host>/<owner>/<repo>` shapes, optionally + * with a `tree/<branch>` segment. Returns `null` on malformed input. + */ + private parseUrl(rawUrl: string): ParsedMarketplaceUrl | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null; + } + const segments = url.pathname + .replace(/\.git$/, '') + .split('/') + .filter((segment) => segment.length > 0); + if (segments.length < 2) { + return null; + } + const [owner, repo, ...rest] = segments; + let branch: string | undefined; + const treeIndex = rest.indexOf('tree'); + if (treeIndex >= 0 && rest[treeIndex + 1]) { + branch = rest[treeIndex + 1]; + } + return { + host: url.host, + owner, + repo, + branch, + }; + } + + /** + * Extract the URL host without throwing — returns `null` on malformed + * provider URLs so the caller can skip the provider rather than crash. + */ + private safeHost(rawUrl: string): string | null { + try { + return new URL(rawUrl).host; + } catch { + return null; + } + } +} diff --git a/packages/deployments/src/application/useCases/validateMarketplaceUrl/index.ts b/packages/deployments/src/application/useCases/validateMarketplaceUrl/index.ts new file mode 100644 index 000000000..5e65482c5 --- /dev/null +++ b/packages/deployments/src/application/useCases/validateMarketplaceUrl/index.ts @@ -0,0 +1 @@ +export { ValidateMarketplaceUrlUseCase } from './ValidateMarketplaceUrlUseCase'; diff --git a/packages/deployments/src/domain/entities/Marketplace.ts b/packages/deployments/src/domain/entities/Marketplace.ts new file mode 100644 index 000000000..6bea8f69e --- /dev/null +++ b/packages/deployments/src/domain/entities/Marketplace.ts @@ -0,0 +1,19 @@ +/** + * Marketplace entity re-exports for the deployments domain. + * + * The canonical `Marketplace` type lives in `@packmind/types` so it can be + * shared with the API, frontend, and ports. This file re-exports the type, + * the branded `MarketplaceId`, and the `createMarketplaceId` factory so + * domain code in `@packmind/deployments` can import them from the standard + * `domain/entities` location alongside other deployments entities. + */ +export { + Marketplace, + MarketplaceId, + createMarketplaceId, + MarketplaceVendor, + MarketplaceState, + MarketplaceDescriptor, + PluginRef, + MARKETPLACE_DESCRIPTOR_FILENAME, +} from '@packmind/types'; diff --git a/packages/deployments/src/domain/entities/index.ts b/packages/deployments/src/domain/entities/index.ts index 87319255f..395ff39d5 100644 --- a/packages/deployments/src/domain/entities/index.ts +++ b/packages/deployments/src/domain/entities/index.ts @@ -1,2 +1,13 @@ // RecipesDeployment is defined in @packmind/types // Import it directly from there, not from this package + +export { + Marketplace, + MarketplaceId, + createMarketplaceId, + MarketplaceVendor, + MarketplaceState, + MarketplaceDescriptor, + PluginRef, + MARKETPLACE_DESCRIPTOR_FILENAME, +} from './Marketplace'; diff --git a/packages/deployments/src/domain/errors/GitProviderTokenInvalidError.ts b/packages/deployments/src/domain/errors/GitProviderTokenInvalidError.ts new file mode 100644 index 000000000..a20384e4b --- /dev/null +++ b/packages/deployments/src/domain/errors/GitProviderTokenInvalidError.ts @@ -0,0 +1,3 @@ +// Re-export from @packmind/types so domain code can import marketplace errors +// from either the local barrel or the shared types package interchangeably. +export { GitProviderTokenInvalidError } from '@packmind/types'; diff --git a/packages/deployments/src/domain/errors/MarketplaceAlreadyLinkedError.ts b/packages/deployments/src/domain/errors/MarketplaceAlreadyLinkedError.ts new file mode 100644 index 000000000..d49e9e5fb --- /dev/null +++ b/packages/deployments/src/domain/errors/MarketplaceAlreadyLinkedError.ts @@ -0,0 +1,3 @@ +// Re-export from @packmind/types so domain code can import marketplace errors +// from either the local barrel or the shared types package interchangeably. +export { MarketplaceAlreadyLinkedError } from '@packmind/types'; diff --git a/packages/deployments/src/domain/errors/MarketplaceDescriptorBadFormatError.ts b/packages/deployments/src/domain/errors/MarketplaceDescriptorBadFormatError.ts new file mode 100644 index 000000000..48563f7a7 --- /dev/null +++ b/packages/deployments/src/domain/errors/MarketplaceDescriptorBadFormatError.ts @@ -0,0 +1,3 @@ +// Re-export from @packmind/types so domain code can import marketplace errors +// from either the local barrel or the shared types package interchangeably. +export { MarketplaceDescriptorBadFormatError } from '@packmind/types'; diff --git a/packages/deployments/src/domain/errors/MarketplaceDescriptorNotFoundError.ts b/packages/deployments/src/domain/errors/MarketplaceDescriptorNotFoundError.ts new file mode 100644 index 000000000..9aa62e01e --- /dev/null +++ b/packages/deployments/src/domain/errors/MarketplaceDescriptorNotFoundError.ts @@ -0,0 +1,3 @@ +// Re-export from @packmind/types so domain code can import marketplace errors +// from either the local barrel or the shared types package interchangeably. +export { MarketplaceDescriptorNotFoundError } from '@packmind/types'; diff --git a/packages/deployments/src/domain/errors/MarketplaceDescriptorParseError.ts b/packages/deployments/src/domain/errors/MarketplaceDescriptorParseError.ts new file mode 100644 index 000000000..41ef6d007 --- /dev/null +++ b/packages/deployments/src/domain/errors/MarketplaceDescriptorParseError.ts @@ -0,0 +1,3 @@ +// Re-export from @packmind/types so domain code can import marketplace errors +// from either the local barrel or the shared types package interchangeably. +export { MarketplaceDescriptorParseError } from '@packmind/types'; diff --git a/packages/deployments/src/domain/errors/MarketplaceNotFoundError.ts b/packages/deployments/src/domain/errors/MarketplaceNotFoundError.ts new file mode 100644 index 000000000..498ae2e32 --- /dev/null +++ b/packages/deployments/src/domain/errors/MarketplaceNotFoundError.ts @@ -0,0 +1,3 @@ +// Re-export from @packmind/types so domain code can import marketplace errors +// from either the local barrel or the shared types package interchangeably. +export { MarketplaceNotFoundError } from '@packmind/types'; diff --git a/packages/deployments/src/domain/errors/MarketplacePluginNameConflictError.ts b/packages/deployments/src/domain/errors/MarketplacePluginNameConflictError.ts new file mode 100644 index 000000000..700ae000a --- /dev/null +++ b/packages/deployments/src/domain/errors/MarketplacePluginNameConflictError.ts @@ -0,0 +1,3 @@ +// Re-export from @packmind/types so domain code can import marketplace errors +// from either the local barrel or the shared types package interchangeably. +export { MarketplacePluginNameConflictError } from '@packmind/types'; diff --git a/packages/deployments/src/domain/errors/MarketplaceUrlNotReachableError.ts b/packages/deployments/src/domain/errors/MarketplaceUrlNotReachableError.ts new file mode 100644 index 000000000..7e925c53b --- /dev/null +++ b/packages/deployments/src/domain/errors/MarketplaceUrlNotReachableError.ts @@ -0,0 +1,3 @@ +// Re-export from @packmind/types so domain code can import marketplace errors +// from either the local barrel or the shared types package interchangeably. +export { MarketplaceUrlNotReachableError } from '@packmind/types'; diff --git a/packages/deployments/src/domain/errors/PluginDistributionInvalidStateError.ts b/packages/deployments/src/domain/errors/PluginDistributionInvalidStateError.ts new file mode 100644 index 000000000..782f42152 --- /dev/null +++ b/packages/deployments/src/domain/errors/PluginDistributionInvalidStateError.ts @@ -0,0 +1,3 @@ +// Re-export from @packmind/types so domain code can import marketplace errors +// from either the local barrel or the shared types package interchangeably. +export { PluginDistributionInvalidStateError } from '@packmind/types'; diff --git a/packages/deployments/src/domain/errors/PluginDistributionNotFoundError.ts b/packages/deployments/src/domain/errors/PluginDistributionNotFoundError.ts new file mode 100644 index 000000000..d297e4109 --- /dev/null +++ b/packages/deployments/src/domain/errors/PluginDistributionNotFoundError.ts @@ -0,0 +1,3 @@ +// Re-export from @packmind/types so domain code can import marketplace errors +// from either the local barrel or the shared types package interchangeably. +export { PluginDistributionNotFoundError } from '@packmind/types'; diff --git a/packages/deployments/src/domain/errors/UnknownMarketplaceDescriptorError.ts b/packages/deployments/src/domain/errors/UnknownMarketplaceDescriptorError.ts new file mode 100644 index 000000000..8dfaf792b --- /dev/null +++ b/packages/deployments/src/domain/errors/UnknownMarketplaceDescriptorError.ts @@ -0,0 +1,3 @@ +// Re-export from @packmind/types so domain code can import marketplace errors +// from either the local barrel or the shared types package interchangeably. +export { UnknownMarketplaceDescriptorError } from '@packmind/types'; diff --git a/packages/deployments/src/domain/errors/index.ts b/packages/deployments/src/domain/errors/index.ts index 08033d7ad..f2fb4caa8 100644 --- a/packages/deployments/src/domain/errors/index.ts +++ b/packages/deployments/src/domain/errors/index.ts @@ -1,3 +1,14 @@ export { PackageNotFoundError } from './PackageNotFoundError'; export { PackagesNotFoundError } from './PackagesNotFoundError'; export { TargetNotFoundError } from './TargetNotFoundError'; +export { MarketplaceAlreadyLinkedError } from './MarketplaceAlreadyLinkedError'; +export { MarketplaceDescriptorBadFormatError } from './MarketplaceDescriptorBadFormatError'; +export { MarketplaceDescriptorNotFoundError } from './MarketplaceDescriptorNotFoundError'; +export { UnknownMarketplaceDescriptorError } from './UnknownMarketplaceDescriptorError'; +export { MarketplaceDescriptorParseError } from './MarketplaceDescriptorParseError'; +export { MarketplaceNotFoundError } from './MarketplaceNotFoundError'; +export { MarketplacePluginNameConflictError } from './MarketplacePluginNameConflictError'; +export { MarketplaceUrlNotReachableError } from './MarketplaceUrlNotReachableError'; +export { GitProviderTokenInvalidError } from './GitProviderTokenInvalidError'; +export { PluginDistributionNotFoundError } from './PluginDistributionNotFoundError'; +export { PluginDistributionInvalidStateError } from './PluginDistributionInvalidStateError'; diff --git a/packages/deployments/src/domain/jobs/IDeploymentsDelayedJobs.ts b/packages/deployments/src/domain/jobs/IDeploymentsDelayedJobs.ts index acd798ac7..6258842d1 100644 --- a/packages/deployments/src/domain/jobs/IDeploymentsDelayedJobs.ts +++ b/packages/deployments/src/domain/jobs/IDeploymentsDelayedJobs.ts @@ -1,5 +1,11 @@ +import { MarketplaceReconciliationDelayedJob } from '../../application/jobs/MarketplaceReconciliationDelayedJob'; import { PublishArtifactsDelayedJob } from '../../application/jobs/PublishArtifactsDelayedJob'; +import { PublishPluginToMarketplaceDelayedJob } from '../../application/jobs/PublishPluginToMarketplaceDelayedJob'; +import { RemovePluginFromMarketplaceDelayedJob } from '../../application/jobs/RemovePluginFromMarketplaceDelayedJob'; export interface IDeploymentsDelayedJobs { publishArtifactsDelayedJob: PublishArtifactsDelayedJob; + marketplaceReconciliationDelayedJob: MarketplaceReconciliationDelayedJob; + publishPluginToMarketplaceDelayedJob: PublishPluginToMarketplaceDelayedJob; + removePluginFromMarketplaceDelayedJob: RemovePluginFromMarketplaceDelayedJob; } diff --git a/packages/deployments/src/domain/repositories/IMarketplaceDistributionRepository.ts b/packages/deployments/src/domain/repositories/IMarketplaceDistributionRepository.ts new file mode 100644 index 000000000..e771fd783 --- /dev/null +++ b/packages/deployments/src/domain/repositories/IMarketplaceDistributionRepository.ts @@ -0,0 +1,148 @@ +import { + DistributionStatus, + IRepository, + MarketplaceDistribution, + MarketplaceDistributionId, + MarketplaceId, + PackageId, + PublishFailureReason, + VersionFingerprint, +} from '@packmind/types'; + +/** + * Patch payload accepted by `updateStatus` — used by the + * `PublishPluginToMarketplaceDelayedJob` to transition a marketplace + * distribution from `in_progress` to its terminal state and capture the + * resulting Git artifacts. + * + * All side-channel fields are optional so the worker can record only + * what's relevant (e.g. `failureReason` + `error` on failure; + * `contentHash` + `prUrl` + `gitCommit` on success/no-op). + */ +export type MarketplaceDistributionStatusUpdate = { + status: DistributionStatus; + prUrl?: string; + gitCommit?: string; + error?: string; + failureReason?: PublishFailureReason; + contentHash?: string; + versionFingerprint?: VersionFingerprint; + /** Stamped by reconciliation on the `pending_merge → success` promotion. */ + publishConfirmedAt?: Date; +}; + +/** + * Persistence contract for the `MarketplaceDistribution` entity. + * + * Soft-delete-aware: concrete implementations extend + * `AbstractRepository<MarketplaceDistribution>` so they inherit the + * standard CRUD methods (`add`, `findById`, `deleteById`, etc.) from + * `IRepository<MarketplaceDistribution>`. + */ +export interface IMarketplaceDistributionRepository extends IRepository<MarketplaceDistribution> { + /** + * Lookup every (non-soft-deleted) distribution targeting a given + * marketplace, most recent first. Used by audit views and the + * reconciliation/idempotency probes. + */ + findByMarketplaceId( + marketplaceId: MarketplaceId, + ): Promise<MarketplaceDistribution[]>; + + /** + * Lookup every (non-soft-deleted) distribution for a given package + * across all marketplaces, most recent first. Used by the package + * detail view. + */ + findByPackageId(packageId: PackageId): Promise<MarketplaceDistribution[]>; + + /** + * Return the most recent (non-soft-deleted) distribution for a given + * (package, marketplace) pair — or `null` when none exists. + * + * Used by the publish use case to compute `isFirstPublishForPackage` + * and by the BullMQ job to short-circuit no-ops against the previous + * `success` row's `contentHash`. + */ + findLatestByPackageAndMarketplace( + packageId: PackageId, + marketplaceId: MarketplaceId, + ): Promise<MarketplaceDistribution | null>; + + /** + * Return the most recent (non-soft-deleted) active (`success` or + * `pending_merge`) distribution for a given `(package, marketplace)` pair — + * or `null` when none exists. + * + * Used by `MarkPluginForRemovalUseCase` when the caller targets a package + * by id (rather than passing the distribution id directly): a publish still + * awaiting its sync-PR merge must be removable too. + */ + findLatestActiveByPackageAndMarketplace( + packageId: PackageId, + marketplaceId: MarketplaceId, + ): Promise<MarketplaceDistribution | null>; + + /** + * Return every (non-soft-deleted) `success` or `pending_merge` distribution + * for a given package across all marketplaces. Used by the package-delete + * cascade listener to flip every active distribution to `to_be_removed` — + * a pending publish must be cascaded too, or deleting its package would + * strand the plugin on the rolling sync branch and its open PR. + */ + findActiveByPackageId( + packageId: PackageId, + ): Promise<MarketplaceDistribution[]>; + + /** + * Return every (non-soft-deleted) `pending_merge`-state distribution for a + * given marketplace. Used by the reconciliation job to drive the + * `pending_merge → success` promotion once the rolling sync PR merges + * (matched via the default-branch `packmind-lock.json` content hash). + */ + findPendingMergesByMarketplaceId( + marketplaceId: MarketplaceId, + ): Promise<MarketplaceDistribution[]>; + + /** + * Return every (non-soft-deleted) `to_be_removed`-state distribution for a + * given marketplace. Used by the reconciliation job to drive the + * `to_be_removed → removed` terminal transition and to suppress drift + * detection for pending removals (AC10). + */ + findPendingRemovalsByMarketplaceId( + marketplaceId: MarketplaceId, + ): Promise<MarketplaceDistribution[]>; + + /** + * Return every (non-soft-deleted) `success`-state distribution for a given + * marketplace. Used by the reconciliation job to detect drift — a success + * slug that vanished from the descriptor. + */ + findSuccessfulByMarketplaceId( + marketplaceId: MarketplaceId, + ): Promise<MarketplaceDistribution[]>; + + /** + * Apply a partial state update — typically called by the BullMQ job + * to transition `in_progress` to `success` / `failure` / `no_changes` + * and record `prUrl`, `gitCommit`, `error`, `failureReason`, and + * `contentHash` as applicable. Bumps `updated_at`. + */ + updateStatus( + id: MarketplaceDistributionId, + patch: MarketplaceDistributionStatusUpdate, + ): Promise<void>; + + /** + * Set or clear the `removalRequestedAt` marker independently of `status`. + * + * `MarkPluginForRemovalUseCase` sets it to "now" the moment a removal is + * requested (the `status` stays `success` until the sync commit lands). + * Bumps `updated_at`. + */ + updateRemovalRequestedAt( + id: MarketplaceDistributionId, + removalRequestedAt: Date | null, + ): Promise<void>; +} diff --git a/packages/deployments/src/domain/repositories/IMarketplaceRepository.ts b/packages/deployments/src/domain/repositories/IMarketplaceRepository.ts new file mode 100644 index 000000000..a783b1814 --- /dev/null +++ b/packages/deployments/src/domain/repositories/IMarketplaceRepository.ts @@ -0,0 +1,81 @@ +import { + GitRepoId, + IRepository, + Marketplace, + MarketplaceDescriptor, + MarketplaceErrorKind, + MarketplaceId, + MarketplaceState, + OrganizationId, +} from '@packmind/types'; + +/** + * Patch payload accepted by `updateState` — used by the reconciliation job to + * refresh a marketplace's health state and (on drift) its descriptor cache. + */ +export type MarketplaceStateUpdate = { + state: MarketplaceState; + lastValidatedAt: Date; + descriptor?: MarketplaceDescriptor; + pluginCount?: number; + // Each field is set only when present; pass `null` explicitly to clear. + errorKind?: MarketplaceErrorKind | null; + errorDetail?: string | null; + pendingPrUrl?: string | null; + outdatedPluginSlugs?: string[] | null; +}; + +/** + * Persistence contract for the `Marketplace` entity. + * + * Soft-delete-aware: default finders exclude soft-deleted rows. Concrete + * implementations extend `AbstractRepository<Marketplace>` so they inherit + * the standard CRUD methods (`add`, `findById`, `deleteById`, etc.) from + * `IRepository<Marketplace>`. + */ +export interface IMarketplaceRepository extends IRepository<Marketplace> { + /** + * List every (non-soft-deleted) marketplace linked to an organization. + * Used by `ListMarketplacesUseCase`. + */ + findByOrganizationId(organizationId: OrganizationId): Promise<Marketplace[]>; + + /** + * Find the active marketplace bound to a specific git repo for an + * organization. Used by the link duplicate-check. + */ + findByOrganizationAndGitRepo( + organizationId: OrganizationId, + gitRepoId: GitRepoId, + ): Promise<Marketplace | null>; + + /** + * Lookup a marketplace by id, scoped to an organization. Used by + * `UnlinkMarketplaceUseCase` to enforce org boundaries. + */ + findByOrganizationAndId( + organizationId: OrganizationId, + id: MarketplaceId, + ): Promise<Marketplace | null>; + + /** + * Return every non-soft-deleted marketplace, regardless of organization. + * Used by the BullMQ-driven reconciliation sweep. + */ + findAllForReconciliation(): Promise<Marketplace[]>; + + /** + * Apply a partial state update — at least `state` + `lastValidatedAt`, + * optionally `descriptor` + `pluginCount` on drift. Bumps `updated_at`. + */ + updateState(id: MarketplaceId, patch: MarketplaceStateUpdate): Promise<void>; + + /** + * Find a marketplace by its tracking token. + * + * Used by the public heartbeat endpoint to resolve the marketplace (and + * therefore its org) without any user credentials. + * Returns `null` when no active (non-soft-deleted) marketplace has that token. + */ + findByTrackingToken(token: string): Promise<Marketplace | null>; +} diff --git a/packages/deployments/src/domain/repositories/IPluginInstallationRepository.ts b/packages/deployments/src/domain/repositories/IPluginInstallationRepository.ts new file mode 100644 index 000000000..2efff8fe8 --- /dev/null +++ b/packages/deployments/src/domain/repositories/IPluginInstallationRepository.ts @@ -0,0 +1,83 @@ +import { + MarketplaceId, + PluginInstallation, + PluginInstallationId, +} from '@packmind/types'; + +/** + * Input shape for upserting a heartbeat row. + * + * The repository computes `identityKey` and `repoKey` from the provided fields, + * so callers do not need to pre-compute them. + */ +export type UpsertHeartbeatInput = { + id: PluginInstallationId; + organizationId: string; + marketplaceId: MarketplaceId; + pluginSlug: string; + packageId: string | null; + /** Version reported as installed; `null` when the client did not resolve one. */ + installedVersion: string | null; + scope: PluginInstallation['scope']; + userId: string | null; + anonymousIdHash: string | null; + anonymousEmailMasked: string | null; + repoRemoteUrl: string | null; + now: Date; +}; + +/** + * Result returned by `upsertHeartbeat`. + * + * `created = true` when a new row was inserted (first-seen signal). + * `created = false` when an existing row's `updatedAt` (last-seen) was bumped. + * The returned `installation` is the current state after the upsert. + */ +export type UpsertHeartbeatResult = { + created: boolean; + installation: PluginInstallation; +}; + +/** + * Persistence contract for the `PluginInstallation` entity. + * + * Designed around the heartbeat semantics described in spec §7.1: + * - Upsert collapses repeated heartbeats per unique key + * - Anonymous→attributed upgrade preserves `createdAt` (first-seen) + * - `listByMarketplace` returns all rows for the org member drill-down + */ +export interface IPluginInstallationRepository { + /** + * Insert or update a heartbeat row. + * + * ### Absent-field key rule + * `identityKey` = `userId ?? anonymousIdHash ?? ''` + * `repoKey` = `''` when `scope === 'user'`, else the normalized `owner/repo` + * slug of `repoRemoteUrl` ?? the raw `repoRemoteUrl` ?? `''` + * + * ### Anonymous → attributed upgrade (§7.1) + * When `userId` is provided and an anonymous row exists with + * `identityKey = anonymousIdHash` for the same (marketplace, slug, scope, repoKey), + * that row is upgraded to use the `userId` as `identityKey`. + * + * ### Merge edge cases (§7.1) + * 1. If an **attributed row already exists** for the `userId` key AND an anonymous + * row exists too (credentials-expired scenario), the two rows are merged: the + * attributed row keeps the earliest `createdAt`; the anonymous row is deleted. + * 2. An **anonymous heartbeat** whose `anonymousIdHash` matches an attributed + * row's stored `anonymousIdHash` bumps that attributed row's `updatedAt` + * rather than inserting a duplicate. + * + * Returns `created = true` only on actual INSERT (not on update/upgrade/merge). + */ + upsertHeartbeat(input: UpsertHeartbeatInput): Promise<UpsertHeartbeatResult>; + + /** + * Return all non-soft-deleted installation rows for a marketplace. + * Used by `ListMarketplacePluginInstallsUseCase` to serve the org-member + * drill-down endpoint. + */ + listByMarketplace( + marketplaceId: MarketplaceId, + ): Promise<PluginInstallation[]>; +} diff --git a/packages/deployments/src/domain/repositories/index.ts b/packages/deployments/src/domain/repositories/index.ts index 1a2454c15..41a7eb662 100644 --- a/packages/deployments/src/domain/repositories/index.ts +++ b/packages/deployments/src/domain/repositories/index.ts @@ -4,3 +4,12 @@ export { IPackageRepository } from './IPackageRepository'; export { IDeploymentsRepositories } from './IDeploymentsRepositories'; export { IDistributionRepository } from './IDistributionRepository'; export { IDistributedPackageRepository } from './IDistributedPackageRepository'; +export { + IMarketplaceRepository, + MarketplaceStateUpdate, +} from './IMarketplaceRepository'; +export { + IPluginInstallationRepository, + UpsertHeartbeatInput, + UpsertHeartbeatResult, +} from './IPluginInstallationRepository'; diff --git a/packages/deployments/src/index.ts b/packages/deployments/src/index.ts index d69f83699..c221ab303 100644 --- a/packages/deployments/src/index.ts +++ b/packages/deployments/src/index.ts @@ -11,10 +11,14 @@ export * from './DeploymentsHexa'; // Re-export schemas export * from './infra/schemas'; +// Re-export application services +export { parsePackageSlug } from './application/services/packageSlugHelpers'; +export { MarketplaceDescriptorParserRegistry } from './application/services/MarketplaceDescriptorParserRegistry'; +export { AnthropicMarketplaceDescriptorParser } from './application/services/parsers/AnthropicMarketplaceDescriptorParser'; + // Re-export domain errors +export * from './domain/errors'; export * from './domain/errors/NoPackageSlugsProvidedError'; -export * from './domain/errors/PackagesNotFoundError'; -export * from './domain/errors/TargetNotFoundError'; /** * Package version diff --git a/packages/deployments/src/infra/jobs/MarketplaceReconciliationJobFactory.ts b/packages/deployments/src/infra/jobs/MarketplaceReconciliationJobFactory.ts new file mode 100644 index 000000000..e4fe2bfd7 --- /dev/null +++ b/packages/deployments/src/infra/jobs/MarketplaceReconciliationJobFactory.ts @@ -0,0 +1,79 @@ +import { IJobFactory, IJobQueue, queueFactory } from '@packmind/node-utils'; +import { PackmindLogger } from '@packmind/logger'; +import { IGitPort, MarketplaceReconciliationJobInput } from '@packmind/types'; +import { GitRepoService } from '@packmind/git'; +import { MarketplaceReconciliationDelayedJob } from '../../application/jobs/MarketplaceReconciliationDelayedJob'; +import { IMarketplaceDistributionRepository } from '../../domain/repositories/IMarketplaceDistributionRepository'; +import { IMarketplaceRepository } from '../../domain/repositories/IMarketplaceRepository'; +import { MarketplaceDescriptorParserRegistry } from '../../application/services/MarketplaceDescriptorParserRegistry'; +import { PackageService } from '../../application/services/PackageService'; +import { PackageVersionFingerprintService } from '../../application/services/PackageVersionFingerprintService'; + +const origin = 'MarketplaceReconciliationJobFactory'; + +/** + * BullMQ factory for the marketplace reconciliation queue. + * + * Mirrors `PublishArtifactsJobFactory`: hands `JobsService` an + * `IJobQueue<MarketplaceReconciliationJobInput>` that delegates `addJob`/ + * `initialize`/`destroy` to the underlying `MarketplaceReconciliationDelayedJob`. + */ +export class MarketplaceReconciliationJobFactory implements IJobFactory<MarketplaceReconciliationJobInput> { + private _delayedJob: MarketplaceReconciliationDelayedJob | null = null; + + constructor( + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly marketplaceDistributionRepository: IMarketplaceDistributionRepository, + private readonly gitRepoService: GitRepoService, + private readonly gitPort: IGitPort, + private readonly parserRegistry: MarketplaceDescriptorParserRegistry, + private readonly packageService: PackageService, + private readonly versionFingerprintService: PackageVersionFingerprintService, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async createQueue(): Promise<IJobQueue<MarketplaceReconciliationJobInput>> { + this.logger.info('Creating MarketplaceReconciliation job queue'); + + this._delayedJob = new MarketplaceReconciliationDelayedJob( + (listeners) => queueFactory(this.getQueueName(), listeners), + this.marketplaceRepository, + this.marketplaceDistributionRepository, + this.gitRepoService, + this.gitPort, + this.parserRegistry, + this.packageService, + this.versionFingerprintService, + ); + + return { + addJob: async ( + input: MarketplaceReconciliationJobInput, + ): Promise<string> => { + if (!this._delayedJob) { + throw new Error('Queue not initialized. Call initialize() first.'); + } + const jobId = await this._delayedJob.addJob(input); + return jobId; + }, + initialize: async (): Promise<void> => { + if (!this._delayedJob) { + throw new Error('DelayedJob not created. Call createQueue() first.'); + } + await this._delayedJob.initialize(); + this.logger.info('MarketplaceReconciliation queue initialized'); + }, + destroy: async (): Promise<void> => { + this.logger.info('MarketplaceReconciliation queue destroyed'); + }, + }; + } + + get delayedJob(): MarketplaceReconciliationDelayedJob | null { + return this._delayedJob; + } + + getQueueName(): string { + return 'marketplace-reconciliation'; + } +} diff --git a/packages/deployments/src/infra/jobs/PublishPluginToMarketplaceJobFactory.ts b/packages/deployments/src/infra/jobs/PublishPluginToMarketplaceJobFactory.ts new file mode 100644 index 000000000..a6e0f8611 --- /dev/null +++ b/packages/deployments/src/infra/jobs/PublishPluginToMarketplaceJobFactory.ts @@ -0,0 +1,95 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + IJobFactory, + IJobQueue, + PackmindEventEmitterService, + queueFactory, +} from '@packmind/node-utils'; +import { GitRepoService } from '@packmind/git'; +import { + IGitPort, + PublishPluginToMarketplaceJobInput, + PUBLISH_PLUGIN_TO_MARKETPLACE_QUEUE, +} from '@packmind/types'; +import { + PluginRenderer, + PublishPluginToMarketplaceDelayedJob, +} from '../../application/jobs/PublishPluginToMarketplaceDelayedJob'; +import { IMarketplaceDistributionRepository } from '../../domain/repositories/IMarketplaceDistributionRepository'; +import { IMarketplaceRepository } from '../../domain/repositories/IMarketplaceRepository'; +import { MarketplaceDescriptorParserRegistry } from '../../application/services/MarketplaceDescriptorParserRegistry'; +import { PackageService } from '../../application/services/PackageService'; +import { PackageVersionFingerprintService } from '../../application/services/PackageVersionFingerprintService'; + +const origin = 'PublishPluginToMarketplaceJobFactory'; + +/** + * BullMQ factory for the marketplace plugin publish queue. + * + * Mirrors `MarketplaceReconciliationJobFactory`. The queue concurrency is + * pinned to 1 worker upstream (single-worker concurrency for the queue) so + * Git pushes onto the rolling `packmind/sync` branch stay serialized across + * concurrent publish attempts. + */ +export class PublishPluginToMarketplaceJobFactory implements IJobFactory<PublishPluginToMarketplaceJobInput> { + private _delayedJob: PublishPluginToMarketplaceDelayedJob | null = null; + + constructor( + private readonly marketplaceDistributionRepository: IMarketplaceDistributionRepository, + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly packageService: PackageService, + private readonly gitRepoService: GitRepoService, + private readonly gitPort: IGitPort, + private readonly parserRegistry: MarketplaceDescriptorParserRegistry, + private readonly renderer: PluginRenderer, + private readonly versionFingerprintService: PackageVersionFingerprintService, + private readonly eventEmitterService: PackmindEventEmitterService, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async createQueue(): Promise<IJobQueue<PublishPluginToMarketplaceJobInput>> { + this.logger.info('Creating PublishPluginToMarketplace job queue'); + + this._delayedJob = new PublishPluginToMarketplaceDelayedJob( + (listeners) => queueFactory(this.getQueueName(), listeners), + this.marketplaceDistributionRepository, + this.marketplaceRepository, + this.packageService, + this.gitRepoService, + this.gitPort, + this.parserRegistry, + this.renderer, + this.versionFingerprintService, + this.eventEmitterService, + ); + + return { + addJob: async ( + input: PublishPluginToMarketplaceJobInput, + ): Promise<string> => { + if (!this._delayedJob) { + throw new Error('Queue not initialized. Call initialize() first.'); + } + return this._delayedJob.addJob(input); + }, + initialize: async (): Promise<void> => { + if (!this._delayedJob) { + throw new Error('DelayedJob not created. Call createQueue() first.'); + } + await this._delayedJob.initialize(); + this.logger.info('PublishPluginToMarketplace queue initialized'); + }, + destroy: async (): Promise<void> => { + this.logger.info('PublishPluginToMarketplace queue destroyed'); + }, + }; + } + + get delayedJob(): PublishPluginToMarketplaceDelayedJob | null { + return this._delayedJob; + } + + getQueueName(): string { + return PUBLISH_PLUGIN_TO_MARKETPLACE_QUEUE; + } +} diff --git a/packages/deployments/src/infra/jobs/RemovePluginFromMarketplaceJobFactory.ts b/packages/deployments/src/infra/jobs/RemovePluginFromMarketplaceJobFactory.ts new file mode 100644 index 000000000..555539cd2 --- /dev/null +++ b/packages/deployments/src/infra/jobs/RemovePluginFromMarketplaceJobFactory.ts @@ -0,0 +1,77 @@ +import { PackmindLogger } from '@packmind/logger'; +import { IJobFactory, IJobQueue, queueFactory } from '@packmind/node-utils'; +import { GitRepoService } from '@packmind/git'; +import { + IGitPort, + RemovePluginFromMarketplaceJobInput, + REMOVE_PLUGIN_FROM_MARKETPLACE_QUEUE, +} from '@packmind/types'; +import { RemovePluginFromMarketplaceDelayedJob } from '../../application/jobs/RemovePluginFromMarketplaceDelayedJob'; +import { IMarketplaceDistributionRepository } from '../../domain/repositories/IMarketplaceDistributionRepository'; +import { IMarketplaceRepository } from '../../domain/repositories/IMarketplaceRepository'; +import { MarketplaceDescriptorParserRegistry } from '../../application/services/MarketplaceDescriptorParserRegistry'; + +const origin = 'RemovePluginFromMarketplaceJobFactory'; + +/** + * BullMQ factory for the marketplace plugin removal queue. + * + * Mirrors `PublishPluginToMarketplaceJobFactory`. Queue concurrency is pinned + * to a single worker upstream so deletion commits on the rolling + * `packmind/sync` branch stay serialized against concurrent publish/removal + * attempts. + */ +export class RemovePluginFromMarketplaceJobFactory implements IJobFactory<RemovePluginFromMarketplaceJobInput> { + private _delayedJob: RemovePluginFromMarketplaceDelayedJob | null = null; + + constructor( + private readonly marketplaceDistributionRepository: IMarketplaceDistributionRepository, + private readonly marketplaceRepository: IMarketplaceRepository, + private readonly gitRepoService: GitRepoService, + private readonly gitPort: IGitPort, + private readonly parserRegistry: MarketplaceDescriptorParserRegistry, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async createQueue(): Promise<IJobQueue<RemovePluginFromMarketplaceJobInput>> { + this.logger.info('Creating RemovePluginFromMarketplace job queue'); + + this._delayedJob = new RemovePluginFromMarketplaceDelayedJob( + (listeners) => queueFactory(this.getQueueName(), listeners), + this.marketplaceDistributionRepository, + this.marketplaceRepository, + this.gitRepoService, + this.gitPort, + this.parserRegistry, + ); + + return { + addJob: async ( + input: RemovePluginFromMarketplaceJobInput, + ): Promise<string> => { + if (!this._delayedJob) { + throw new Error('Queue not initialized. Call initialize() first.'); + } + return this._delayedJob.addJob(input); + }, + initialize: async (): Promise<void> => { + if (!this._delayedJob) { + throw new Error('DelayedJob not created. Call createQueue() first.'); + } + await this._delayedJob.initialize(); + this.logger.info('RemovePluginFromMarketplace queue initialized'); + }, + destroy: async (): Promise<void> => { + this.logger.info('RemovePluginFromMarketplace queue destroyed'); + }, + }; + } + + get delayedJob(): RemovePluginFromMarketplaceDelayedJob | null { + return this._delayedJob; + } + + getQueueName(): string { + return REMOVE_PLUGIN_FROM_MARKETPLACE_QUEUE; + } +} diff --git a/packages/deployments/src/infra/repositories/DeploymentsRepositories.ts b/packages/deployments/src/infra/repositories/DeploymentsRepositories.ts index 50ef33f75..9fbc0cf01 100644 --- a/packages/deployments/src/infra/repositories/DeploymentsRepositories.ts +++ b/packages/deployments/src/infra/repositories/DeploymentsRepositories.ts @@ -18,6 +18,7 @@ import { RenderModeConfigurationSchema } from '../schemas/RenderModeConfiguratio import { PackageSchema } from '../schemas/PackageSchema'; import { DistributionSchema } from '../schemas/DistributionSchema'; import { DistributedPackageSchema } from '../schemas/DistributedPackageSchema'; +import { MarketplaceDistributionSchema } from '../schemas/MarketplaceDistributionSchema'; import { Target, RenderModeConfiguration, @@ -25,6 +26,7 @@ import { Package, Distribution, DistributedPackage, + MarketplaceDistribution, } from '@packmind/types'; /** @@ -65,6 +67,9 @@ export class DeploymentsRepositories implements IDeploymentsRepositories { this.dataSource.getRepository( DistributionSchema, ) as Repository<Distribution>, + this.dataSource.getRepository( + MarketplaceDistributionSchema, + ) as Repository<MarketplaceDistribution>, ); this.distributedPackageRepository = new DistributedPackageRepository( this.dataSource.getRepository( diff --git a/packages/deployments/src/infra/repositories/DistributionRepository.spec.ts b/packages/deployments/src/infra/repositories/DistributionRepository.spec.ts index 598d22bfc..28051014e 100644 --- a/packages/deployments/src/infra/repositories/DistributionRepository.spec.ts +++ b/packages/deployments/src/infra/repositories/DistributionRepository.spec.ts @@ -17,6 +17,7 @@ import { createUserId, Distribution, DistributionStatus, + MarketplaceDistribution, RenderMode, StandardId, RecipeId, @@ -29,7 +30,13 @@ import { OutdatedDeploymentsByTarget } from '../../domain/repositories/IDistribu describe('DistributionRepository', () => { let repository: DistributionRepository; let mockTypeOrmRepository: jest.Mocked<Repository<Distribution>>; + let mockMarketplaceRepository: jest.Mocked< + Repository<MarketplaceDistribution> + >; let mockQueryBuilder: jest.Mocked<SelectQueryBuilder<Distribution>>; + let mockMarketplaceQueryBuilder: jest.Mocked< + SelectQueryBuilder<MarketplaceDistribution> + >; let logger: jest.Mocked<PackmindLogger>; const organizationId = createOrganizationId('org-123'); @@ -59,15 +66,49 @@ describe('DistributionRepository', () => { return qb; }; + const createMockMarketplaceQueryBuilder = (): jest.Mocked< + SelectQueryBuilder<MarketplaceDistribution> + > => { + const qb = { + innerJoin: jest.fn().mockReturnThis(), + innerJoinAndSelect: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + setParameter: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + distinctOn: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + getMany: jest.fn(), + getRawMany: jest.fn(), + } as unknown as jest.Mocked<SelectQueryBuilder<MarketplaceDistribution>>; + return qb; + }; + beforeEach(() => { logger = stubLogger(); mockQueryBuilder = createMockQueryBuilder(); + mockMarketplaceQueryBuilder = createMockMarketplaceQueryBuilder(); mockTypeOrmRepository = { createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), save: jest.fn(), } as unknown as jest.Mocked<Repository<Distribution>>; - repository = new DistributionRepository(mockTypeOrmRepository, logger); + mockMarketplaceRepository = { + createQueryBuilder: jest + .fn() + .mockReturnValue(mockMarketplaceQueryBuilder), + save: jest.fn(), + } as unknown as jest.Mocked<Repository<MarketplaceDistribution>>; + + repository = new DistributionRepository( + mockTypeOrmRepository, + mockMarketplaceRepository, + logger, + ); }); afterEach(() => { @@ -2040,6 +2081,9 @@ describe('DistributionRepository', () => { lastDeployedAt: '2026-04-15T08:30:00.000Z', }, ]); + (mockMarketplaceQueryBuilder.getRawMany as jest.Mock).mockResolvedValue( + [], + ); result = await repository.findLastSuccessfulDistributionDateByProviderIds( @@ -2073,6 +2117,9 @@ describe('DistributionRepository', () => { describe('when a provider has no matching distribution', () => { it('omits the provider from the map', async () => { (mockQueryBuilder.getRawMany as jest.Mock).mockResolvedValue([]); + (mockMarketplaceQueryBuilder.getRawMany as jest.Mock).mockResolvedValue( + [], + ); const result = await repository.findLastSuccessfulDistributionDateByProviderIds( @@ -2083,5 +2130,83 @@ describe('DistributionRepository', () => { expect(result.has(providerA)).toBe(false); }); }); + + describe('when only marketplace publishes exist for a provider', () => { + it('returns the marketplace timestamp for that provider', async () => { + (mockQueryBuilder.getRawMany as jest.Mock).mockResolvedValue([]); + (mockMarketplaceQueryBuilder.getRawMany as jest.Mock).mockResolvedValue( + [ + { + providerId: providerA, + lastDeployedAt: new Date('2026-05-15T14:00:00.000Z'), + }, + ], + ); + + const result = + await repository.findLastSuccessfulDistributionDateByProviderIds( + organizationId, + [providerA], + ); + + expect(result.get(providerA)).toBe('2026-05-15T14:00:00.000Z'); + }); + }); + + describe('when both distribution and marketplace timestamps exist', () => { + describe('when marketplace timestamp is later', () => { + it('returns the later marketplace timestamp', async () => { + (mockQueryBuilder.getRawMany as jest.Mock).mockResolvedValue([ + { + providerId: providerA, + lastDeployedAt: '2026-05-01T10:00:00.000Z', + }, + ]); + ( + mockMarketplaceQueryBuilder.getRawMany as jest.Mock + ).mockResolvedValue([ + { + providerId: providerA, + lastDeployedAt: '2026-05-15T14:00:00.000Z', + }, + ]); + + const result = + await repository.findLastSuccessfulDistributionDateByProviderIds( + organizationId, + [providerA], + ); + + expect(result.get(providerA)).toBe('2026-05-15T14:00:00.000Z'); + }); + }); + + describe('when distribution timestamp is later', () => { + it('returns the later distribution timestamp', async () => { + (mockQueryBuilder.getRawMany as jest.Mock).mockResolvedValue([ + { + providerId: providerA, + lastDeployedAt: '2026-05-20T16:00:00.000Z', + }, + ]); + ( + mockMarketplaceQueryBuilder.getRawMany as jest.Mock + ).mockResolvedValue([ + { + providerId: providerA, + lastDeployedAt: '2026-05-15T14:00:00.000Z', + }, + ]); + + const result = + await repository.findLastSuccessfulDistributionDateByProviderIds( + organizationId, + [providerA], + ); + + expect(result.get(providerA)).toBe('2026-05-20T16:00:00.000Z'); + }); + }); + }); }); }); diff --git a/packages/deployments/src/infra/repositories/DistributionRepository.ts b/packages/deployments/src/infra/repositories/DistributionRepository.ts index 1e45def46..84b522307 100644 --- a/packages/deployments/src/infra/repositories/DistributionRepository.ts +++ b/packages/deployments/src/infra/repositories/DistributionRepository.ts @@ -6,6 +6,7 @@ import { DistributionStatus, GitCommit, GitProviderId, + MarketplaceDistribution, OrganizationId, PackageId, RecipeId, @@ -28,6 +29,7 @@ import { OutdatedStandardDeployment, } from '../../domain/repositories/IDistributionRepository'; import { DistributionSchema } from '../schemas/DistributionSchema'; +import { MarketplaceDistributionSchema } from '../schemas/MarketplaceDistributionSchema'; const origin = 'DistributionRepository'; @@ -56,6 +58,9 @@ export class DistributionRepository implements IDistributionRepository { private readonly repository: Repository<Distribution> = localDataSource.getRepository<Distribution>( DistributionSchema, ), + private readonly marketplaceDistributionRepository: Repository<MarketplaceDistribution> = localDataSource.getRepository<MarketplaceDistribution>( + MarketplaceDistributionSchema, + ), private readonly logger: PackmindLogger = new PackmindLogger(origin), ) { this.logger.info('DistributionRepository initialized'); @@ -1777,7 +1782,7 @@ export class DistributionRepository implements IDistributionRepository { try { type Row = { providerId: GitProviderId; lastDeployedAt: string }; - const rows = await this.repository + const distributionRows = await this.repository .createQueryBuilder('distribution') .innerJoin('distribution.target', 'target') .innerJoin('target.gitRepo', 'gitRepo') @@ -1795,11 +1800,44 @@ export class DistributionRepository implements IDistributionRepository { .addSelect('MAX(distribution.createdAt)', 'lastDeployedAt') .getRawMany<Row>(); + const marketplaceRows = await this.marketplaceDistributionRepository + .createQueryBuilder('mdist') + .innerJoin('mdist.marketplace', 'marketplace') + .innerJoin('marketplace.gitRepo', 'gitRepo') + .where('mdist.organizationId = :organizationId', { + organizationId, + }) + .andWhere('mdist.status = :status', { + status: DistributionStatus.success, + }) + .andWhere('gitRepo.providerId IN (:...providerIds)', { + providerIds: providerIds as string[], + }) + .groupBy('gitRepo.providerId') + .select('gitRepo.provider_id', 'providerId') + .addSelect('MAX(mdist.createdAt)', 'lastDeployedAt') + .getRawMany<Row>(); + const result = new Map<GitProviderId, string>(); - for (const row of rows) { + + // Add distribution rows + for (const row of distributionRows) { result.set(row.providerId, toIsoString(row.lastDeployedAt)); } + // Merge marketplace rows, keeping the later timestamp per providerId + for (const row of marketplaceRows) { + const marketplaceTimestamp = toIsoString(row.lastDeployedAt); + const existingTimestamp = result.get(row.providerId); + + if ( + existingTimestamp == null || + marketplaceTimestamp > existingTimestamp + ) { + result.set(row.providerId, marketplaceTimestamp); + } + } + this.logger.info( 'Last successful distribution date by provider IDs found', { diff --git a/packages/deployments/src/infra/repositories/MarketplaceDistributionRepository.spec.ts b/packages/deployments/src/infra/repositories/MarketplaceDistributionRepository.spec.ts new file mode 100644 index 000000000..ed6a0bb72 --- /dev/null +++ b/packages/deployments/src/infra/repositories/MarketplaceDistributionRepository.spec.ts @@ -0,0 +1,1266 @@ +import { + createTestDatasourceFixture, + itHandlesSoftDelete, + stubLogger, +} from '@packmind/test-utils'; +import { Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; +import { MarketplaceDistributionRepository } from './MarketplaceDistributionRepository'; +import { MarketplaceDistributionSchema } from '../schemas/MarketplaceDistributionSchema'; +import { MarketplaceSchema } from '../schemas/MarketplaceSchema'; +import { PackageSchema } from '../schemas/PackageSchema'; +import { marketplaceDistributionFactory } from './__factories__/marketplaceDistributionFactory'; +import { marketplaceFactory } from './__factories__/marketplaceFactory'; +import { packageFactory } from '../../../test/packageFactory'; +import { + GitRepoSchema, + GitProviderSchema, + OrganizationGitHubAppSchema, +} from '@packmind/git'; +import { gitProviderFactory, gitRepoFactory } from '@packmind/git/test'; +import { + OrganizationSchema, + UserOrganizationMembershipSchema, + UserSchema, +} from '@packmind/accounts'; +import { userFactory } from '@packmind/accounts/test'; +import { + DistributionStatus, + GitProvider, + GitRepo, + Marketplace, + MarketplaceDistribution, + Organization, + OrganizationId, + Package, + PublishFailureReason, + User, + createMarketplaceDistributionId, + createMarketplaceId, + createOrganizationId, + createPackageId, +} from '@packmind/types'; +import { PackmindLogger } from '@packmind/logger'; + +describe('MarketplaceDistributionRepository', () => { + const fixture = createTestDatasourceFixture([ + OrganizationSchema, + UserSchema, + UserOrganizationMembershipSchema, + OrganizationGitHubAppSchema, + GitProviderSchema, + GitRepoSchema, + MarketplaceSchema, + PackageSchema, + MarketplaceDistributionSchema, + ]); + + let repository: MarketplaceDistributionRepository; + let organizationRepo: Repository<Organization>; + let userRepo: Repository<User>; + let gitProviderRepo: Repository<GitProvider>; + let gitRepoRepo: Repository<GitRepo>; + let marketplaceRepo: Repository<Marketplace>; + let packageRepo: Repository<Package>; + let marketplaceDistributionRepo: Repository<MarketplaceDistribution>; + let logger: jest.Mocked<PackmindLogger>; + + let organization: Organization; + let user: User; + let gitProvider: GitProvider; + let gitRepo: GitRepo; + let otherGitRepo: GitRepo; + let marketplace: Marketplace; + let otherMarketplace: Marketplace; + let pkg: Package; + let otherPackage: Package; + + // Soft-delete spec needs a never-used row so it can be deleted/restored + // without interfering with the other tests. + let softDeletePackageId: Package['id']; + let softDeleteMarketplaceId: Marketplace['id']; + let softDeleteOrganizationId: OrganizationId; + + beforeAll(() => fixture.initialize()); + + const createOrganization = async (slugSuffix: string) => + organizationRepo.save({ + id: createOrganizationId(uuidv4()), + name: `Org ${slugSuffix}`, + slug: `org-${slugSuffix}-${uuidv4()}`, + }); + + beforeEach(async () => { + logger = stubLogger(); + repository = new MarketplaceDistributionRepository( + fixture.datasource.getRepository(MarketplaceDistributionSchema), + logger, + ); + + organizationRepo = fixture.datasource.getRepository(OrganizationSchema); + userRepo = fixture.datasource.getRepository(UserSchema); + gitProviderRepo = fixture.datasource.getRepository(GitProviderSchema); + gitRepoRepo = fixture.datasource.getRepository(GitRepoSchema); + marketplaceRepo = fixture.datasource.getRepository(MarketplaceSchema); + packageRepo = fixture.datasource.getRepository(PackageSchema); + marketplaceDistributionRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + + organization = await createOrganization('primary'); + + user = await userRepo.save(userFactory({ memberships: [] })); + + gitProvider = await gitProviderRepo.save( + gitProviderFactory({ organizationId: organization.id }), + ); + + gitRepo = await gitRepoRepo.save( + gitRepoFactory({ providerId: gitProvider.id, type: 'marketplace' }), + ); + otherGitRepo = await gitRepoRepo.save( + gitRepoFactory({ providerId: gitProvider.id, type: 'marketplace' }), + ); + + marketplace = await marketplaceRepo.save( + marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + }), + ); + otherMarketplace = await marketplaceRepo.save( + marketplaceFactory({ + organizationId: organization.id, + gitRepoId: otherGitRepo.id, + addedBy: user.id, + }), + ); + + pkg = await packageRepo.save( + packageFactory({ + createdBy: user.id, + }), + ); + otherPackage = await packageRepo.save( + packageFactory({ + createdBy: user.id, + }), + ); + + // Soft-delete spec uses a fresh marketplace/package pair so it can be + // safely deleted/restored without colliding with the other tests. + const softDeleteOrg = await createOrganization('soft-delete'); + softDeleteOrganizationId = softDeleteOrg.id; + const softDeleteGitRepo = await gitRepoRepo.save( + gitRepoFactory({ providerId: gitProvider.id, type: 'marketplace' }), + ); + const softDeleteMarketplace = await marketplaceRepo.save( + marketplaceFactory({ + organizationId: softDeleteOrg.id, + gitRepoId: softDeleteGitRepo.id, + addedBy: user.id, + }), + ); + softDeleteMarketplaceId = softDeleteMarketplace.id; + const softDeletePackage = await packageRepo.save( + packageFactory({ + createdBy: user.id, + }), + ); + softDeletePackageId = softDeletePackage.id; + }); + + afterEach(async () => { + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + itHandlesSoftDelete<MarketplaceDistribution>({ + entityFactory: () => + marketplaceDistributionFactory({ + organizationId: softDeleteOrganizationId, + marketplaceId: softDeleteMarketplaceId, + packageId: softDeletePackageId, + authorId: user.id, + }), + getRepository: () => repository, + queryDeletedEntity: async (id) => + marketplaceDistributionRepo.findOne({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + where: { id: id as any }, + withDeleted: true, + }), + }); + + describe('add', () => { + it('inserts a marketplace distribution row', async () => { + const distribution = marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + pluginSlug: 'my-plugin', + }); + + const saved = await repository.add(distribution); + + expect(saved).toMatchObject({ + id: distribution.id, + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + pluginSlug: 'my-plugin', + status: DistributionStatus.in_progress, + source: 'app', + }); + }); + + describe('when nullable side-channel columns are omitted', () => { + let found: MarketplaceDistribution | null; + + beforeEach(async () => { + const distribution = marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }); + + await repository.add(distribution); + found = await repository.findById(distribution.id); + }); + + it('persists prUrl as falsy', async () => { + expect(found?.prUrl).toBeFalsy(); + }); + + it('persists gitCommit as falsy', async () => { + expect(found?.gitCommit).toBeFalsy(); + }); + + it('persists error as falsy', async () => { + expect(found?.error).toBeFalsy(); + }); + + it('persists failureReason as falsy', async () => { + expect(found?.failureReason).toBeFalsy(); + }); + + it('persists contentHash as falsy', async () => { + expect(found?.contentHash).toBeFalsy(); + }); + }); + }); + + describe('findById', () => { + describe('when the row exists', () => { + it('returns the row', async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + + const result = await repository.findById(distribution.id); + + expect(result?.id).toEqual(distribution.id); + }); + }); + + it('returns null for an unknown id', async () => { + const result = await repository.findById( + createMarketplaceDistributionId(uuidv4()), + ); + + expect(result).toBeNull(); + }); + }); + + describe('findByMarketplaceId', () => { + it('returns distributions targeting the marketplace ordered desc by createdAt', async () => { + const first = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + const second = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: otherPackage.id, + authorId: user.id, + }), + ); + + const result = await repository.findByMarketplaceId(marketplace.id); + + expect(result.map((row) => row.id).sort()).toEqual( + [first.id, second.id].sort(), + ); + }); + + describe('when other marketplaces have distributions', () => { + let ours: MarketplaceDistribution; + let result: MarketplaceDistribution[]; + + beforeEach(async () => { + ours = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: otherMarketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + + result = await repository.findByMarketplaceId(marketplace.id); + }); + + it('returns only the requested marketplace distributions', async () => { + expect(result).toHaveLength(1); + }); + + it('returns the distribution belonging to the requested marketplace', async () => { + expect(result[0].id).toEqual(ours.id); + }); + }); + + it('excludes soft-deleted distributions', async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + await repository.deleteById(distribution.id); + + const result = await repository.findByMarketplaceId(marketplace.id); + + expect(result).toEqual([]); + }); + + describe('when the marketplace has no distributions', () => { + it('returns an empty array', async () => { + const result = await repository.findByMarketplaceId( + createMarketplaceId(uuidv4()), + ); + + expect(result).toEqual([]); + }); + }); + }); + + describe('findByPackageId', () => { + it('returns every distribution for a package across marketplaces', async () => { + const onMarketplaceA = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + const onMarketplaceB = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: otherMarketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + + const result = await repository.findByPackageId(pkg.id); + + expect(result.map((row) => row.id).sort()).toEqual( + [onMarketplaceA.id, onMarketplaceB.id].sort(), + ); + }); + + describe('when other packages have distributions', () => { + let ours: MarketplaceDistribution; + let result: MarketplaceDistribution[]; + + beforeEach(async () => { + ours = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: otherPackage.id, + authorId: user.id, + }), + ); + + result = await repository.findByPackageId(pkg.id); + }); + + it('returns only the requested package distributions', async () => { + expect(result).toHaveLength(1); + }); + + it('returns the distribution belonging to the requested package', async () => { + expect(result[0].id).toEqual(ours.id); + }); + }); + + it('excludes soft-deleted distributions', async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + await repository.deleteById(distribution.id); + + const result = await repository.findByPackageId(pkg.id); + + expect(result).toEqual([]); + }); + + describe('when the package has no distributions', () => { + it('returns an empty array', async () => { + const result = await repository.findByPackageId( + createPackageId(uuidv4()), + ); + + expect(result).toEqual([]); + }); + }); + }); + + describe('findLatestByPackageAndMarketplace', () => { + it('returns the most-recent distribution for the (package, marketplace) pair', async () => { + await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + // Insert a small wait so createdAt differs. + await new Promise((resolve) => setTimeout(resolve, 10)); + const latest = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + + const result = await repository.findLatestByPackageAndMarketplace( + pkg.id, + marketplace.id, + ); + + expect(result?.id).toEqual(latest.id); + }); + + describe('when no distribution targets the pair', () => { + it('returns null', async () => { + const result = await repository.findLatestByPackageAndMarketplace( + pkg.id, + marketplace.id, + ); + + expect(result).toBeNull(); + }); + }); + + it('ignores distributions from other (package, marketplace) pairs', async () => { + const ours = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: otherMarketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: otherPackage.id, + authorId: user.id, + }), + ); + + const result = await repository.findLatestByPackageAndMarketplace( + pkg.id, + marketplace.id, + ); + + expect(result?.id).toEqual(ours.id); + }); + + it('excludes soft-deleted distributions', async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + await repository.deleteById(distribution.id); + + const result = await repository.findLatestByPackageAndMarketplace( + pkg.id, + marketplace.id, + ); + + expect(result).toBeNull(); + }); + }); + + describe('findLatestActiveByPackageAndMarketplace', () => { + describe('when both success and non-success rows exist', () => { + let latest: MarketplaceDistribution; + let older: MarketplaceDistribution; + let result: MarketplaceDistribution | null; + + beforeEach(async () => { + // First, an in-progress row that must be ignored. + await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + // Then a success-state row. + older = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.success, + }), + ); + // Wait so createdAt orderings are distinct. + await new Promise((resolve) => setTimeout(resolve, 10)); + latest = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.success, + }), + ); + + result = await repository.findLatestActiveByPackageAndMarketplace( + pkg.id, + marketplace.id, + ); + }); + + it('returns the most recent success-state row for the pair', () => { + expect(result?.id).toEqual(latest.id); + }); + + it('does not return any earlier success-state row', () => { + expect(result?.id).not.toEqual(older.id); + }); + }); + + describe('when the latest active row is a pending_merge publish', () => { + it('returns the pending_merge row', async () => { + const pending = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.pending_merge, + }), + ); + + const result = await repository.findLatestActiveByPackageAndMarketplace( + pkg.id, + marketplace.id, + ); + + expect(result?.id).toEqual(pending.id); + }); + }); + + describe('when no active distribution exists', () => { + it('returns null even if other statuses exist', async () => { + await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.failure, + }), + ); + + const result = await repository.findLatestActiveByPackageAndMarketplace( + pkg.id, + marketplace.id, + ); + + expect(result).toBeNull(); + }); + }); + + it('excludes soft-deleted distributions', async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.success, + }), + ); + await repository.deleteById(distribution.id); + + const result = await repository.findLatestActiveByPackageAndMarketplace( + pkg.id, + marketplace.id, + ); + + expect(result).toBeNull(); + }); + }); + + describe('findActiveByPackageId', () => { + it('returns success and pending_merge distributions for the package', async () => { + const liveA = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.success, + }), + ); + const liveB = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: otherMarketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.pending_merge, + }), + ); + // Neither success nor pending_merge — must be excluded. + await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.to_be_removed, + }), + ); + + const result = await repository.findActiveByPackageId(pkg.id); + + expect(result.map((row) => row.id).sort()).toEqual( + [liveA.id, liveB.id].sort(), + ); + }); + + describe('when no success-state distribution exists', () => { + it('returns an empty array', async () => { + const result = await repository.findActiveByPackageId(pkg.id); + expect(result).toEqual([]); + }); + }); + + it('excludes soft-deleted distributions', async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.success, + }), + ); + await repository.deleteById(distribution.id); + + const result = await repository.findActiveByPackageId(pkg.id); + + expect(result).toEqual([]); + }); + }); + + describe('findPendingRemovalsByMarketplaceId', () => { + describe('with mixed status and marketplace rows', () => { + let pending: MarketplaceDistribution; + let result: MarketplaceDistribution[]; + + beforeEach(async () => { + pending = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.to_be_removed, + }), + ); + // Other status — must be excluded. + await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.success, + }), + ); + // Pending on a different marketplace — must be excluded. + await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: otherMarketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.to_be_removed, + }), + ); + + result = await repository.findPendingRemovalsByMarketplaceId( + marketplace.id, + ); + }); + + it('returns exactly one row', () => { + expect(result).toHaveLength(1); + }); + + it('returns the pending row for the requested marketplace', () => { + expect(result[0].id).toEqual(pending.id); + }); + }); + + describe('when no pending removal exists', () => { + it('returns an empty array', async () => { + const result = await repository.findPendingRemovalsByMarketplaceId( + marketplace.id, + ); + expect(result).toEqual([]); + }); + }); + + it('excludes soft-deleted distributions', async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.to_be_removed, + }), + ); + await repository.deleteById(distribution.id); + + const result = await repository.findPendingRemovalsByMarketplaceId( + marketplace.id, + ); + + expect(result).toEqual([]); + }); + }); + + describe('findPendingMergesByMarketplaceId', () => { + describe('with mixed status and marketplace rows', () => { + let pendingMerge: MarketplaceDistribution; + let result: MarketplaceDistribution[]; + + beforeEach(async () => { + pendingMerge = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.pending_merge, + }), + ); + // Other status — must be excluded. + await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.success, + }), + ); + // Pending merge on a different marketplace — must be excluded. + await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: otherMarketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.pending_merge, + }), + ); + + result = await repository.findPendingMergesByMarketplaceId( + marketplace.id, + ); + }); + + it('returns exactly one row', () => { + expect(result).toHaveLength(1); + }); + + it('returns the pending-merge row for the requested marketplace', () => { + expect(result[0].id).toEqual(pendingMerge.id); + }); + }); + + describe('when no pending merge exists', () => { + it('returns an empty array', async () => { + const result = await repository.findPendingMergesByMarketplaceId( + marketplace.id, + ); + expect(result).toEqual([]); + }); + }); + + it('excludes soft-deleted distributions', async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.pending_merge, + }), + ); + await repository.deleteById(distribution.id); + + const result = await repository.findPendingMergesByMarketplaceId( + marketplace.id, + ); + + expect(result).toEqual([]); + }); + }); + + describe('findSuccessfulByMarketplaceId', () => { + describe('with mixed status and marketplace rows', () => { + let success: MarketplaceDistribution; + let result: MarketplaceDistribution[]; + + beforeEach(async () => { + success = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.success, + }), + ); + // Non-success — must be excluded. + await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.to_be_removed, + }), + ); + // Success on a different marketplace — must be excluded. + await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: otherMarketplace.id, + packageId: otherPackage.id, + authorId: user.id, + status: DistributionStatus.success, + }), + ); + + result = await repository.findSuccessfulByMarketplaceId(marketplace.id); + }); + + it('returns exactly one row', () => { + expect(result).toHaveLength(1); + }); + + it('returns the success row for the requested marketplace', () => { + expect(result[0].id).toEqual(success.id); + }); + }); + + describe('when no success-state distribution exists', () => { + it('returns an empty array', async () => { + const result = await repository.findSuccessfulByMarketplaceId( + marketplace.id, + ); + expect(result).toEqual([]); + }); + }); + + it('excludes soft-deleted distributions', async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.success, + }), + ); + await repository.deleteById(distribution.id); + + const result = await repository.findSuccessfulByMarketplaceId( + marketplace.id, + ); + + expect(result).toEqual([]); + }); + }); + + describe('updateStatus', () => { + describe('when status is set to success with side-channel fields', () => { + let refreshed: MarketplaceDistribution | null; + + beforeEach(async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + + await repository.updateStatus(distribution.id, { + status: DistributionStatus.success, + prUrl: 'https://example.com/pr/1', + gitCommit: 'abc123', + contentHash: 'hash-1', + }); + + refreshed = await repository.findById(distribution.id); + }); + + it('writes the success status', async () => { + expect(refreshed?.status).toBe(DistributionStatus.success); + }); + + it('writes the prUrl', async () => { + expect(refreshed?.prUrl).toBe('https://example.com/pr/1'); + }); + + it('writes the gitCommit', async () => { + expect(refreshed?.gitCommit).toBe('abc123'); + }); + + it('writes the contentHash', async () => { + expect(refreshed?.contentHash).toBe('hash-1'); + }); + + it('leaves error falsy', async () => { + expect(refreshed?.error).toBeFalsy(); + }); + + it('leaves failureReason falsy', async () => { + expect(refreshed?.failureReason).toBeFalsy(); + }); + + it('leaves publishConfirmedAt falsy', async () => { + expect(refreshed?.publishConfirmedAt).toBeFalsy(); + }); + }); + + describe('when a pending_merge row is promoted to success with publishConfirmedAt', () => { + const confirmedAt = new Date('2026-06-10T12:00:00Z'); + let refreshed: MarketplaceDistribution | null; + + beforeEach(async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.pending_merge, + }), + ); + + await repository.updateStatus(distribution.id, { + status: DistributionStatus.success, + publishConfirmedAt: confirmedAt, + }); + + refreshed = await repository.findById(distribution.id); + }); + + it('writes the success status', async () => { + expect(refreshed?.status).toBe(DistributionStatus.success); + }); + + it('writes the publishConfirmedAt stamp', async () => { + expect(refreshed?.publishConfirmedAt).toEqual(confirmedAt); + }); + }); + + describe('when status is set to failure with error and failureReason', () => { + const failureReason: PublishFailureReason = 'name_conflict_unmanaged'; + let refreshed: MarketplaceDistribution | null; + + beforeEach(async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + + await repository.updateStatus(distribution.id, { + status: DistributionStatus.failure, + error: 'collision', + failureReason, + }); + + refreshed = await repository.findById(distribution.id); + }); + + it('writes the failure status', async () => { + expect(refreshed?.status).toBe(DistributionStatus.failure); + }); + + it('writes the error message', async () => { + expect(refreshed?.error).toBe('collision'); + }); + + it('writes the failure reason', async () => { + expect(refreshed?.failureReason).toBe(failureReason); + }); + }); + + describe('when patch fields are omitted on a follow-up update', () => { + let refreshed: MarketplaceDistribution | null; + + beforeEach(async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + }), + ); + + // First update sets prUrl + contentHash + await repository.updateStatus(distribution.id, { + status: DistributionStatus.success, + prUrl: 'https://example.com/pr/1', + contentHash: 'hash-1', + }); + + // Second update changes status only — prUrl + contentHash must remain. + await repository.updateStatus(distribution.id, { + status: DistributionStatus.no_changes, + }); + + refreshed = await repository.findById(distribution.id); + }); + + it('updates the status to the new value', async () => { + expect(refreshed?.status).toBe(DistributionStatus.no_changes); + }); + + it('preserves the previously written prUrl', async () => { + expect(refreshed?.prUrl).toBe('https://example.com/pr/1'); + }); + + it('preserves the previously written contentHash', async () => { + expect(refreshed?.contentHash).toBe('hash-1'); + }); + }); + + describe('when the target distribution does not exist', () => { + it('throws', async () => { + await expect( + repository.updateStatus(createMarketplaceDistributionId(uuidv4()), { + status: DistributionStatus.failure, + }), + ).rejects.toThrow(); + }); + }); + + describe('removal lifecycle transitions', () => { + it('transitions success → to_be_removed', async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.success, + }), + ); + + await repository.updateStatus(distribution.id, { + status: DistributionStatus.to_be_removed, + }); + + const refreshed = await repository.findById(distribution.id); + expect(refreshed?.status).toBe(DistributionStatus.to_be_removed); + }); + + it('transitions to_be_removed → success (cancellation)', async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.to_be_removed, + }), + ); + + await repository.updateStatus(distribution.id, { + status: DistributionStatus.success, + }); + + const refreshed = await repository.findById(distribution.id); + expect(refreshed?.status).toBe(DistributionStatus.success); + }); + + it('transitions to_be_removed → removed (terminal)', async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.to_be_removed, + }), + ); + + await repository.updateStatus(distribution.id, { + status: DistributionStatus.removed, + }); + + const refreshed = await repository.findById(distribution.id); + expect(refreshed?.status).toBe(DistributionStatus.removed); + }); + }); + }); + + describe('updateRemovalRequestedAt', () => { + describe('when a removal request marker is set', () => { + let refreshed: MarketplaceDistribution | null; + const requestedAt = new Date('2026-06-10T12:00:00.000Z'); + + beforeEach(async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.success, + }), + ); + + await repository.updateRemovalRequestedAt(distribution.id, requestedAt); + + refreshed = await repository.findById(distribution.id); + }); + + it('persists the removalRequestedAt timestamp', async () => { + expect(refreshed?.removalRequestedAt).toEqual(requestedAt); + }); + + it('leaves the status untouched', async () => { + expect(refreshed?.status).toBe(DistributionStatus.success); + }); + }); + + describe('when the marker is cleared', () => { + let refreshed: MarketplaceDistribution | null; + + beforeEach(async () => { + const distribution = await repository.add( + marketplaceDistributionFactory({ + organizationId: organization.id, + marketplaceId: marketplace.id, + packageId: pkg.id, + authorId: user.id, + status: DistributionStatus.success, + removalRequestedAt: new Date('2026-06-10T12:00:00.000Z'), + }), + ); + + await repository.updateRemovalRequestedAt(distribution.id, null); + + refreshed = await repository.findById(distribution.id); + }); + + it('nulls the removalRequestedAt timestamp', async () => { + expect(refreshed?.removalRequestedAt).toBeNull(); + }); + }); + + describe('when the target distribution does not exist', () => { + it('throws', async () => { + await expect( + repository.updateRemovalRequestedAt( + createMarketplaceDistributionId(uuidv4()), + new Date('2026-06-10T12:00:00.000Z'), + ), + ).rejects.toThrow(); + }); + }); + }); +}); diff --git a/packages/deployments/src/infra/repositories/MarketplaceDistributionRepository.ts b/packages/deployments/src/infra/repositories/MarketplaceDistributionRepository.ts new file mode 100644 index 000000000..ad80427cc --- /dev/null +++ b/packages/deployments/src/infra/repositories/MarketplaceDistributionRepository.ts @@ -0,0 +1,467 @@ +import { Repository } from 'typeorm'; +import { + DistributionStatus, + MarketplaceDistribution, + MarketplaceDistributionId, + MarketplaceId, + PackageId, +} from '@packmind/types'; +import { PackmindLogger } from '@packmind/logger'; +import { AbstractRepository, localDataSource } from '@packmind/node-utils'; +import { + IMarketplaceDistributionRepository, + MarketplaceDistributionStatusUpdate, +} from '../../domain/repositories/IMarketplaceDistributionRepository'; +import { MarketplaceDistributionSchema } from '../schemas/MarketplaceDistributionSchema'; + +const origin = 'MarketplaceDistributionRepository'; + +/** + * TypeORM-backed implementation of `IMarketplaceDistributionRepository`. + * + * Inherits soft-delete-aware CRUD from `AbstractRepository`. The default + * finders (`findById`, `findByMarketplaceId`, etc.) exclude soft-deleted + * rows so audit history survives package/marketplace removal without + * cluttering the live UI. + */ +export class MarketplaceDistributionRepository + extends AbstractRepository<MarketplaceDistribution> + implements IMarketplaceDistributionRepository +{ + constructor( + repository: Repository<MarketplaceDistribution> = localDataSource.getRepository<MarketplaceDistribution>( + MarketplaceDistributionSchema, + ), + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super( + 'marketplace-distribution', + repository, + MarketplaceDistributionSchema, + logger, + ); + this.logger.info('MarketplaceDistributionRepository initialized'); + } + + protected override loggableEntity( + entity: MarketplaceDistribution, + ): Partial<MarketplaceDistribution> { + return { + id: entity.id, + organizationId: entity.organizationId, + marketplaceId: entity.marketplaceId, + packageId: entity.packageId, + pluginSlug: entity.pluginSlug, + status: entity.status, + source: entity.source, + }; + } + + async findByMarketplaceId( + marketplaceId: MarketplaceId, + ): Promise<MarketplaceDistribution[]> { + this.logger.info('Finding marketplace distributions by marketplace ID', { + marketplaceId, + }); + + try { + const rows = await this.repository + .createQueryBuilder('marketplace_distribution') + .where('marketplace_distribution.marketplaceId = :marketplaceId', { + marketplaceId, + }) + .orderBy('marketplace_distribution.createdAt', 'DESC') + .getMany(); + + this.logger.info('Marketplace distributions found by marketplace ID', { + marketplaceId, + count: rows.length, + }); + return rows; + } catch (error) { + this.logger.error( + 'Failed to find marketplace distributions by marketplace ID', + { + marketplaceId, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async findByPackageId( + packageId: PackageId, + ): Promise<MarketplaceDistribution[]> { + this.logger.info('Finding marketplace distributions by package ID', { + packageId, + }); + + try { + const rows = await this.repository + .createQueryBuilder('marketplace_distribution') + .where('marketplace_distribution.packageId = :packageId', { + packageId, + }) + .orderBy('marketplace_distribution.createdAt', 'DESC') + .getMany(); + + this.logger.info('Marketplace distributions found by package ID', { + packageId, + count: rows.length, + }); + return rows; + } catch (error) { + this.logger.error( + 'Failed to find marketplace distributions by package ID', + { + packageId, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async findLatestByPackageAndMarketplace( + packageId: PackageId, + marketplaceId: MarketplaceId, + ): Promise<MarketplaceDistribution | null> { + this.logger.info( + 'Finding latest marketplace distribution by package and marketplace', + { packageId, marketplaceId }, + ); + + try { + const row = await this.repository + .createQueryBuilder('marketplace_distribution') + .where('marketplace_distribution.packageId = :packageId', { + packageId, + }) + .andWhere('marketplace_distribution.marketplaceId = :marketplaceId', { + marketplaceId, + }) + .orderBy('marketplace_distribution.createdAt', 'DESC') + .getOne(); + + this.logger.info( + 'Latest marketplace distribution lookup by package and marketplace', + { packageId, marketplaceId, found: !!row }, + ); + return row; + } catch (error) { + this.logger.error( + 'Failed to find latest marketplace distribution by package and marketplace', + { + packageId, + marketplaceId, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async findLatestActiveByPackageAndMarketplace( + packageId: PackageId, + marketplaceId: MarketplaceId, + ): Promise<MarketplaceDistribution | null> { + this.logger.info( + 'Finding latest active marketplace distribution by package and marketplace', + { packageId, marketplaceId }, + ); + + try { + const row = await this.repository + .createQueryBuilder('marketplace_distribution') + .where('marketplace_distribution.packageId = :packageId', { + packageId, + }) + .andWhere('marketplace_distribution.marketplaceId = :marketplaceId', { + marketplaceId, + }) + .andWhere('marketplace_distribution.status IN (:...statuses)', { + statuses: [ + DistributionStatus.success, + DistributionStatus.pending_merge, + ], + }) + .orderBy('marketplace_distribution.createdAt', 'DESC') + .getOne(); + + this.logger.info( + 'Latest active marketplace distribution lookup completed', + { packageId, marketplaceId, found: !!row }, + ); + return row; + } catch (error) { + this.logger.error( + 'Failed to find latest active marketplace distribution', + { + packageId, + marketplaceId, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async findActiveByPackageId( + packageId: PackageId, + ): Promise<MarketplaceDistribution[]> { + this.logger.info( + 'Finding active (success or pending_merge) marketplace distributions by package ID', + { packageId }, + ); + + try { + const rows = await this.repository + .createQueryBuilder('marketplace_distribution') + .where('marketplace_distribution.packageId = :packageId', { + packageId, + }) + .andWhere('marketplace_distribution.status IN (:...statuses)', { + statuses: [ + DistributionStatus.success, + DistributionStatus.pending_merge, + ], + }) + .orderBy('marketplace_distribution.createdAt', 'DESC') + .getMany(); + + this.logger.info('Active marketplace distributions found by package ID', { + packageId, + count: rows.length, + }); + return rows; + } catch (error) { + this.logger.error( + 'Failed to find active marketplace distributions by package ID', + { + packageId, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async findPendingMergesByMarketplaceId( + marketplaceId: MarketplaceId, + ): Promise<MarketplaceDistribution[]> { + this.logger.info( + 'Finding pending-merge marketplace distributions by marketplace ID', + { marketplaceId }, + ); + + try { + const rows = await this.repository + .createQueryBuilder('marketplace_distribution') + .where('marketplace_distribution.marketplaceId = :marketplaceId', { + marketplaceId, + }) + .andWhere('marketplace_distribution.status = :status', { + status: DistributionStatus.pending_merge, + }) + .orderBy('marketplace_distribution.createdAt', 'DESC') + .getMany(); + + this.logger.info( + 'Pending-merge marketplace distributions found by marketplace ID', + { marketplaceId, count: rows.length }, + ); + return rows; + } catch (error) { + this.logger.error( + 'Failed to find pending-merge marketplace distributions', + { + marketplaceId, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async findPendingRemovalsByMarketplaceId( + marketplaceId: MarketplaceId, + ): Promise<MarketplaceDistribution[]> { + this.logger.info( + 'Finding pending-removal marketplace distributions by marketplace ID', + { marketplaceId }, + ); + + try { + const rows = await this.repository + .createQueryBuilder('marketplace_distribution') + .where('marketplace_distribution.marketplaceId = :marketplaceId', { + marketplaceId, + }) + .andWhere('marketplace_distribution.status = :status', { + status: DistributionStatus.to_be_removed, + }) + .orderBy('marketplace_distribution.createdAt', 'DESC') + .getMany(); + + this.logger.info( + 'Pending-removal marketplace distributions found by marketplace ID', + { marketplaceId, count: rows.length }, + ); + return rows; + } catch (error) { + this.logger.error( + 'Failed to find pending-removal marketplace distributions', + { + marketplaceId, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async findSuccessfulByMarketplaceId( + marketplaceId: MarketplaceId, + ): Promise<MarketplaceDistribution[]> { + this.logger.info( + 'Finding successful marketplace distributions by marketplace ID', + { marketplaceId }, + ); + + try { + const rows = await this.repository + .createQueryBuilder('marketplace_distribution') + .where('marketplace_distribution.marketplaceId = :marketplaceId', { + marketplaceId, + }) + .andWhere('marketplace_distribution.status = :status', { + status: DistributionStatus.success, + }) + .orderBy('marketplace_distribution.createdAt', 'DESC') + .getMany(); + + this.logger.info( + 'Successful marketplace distributions found by marketplace ID', + { marketplaceId, count: rows.length }, + ); + return rows; + } catch (error) { + this.logger.error('Failed to find successful marketplace distributions', { + marketplaceId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async updateStatus( + id: MarketplaceDistributionId, + patch: MarketplaceDistributionStatusUpdate, + ): Promise<void> { + this.logger.info('Updating marketplace distribution status', { + id, + status: patch.status, + hasPrUrl: patch.prUrl !== undefined, + hasGitCommit: patch.gitCommit !== undefined, + hasError: patch.error !== undefined, + hasFailureReason: patch.failureReason !== undefined, + hasContentHash: patch.contentHash !== undefined, + }); + + try { + const updates: Partial<MarketplaceDistribution> = { + status: patch.status, + }; + + if (patch.prUrl !== undefined) { + updates.prUrl = patch.prUrl; + } + if (patch.gitCommit !== undefined) { + updates.gitCommit = patch.gitCommit; + } + if (patch.error !== undefined) { + updates.error = patch.error; + } + if (patch.failureReason !== undefined) { + updates.failureReason = patch.failureReason; + } + if (patch.contentHash !== undefined) { + updates.contentHash = patch.contentHash; + } + if (patch.versionFingerprint !== undefined) { + updates.versionFingerprint = patch.versionFingerprint; + } + if (patch.publishConfirmedAt !== undefined) { + updates.publishConfirmedAt = patch.publishConfirmedAt; + } + + const result = await this.repository + .createQueryBuilder() + .update() + .set(updates) + .where('id = :id', { id }) + .execute(); + + if (result.affected === 0) { + this.logger.warn( + 'No marketplace distribution updated by updateStatus', + { id }, + ); + throw new Error(`No marketplace distribution with id ${id} found`); + } + + this.logger.info('Marketplace distribution status updated successfully', { + id, + status: patch.status, + }); + } catch (error) { + this.logger.error('Failed to update marketplace distribution status', { + id, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async updateRemovalRequestedAt( + id: MarketplaceDistributionId, + removalRequestedAt: Date | null, + ): Promise<void> { + this.logger.info('Updating marketplace distribution removalRequestedAt', { + id, + requested: removalRequestedAt !== null, + }); + + try { + const result = await this.repository + .createQueryBuilder() + .update() + .set({ removalRequestedAt }) + .where('id = :id', { id }) + .execute(); + + if (result.affected === 0) { + this.logger.warn( + 'No marketplace distribution updated by updateRemovalRequestedAt', + { id }, + ); + throw new Error(`No marketplace distribution with id ${id} found`); + } + + this.logger.info( + 'Marketplace distribution removalRequestedAt updated successfully', + { id, requested: removalRequestedAt !== null }, + ); + } catch (error) { + this.logger.error( + 'Failed to update marketplace distribution removalRequestedAt', + { + id, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } +} diff --git a/packages/deployments/src/infra/repositories/MarketplaceRepository.spec.ts b/packages/deployments/src/infra/repositories/MarketplaceRepository.spec.ts new file mode 100644 index 000000000..afd04a886 --- /dev/null +++ b/packages/deployments/src/infra/repositories/MarketplaceRepository.spec.ts @@ -0,0 +1,594 @@ +import { + createTestDatasourceFixture, + itHandlesSoftDelete, + stubLogger, +} from '@packmind/test-utils'; +import { Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; +import { MarketplaceRepository } from './MarketplaceRepository'; +import { MarketplaceSchema } from '../schemas/MarketplaceSchema'; +import { marketplaceFactory } from './__factories__/marketplaceFactory'; +import { + GitRepoSchema, + GitProviderSchema, + OrganizationGitHubAppSchema, +} from '@packmind/git'; +import { gitProviderFactory, gitRepoFactory } from '@packmind/git/test'; +import { + OrganizationSchema, + UserOrganizationMembershipSchema, + UserSchema, +} from '@packmind/accounts'; +import { userFactory } from '@packmind/accounts/test'; +import { + GitProvider, + GitRepo, + Marketplace, + Organization, + OrganizationId, + User, + createMarketplaceId, + createOrganizationId, +} from '@packmind/types'; +import { PackmindLogger } from '@packmind/logger'; + +describe('MarketplaceRepository', () => { + const fixture = createTestDatasourceFixture([ + OrganizationSchema, + UserSchema, + UserOrganizationMembershipSchema, + OrganizationGitHubAppSchema, + GitProviderSchema, + GitRepoSchema, + MarketplaceSchema, + ]); + + let repository: MarketplaceRepository; + let organizationRepo: Repository<Organization>; + let userRepo: Repository<User>; + let gitProviderRepo: Repository<GitProvider>; + let gitRepoRepo: Repository<GitRepo>; + let marketplaceRepo: Repository<Marketplace>; + let logger: jest.Mocked<PackmindLogger>; + + let organization: Organization; + let otherOrganization: Organization; + let user: User; + let gitProvider: GitProvider; + let gitRepo: GitRepo; + let otherGitRepo: GitRepo; + // Soft-delete spec needs a never-used (org, gitRepo) pair so the + // unique partial index doesn't collide with rows from other tests. + let softDeleteGitRepoId: GitRepo['id']; + let softDeleteOrganizationId: OrganizationId; + + beforeAll(() => fixture.initialize()); + + const createOrganization = async (slugSuffix: string) => + organizationRepo.save({ + id: createOrganizationId(uuidv4()), + name: `Org ${slugSuffix}`, + slug: `org-${slugSuffix}-${uuidv4()}`, + }); + + beforeEach(async () => { + logger = stubLogger(); + repository = new MarketplaceRepository( + fixture.datasource.getRepository(MarketplaceSchema), + logger, + ); + + organizationRepo = fixture.datasource.getRepository(OrganizationSchema); + userRepo = fixture.datasource.getRepository(UserSchema); + gitProviderRepo = fixture.datasource.getRepository(GitProviderSchema); + gitRepoRepo = fixture.datasource.getRepository(GitRepoSchema); + marketplaceRepo = fixture.datasource.getRepository(MarketplaceSchema); + + organization = await createOrganization('primary'); + otherOrganization = await createOrganization('other'); + + // userFactory seeds a `memberships` array referencing a fresh + // organization id that doesn't exist in the fixture; we clear it so + // TypeORM does not cascade-insert into UserOrganizationMembership. + // Only the `addedBy` FK on `marketplaces` matters here. + user = await userRepo.save(userFactory({ memberships: [] })); + + gitProvider = await gitProviderRepo.save( + gitProviderFactory({ organizationId: organization.id }), + ); + + gitRepo = await gitRepoRepo.save( + gitRepoFactory({ providerId: gitProvider.id, type: 'marketplace' }), + ); + otherGitRepo = await gitRepoRepo.save( + gitRepoFactory({ providerId: gitProvider.id, type: 'marketplace' }), + ); + + const softDeleteRepo = await gitRepoRepo.save( + gitRepoFactory({ providerId: gitProvider.id, type: 'marketplace' }), + ); + softDeleteGitRepoId = softDeleteRepo.id; + const softDeleteOrg = await createOrganization('soft-delete'); + softDeleteOrganizationId = softDeleteOrg.id; + }); + + afterEach(async () => { + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + itHandlesSoftDelete<Marketplace>({ + entityFactory: () => + marketplaceFactory({ + organizationId: softDeleteOrganizationId, + gitRepoId: softDeleteGitRepoId, + addedBy: user.id, + }), + getRepository: () => repository, + queryDeletedEntity: async (id) => + marketplaceRepo.findOne({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + where: { id: id as any }, + withDeleted: true, + }), + }); + + describe('add', () => { + it('inserts a marketplace row', async () => { + const marketplace = marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + }); + + const saved = await repository.add(marketplace); + + expect(saved).toMatchObject({ + id: marketplace.id, + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + state: 'healthy', + }); + }); + + describe('persists the descriptor and pluginCount', () => { + let marketplace: Marketplace; + let found: Marketplace | null; + + beforeEach(async () => { + marketplace = marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + pluginCount: 7, + }); + + await repository.add(marketplace); + found = await repository.findById(marketplace.id); + }); + + it('persists the descriptor', () => { + expect(found?.descriptor).toEqual(marketplace.descriptor); + }); + + it('persists the pluginCount', () => { + expect(found?.pluginCount).toBe(7); + }); + }); + }); + + describe('findByOrganizationId', () => { + describe('returns marketplaces linked to the organization', () => { + let marketplaceA: Marketplace; + let marketplaceB: Marketplace; + let result: Marketplace[]; + + beforeEach(async () => { + marketplaceA = marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + }); + marketplaceB = marketplaceFactory({ + organizationId: organization.id, + gitRepoId: otherGitRepo.id, + addedBy: user.id, + }); + await repository.add(marketplaceA); + await repository.add(marketplaceB); + + result = await repository.findByOrganizationId(organization.id); + }); + + it('returns both marketplaces', () => { + expect(result).toHaveLength(2); + }); + + it('returns the marketplaces of the organization', () => { + expect(result.map((m) => m.id).sort()).toEqual( + [marketplaceA.id, marketplaceB.id].sort(), + ); + }); + }); + + describe('excludes marketplaces from other organizations', () => { + let ours: Marketplace; + let result: Marketplace[]; + + beforeEach(async () => { + ours = marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + }); + const theirs = marketplaceFactory({ + organizationId: otherOrganization.id, + gitRepoId: otherGitRepo.id, + addedBy: user.id, + }); + await repository.add(ours); + await repository.add(theirs); + + result = await repository.findByOrganizationId(organization.id); + }); + + it('returns only one marketplace', () => { + expect(result).toHaveLength(1); + }); + + it('returns the marketplace of the organization', () => { + expect(result[0].id).toEqual(ours.id); + }); + }); + + it('excludes soft-deleted marketplaces', async () => { + const marketplace = await repository.add( + marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + }), + ); + await repository.deleteById(marketplace.id); + + const result = await repository.findByOrganizationId(organization.id); + + expect(result).toHaveLength(0); + }); + + describe('when the organization has no marketplaces', () => { + it('returns an empty array', async () => { + const result = await repository.findByOrganizationId(organization.id); + + expect(result).toEqual([]); + }); + }); + }); + + describe('findByOrganizationAndGitRepo', () => { + it('returns the marketplace matching the (org, gitRepo) pair', async () => { + const marketplace = marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + }); + await repository.add(marketplace); + + const result = await repository.findByOrganizationAndGitRepo( + organization.id, + gitRepo.id, + ); + + expect(result?.id).toEqual(marketplace.id); + }); + + describe('when the gitRepo is linked to a different organization', () => { + it('returns null', async () => { + await repository.add( + marketplaceFactory({ + organizationId: otherOrganization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + }), + ); + + const result = await repository.findByOrganizationAndGitRepo( + organization.id, + gitRepo.id, + ); + + expect(result).toBeNull(); + }); + }); + + describe('when the marketplace has been soft-deleted', () => { + it('returns null', async () => { + const marketplace = await repository.add( + marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + }), + ); + await repository.deleteById(marketplace.id); + + const result = await repository.findByOrganizationAndGitRepo( + organization.id, + gitRepo.id, + ); + + expect(result).toBeNull(); + }); + }); + }); + + describe('findByOrganizationAndId', () => { + describe('when it belongs to the organization', () => { + it('returns the marketplace', async () => { + const marketplace = marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + }); + await repository.add(marketplace); + + const result = await repository.findByOrganizationAndId( + organization.id, + marketplace.id, + ); + + expect(result?.id).toEqual(marketplace.id); + }); + }); + + describe('when the marketplace belongs to a different organization', () => { + it('returns null', async () => { + const marketplace = marketplaceFactory({ + organizationId: otherOrganization.id, + gitRepoId: otherGitRepo.id, + addedBy: user.id, + }); + await repository.add(marketplace); + + const result = await repository.findByOrganizationAndId( + organization.id, + marketplace.id, + ); + + expect(result).toBeNull(); + }); + }); + + describe('when the marketplace id does not exist', () => { + it('returns null', async () => { + const result = await repository.findByOrganizationAndId( + organization.id, + createMarketplaceId(uuidv4()), + ); + + expect(result).toBeNull(); + }); + }); + + it('excludes soft-deleted marketplaces', async () => { + const marketplace = await repository.add( + marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + }), + ); + await repository.deleteById(marketplace.id); + + const result = await repository.findByOrganizationAndId( + organization.id, + marketplace.id, + ); + + expect(result).toBeNull(); + }); + }); + + describe('findAllForReconciliation', () => { + it('returns all marketplaces across organizations', async () => { + const marketplaceA = marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + }); + const marketplaceB = marketplaceFactory({ + organizationId: otherOrganization.id, + gitRepoId: otherGitRepo.id, + addedBy: user.id, + }); + await repository.add(marketplaceA); + await repository.add(marketplaceB); + + const result = await repository.findAllForReconciliation(); + + expect(result.map((m) => m.id).sort()).toEqual( + [marketplaceA.id, marketplaceB.id].sort(), + ); + }); + + it('excludes soft-deleted marketplaces', async () => { + const alive = await repository.add( + marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + }), + ); + const deleted = await repository.add( + marketplaceFactory({ + organizationId: otherOrganization.id, + gitRepoId: otherGitRepo.id, + addedBy: user.id, + }), + ); + await repository.deleteById(deleted.id); + + const result = await repository.findAllForReconciliation(); + + expect(result.map((m) => m.id)).toEqual([alive.id]); + }); + }); + + describe('updateState', () => { + describe('when descriptor/pluginCount are omitted', () => { + let marketplace: Marketplace; + let validatedAt: Date; + let refreshed: Marketplace | null; + + beforeEach(async () => { + marketplace = await repository.add( + marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + state: 'healthy', + }), + ); + validatedAt = new Date('2026-03-15T10:00:00.000Z'); + + await repository.updateState(marketplace.id, { + state: 'unreachable', + lastValidatedAt: validatedAt, + }); + + refreshed = await repository.findById(marketplace.id); + }); + + it('updates state', () => { + expect(refreshed?.state).toBe('unreachable'); + }); + + it('updates lastValidatedAt', () => { + expect(refreshed?.lastValidatedAt).toEqual(validatedAt); + }); + + it('leaves the descriptor untouched', () => { + expect(refreshed?.descriptor).toEqual(marketplace.descriptor); + }); + + it('leaves the pluginCount untouched', () => { + expect(refreshed?.pluginCount).toBe(marketplace.pluginCount); + }); + }); + + describe('when descriptor and pluginCount are supplied (drift case)', () => { + let validatedAt: Date; + let newDescriptor: { + vendor: 'anthropic'; + name: string; + version: string; + plugins: { slug: string; name: string; version?: string }[]; + raw: { changed: boolean }; + }; + let refreshed: Marketplace | null; + + beforeEach(async () => { + const marketplace = await repository.add( + marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + state: 'healthy', + pluginCount: 2, + }), + ); + validatedAt = new Date('2026-04-01T12:00:00.000Z'); + newDescriptor = { + vendor: 'anthropic' as const, + name: 'updated-marketplace', + version: '2.0.0', + plugins: [ + { slug: 'plugin-one', name: 'Plugin One', version: '2.0.0' }, + { slug: 'plugin-three', name: 'Plugin Three' }, + { slug: 'plugin-four', name: 'Plugin Four' }, + ], + raw: { changed: true }, + }; + + await repository.updateState(marketplace.id, { + state: 'drift', + lastValidatedAt: validatedAt, + descriptor: newDescriptor, + pluginCount: newDescriptor.plugins.length, + }); + + refreshed = await repository.findById(marketplace.id); + }); + + it('updates state', () => { + expect(refreshed?.state).toBe('drift'); + }); + + it('updates lastValidatedAt', () => { + expect(refreshed?.lastValidatedAt).toEqual(validatedAt); + }); + + it('updates descriptor', () => { + expect(refreshed?.descriptor).toEqual(newDescriptor); + }); + + it('updates pluginCount', () => { + expect(refreshed?.pluginCount).toBe(3); + }); + }); + + describe('when the target marketplace does not exist', () => { + it('throws', async () => { + await expect( + repository.updateState(createMarketplaceId(uuidv4()), { + state: 'healthy', + lastValidatedAt: new Date(), + }), + ).rejects.toThrow(); + }); + }); + }); + + describe('unique (organizationId, gitRepoId) partial index', () => { + describe('re-linking a (org, gitRepo) pair after soft-delete', () => { + let initial: Marketplace; + let relinked: Marketplace; + let activeForRepo: Marketplace | null; + + beforeEach(async () => { + initial = await repository.add( + marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + }), + ); + await repository.deleteById(initial.id); + + relinked = await repository.add( + marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + }), + ); + + activeForRepo = await repository.findByOrganizationAndGitRepo( + organization.id, + gitRepo.id, + ); + }); + + it('creates a new marketplace distinct from the soft-deleted one', () => { + expect(relinked.id).not.toEqual(initial.id); + }); + + it('returns the re-linked marketplace as active for the repo', () => { + expect(activeForRepo?.id).toEqual(relinked.id); + }); + }); + }); +}); diff --git a/packages/deployments/src/infra/repositories/MarketplaceRepository.ts b/packages/deployments/src/infra/repositories/MarketplaceRepository.ts new file mode 100644 index 000000000..060cff57f --- /dev/null +++ b/packages/deployments/src/infra/repositories/MarketplaceRepository.ts @@ -0,0 +1,268 @@ +import { Repository } from 'typeorm'; +import { + GitRepoId, + Marketplace, + MarketplaceId, + OrganizationId, +} from '@packmind/types'; +import { PackmindLogger } from '@packmind/logger'; +import { AbstractRepository, localDataSource } from '@packmind/node-utils'; +import { + IMarketplaceRepository, + MarketplaceStateUpdate, +} from '../../domain/repositories/IMarketplaceRepository'; +import { MarketplaceSchema } from '../schemas/MarketplaceSchema'; + +const origin = 'MarketplaceRepository'; + +/** + * TypeORM-backed implementation of `IMarketplaceRepository`. + * + * Inherits soft-delete-aware CRUD from `AbstractRepository`. The default + * finders (`findById`, `findByOrganizationId`, etc.) exclude soft-deleted + * rows; the unique partial index on `(organization_id, git_repo_id)` lets a + * marketplace be re-linked after unlinking without colliding with the + * historic row. + */ +export class MarketplaceRepository + extends AbstractRepository<Marketplace> + implements IMarketplaceRepository +{ + constructor( + repository: Repository<Marketplace> = localDataSource.getRepository<Marketplace>( + MarketplaceSchema, + ), + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super('marketplace', repository, MarketplaceSchema, logger); + this.logger.info('MarketplaceRepository initialized'); + } + + protected override loggableEntity(entity: Marketplace): Partial<Marketplace> { + return { + id: entity.id, + organizationId: entity.organizationId, + gitRepoId: entity.gitRepoId, + name: entity.name, + vendor: entity.vendor, + state: entity.state, + }; + } + + async findByOrganizationId( + organizationId: OrganizationId, + ): Promise<Marketplace[]> { + this.logger.info('Finding marketplaces by organization ID', { + organizationId, + }); + + try { + const marketplaces = await this.repository + .createQueryBuilder('marketplace') + .where('marketplace.organizationId = :organizationId', { + organizationId, + }) + .orderBy('marketplace.linkedAt', 'DESC') + .getMany(); + + this.logger.info('Marketplaces found by organization ID', { + organizationId, + count: marketplaces.length, + }); + return marketplaces; + } catch (error) { + this.logger.error('Failed to find marketplaces by organization ID', { + organizationId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async findByOrganizationAndGitRepo( + organizationId: OrganizationId, + gitRepoId: GitRepoId, + ): Promise<Marketplace | null> { + this.logger.info('Finding marketplace by organization and git repo', { + organizationId, + gitRepoId, + }); + + try { + const marketplace = await this.repository + .createQueryBuilder('marketplace') + .where('marketplace.organizationId = :organizationId', { + organizationId, + }) + .andWhere('marketplace.gitRepoId = :gitRepoId', { gitRepoId }) + .getOne(); + + this.logger.info('Marketplace lookup by organization and git repo', { + organizationId, + gitRepoId, + found: !!marketplace, + }); + return marketplace; + } catch (error) { + this.logger.error( + 'Failed to find marketplace by organization and git repo', + { + organizationId, + gitRepoId, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async findByOrganizationAndId( + organizationId: OrganizationId, + id: MarketplaceId, + ): Promise<Marketplace | null> { + this.logger.info('Finding marketplace by organization and id', { + organizationId, + id, + }); + + try { + const marketplace = await this.repository + .createQueryBuilder('marketplace') + .where('marketplace.organizationId = :organizationId', { + organizationId, + }) + .andWhere('marketplace.id = :id', { id }) + .getOne(); + + this.logger.info('Marketplace lookup by organization and id', { + organizationId, + id, + found: !!marketplace, + }); + return marketplace; + } catch (error) { + this.logger.error('Failed to find marketplace by organization and id', { + organizationId, + id, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async findByTrackingToken(token: string): Promise<Marketplace | null> { + this.logger.info('Finding marketplace by tracking token (prefix only)', { + tokenPrefix: token.substring(0, 6), + }); + + try { + const marketplace = await this.repository + .createQueryBuilder('marketplace') + .where('marketplace.trackingToken = :token', { token }) + .getOne(); + + this.logger.info('Marketplace lookup by tracking token', { + tokenPrefix: token.substring(0, 6), + found: !!marketplace, + }); + return marketplace; + } catch (error) { + this.logger.error('Failed to find marketplace by tracking token', { + tokenPrefix: token.substring(0, 6), + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async findAllForReconciliation(): Promise<Marketplace[]> { + this.logger.info('Finding all marketplaces for reconciliation sweep'); + + try { + const marketplaces = await this.repository + .createQueryBuilder('marketplace') + .orderBy('marketplace.linkedAt', 'ASC') + .getMany(); + + this.logger.info('Marketplaces fetched for reconciliation', { + count: marketplaces.length, + }); + return marketplaces; + } catch (error) { + this.logger.error('Failed to fetch marketplaces for reconciliation', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async updateState( + id: MarketplaceId, + patch: MarketplaceStateUpdate, + ): Promise<void> { + this.logger.info('Updating marketplace state', { + id, + state: patch.state, + hasDescriptor: patch.descriptor !== undefined, + hasPluginCount: patch.pluginCount !== undefined, + }); + + try { + const updates: Partial<Marketplace> = { + state: patch.state, + lastValidatedAt: patch.lastValidatedAt, + }; + + if (patch.descriptor !== undefined) { + updates.descriptor = patch.descriptor; + } + + if (patch.pluginCount !== undefined) { + updates.pluginCount = patch.pluginCount; + } + + if (patch.errorKind !== undefined) { + updates.errorKind = patch.errorKind; + } + + if (patch.errorDetail !== undefined) { + updates.errorDetail = patch.errorDetail; + } + + if (patch.pendingPrUrl !== undefined) { + updates.pendingPrUrl = patch.pendingPrUrl; + } + + if (patch.outdatedPluginSlugs !== undefined) { + updates.outdatedPluginSlugs = patch.outdatedPluginSlugs; + } + + const result = await this.repository + .createQueryBuilder() + .update() + // The TypeORM QueryDeepPartialEntity narrowing rejects the `raw: + // unknown` field on `MarketplaceDescriptor`; the unknown cast keeps + // the upsert payload type-safe at the boundary while preserving the + // domain shape passed in. + .set(updates as unknown as Parameters<typeof this.repository.update>[1]) + .where('id = :id', { id }) + .execute(); + + if (result.affected === 0) { + this.logger.warn('No marketplace updated by updateState', { id }); + throw new Error(`No marketplace with id ${id} found`); + } + + this.logger.info('Marketplace state updated successfully', { + id, + state: patch.state, + }); + } catch (error) { + this.logger.error('Failed to update marketplace state', { + id, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/deployments/src/infra/repositories/PluginInstallationRepository.spec.ts b/packages/deployments/src/infra/repositories/PluginInstallationRepository.spec.ts new file mode 100644 index 000000000..2e091c4d7 --- /dev/null +++ b/packages/deployments/src/infra/repositories/PluginInstallationRepository.spec.ts @@ -0,0 +1,703 @@ +import { createTestDatasourceFixture, stubLogger } from '@packmind/test-utils'; +import { Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; +import { + PluginInstallationRepository, + normalizeRepoSlug, +} from './PluginInstallationRepository'; +import { MarketplaceRepository } from './MarketplaceRepository'; +import { PluginInstallationSchema } from '../schemas/PluginInstallationSchema'; +import { MarketplaceSchema } from '../schemas/MarketplaceSchema'; +import { + GitRepoSchema, + GitProviderSchema, + OrganizationGitHubAppSchema, +} from '@packmind/git'; +import { gitProviderFactory, gitRepoFactory } from '@packmind/git/test'; +import { + OrganizationSchema, + UserOrganizationMembershipSchema, + UserSchema, +} from '@packmind/accounts'; +import { userFactory } from '@packmind/accounts/test'; +import { + GitProvider, + GitRepo, + Marketplace, + Organization, + PluginInstallation, + User, + createOrganizationId, + createPluginInstallationId, +} from '@packmind/types'; +import { PackmindLogger } from '@packmind/logger'; +import { marketplaceFactory } from './__factories__/marketplaceFactory'; +import { UpsertHeartbeatInput } from '../../domain/repositories/IPluginInstallationRepository'; + +describe('PluginInstallationRepository', () => { + const fixture = createTestDatasourceFixture([ + OrganizationSchema, + UserSchema, + UserOrganizationMembershipSchema, + OrganizationGitHubAppSchema, + GitProviderSchema, + GitRepoSchema, + MarketplaceSchema, + PluginInstallationSchema, + ]); + + let repository: PluginInstallationRepository; + let marketplaceRepository: MarketplaceRepository; + let organizationRepo: Repository<Organization>; + let userRepo: Repository<User>; + let gitProviderRepo: Repository<GitProvider>; + let gitRepoRepo: Repository<GitRepo>; + let rawInstallationRepo: Repository<PluginInstallation>; + let logger: jest.Mocked<PackmindLogger>; + + let organization: Organization; + let user: User; + let gitProvider: GitProvider; + let gitRepo: GitRepo; + let marketplace: Marketplace; + + const NOW = new Date('2026-06-01T10:00:00.000Z'); + const LATER = new Date('2026-06-15T10:00:00.000Z'); + + // Use real UUIDs for userId (uuid column in pg-mem) + const USER_UUID_A = uuidv4(); + const USER_UUID_UPGRADE = uuidv4(); + const USER_UUID_MERGE = uuidv4(); + const USER_UUID_EDGE2 = uuidv4(); + + const createOrganization = async () => + organizationRepo.save({ + id: createOrganizationId(uuidv4()), + name: `Org ${uuidv4()}`, + slug: `org-${uuidv4()}`, + }); + + function makeInput( + overrides: Partial<UpsertHeartbeatInput> = {}, + ): UpsertHeartbeatInput { + return { + id: createPluginInstallationId(uuidv4()), + organizationId: organization.id, + marketplaceId: marketplace.id, + pluginSlug: 'test-plugin', + packageId: null, + installedVersion: null, + scope: 'user', + userId: null, + anonymousIdHash: null, + anonymousEmailMasked: null, + repoRemoteUrl: null, + now: NOW, + ...overrides, + }; + } + + beforeAll(() => fixture.initialize()); + + beforeEach(async () => { + logger = stubLogger(); + repository = new PluginInstallationRepository( + fixture.datasource.getRepository(PluginInstallationSchema), + logger, + ); + marketplaceRepository = new MarketplaceRepository( + fixture.datasource.getRepository(MarketplaceSchema), + logger, + ); + + organizationRepo = fixture.datasource.getRepository(OrganizationSchema); + userRepo = fixture.datasource.getRepository(UserSchema); + gitProviderRepo = fixture.datasource.getRepository(GitProviderSchema); + gitRepoRepo = fixture.datasource.getRepository(GitRepoSchema); + rawInstallationRepo = fixture.datasource.getRepository( + PluginInstallationSchema, + ); + + organization = await createOrganization(); + user = await userRepo.save(userFactory({ memberships: [] })); + gitProvider = await gitProviderRepo.save( + gitProviderFactory({ organizationId: organization.id }), + ); + gitRepo = await gitRepoRepo.save( + gitRepoFactory({ providerId: gitProvider.id, type: 'marketplace' }), + ); + marketplace = await marketplaceRepository.add( + marketplaceFactory({ + organizationId: organization.id, + gitRepoId: gitRepo.id, + addedBy: user.id, + trackingToken: uuidv4(), + }), + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + // --------------------------------------------------------------------------- + // Basic create + // --------------------------------------------------------------------------- + + describe('upsertHeartbeat', () => { + describe('when no row exists for the unique key', () => { + it('inserts a new row and returns created=true', async () => { + const input = makeInput({ + anonymousIdHash: 'abc123hash', + scope: 'user', + }); + + const result = await repository.upsertHeartbeat(input); + + expect(result.created).toBe(true); + }); + + it('persists the correct pluginSlug', async () => { + const input = makeInput({ anonymousIdHash: 'abc123hash' }); + + const result = await repository.upsertHeartbeat(input); + + expect(result.installation.pluginSlug).toBe('test-plugin'); + }); + + it('sets createdAt to now', async () => { + const input = makeInput({ anonymousIdHash: 'abc123hash' }); + + const result = await repository.upsertHeartbeat(input); + + expect(result.installation.createdAt).toEqual(NOW); + }); + + it('sets updatedAt to now', async () => { + const input = makeInput({ anonymousIdHash: 'abc123hash' }); + + const result = await repository.upsertHeartbeat(input); + + expect(result.installation.updatedAt).toEqual(NOW); + }); + }); + + // ------------------------------------------------------------------------- + // Absent-field key rule: identity-less heartbeats collapse into one row + // ------------------------------------------------------------------------- + + describe('when identity fields are absent (identity-less heartbeat)', () => { + it('uses empty string as identityKey', async () => { + const input = makeInput({ + userId: null, + anonymousIdHash: null, + scope: 'user', + }); + + await repository.upsertHeartbeat(input); + + const rows = await rawInstallationRepo.find(); + expect(rows[0].identityKey).toBe(''); + }); + + it('collapses two identity-less heartbeats into one row', async () => { + const input1 = makeInput({ + userId: null, + anonymousIdHash: null, + scope: 'user', + }); + const input2 = makeInput({ + id: createPluginInstallationId(uuidv4()), + userId: null, + anonymousIdHash: null, + scope: 'user', + now: LATER, + }); + + await repository.upsertHeartbeat(input1); + await repository.upsertHeartbeat(input2); + + const rows = await rawInstallationRepo.find(); + expect(rows).toHaveLength(1); + }); + }); + + describe('when repo fields are absent for project scope', () => { + it('uses empty string as repoKey', async () => { + const input = makeInput({ + scope: 'project', + userId: USER_UUID_A, + repoRemoteUrl: null, + }); + + await repository.upsertHeartbeat(input); + + const rows = await rawInstallationRepo.find(); + expect(rows[0].repoKey).toBe(''); + }); + }); + + describe('when user scope is used', () => { + it('always sets repoKey to empty string regardless of repo fields', async () => { + const input = makeInput({ + scope: 'user', + userId: USER_UUID_A, + repoRemoteUrl: 'https://github.com/acme/frontend.git', + }); + + await repository.upsertHeartbeat(input); + + const rows = await rawInstallationRepo.find(); + expect(rows[0].repoKey).toBe(''); + }); + + it('does not track the repository (repoRemoteUrl is null)', async () => { + const input = makeInput({ + scope: 'user', + userId: USER_UUID_A, + repoRemoteUrl: 'https://github.com/acme/frontend.git', + }); + + await repository.upsertHeartbeat(input); + + const rows = await rawInstallationRepo.find(); + expect(rows[0].repoRemoteUrl).toBeNull(); + }); + }); + + describe('when project scope is used', () => { + it('tracks the repository (repoRemoteUrl is persisted)', async () => { + const input = makeInput({ + scope: 'project', + userId: USER_UUID_A, + repoRemoteUrl: 'https://github.com/acme/frontend.git', + }); + + await repository.upsertHeartbeat(input); + + const rows = await rawInstallationRepo.find(); + expect(rows[0].repoRemoteUrl).toBe( + 'https://github.com/acme/frontend.git', + ); + }); + }); + + // ------------------------------------------------------------------------- + // lastSeen bump + // ------------------------------------------------------------------------- + + describe('when the same heartbeat arrives again (lastSeen bump)', () => { + let firstResult: Awaited<ReturnType<typeof repository.upsertHeartbeat>>; + let secondResult: Awaited<ReturnType<typeof repository.upsertHeartbeat>>; + + beforeEach(async () => { + firstResult = await repository.upsertHeartbeat( + makeInput({ anonymousIdHash: 'hash-repeat' }), + ); + secondResult = await repository.upsertHeartbeat( + makeInput({ + id: createPluginInstallationId(uuidv4()), + anonymousIdHash: 'hash-repeat', + now: LATER, + }), + ); + }); + + it('does not create a new row', async () => { + expect(secondResult.created).toBe(false); + }); + + it('preserves createdAt', () => { + expect(secondResult.installation.createdAt).toEqual( + firstResult.installation.createdAt, + ); + }); + + it('bumps updatedAt', () => { + expect(secondResult.installation.updatedAt).toEqual(LATER); + }); + + it('keeps exactly one row in the database', async () => { + const rows = await rawInstallationRepo.find(); + expect(rows).toHaveLength(1); + }); + }); + + // ------------------------------------------------------------------------- + // installedVersion + // ------------------------------------------------------------------------- + + describe('installedVersion', () => { + describe('when a heartbeat reports an installed version', () => { + it('persists the reported version on insert', async () => { + const input = makeInput({ + anonymousIdHash: 'hash-version', + installedVersion: '0.1.0', + }); + + const result = await repository.upsertHeartbeat(input); + + expect(result.installation.installedVersion).toBe('0.1.0'); + }); + }); + + describe('when a later heartbeat reports a different version', () => { + let secondResult: Awaited< + ReturnType<typeof repository.upsertHeartbeat> + >; + + beforeEach(async () => { + await repository.upsertHeartbeat( + makeInput({ + anonymousIdHash: 'hash-upgrade-version', + installedVersion: '0.1.0', + }), + ); + secondResult = await repository.upsertHeartbeat( + makeInput({ + id: createPluginInstallationId(uuidv4()), + anonymousIdHash: 'hash-upgrade-version', + installedVersion: '0.2.0', + now: LATER, + }), + ); + }); + + it('refreshes installedVersion to the latest reported value', () => { + expect(secondResult.installation.installedVersion).toBe('0.2.0'); + }); + + it('keeps exactly one row in the database', async () => { + const rows = await rawInstallationRepo.find(); + expect(rows).toHaveLength(1); + }); + }); + + describe('when the heartbeat omits a version', () => { + it('stores null', async () => { + const input = makeInput({ + anonymousIdHash: 'hash-no-version', + installedVersion: null, + }); + + const result = await repository.upsertHeartbeat(input); + + expect(result.installation.installedVersion).toBeNull(); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Anonymous → attributed upgrade + // ------------------------------------------------------------------------- + + describe('anonymous to attributed upgrade', () => { + const ANON_HASH = 'sha256-anon-hash'; + const USER_ID = USER_UUID_UPGRADE; + + describe('when an anonymous row exists and an attributed heartbeat arrives', () => { + let anonResult: Awaited<ReturnType<typeof repository.upsertHeartbeat>>; + let upgradeResult: Awaited< + ReturnType<typeof repository.upsertHeartbeat> + >; + + beforeEach(async () => { + anonResult = await repository.upsertHeartbeat( + makeInput({ anonymousIdHash: ANON_HASH }), + ); + upgradeResult = await repository.upsertHeartbeat( + makeInput({ + id: createPluginInstallationId(uuidv4()), + userId: USER_ID, + anonymousIdHash: ANON_HASH, + now: LATER, + }), + ); + }); + + it('does not create a new row', () => { + expect(upgradeResult.created).toBe(false); + }); + + it('preserves createdAt from the anonymous row', () => { + expect(upgradeResult.installation.createdAt).toEqual( + anonResult.installation.createdAt, + ); + }); + + it('upgrades identityKey to userId', async () => { + const rows = await rawInstallationRepo.find(); + expect(rows[0].identityKey).toBe(USER_ID); + }); + + it('keeps exactly one row in the database', async () => { + const rows = await rawInstallationRepo.find(); + expect(rows).toHaveLength(1); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Edge case 1: both attributed + anonymous rows exist (merge) + // ------------------------------------------------------------------------- + + describe('edge case 1: merge when both attributed and anonymous rows exist', () => { + const ANON_HASH = 'sha256-merge-hash'; + const USER_ID = USER_UUID_MERGE; + const EARLIER = new Date('2026-05-01T10:00:00.000Z'); + + describe('when credentials expired causing an anonymous row after an attributed one', () => { + let mergeResult: Awaited<ReturnType<typeof repository.upsertHeartbeat>>; + + beforeEach(async () => { + // Attributed row created first + await repository.upsertHeartbeat( + makeInput({ + userId: USER_ID, + anonymousIdHash: ANON_HASH, + now: NOW, + }), + ); + + // Anonymous row created separately (simulating expired credentials) + // Insert directly to bypass the upgrade path + const anonRow = { + id: createPluginInstallationId(uuidv4()), + organizationId: organization.id, + marketplaceId: marketplace.id, + pluginSlug: 'test-plugin', + packageId: null, + scope: 'user' as const, + userId: null, + anonymousIdHash: ANON_HASH, + anonymousEmailMasked: null, + identityKey: ANON_HASH, + repoRemoteUrl: null, + repoKey: '', + createdAt: EARLIER, + updatedAt: EARLIER, + deletedAt: null, + deletedBy: null, + }; + await rawInstallationRepo.save(anonRow); + + // Now re-authenticated heartbeat arrives + mergeResult = await repository.upsertHeartbeat( + makeInput({ + id: createPluginInstallationId(uuidv4()), + userId: USER_ID, + anonymousIdHash: ANON_HASH, + now: LATER, + }), + ); + }); + + it('keeps exactly one row after merge', async () => { + const rows = await rawInstallationRepo.find(); + expect(rows).toHaveLength(1); + }); + + it('does not create a new row', () => { + expect(mergeResult.created).toBe(false); + }); + + it('preserves the earliest createdAt', () => { + expect(mergeResult.installation.createdAt).toEqual(EARLIER); + }); + + it('updates updatedAt to the latest value', () => { + expect(mergeResult.installation.updatedAt).toEqual(LATER); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Edge case 2: anonymous heartbeat matches attributed row's anonymousIdHash + // ------------------------------------------------------------------------- + + describe('edge case 2: anonymous heartbeat bumps attributed row with matching hash', () => { + const ANON_HASH = 'sha256-attr-hash'; + const USER_ID = USER_UUID_EDGE2; + + describe('when an attributed row exists and an anonymous heartbeat with the same hash arrives', () => { + let anonBumpResult: Awaited< + ReturnType<typeof repository.upsertHeartbeat> + >; + + beforeEach(async () => { + await repository.upsertHeartbeat( + makeInput({ + userId: USER_ID, + anonymousIdHash: ANON_HASH, + now: NOW, + }), + ); + + // Anonymous heartbeat with same hash (e.g. credentials were not available) + anonBumpResult = await repository.upsertHeartbeat( + makeInput({ + id: createPluginInstallationId(uuidv4()), + userId: null, + anonymousIdHash: ANON_HASH, + now: LATER, + }), + ); + }); + + it('does not create a new row', () => { + expect(anonBumpResult.created).toBe(false); + }); + + it('bumps the attributed row updatedAt', () => { + expect(anonBumpResult.installation.updatedAt).toEqual(LATER); + }); + + it('keeps exactly one row in the database', async () => { + const rows = await rawInstallationRepo.find(); + expect(rows).toHaveLength(1); + }); + + it('attributed row retains userId', async () => { + const rows = await rawInstallationRepo.find(); + expect(rows[0].userId).toBe(USER_ID); + }); + }); + }); + }); + + // --------------------------------------------------------------------------- + // listByMarketplace + // --------------------------------------------------------------------------- + + describe('listByMarketplace', () => { + describe('when marketplace has no installs', () => { + it('returns an empty array', async () => { + const result = await repository.listByMarketplace(marketplace.id); + + expect(result).toEqual([]); + }); + }); + + describe('when marketplace has multiple installs', () => { + let result: PluginInstallation[]; + + beforeEach(async () => { + await repository.upsertHeartbeat( + makeInput({ anonymousIdHash: 'hash-a', pluginSlug: 'plugin-a' }), + ); + await repository.upsertHeartbeat( + makeInput({ anonymousIdHash: 'hash-b', pluginSlug: 'plugin-b' }), + ); + + result = await repository.listByMarketplace(marketplace.id); + }); + + it('returns all installs for the marketplace', () => { + expect(result).toHaveLength(2); + }); + + it('returns rows with the correct marketplaceId', () => { + expect(result.every((r) => r.marketplaceId === marketplace.id)).toBe( + true, + ); + }); + }); + + describe('when another marketplace exists', () => { + let otherMarketplace: Marketplace; + let result: PluginInstallation[]; + + beforeEach(async () => { + const otherGitRepo = await gitRepoRepo.save( + gitRepoFactory({ providerId: gitProvider.id, type: 'marketplace' }), + ); + otherMarketplace = await marketplaceRepository.add( + marketplaceFactory({ + organizationId: organization.id, + gitRepoId: otherGitRepo.id, + addedBy: user.id, + }), + ); + + await repository.upsertHeartbeat( + makeInput({ anonymousIdHash: 'hash-mine' }), + ); + await repository.upsertHeartbeat( + makeInput({ + marketplaceId: otherMarketplace.id, + anonymousIdHash: 'hash-other', + }), + ); + + result = await repository.listByMarketplace(marketplace.id); + }); + + it('returns only installs for the requested marketplace', () => { + expect(result).toHaveLength(1); + }); + + it('excludes installs from the other marketplace', () => { + expect(result[0].marketplaceId).toBe(marketplace.id); + }); + }); + }); +}); + +describe('normalizeRepoSlug', () => { + describe('when given null or empty', () => { + it('returns null for null', () => { + expect(normalizeRepoSlug(null)).toBeNull(); + }); + + it('returns null for undefined', () => { + expect(normalizeRepoSlug(undefined)).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(normalizeRepoSlug('')).toBeNull(); + }); + }); + + describe('when given HTTPS URLs', () => { + it('normalizes a github https URL', () => { + expect(normalizeRepoSlug('https://github.com/acme/frontend.git')).toBe( + 'acme/frontend', + ); + }); + + it('normalizes a gitlab https URL', () => { + expect(normalizeRepoSlug('https://gitlab.com/group/subgroup.git')).toBe( + 'group/subgroup', + ); + }); + + it('normalizes a URL without .git suffix', () => { + expect(normalizeRepoSlug('https://github.com/acme/billing')).toBe( + 'acme/billing', + ); + }); + }); + + describe('when given SSH URLs', () => { + it('normalizes a github SSH URL', () => { + expect(normalizeRepoSlug('git@github.com:acme/frontend.git')).toBe( + 'acme/frontend', + ); + }); + + it('normalizes an SSH URL without .git suffix', () => { + expect(normalizeRepoSlug('git@github.com:acme/billing')).toBe( + 'acme/billing', + ); + }); + }); + + describe('when given a malformed URL', () => { + it('returns null', () => { + expect(normalizeRepoSlug('not-a-url')).toBeNull(); + }); + }); +}); diff --git a/packages/deployments/src/infra/repositories/PluginInstallationRepository.ts b/packages/deployments/src/infra/repositories/PluginInstallationRepository.ts new file mode 100644 index 000000000..ee1c1d728 --- /dev/null +++ b/packages/deployments/src/infra/repositories/PluginInstallationRepository.ts @@ -0,0 +1,465 @@ +import { IsNull, Not, Repository } from 'typeorm'; +import { + MarketplaceId, + PluginInstallation, + PluginInstallationId, +} from '@packmind/types'; +import { PackmindLogger } from '@packmind/logger'; +import { localDataSource } from '@packmind/node-utils'; +import { + IPluginInstallationRepository, + UpsertHeartbeatInput, + UpsertHeartbeatResult, +} from '../../domain/repositories/IPluginInstallationRepository'; +import { PluginInstallationSchema } from '../schemas/PluginInstallationSchema'; + +const origin = 'PluginInstallationRepository'; + +/** + * Normalizes a raw git remote URL to an `owner/repo` slug. + * + * Handles: + * - HTTPS: `https://github.com/owner/repo.git` + * - SSH: `git@github.com:owner/repo.git` + * + * Returns `null` when the URL cannot be normalized. + */ +export function normalizeRepoSlug( + rawUrl: string | null | undefined, +): string | null { + if (!rawUrl) { + return null; + } + const trimmed = rawUrl.trim(); + + // SSH form: git@<host>:<owner>/<repo>[.git] + const sshMatch = trimmed.match(/^git@[^:]+:([^/]+\/.+?)(?:\.git)?$/); + if (sshMatch) { + return sshMatch[1]; + } + + // HTTPS form: https://<host>/<owner>/<repo>[.git] + try { + const url = new URL(trimmed); + const parts = url.pathname + .replace(/^\//, '') + .replace(/\.git$/, '') + .split('/'); + if (parts.length >= 2 && parts[0] && parts[1]) { + return `${parts[0]}/${parts[1]}`; + } + } catch { + // Intentional: malformed URL falls through to null + } + + return null; +} + +/** + * Computes the `identityKey` from the raw identity fields. + * NOT NULL rule: `userId ?? anonymousIdHash ?? ''` + */ +function computeIdentityKey( + userId: string | null, + anonymousIdHash: string | null, +): string { + return userId ?? anonymousIdHash ?? ''; +} + +/** + * Computes the `repoKey` from scope + the raw repo remote URL. + * NOT NULL rule: `''` for user scope; else the normalized `owner/repo` slug + * of `repoRemoteUrl` ?? the raw `repoRemoteUrl` ?? `''`. + */ +function computeRepoKey( + scope: PluginInstallation['scope'], + repoRemoteUrl: string | null, +): string { + if (scope === 'user') { + return ''; + } + return normalizeRepoSlug(repoRemoteUrl) ?? repoRemoteUrl ?? ''; +} + +/** + * Computes the stored `repoRemoteUrl`. A user-scope install is global and not + * bound to a repository — the repo the user happened to be in when the + * heartbeat fired is incidental and must not be tracked, so it is dropped to + * `null` regardless of what the client sent. + */ +function computeRepoRemoteUrl( + scope: PluginInstallation['scope'], + repoRemoteUrl: string | null, +): string | null { + if (scope === 'user') { + return null; + } + return repoRemoteUrl; +} + +/** + * TypeORM-backed implementation of `IPluginInstallationRepository`. + * + * Implements heartbeat-upsert semantics including the anonymous→attributed + * identity upgrade and the two merge edge cases defined in spec §7.1. + * + * `createdAt` (first-seen) and `updatedAt` (last-seen) are managed explicitly + * here — the schema declares them as plain columns rather than TypeORM + * create/update-date columns, since the QueryBuilder updates below would + * otherwise bypass `@UpdateDateColumn`. + */ +export class PluginInstallationRepository implements IPluginInstallationRepository { + protected readonly logger: PackmindLogger; + + constructor( + private readonly repository: Repository<PluginInstallation> = localDataSource.getRepository<PluginInstallation>( + PluginInstallationSchema, + ), + logger: PackmindLogger = new PackmindLogger(origin), + ) { + this.logger = logger; + this.logger.info('PluginInstallationRepository initialized'); + } + + /** + * Insert or update a heartbeat row implementing spec §7.1 upsert logic. + * + * Decision tree: + * 1. Attributed heartbeat (userId provided): + * a. Look for an existing attributed row keyed by userId. + * b. Look for an anonymous row keyed by anonymousIdHash (upgrade candidate). + * c. **Edge case 1** — both rows exist: merge (keep earliest createdAt, + * update attributed updatedAt, hard-delete anonymous row). + * d. **Upgrade** — only anonymous row exists: promote it in-place to userId. + * e. **Normal** — only attributed row exists: bump updatedAt. + * f. No row: insert new attributed row. + * + * 2. Anonymous heartbeat (no userId): + * a. **Edge case 2** — an attributed row already carries the same + * anonymousIdHash: bump that row's updatedAt (don't insert duplicate). + * b. Normal upsert on identityKey = anonymousIdHash ?? ''. + */ + async upsertHeartbeat( + input: UpsertHeartbeatInput, + ): Promise<UpsertHeartbeatResult> { + this.logger.info('Upserting plugin install heartbeat', { + marketplaceId: input.marketplaceId, + pluginSlug: input.pluginSlug, + scope: input.scope, + }); + + try { + const identityKey = computeIdentityKey( + input.userId, + input.anonymousIdHash, + ); + const repoKey = computeRepoKey(input.scope, input.repoRemoteUrl); + + if (input.userId) { + return await this.upsertAttributedHeartbeat( + input, + identityKey, + repoKey, + ); + } else { + return await this.upsertAnonymousHeartbeat(input, identityKey, repoKey); + } + } catch (error) { + this.logger.error('Failed to upsert plugin install heartbeat', { + marketplaceId: input.marketplaceId, + pluginSlug: input.pluginSlug, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async listByMarketplace( + marketplaceId: MarketplaceId, + ): Promise<PluginInstallation[]> { + this.logger.info('Listing plugin installations by marketplace', { + marketplaceId, + }); + + try { + const installations = await this.repository + .createQueryBuilder('installation') + .where('installation.marketplaceId = :marketplaceId', { marketplaceId }) + .orderBy('installation.createdAt', 'DESC') + .getMany(); + + this.logger.info('Plugin installations fetched by marketplace', { + marketplaceId, + count: installations.length, + }); + return installations; + } catch (error) { + this.logger.error('Failed to list plugin installations by marketplace', { + marketplaceId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private async upsertAttributedHeartbeat( + input: UpsertHeartbeatInput, + identityKey: string, + repoKey: string, + ): Promise<UpsertHeartbeatResult> { + // Look for existing attributed row for this userId + const attributedRow = await this.findUniqueRow( + input.marketplaceId, + input.pluginSlug, + input.scope, + identityKey, // userId + repoKey, + ); + + // Look for existing anonymous row (upgrade candidate) + const anonymousKey = input.anonymousIdHash ?? ''; + const anonymousRow = + anonymousKey !== identityKey && anonymousKey !== '' + ? await this.findUniqueRow( + input.marketplaceId, + input.pluginSlug, + input.scope, + anonymousKey, + repoKey, + ) + : null; + + if (attributedRow && anonymousRow) { + // Edge case 1: both attributed + anonymous rows exist — merge them + return await this.mergeRows(attributedRow, anonymousRow, input); + } + + if (!attributedRow && anonymousRow) { + // Upgrade: anonymous → attributed in place + return await this.upgradeAnonymousRow(anonymousRow, input, identityKey); + } + + if (attributedRow) { + // Normal: bump updatedAt + refresh installedVersion on existing attributed row + await this.bumpLastSeen( + attributedRow.id, + input.now, + input.installedVersion, + ); + return { + created: false, + installation: { + ...attributedRow, + updatedAt: input.now, + installedVersion: input.installedVersion, + }, + }; + } + + // New: insert attributed row + return await this.insertNewRow(input, identityKey, repoKey); + } + + private async upsertAnonymousHeartbeat( + input: UpsertHeartbeatInput, + identityKey: string, // anonymousIdHash ?? '' + repoKey: string, + ): Promise<UpsertHeartbeatResult> { + // Edge case 2: check whether an attributed row carries this anonymousIdHash + if (input.anonymousIdHash) { + const attributedRowWithSameHash = await this.repository.findOne({ + where: { + marketplaceId: input.marketplaceId, + pluginSlug: input.pluginSlug, + scope: input.scope, + repoKey, + anonymousIdHash: input.anonymousIdHash, + userId: Not(IsNull()), + }, + }); + + if (attributedRowWithSameHash) { + // Bump the attributed row — don't insert a duplicate + await this.bumpLastSeen( + attributedRowWithSameHash.id, + input.now, + input.installedVersion, + ); + return { + created: false, + installation: { + ...attributedRowWithSameHash, + updatedAt: input.now, + installedVersion: input.installedVersion, + }, + }; + } + } + + // Normal anonymous upsert + const existingRow = await this.findUniqueRow( + input.marketplaceId, + input.pluginSlug, + input.scope, + identityKey, + repoKey, + ); + + if (existingRow) { + await this.bumpLastSeen( + existingRow.id, + input.now, + input.installedVersion, + ); + return { + created: false, + installation: { + ...existingRow, + updatedAt: input.now, + installedVersion: input.installedVersion, + }, + }; + } + + return await this.insertNewRow(input, identityKey, repoKey); + } + + private async findUniqueRow( + marketplaceId: MarketplaceId, + pluginSlug: string, + scope: PluginInstallation['scope'], + identityKey: string, + repoKey: string, + ): Promise<PluginInstallation | null> { + return this.repository + .createQueryBuilder('installation') + .where('installation.marketplaceId = :marketplaceId', { marketplaceId }) + .andWhere('installation.pluginSlug = :pluginSlug', { pluginSlug }) + .andWhere('installation.scope = :scope', { scope }) + .andWhere('installation.identityKey = :identityKey', { identityKey }) + .andWhere('installation.repoKey = :repoKey', { repoKey }) + .getOne(); + } + + /** + * Bump last-seen (`updatedAt`) and refresh `installedVersion` to the latest + * value reported by this heartbeat, so a row reflects the version a consumer + * is currently running (e.g. after they upgrade the plugin). + */ + private async bumpLastSeen( + id: PluginInstallationId, + now: Date, + installedVersion: string | null, + ): Promise<void> { + await this.repository + .createQueryBuilder() + .update() + .set({ updatedAt: now, installedVersion }) + .where('id = :id', { id }) + .execute(); + } + + private async insertNewRow( + input: UpsertHeartbeatInput, + identityKey: string, + repoKey: string, + ): Promise<UpsertHeartbeatResult> { + const installation: PluginInstallation = { + id: input.id, + organizationId: + input.organizationId as PluginInstallation['organizationId'], + marketplaceId: input.marketplaceId, + pluginSlug: input.pluginSlug, + packageId: input.packageId as PluginInstallation['packageId'], + installedVersion: input.installedVersion, + scope: input.scope, + userId: input.userId as PluginInstallation['userId'], + anonymousIdHash: input.anonymousIdHash, + anonymousEmailMasked: input.anonymousEmailMasked, + identityKey, + repoRemoteUrl: computeRepoRemoteUrl(input.scope, input.repoRemoteUrl), + repoKey, + // createdAt = first-seen, updatedAt = last-seen — both managed here. + createdAt: input.now, + updatedAt: input.now, + // Soft-delete is managed by TypeORM / DB defaults + deletedAt: null, + deletedBy: null, + }; + + const saved = await this.repository.save(installation); + return { created: true, installation: saved }; + } + + private async upgradeAnonymousRow( + anonymousRow: PluginInstallation, + input: UpsertHeartbeatInput, + newIdentityKey: string, // userId (the new attributed key) + ): Promise<UpsertHeartbeatResult> { + await this.repository + .createQueryBuilder() + .update() + .set({ + userId: input.userId as PluginInstallation['userId'], + identityKey: newIdentityKey, + anonymousIdHash: input.anonymousIdHash, + anonymousEmailMasked: input.anonymousEmailMasked, + installedVersion: input.installedVersion, + updatedAt: input.now, + }) + .where('id = :id', { id: anonymousRow.id }) + .execute(); + + const upgraded: PluginInstallation = { + ...anonymousRow, + userId: input.userId as PluginInstallation['userId'], + identityKey: newIdentityKey, + installedVersion: input.installedVersion, + updatedAt: input.now, + }; + // Upgrade does not count as a "new creation" — it's a promotion + return { created: false, installation: upgraded }; + } + + private async mergeRows( + attributedRow: PluginInstallation, + anonymousRow: PluginInstallation, + input: UpsertHeartbeatInput, + ): Promise<UpsertHeartbeatResult> { + // Keep earliest createdAt (first-seen), latest updatedAt (last-seen) + const mergedCreatedAt = + attributedRow.createdAt <= anonymousRow.createdAt + ? attributedRow.createdAt + : anonymousRow.createdAt; + + await this.repository + .createQueryBuilder() + .update() + .set({ + createdAt: mergedCreatedAt, + updatedAt: input.now, + installedVersion: input.installedVersion, + }) + .where('id = :id', { id: attributedRow.id }) + .execute(); + + // Hard-delete the anonymous row (it's a duplicate) + await this.repository + .createQueryBuilder() + .delete() + .where('id = :id', { id: anonymousRow.id }) + .execute(); + + const merged: PluginInstallation = { + ...attributedRow, + createdAt: mergedCreatedAt, + updatedAt: input.now, + installedVersion: input.installedVersion, + }; + return { created: false, installation: merged }; + } +} diff --git a/packages/deployments/src/infra/repositories/__factories__/marketplaceDistributionFactory.ts b/packages/deployments/src/infra/repositories/__factories__/marketplaceDistributionFactory.ts new file mode 100644 index 000000000..830a0a74f --- /dev/null +++ b/packages/deployments/src/infra/repositories/__factories__/marketplaceDistributionFactory.ts @@ -0,0 +1,64 @@ +import { Factory } from '@packmind/test-utils'; +import { + DistributionStatus, + MarketplaceDistribution, + createMarketplaceDistributionId, + createMarketplaceId, + createOrganizationId, + createPackageId, + createUserId, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; + +/** + * Factory for building `MarketplaceDistribution` rows in tests. + * + * Defaults match a freshly-enqueued `in_progress` row created by the + * publish use case — overridable per test. Timestamps and soft-delete + * columns are intentionally omitted: TypeORM populates `created_at` / + * `updated_at` via column defaults, and `deleted_at` / `deleted_by` stay + * `null` until an explicit soft-delete. + */ +export const marketplaceDistributionFactory: Factory< + MarketplaceDistribution +> = (overrides?: Partial<MarketplaceDistribution>) => { + return { + id: createMarketplaceDistributionId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + marketplaceId: createMarketplaceId(uuidv4()), + packageId: createPackageId(uuidv4()), + pluginSlug: 'sample-plugin', + authorId: createUserId(uuidv4()), + status: DistributionStatus.in_progress, + source: 'app', + ...overrides, + } as MarketplaceDistribution; +}; + +/** + * Chainable helper: builds a `to_be_removed` distribution. + * + * Used by repository specs, use-case specs and integration tests covering + * the pending-removal lifecycle. + */ +export const toBeRemovedMarketplaceDistributionFactory: Factory< + MarketplaceDistribution +> = (overrides?: Partial<MarketplaceDistribution>) => + marketplaceDistributionFactory({ + status: DistributionStatus.to_be_removed, + ...overrides, + }); + +/** + * Chainable helper: builds a terminal `removed` distribution. + * + * Used by reconciliation-job specs covering the terminal transition once a + * pending-removal slug has disappeared from the marketplace descriptor. + */ +export const removedMarketplaceDistributionFactory: Factory< + MarketplaceDistribution +> = (overrides?: Partial<MarketplaceDistribution>) => + marketplaceDistributionFactory({ + status: DistributionStatus.removed, + ...overrides, + }); diff --git a/packages/deployments/src/infra/repositories/__factories__/marketplaceFactory.ts b/packages/deployments/src/infra/repositories/__factories__/marketplaceFactory.ts new file mode 100644 index 000000000..32a6b6f13 --- /dev/null +++ b/packages/deployments/src/infra/repositories/__factories__/marketplaceFactory.ts @@ -0,0 +1,62 @@ +import { Factory } from '@packmind/test-utils'; +import { + Marketplace, + MarketplaceDescriptor, + createGitRepoId, + createMarketplaceId, + createOrganizationId, + createUserId, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; + +const defaultDescriptor = (): MarketplaceDescriptor => ({ + vendor: 'anthropic', + name: 'test-marketplace', + version: '1.0.0', + plugins: [ + { slug: 'plugin-one', name: 'Plugin One', version: '1.0.0' }, + { slug: 'plugin-two', name: 'Plugin Two' }, + ], + raw: { + name: 'test-marketplace', + version: '1.0.0', + plugins: [ + { slug: 'plugin-one', name: 'Plugin One', version: '1.0.0' }, + { slug: 'plugin-two', name: 'Plugin Two' }, + ], + }, +}); + +/** + * Factory for building `Marketplace` rows in tests. + * + * Provides sensible defaults so most callers can override only the + * specific field they care about (e.g. `organizationId`, `gitRepoId`). + * Timestamps and soft-delete columns are intentionally omitted — TypeORM + * populates `created_at` / `updated_at` via column defaults, and + * `deleted_at` / `deleted_by` stay `null` until an explicit soft-delete. + */ +export const marketplaceFactory: Factory<Marketplace> = ( + overrides?: Partial<Marketplace>, +) => { + const descriptor = overrides?.descriptor ?? defaultDescriptor(); + return { + id: createMarketplaceId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + gitRepoId: createGitRepoId(uuidv4()), + name: 'Test Marketplace', + vendor: 'anthropic', + addedBy: createUserId(uuidv4()), + linkedAt: new Date('2026-01-01T00:00:00.000Z'), + state: 'healthy', + lastValidatedAt: null, + descriptor, + pluginCount: descriptor.plugins.length, + errorKind: null, + errorDetail: null, + pendingPrUrl: null, + outdatedPluginSlugs: null, + trackingToken: null, + ...overrides, + } as Marketplace; +}; diff --git a/packages/deployments/src/infra/repositories/__factories__/pluginInstallationFactory.ts b/packages/deployments/src/infra/repositories/__factories__/pluginInstallationFactory.ts new file mode 100644 index 000000000..be0d20f7d --- /dev/null +++ b/packages/deployments/src/infra/repositories/__factories__/pluginInstallationFactory.ts @@ -0,0 +1,60 @@ +import { Factory } from '@packmind/test-utils'; +import { + PluginInstallation, + createMarketplaceId, + createOrganizationId, + createPluginInstallationId, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; + +/** + * Factory for building `PluginInstallation` rows in tests. + * + * Provides sensible defaults; callers override the fields they need. + * `identityKey` and `repoKey` are NOT NULL per spec §7.1 — defaults + * mirror the absent-field key rule: + * identityKey = userId ?? anonymousIdHash ?? '' + * repoKey = '' for user scope; normalized slug ?? repoRemoteUrl ?? '' otherwise + */ +export const pluginInstallationFactory: Factory<PluginInstallation> = ( + overrides?: Partial<PluginInstallation>, +): PluginInstallation => { + const userId = overrides?.userId ?? null; + const anonymousIdHash = overrides?.anonymousIdHash ?? null; + const scope = overrides?.scope ?? 'user'; + const repoRemoteUrl = overrides?.repoRemoteUrl ?? null; + + const identityKey = + overrides?.identityKey !== undefined + ? overrides.identityKey + : (userId ?? anonymousIdHash ?? ''); + + const repoKey = + overrides?.repoKey !== undefined + ? overrides.repoKey + : scope === 'user' + ? '' + : (repoRemoteUrl ?? ''); + + return { + id: createPluginInstallationId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + marketplaceId: createMarketplaceId(uuidv4()), + pluginSlug: 'test-plugin', + packageId: null, + installedVersion: null, + scope, + userId, + anonymousIdHash, + anonymousEmailMasked: null, + repoRemoteUrl, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + deletedAt: null, + deletedBy: null, + ...overrides, + // Recompute computed keys when overrides change the source fields + identityKey, + repoKey, + }; +}; diff --git a/packages/deployments/src/infra/repositories/index.ts b/packages/deployments/src/infra/repositories/index.ts index aa7f562ee..fa142de9f 100644 --- a/packages/deployments/src/infra/repositories/index.ts +++ b/packages/deployments/src/infra/repositories/index.ts @@ -4,6 +4,8 @@ import { PackageRepository } from './PackageRepository'; import { DistributionRepository } from './DistributionRepository'; import { DistributedPackageRepository } from './DistributedPackageRepository'; import { DeploymentsRepositories } from './DeploymentsRepositories'; +import { MarketplaceRepository } from './MarketplaceRepository'; +import { PluginInstallationRepository } from './PluginInstallationRepository'; export { TargetRepository, @@ -12,4 +14,6 @@ export { DistributionRepository, DistributedPackageRepository, DeploymentsRepositories, + MarketplaceRepository, + PluginInstallationRepository, }; diff --git a/packages/deployments/src/infra/schemas/MarketplaceDistributionSchema.ts b/packages/deployments/src/infra/schemas/MarketplaceDistributionSchema.ts new file mode 100644 index 000000000..bb4f474f9 --- /dev/null +++ b/packages/deployments/src/infra/schemas/MarketplaceDistributionSchema.ts @@ -0,0 +1,167 @@ +import { EntitySchema } from 'typeorm'; +import { + MarketplaceDistribution, + Marketplace, + Organization, + Package, + User, +} from '@packmind/types'; +import { + WithTimestamps, + WithSoftDelete, + uuidSchema, + timestampsSchemas, + softDeleteSchemas, +} from '@packmind/node-utils'; + +/** + * TypeORM schema for the `marketplace_distributions` table. + * + * Each row records a single attempt to publish a Packmind package as a + * managed plugin on a linked marketplace. Mirrors the code-repository + * `Distribution` schema so the frontend can surface progress, success, + * no-op, and failure to the user through the same conceptual model. + * + * Soft-delete-aware: rows survive package/marketplace removal for audit. + */ +export const MarketplaceDistributionSchema = new EntitySchema< + WithSoftDelete< + WithTimestamps< + MarketplaceDistribution & { + organization?: Organization; + marketplace?: Marketplace; + packageRef?: Package; + author?: User; + } + > + > +>({ + name: 'MarketplaceDistribution', + tableName: 'marketplace_distributions', + columns: { + organizationId: { + name: 'organization_id', + type: 'uuid', + nullable: false, + }, + marketplaceId: { + name: 'marketplace_id', + type: 'uuid', + nullable: false, + }, + packageId: { + name: 'package_id', + type: 'uuid', + nullable: false, + }, + pluginSlug: { + name: 'plugin_slug', + type: 'varchar', + nullable: false, + }, + authorId: { + name: 'author_id', + type: 'uuid', + nullable: false, + }, + status: { + type: 'varchar', + nullable: false, + }, + source: { + type: 'varchar', + nullable: false, + default: 'app', + }, + prUrl: { + name: 'pr_url', + type: 'text', + nullable: true, + }, + gitCommit: { + name: 'git_commit', + type: 'varchar', + nullable: true, + }, + error: { + type: 'text', + nullable: true, + }, + failureReason: { + name: 'failure_reason', + type: 'varchar', + nullable: true, + }, + contentHash: { + name: 'content_hash', + type: 'varchar', + nullable: true, + }, + versionFingerprint: { + name: 'version_fingerprint', + type: 'jsonb', + nullable: true, + }, + publishConfirmedAt: { + name: 'publish_confirmed_at', + type: 'timestamp with time zone', + nullable: true, + }, + removalRequestedAt: { + name: 'removal_requested_at', + type: 'timestamp with time zone', + nullable: true, + }, + ...uuidSchema, + ...timestampsSchemas, + ...softDeleteSchemas, + }, + relations: { + organization: { + type: 'many-to-one', + target: 'Organization', + joinColumn: { + name: 'organization_id', + }, + onDelete: 'CASCADE', + }, + marketplace: { + type: 'many-to-one', + target: 'Marketplace', + joinColumn: { + name: 'marketplace_id', + }, + onDelete: 'CASCADE', + }, + packageRef: { + type: 'many-to-one', + target: 'Package', + joinColumn: { + name: 'package_id', + }, + onDelete: 'CASCADE', + }, + author: { + type: 'many-to-one', + target: 'User', + joinColumn: { + name: 'author_id', + }, + onDelete: 'RESTRICT', + }, + }, + indices: [ + { + name: 'idx_marketplace_distributions_marketplace_id', + columns: ['marketplaceId'], + }, + { + name: 'idx_marketplace_distributions_package_marketplace', + columns: ['packageId', 'marketplaceId'], + }, + { + name: 'idx_marketplace_distributions_status', + columns: ['status'], + }, + ], +}); diff --git a/packages/deployments/src/infra/schemas/MarketplaceSchema.ts b/packages/deployments/src/infra/schemas/MarketplaceSchema.ts new file mode 100644 index 000000000..b5a12a080 --- /dev/null +++ b/packages/deployments/src/infra/schemas/MarketplaceSchema.ts @@ -0,0 +1,156 @@ +import { EntitySchema } from 'typeorm'; +import { GitRepo, Marketplace, Organization, User } from '@packmind/types'; +import { + WithTimestamps, + WithSoftDelete, + uuidSchema, + timestampsSchemas, + softDeleteSchemas, +} from '@packmind/node-utils'; + +/** + * TypeORM schema for the `marketplaces` table. + * + * Each row binds an organization to a `GitRepo` (with `type='marketplace'`) + * that hosts a marketplace descriptor (e.g. `marketplace.json`). The parsed + * descriptor and its `pluginCount` are denormalized on the row so the list + * endpoint stays fast; the reconciliation background job keeps them fresh. + * + * Soft-delete-aware: a unique partial index on `(organization_id, git_repo_id)` + * scoped to `deleted_at IS NULL` lets a marketplace be re-linked after + * unlinking without colliding with the historic row. + */ +export const MarketplaceSchema = new EntitySchema< + WithSoftDelete< + WithTimestamps< + Marketplace & { + organization?: Organization; + gitRepo?: GitRepo; + addedByUser?: User; + } + > + > +>({ + name: 'Marketplace', + tableName: 'marketplaces', + columns: { + organizationId: { + name: 'organization_id', + type: 'uuid', + nullable: false, + }, + gitRepoId: { + name: 'git_repo_id', + type: 'uuid', + nullable: false, + }, + name: { + type: 'varchar', + nullable: false, + }, + vendor: { + type: 'varchar', + nullable: false, + }, + addedBy: { + name: 'added_by', + type: 'uuid', + nullable: false, + }, + linkedAt: { + name: 'linked_at', + type: 'timestamp with time zone', + nullable: false, + }, + state: { + type: 'varchar', + nullable: false, + default: 'healthy', + }, + lastValidatedAt: { + name: 'last_validated_at', + type: 'timestamp with time zone', + nullable: true, + }, + descriptor: { + type: 'jsonb', + nullable: false, + }, + pluginCount: { + name: 'plugin_count', + type: 'integer', + nullable: false, + default: 0, + }, + errorKind: { + name: 'error_kind', + type: 'varchar', + nullable: true, + }, + errorDetail: { + name: 'error_detail', + type: 'text', + nullable: true, + }, + pendingPrUrl: { + name: 'pending_pr_url', + type: 'text', + nullable: true, + }, + outdatedPluginSlugs: { + name: 'outdated_plugin_slugs', + type: 'jsonb', + nullable: true, + }, + trackingToken: { + name: 'tracking_token', + type: 'varchar', + nullable: true, + }, + ...uuidSchema, + ...timestampsSchemas, + ...softDeleteSchemas, + }, + relations: { + organization: { + type: 'many-to-one', + target: 'Organization', + joinColumn: { + name: 'organization_id', + }, + onDelete: 'CASCADE', + }, + gitRepo: { + type: 'many-to-one', + target: 'GitRepo', + joinColumn: { + name: 'git_repo_id', + }, + onDelete: 'CASCADE', + }, + addedByUser: { + type: 'many-to-one', + target: 'User', + joinColumn: { + name: 'added_by', + }, + onDelete: 'RESTRICT', + }, + }, + indices: [ + { + name: 'idx_marketplace_organization', + columns: ['organizationId'], + }, + { + name: 'idx_marketplace_organization_git_repo', + columns: ['organizationId', 'gitRepoId'], + unique: true, + where: 'deleted_at IS NULL', + }, + { + name: 'idx_marketplace_tracking_token', + columns: ['trackingToken'], + }, + ], +}); diff --git a/packages/deployments/src/infra/schemas/PluginInstallationSchema.ts b/packages/deployments/src/infra/schemas/PluginInstallationSchema.ts new file mode 100644 index 000000000..4948d343a --- /dev/null +++ b/packages/deployments/src/infra/schemas/PluginInstallationSchema.ts @@ -0,0 +1,165 @@ +import { EntitySchema } from 'typeorm'; +import { Marketplace, Organization, PluginInstallation } from '@packmind/types'; +import { + WithTimestamps, + WithSoftDelete, + uuidSchema, + timestampsSchemas, + softDeleteSchemas, +} from '@packmind/node-utils'; + +/** + * TypeORM schema for the `plugin_installations` table. + * + * Each row is a heartbeat record: evidence that a Packmind marketplace plugin + * was active in a Claude Code session. The UNIQUE constraint on + * `(marketplace_id, plugin_slug, scope, identity_key, repo_key)` collapses + * repeated heartbeats into a single row. `created_at` marks the first-seen time + * (kept as the earliest value on merge); `updated_at` is bumped to the last-seen + * time on every heartbeat. Both are managed explicitly by the repository. + * + * ### Absent-field key rule (§7.1) + * `identity_key` and `repo_key` are NOT NULL — computed at write time with + * `''` as the fallback value when the underlying identifier is unavailable. + * This prevents Postgres from treating each identity-less heartbeat as a new + * row (Postgres considers NULLs as distinct in UNIQUE indexes). + * + * Soft-delete-aware: rows survive marketplace removal for analytics. + */ +export const PluginInstallationSchema = new EntitySchema< + WithSoftDelete< + WithTimestamps< + PluginInstallation & { + organization?: Organization; + marketplace?: Marketplace; + } + > + > +>({ + name: 'PluginInstallation', + tableName: 'plugin_installations', + columns: { + organizationId: { + name: 'organization_id', + type: 'uuid', + nullable: false, + }, + marketplaceId: { + name: 'marketplace_id', + type: 'uuid', + nullable: false, + }, + pluginSlug: { + name: 'plugin_slug', + type: 'varchar', + nullable: false, + }, + packageId: { + name: 'package_id', + type: 'uuid', + nullable: true, + }, + /** + * Version reported as installed (from the plugin manifest). Nullable: rows + * predating install-version tracking, or heartbeats that could not resolve + * a version, stay null. Refreshed to the latest reported value on each + * heartbeat by the repository. + */ + installedVersion: { + name: 'installed_version', + type: 'varchar', + nullable: true, + }, + scope: { + type: 'varchar', + nullable: false, + }, + userId: { + name: 'user_id', + type: 'uuid', + nullable: true, + }, + anonymousIdHash: { + name: 'anonymous_id_hash', + type: 'varchar', + nullable: true, + }, + anonymousEmailMasked: { + name: 'anonymous_email_masked', + type: 'varchar', + nullable: true, + }, + /** + * Computed, NOT NULL. + * Value: `userId ?? anonymousIdHash ?? ''` + */ + identityKey: { + name: 'identity_key', + type: 'varchar', + nullable: false, + default: '', + }, + repoRemoteUrl: { + name: 'repo_remote_url', + type: 'text', + nullable: true, + }, + /** + * Computed, NOT NULL. + * Value: `''` when `scope = 'user'`, else the normalized `owner/repo` slug + * of `repoRemoteUrl` ?? the raw `repoRemoteUrl` ?? `''`. + */ + repoKey: { + name: 'repo_key', + type: 'varchar', + nullable: false, + default: '', + }, + ...uuidSchema, + // `createdAt` doubles as first-seen, `updatedAt` as last-seen. The + // repository sets both explicitly on insert and bumps `updatedAt` via + // QueryBuilder on every heartbeat (QueryBuilder `.update()` does not + // trigger `@UpdateDateColumn`, so the bump is always explicit). + ...timestampsSchemas, + ...softDeleteSchemas, + }, + relations: { + organization: { + type: 'many-to-one', + target: 'Organization', + joinColumn: { + name: 'organization_id', + }, + onDelete: 'CASCADE', + }, + marketplace: { + type: 'many-to-one', + target: 'Marketplace', + joinColumn: { + name: 'marketplace_id', + }, + onDelete: 'CASCADE', + }, + }, + indices: [ + { + name: 'idx_plugin_installations_marketplace_id', + columns: ['marketplaceId'], + }, + { + name: 'idx_plugin_installations_organization_id', + columns: ['organizationId'], + }, + { + name: 'uq_plugin_installations_unique_heartbeat', + columns: [ + 'marketplaceId', + 'pluginSlug', + 'scope', + 'identityKey', + 'repoKey', + ], + unique: true, + }, + ], +}); diff --git a/packages/deployments/src/infra/schemas/index.ts b/packages/deployments/src/infra/schemas/index.ts index 81e81b869..0dd33590c 100644 --- a/packages/deployments/src/infra/schemas/index.ts +++ b/packages/deployments/src/infra/schemas/index.ts @@ -6,6 +6,9 @@ import { PackageStandardsSchema } from './PackageStandardsSchema'; import { PackageSkillsSchema } from './PackageSkillsSchema'; import { DistributionSchema } from './DistributionSchema'; import { DistributedPackageSchema } from './DistributedPackageSchema'; +import { MarketplaceSchema } from './MarketplaceSchema'; +import { MarketplaceDistributionSchema } from './MarketplaceDistributionSchema'; +import { PluginInstallationSchema } from './PluginInstallationSchema'; export { TargetSchema, @@ -16,6 +19,9 @@ export { PackageSkillsSchema, DistributionSchema, DistributedPackageSchema, + MarketplaceSchema, + MarketplaceDistributionSchema, + PluginInstallationSchema, }; export const deploymentsSchemas = [ TargetSchema, @@ -26,4 +32,7 @@ export const deploymentsSchemas = [ PackageSkillsSchema, DistributionSchema, DistributedPackageSchema, + MarketplaceSchema, + MarketplaceDistributionSchema, + PluginInstallationSchema, ]; diff --git a/packages/editions/src/oss/amplitude/application/EventTrackingAdapter.ts b/packages/editions/src/oss/amplitude/application/EventTrackingAdapter.ts index 134d17451..6e9947795 100644 --- a/packages/editions/src/oss/amplitude/application/EventTrackingAdapter.ts +++ b/packages/editions/src/oss/amplitude/application/EventTrackingAdapter.ts @@ -2,12 +2,12 @@ import { PackmindLogger } from '@packmind/logger'; import { IEventTrackingPort, OrganizationId, UserId } from '@packmind/types'; /** - * OSS stub implementation of AnalyticsAdapter - * Wraps AmplitudeTrackEventService but does not perform actual tracking + * OSS stub implementation of EventTrackingAdapter + * Does not perform actual event tracking in OSS version */ export class EventTrackingAdapter implements IEventTrackingPort { constructor( - private readonly logger = new PackmindLogger('AnalyticsAdapter'), + private readonly logger = new PackmindLogger('EventTrackingAdapter'), ) {} async trackEvent( diff --git a/packages/editions/src/oss/analytics/domain/entities/index.ts b/packages/editions/src/oss/analytics/domain/entities/index.ts new file mode 100644 index 000000000..8eda4a056 --- /dev/null +++ b/packages/editions/src/oss/analytics/domain/entities/index.ts @@ -0,0 +1,2 @@ +// OSS stub exports - no entities needed +export {}; diff --git a/packages/editions/src/oss/analytics/infra/schemas/index.ts b/packages/editions/src/oss/analytics/infra/schemas/index.ts new file mode 100644 index 000000000..65316dbfc --- /dev/null +++ b/packages/editions/src/oss/analytics/infra/schemas/index.ts @@ -0,0 +1,2 @@ +// OSS stub exports - no schemas needed +export const recipesUsageSchemas: [] = []; diff --git a/packages/editions/src/oss/linter/LinterAdapter.ts b/packages/editions/src/oss/linter/LinterAdapter.ts index 8bdf313bb..64f63b43c 100644 --- a/packages/editions/src/oss/linter/LinterAdapter.ts +++ b/packages/editions/src/oss/linter/LinterAdapter.ts @@ -41,6 +41,8 @@ import { GetDetectionHeuristicsCommand, UpdateRuleDetectionHeuristicsResponse, UpdateRuleDetectionHeuristicsCommand, + UpdateHeuristicsFollowingChatbotInputCommand, + UpdateHeuristicsFollowingChatbotInputResponse, CopyDetectionProgramsToNewRuleCommand, CopyDetectionProgramsToNewRuleResponse, CopyRuleDetectionAssessmentsCommand, @@ -232,6 +234,13 @@ export class LinterAdapter implements ILinterPort { throw new Error('Method not implemented.'); } + updateHeuristicsFollowingChatbotInput( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + command: UpdateHeuristicsFollowingChatbotInputCommand, + ): Promise<UpdateHeuristicsFollowingChatbotInputResponse> { + throw new Error('Method not implemented.'); + } + async copyDetectionHeuristics( // eslint-disable-next-line @typescript-eslint/no-unused-vars command: CopyDetectionHeuristicsCommand, diff --git a/packages/editions/src/oss/linter/domain/index.ts b/packages/editions/src/oss/linter/domain/index.ts new file mode 100644 index 000000000..f5ac43b2c --- /dev/null +++ b/packages/editions/src/oss/linter/domain/index.ts @@ -0,0 +1,8 @@ +// OSS stub exports - re-export from @packmind/types +export { + AssessRuleDetectionInput, + AssessRuleDetectionOutput, + GenerateProgramInput, + GenerateProgramOutput, +} from '@packmind/types'; +export type { ILinterPort } from '@packmind/types'; diff --git a/packages/editions/src/oss/linter/infra/schemas/index.ts b/packages/editions/src/oss/linter/infra/schemas/index.ts new file mode 100644 index 000000000..46718535c --- /dev/null +++ b/packages/editions/src/oss/linter/infra/schemas/index.ts @@ -0,0 +1,2 @@ +// OSS stub exports - no schemas needed +export const linterSchemas: [] = []; diff --git a/packages/editions/src/oss/spaces-management/SpacesManagementAdapter.ts b/packages/editions/src/oss/spaces-management/SpacesManagementAdapter.ts index 5e8ce26d7..37fbc7cf9 100644 --- a/packages/editions/src/oss/spaces-management/SpacesManagementAdapter.ts +++ b/packages/editions/src/oss/spaces-management/SpacesManagementAdapter.ts @@ -2,14 +2,22 @@ import { BrowseSpacesCommand, BrowseSpacesResponse, CreateSpaceResponse, + DeleteSpaceCommand, + DeleteSpaceResponse, ISpacesManagementPort, JoinSpaceBySlugCommand, JoinSpaceCommand, JoinSpaceResponse, LeaveSpaceCommand, LeaveSpaceResponse, + ListOrganizationSpacesForManagementCommand, + ListOrganizationSpacesForManagementResponse, MoveArtifactsToSpaceCommand, MoveArtifactsToSpaceResponse, + PinSpaceCommand, + PinSpaceResponse, + UnpinSpaceCommand, + UnpinSpaceResponse, UpdateSpaceCommand, UpdateSpaceResponse, } from '@packmind/types'; @@ -60,4 +68,32 @@ export class SpacesManagementAdapter implements ISpacesManagementPort { ): Promise<LeaveSpaceResponse> { return {} as LeaveSpaceResponse; } + + async deleteSpace( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _command: DeleteSpaceCommand, + ): Promise<DeleteSpaceResponse> { + throw new Error('Method not implemented.'); + } + + async pinSpace( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _command: PinSpaceCommand, + ): Promise<PinSpaceResponse> { + throw new Error('Method not implemented.'); + } + + async unpinSpace( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _command: UnpinSpaceCommand, + ): Promise<UnpinSpaceResponse> { + throw new Error('Method not implemented.'); + } + + async listOrganizationSpacesForManagement( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _command: ListOrganizationSpacesForManagementCommand, + ): Promise<ListOrganizationSpacesForManagementResponse> { + throw new Error('Method not implemented.'); + } } diff --git a/packages/git/src/application/GitProviderService.spec.ts b/packages/git/src/application/GitProviderService.spec.ts index bcab6a418..501e94913 100644 --- a/packages/git/src/application/GitProviderService.spec.ts +++ b/packages/git/src/application/GitProviderService.spec.ts @@ -12,7 +12,11 @@ import { import { createOrganizationId } from '@packmind/types'; import { PackmindLogger } from '@packmind/logger'; import { stubLogger } from '@packmind/test-utils'; -import { gitProviderFactory, gitlabProviderFactory } from '../../test'; +import { + gitProviderFactory, + gitlabProviderFactory, + gitRepoFactory, +} from '../../test'; describe('GitProviderService', () => { let gitProviderService: GitProviderService; @@ -668,4 +672,121 @@ describe('GitProviderService', () => { }); }); }); + + describe('checkMarketplaceRepoExists', () => { + const marketplaceRepo = gitRepoFactory({ + providerId: createGitProviderId('provider-1'), + type: 'marketplace', + }); + + describe('when the provider cannot be resolved', () => { + beforeEach(() => { + mockGitProviderRepository.findById.mockResolvedValue(null); + }); + + it('short-circuits to auth_failed without probing the repo', async () => { + const result = + await gitProviderService.checkMarketplaceRepoExists(marketplaceRepo); + + expect(result).toEqual({ exists: false, reason: 'auth_failed' }); + }); + + it('never builds a git repo instance', async () => { + await gitProviderService.checkMarketplaceRepoExists(marketplaceRepo); + + expect(mockGitRepoFactory.createGitRepo).not.toHaveBeenCalled(); + }); + }); + + describe('when a token-auth provider has no token', () => { + it('short-circuits to auth_failed', async () => { + mockGitProviderRepository.findById.mockResolvedValue({ + ...mockGitProvider, + authMethod: 'token', + token: null, + }); + + const result = + await gitProviderService.checkMarketplaceRepoExists(marketplaceRepo); + + expect(result).toEqual({ exists: false, reason: 'auth_failed' }); + }); + }); + + describe('when the provider resolves with credentials', () => { + it('delegates the probe to the git repo instance', async () => { + mockGitProviderRepository.findById.mockResolvedValue(mockGitProvider); + const mockRepoInstance = { + checkRepositoryExists: jest + .fn() + .mockResolvedValue({ exists: false, reason: 'repo_not_found' }), + }; + mockGitRepoFactory.createGitRepo.mockResolvedValue( + mockRepoInstance as never, + ); + + const result = + await gitProviderService.checkMarketplaceRepoExists(marketplaceRepo); + + expect(result).toEqual({ exists: false, reason: 'repo_not_found' }); + }); + }); + }); + + describe('findOpenSyncPullRequest', () => { + const marketplaceRepo = gitRepoFactory({ + providerId: createGitProviderId('provider-1'), + type: 'marketplace', + }); + + describe('when the provider cannot be resolved', () => { + it('throws a provider-not-found error', async () => { + mockGitProviderRepository.findById.mockResolvedValue(null); + + await expect( + gitProviderService.findOpenSyncPullRequest( + marketplaceRepo, + 'packmind/sync', + ), + ).rejects.toThrow('Git provider not found'); + }); + }); + + describe('when a token-auth provider has no token', () => { + it('throws a token-not-configured error', async () => { + mockGitProviderRepository.findById.mockResolvedValue({ + ...mockGitProvider, + authMethod: 'token', + token: null, + }); + + await expect( + gitProviderService.findOpenSyncPullRequest( + marketplaceRepo, + 'packmind/sync', + ), + ).rejects.toThrow('Git provider token not configured'); + }); + }); + + describe('when the provider resolves with credentials', () => { + it('delegates the lookup to the git repo instance', async () => { + mockGitProviderRepository.findById.mockResolvedValue(mockGitProvider); + const openPr = { url: 'https://github.com/o/r/pull/3', number: 3 }; + const mockRepoInstance = { + findOpenPullRequest: jest.fn().mockResolvedValue(openPr), + }; + mockGitRepoFactory.createGitRepo.mockResolvedValue( + mockRepoInstance as never, + ); + + const result = await gitProviderService.findOpenSyncPullRequest( + marketplaceRepo, + 'packmind/sync', + ); + + expect(result).toEqual(openPr); + }); + }); + }); }); diff --git a/packages/git/src/application/GitProviderService.ts b/packages/git/src/application/GitProviderService.ts index 2fbfa22ce..fc7ec4a19 100644 --- a/packages/git/src/application/GitProviderService.ts +++ b/packages/git/src/application/GitProviderService.ts @@ -7,6 +7,7 @@ import { GitProviderId, ListAvailableReposResponse, createGitProviderId, + createGitRepoId, } from '@packmind/types'; import { GitRepo } from '@packmind/types'; import { OrganizationId, UserId } from '@packmind/types'; @@ -125,6 +126,122 @@ export class GitProviderService { return providerInstance.checkBranchExists(owner, repo, branch); } + async createBranchFromBase( + gitProviderId: GitProviderId, + owner: string, + repo: string, + baseBranch: string, + targetBranch: string, + ): Promise<void> { + // Resolve provider + token, then build an IGitRepo bound to the BASE + // branch so the underlying client knows where to fork from. The factory + // only consumes owner/repo/branch from the GitRepo shape, so the synthetic + // id/providerId/type fields are inert here. + const gitProvider = + await this.gitProviderRepository.findById(gitProviderId); + + if (!gitProvider) { + throw new Error('Git provider not found'); + } + + if (gitProvider.authMethod !== 'app' && !gitProvider.token) { + throw new Error('Git provider token not configured'); + } + + const syntheticGitRepo: GitRepo = { + id: createGitRepoId(uuidv4()), + owner, + repo, + branch: baseBranch, + providerId: gitProviderId, + type: 'standard', + }; + + const gitRepoInstance = await this.gitRepoFactory.createGitRepo( + syntheticGitRepo, + gitProvider, + ); + + await gitRepoInstance.createBranchFromBase(targetBranch); + } + + async openOrUpdatePullRequest( + gitRepo: GitRepo, + command: { + head: string; + title: string; + body?: string; + }, + ): Promise<{ url: string; number: number; wasCreated: boolean }> { + // Resolve provider + token, then build an IGitRepo bound to the BASE + // branch (the repo's configured `branch` field is the merge target). + const gitProvider = await this.gitProviderRepository.findById( + gitRepo.providerId, + ); + + if (!gitProvider) { + throw new Error('Git provider not found'); + } + + if (gitProvider.authMethod !== 'app' && !gitProvider.token) { + throw new Error('Git provider token not configured'); + } + + const gitRepoInstance = await this.gitRepoFactory.createGitRepo( + gitRepo, + gitProvider, + ); + + return gitRepoInstance.openOrUpdatePullRequest(command); + } + + async findOpenSyncPullRequest( + gitRepo: GitRepo, + head: string, + ): Promise<{ url: string; number: number } | null> { + const gitProvider = await this.gitProviderRepository.findById( + gitRepo.providerId, + ); + + if (!gitProvider) { + throw new Error('Git provider not found'); + } + + if (gitProvider.authMethod !== 'app' && !gitProvider.token) { + throw new Error('Git provider token not configured'); + } + + const gitRepoInstance = await this.gitRepoFactory.createGitRepo( + gitRepo, + gitProvider, + ); + + return gitRepoInstance.findOpenPullRequest(head); + } + + async checkMarketplaceRepoExists(gitRepo: GitRepo): Promise<{ + exists: boolean; + reason?: 'auth_failed' | 'repo_not_found' | 'network_transient'; + }> { + const gitProvider = await this.gitProviderRepository.findById( + gitRepo.providerId, + ); + + if ( + !gitProvider || + (gitProvider.authMethod !== 'app' && !gitProvider.token) + ) { + return { exists: false, reason: 'auth_failed' }; + } + + const gitRepoInstance = await this.gitRepoFactory.createGitRepo( + gitRepo, + gitProvider, + ); + + return gitRepoInstance.checkRepositoryExists(); + } + async listAvailableTargets( gitRepo: GitRepo, path?: string, diff --git a/packages/git/src/application/GitRepoService.spec.ts b/packages/git/src/application/GitRepoService.spec.ts index 37e1b3cb4..547751b33 100644 --- a/packages/git/src/application/GitRepoService.spec.ts +++ b/packages/git/src/application/GitRepoService.spec.ts @@ -14,6 +14,7 @@ describe('GitRepoService', () => { repo: 'test-repo', branch: 'main', providerId: createGitProviderId('provider-1'), + type: 'standard', }); beforeEach(() => { @@ -23,6 +24,7 @@ describe('GitRepoService', () => { deleteById: jest.fn(), restoreById: jest.fn(), findByOwnerAndRepo: jest.fn(), + findByOwnerAndRepoInOrganization: jest.fn(), findByProviderId: jest.fn(), findByOrganizationId: jest.fn(), list: jest.fn(), @@ -42,6 +44,7 @@ describe('GitRepoService', () => { repo: 'test-repo', branch: 'main', providerId: createGitProviderId('provider-1'), + type: 'standard', }; let result: GitRepo; @@ -65,7 +68,7 @@ describe('GitRepoService', () => { }); describe('findGitRepoById', () => { - describe('when repository exists', () => { + describe('when the repository exists and is standard', () => { let result: GitRepo | null; beforeEach(async () => { @@ -86,7 +89,7 @@ describe('GitRepoService', () => { }); }); - describe('when repository does not exist', () => { + describe('when the repository does not exist', () => { let result: GitRepo | null; beforeEach(async () => { @@ -106,6 +109,80 @@ describe('GitRepoService', () => { expect(result).toBeNull(); }); }); + + describe('when the repository is marketplace-typed', () => { + let result: GitRepo | null; + + beforeEach(async () => { + const marketplaceRepo: GitRepo = gitRepoFactory({ + id: createGitRepoId('marketplace-repo-1'), + type: 'marketplace', + }); + mockGitRepoRepository.findById.mockResolvedValue(marketplaceRepo); + result = await gitRepoService.findGitRepoById( + createGitRepoId('marketplace-repo-1'), + ); + }); + + it('returns null so marketplace rows never leak through findGitRepoById', () => { + expect(result).toBeNull(); + }); + }); + }); + + describe('findGitRepoByOwnerAndRepo', () => { + it('filters to type=standard by default', async () => { + mockGitRepoRepository.findByOwnerAndRepo.mockResolvedValue(mockGitRepo); + + await gitRepoService.findGitRepoByOwnerAndRepo('test-owner', 'test-repo'); + + expect(mockGitRepoRepository.findByOwnerAndRepo).toHaveBeenCalledWith( + 'test-owner', + 'test-repo', + expect.objectContaining({ type: 'standard' }), + ); + }); + + it('propagates includeDeleted while keeping the standard type filter', async () => { + mockGitRepoRepository.findByOwnerAndRepo.mockResolvedValue(null); + + await gitRepoService.findGitRepoByOwnerAndRepo( + 'test-owner', + 'test-repo', + { includeDeleted: true }, + ); + + expect(mockGitRepoRepository.findByOwnerAndRepo).toHaveBeenCalledWith( + 'test-owner', + 'test-repo', + expect.objectContaining({ includeDeleted: true, type: 'standard' }), + ); + }); + }); + + describe('findGitRepoByOwnerRepoAndBranchInOrganization', () => { + it('filters to type=standard by default', async () => { + mockGitRepoRepository.findByOwnerRepoAndBranchInOrganization.mockResolvedValue( + mockGitRepo, + ); + + await gitRepoService.findGitRepoByOwnerRepoAndBranchInOrganization( + 'test-owner', + 'test-repo', + 'main', + createOrganizationId('org-1'), + ); + + expect( + mockGitRepoRepository.findByOwnerRepoAndBranchInOrganization, + ).toHaveBeenCalledWith( + 'test-owner', + 'test-repo', + 'main', + createOrganizationId('org-1'), + expect.objectContaining({ type: 'standard' }), + ); + }); }); describe('findGitReposByProviderId', () => { @@ -119,9 +196,10 @@ describe('GitRepoService', () => { ); }); - it('calls repository findByProviderId with correct provider id', () => { + it('calls repository findByProviderId filtered to standard', () => { expect(mockGitRepoRepository.findByProviderId).toHaveBeenCalledWith( createGitProviderId('provider-1'), + expect.objectContaining({ type: 'standard' }), ); }); @@ -141,9 +219,10 @@ describe('GitRepoService', () => { ); }); - it('calls repository findByOrganizationId with correct organization id', () => { + it('calls repository findByOrganizationId filtered to standard', () => { expect(mockGitRepoRepository.findByOrganizationId).toHaveBeenCalledWith( createOrganizationId('org-1'), + expect.objectContaining({ type: 'standard' }), ); }); @@ -165,9 +244,10 @@ describe('GitRepoService', () => { ); }); - it('calls repository list with the organization id', () => { + it('calls repository list with the organization id filtered to standard', () => { expect(mockGitRepoRepository.list).toHaveBeenCalledWith( createOrganizationId('org-1'), + expect.objectContaining({ type: 'standard' }), ); }); @@ -184,8 +264,11 @@ describe('GitRepoService', () => { result = await gitRepoService.listGitRepos(); }); - it('calls repository list with undefined', () => { - expect(mockGitRepoRepository.list).toHaveBeenCalledWith(undefined); + it('calls repository list with undefined and the standard filter', () => { + expect(mockGitRepoRepository.list).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ type: 'standard' }), + ); }); it('returns all repositories', () => { @@ -208,4 +291,120 @@ describe('GitRepoService', () => { ); }); }); + + describe('findMarketplaceGitRepo', () => { + const marketplaceRepo: GitRepo = gitRepoFactory({ + id: createGitRepoId('marketplace-repo-1'), + type: 'marketplace', + }); + let result: GitRepo | null; + + beforeEach(async () => { + mockGitRepoRepository.findByOwnerAndRepoInOrganization.mockResolvedValue( + marketplaceRepo, + ); + + result = await gitRepoService.findMarketplaceGitRepo( + createOrganizationId('org-1'), + 'test-owner', + 'test-repo', + ); + }); + + it('filters to type=marketplace and scopes to the organization', () => { + expect( + mockGitRepoRepository.findByOwnerAndRepoInOrganization, + ).toHaveBeenCalledWith( + 'test-owner', + 'test-repo', + createOrganizationId('org-1'), + expect.objectContaining({ type: 'marketplace' }), + ); + }); + + it('returns the marketplace repository', () => { + expect(result).toEqual(marketplaceRepo); + }); + }); + + describe('findMarketplaceGitReposByOrganization', () => { + const marketplaceRepos = [ + gitRepoFactory({ type: 'marketplace' }), + gitRepoFactory({ type: 'marketplace' }), + ]; + let result: GitRepo[]; + + beforeEach(async () => { + mockGitRepoRepository.findByOrganizationId.mockResolvedValue( + marketplaceRepos, + ); + + result = await gitRepoService.findMarketplaceGitReposByOrganization( + createOrganizationId('org-1'), + ); + }); + + it('filters to type=marketplace', () => { + expect(mockGitRepoRepository.findByOrganizationId).toHaveBeenCalledWith( + createOrganizationId('org-1'), + expect.objectContaining({ type: 'marketplace' }), + ); + }); + + it('returns the marketplace repositories', () => { + expect(result).toEqual(marketplaceRepos); + }); + }); + + describe('findGitRepoIgnoringType', () => { + let result: GitRepo | null; + + beforeEach(async () => { + mockGitRepoRepository.findByOwnerAndRepoInOrganization.mockResolvedValue( + mockGitRepo, + ); + + result = await gitRepoService.findGitRepoIgnoringType( + createOrganizationId('org-1'), + 'test-owner', + 'test-repo', + ); + }); + + it('passes the special "any" sentinel so neither type is excluded', () => { + expect( + mockGitRepoRepository.findByOwnerAndRepoInOrganization, + ).toHaveBeenCalledWith( + 'test-owner', + 'test-repo', + createOrganizationId('org-1'), + expect.objectContaining({ type: 'any' }), + ); + }); + + it('returns the matched repository', () => { + expect(result).toEqual(mockGitRepo); + }); + + it('forwards a supplied providerId filter to the repository', async () => { + await gitRepoService.findGitRepoIgnoringType( + createOrganizationId('org-1'), + 'test-owner', + 'test-repo', + { providerId: createGitProviderId('provider-1') }, + ); + + expect( + mockGitRepoRepository.findByOwnerAndRepoInOrganization, + ).toHaveBeenLastCalledWith( + 'test-owner', + 'test-repo', + createOrganizationId('org-1'), + expect.objectContaining({ + type: 'any', + providerId: createGitProviderId('provider-1'), + }), + ); + }); + }); }); diff --git a/packages/git/src/application/GitRepoService.ts b/packages/git/src/application/GitRepoService.ts index 7e272ffd4..21eac35de 100644 --- a/packages/git/src/application/GitRepoService.ts +++ b/packages/git/src/application/GitRepoService.ts @@ -5,6 +5,14 @@ import { OrganizationId, UserId } from '@packmind/types'; import { v4 as uuidv4 } from 'uuid'; import { QueryOption } from '@packmind/types'; +/** + * Service surface for `GitRepo` persistence. + * + * All public finders defined here default to `type='standard'` so that + * marketplace-typed repositories never leak into skill/standard deployment + * flows. Marketplace-aware variants must be opted into explicitly via the + * `findMarketplace*` and `findGitRepoIgnoringType` methods. + */ export class GitRepoService { constructor(private readonly gitRepoRepository: IGitRepoRepository) {} @@ -16,8 +24,22 @@ export class GitRepoService { return this.gitRepoRepository.add(gitRepoWithId); } + /** + * Find a standard-type GitRepo by id. + * + * Marketplace-typed repos are filtered out so a stale marketplace id passed + * to a standard-flow caller resolves to `null` rather than leaking the + * marketplace row. + */ async findGitRepoById(id: GitRepoId): Promise<GitRepo | null> { - return this.gitRepoRepository.findById(id); + const gitRepo = await this.gitRepoRepository.findById(id); + if (!gitRepo) { + return null; + } + if (gitRepo.type !== 'standard') { + return null; + } + return gitRepo; } async findGitRepoByOwnerAndRepo( @@ -25,7 +47,10 @@ export class GitRepoService { repo: string, opts?: Pick<QueryOption, 'includeDeleted'>, ): Promise<GitRepo | null> { - return this.gitRepoRepository.findByOwnerAndRepo(owner, repo, opts); + return this.gitRepoRepository.findByOwnerAndRepo(owner, repo, { + ...opts, + type: 'standard', + }); } async findGitRepoByOwnerRepoAndBranchInOrganization( @@ -40,27 +65,118 @@ export class GitRepoService { repo, branch, organizationId, - opts, + { ...opts, type: 'standard' }, ); } async findGitReposByProviderId( providerId: GitProviderId, ): Promise<GitRepo[]> { - return this.gitRepoRepository.findByProviderId(providerId); + return this.gitRepoRepository.findByProviderId(providerId, { + type: 'standard', + }); } async findGitReposByOrganizationId( organizationId: OrganizationId, ): Promise<GitRepo[]> { - return this.gitRepoRepository.findByOrganizationId(organizationId); + return this.gitRepoRepository.findByOrganizationId(organizationId, { + type: 'standard', + }); } async listGitRepos(organizationId?: OrganizationId): Promise<GitRepo[]> { - return this.gitRepoRepository.list(organizationId); + return this.gitRepoRepository.list(organizationId, { type: 'standard' }); } async deleteGitRepo(id: GitRepoId, userId: UserId): Promise<void> { return this.gitRepoRepository.deleteById(id, userId); } + + // --------------------------------------------------------------------------- + // Marketplace-aware variants — explicitly opted into by marketplace use cases. + // --------------------------------------------------------------------------- + + /** + * Find a marketplace-typed GitRepo by id. + * + * Returns null when no row is found or when the row's type is not + * `'marketplace'`. Used by the reconciliation job to load the underlying + * `GitRepo` for a `Marketplace` without leaking standard-typed rows. + */ + async findMarketplaceGitRepoById(id: GitRepoId): Promise<GitRepo | null> { + const gitRepo = await this.gitRepoRepository.findById(id); + if (!gitRepo) { + return null; + } + if (gitRepo.type !== 'marketplace') { + return null; + } + return gitRepo; + } + + /** + * Find a GitRepo by id without any type filter. + * + * Used by `DeleteGitRepoUseCase`, which serves both standard and + * marketplace deletion paths. The use case looks up the repo by id and + * then enforces type-appropriate authorization downstream. + */ + async findGitRepoByIdIgnoringType(id: GitRepoId): Promise<GitRepo | null> { + return this.gitRepoRepository.findById(id); + } + + /** + * Find a marketplace-typed GitRepo by owner/repo within an organization. + * + * Excludes standard-typed repos. + */ + async findMarketplaceGitRepo( + organizationId: OrganizationId, + owner: string, + repo: string, + opts?: Pick<QueryOption, 'includeDeleted'>, + ): Promise<GitRepo | null> { + return this.gitRepoRepository.findByOwnerAndRepoInOrganization( + owner, + repo, + organizationId, + { ...opts, type: 'marketplace' }, + ); + } + + /** + * List all marketplace-typed GitRepos for an organization. + */ + async findMarketplaceGitReposByOrganization( + organizationId: OrganizationId, + ): Promise<GitRepo[]> { + return this.gitRepoRepository.findByOrganizationId(organizationId, { + type: 'marketplace', + }); + } + + /** + * Find a GitRepo by owner/repo within an organization without any type + * filter. Used by `LinkMarketplaceUseCase` for its pre-flight collision + * check between standard and marketplace types. + * + * Pass `opts.providerId` to scope the lookup to a single Git provider — + * the same `owner/repo` pair can legitimately exist on two different + * providers within one org (e.g. a GitHub and a GitLab `acme/plugins`), so + * the collision check must not treat them as the same repository. + */ + async findGitRepoIgnoringType( + organizationId: OrganizationId, + owner: string, + repo: string, + opts?: Pick<QueryOption, 'includeDeleted'> & { providerId?: GitProviderId }, + ): Promise<GitRepo | null> { + return this.gitRepoRepository.findByOwnerAndRepoInOrganization( + owner, + repo, + organizationId, + { ...opts, type: 'any' }, + ); + } } diff --git a/packages/git/src/application/adapter/GitAdapter.ts b/packages/git/src/application/adapter/GitAdapter.ts index c4498cbf3..4f13a823c 100644 --- a/packages/git/src/application/adapter/GitAdapter.ts +++ b/packages/git/src/application/adapter/GitAdapter.ts @@ -370,6 +370,52 @@ export class GitAdapter implements IBaseAdapter<IGitPort>, IGitPort { ); } + public async createBranchFromBase( + repo: GitRepo, + branch: string, + ): Promise<void> { + // Mirrors the commitToGit plumbing: resolve the provider via the + // GitProviderService and let it dispatch to the right IGitRepo + // implementation. The repo's `branch` field is the BASE branch used to + // bootstrap the target branch when it is missing. + await this.gitServices + .getGitProviderService() + .createBranchFromBase( + repo.providerId, + repo.owner, + repo.repo, + repo.branch, + branch, + ); + } + + public async openOrUpdatePullRequest( + repo: GitRepo, + command: { head: string; title: string; body?: string }, + ): Promise<{ url: string; number: number; wasCreated: boolean }> { + return this.gitServices + .getGitProviderService() + .openOrUpdatePullRequest(repo, command); + } + + public async findOpenSyncPullRequest( + repo: GitRepo, + head: string, + ): Promise<{ url: string; number: number } | null> { + return this.gitServices + .getGitProviderService() + .findOpenSyncPullRequest(repo, head); + } + + public async checkMarketplaceRepoExists(repo: GitRepo): Promise<{ + exists: boolean; + reason?: 'auth_failed' | 'repo_not_found' | 'network_transient'; + }> { + return this.gitServices + .getGitProviderService() + .checkMarketplaceRepoExists(repo); + } + public async getFileFromRepo( gitRepo: GitRepo, filePath: string, diff --git a/packages/git/src/application/useCases/addGitRepo/AddGitRepoUseCase.spec.ts b/packages/git/src/application/useCases/addGitRepo/AddGitRepoUseCase.spec.ts index 83dd4b59b..c1204ed1b 100644 --- a/packages/git/src/application/useCases/addGitRepo/AddGitRepoUseCase.spec.ts +++ b/packages/git/src/application/useCases/addGitRepo/AddGitRepoUseCase.spec.ts @@ -123,6 +123,7 @@ describe('AddGitRepoUseCase', () => { repo: 'testrepo', branch: 'main', providerId: gitProviderId, + type: 'standard', }; mockGitProviderService.findGitProviderById.mockResolvedValue( @@ -152,16 +153,70 @@ describe('AddGitRepoUseCase', () => { ).toHaveBeenCalledWith('testowner', 'testrepo', 'main', organizationId); }); - it('calls addGitRepo with the correct repository data', () => { + it('calls addGitRepo with the correct repository data including type=standard', () => { expect(mockGitRepoService.addGitRepo).toHaveBeenCalledWith({ owner: 'testowner', repo: 'testrepo', branch: 'main', providerId: gitProviderId, + type: 'standard', }); }); }); + describe('when an existing marketplace-typed repo shares the same coordinates', () => { + let command: AddGitRepoCommand; + let mockProvider: GitProvider; + let expectedResult: GitRepo; + let result: GitRepo; + + beforeEach(async () => { + command = { + userId, + organizationId, + gitProviderId, + owner: 'testowner', + repo: 'testrepo', + branch: 'main', + }; + + mockProvider = { + id: gitProviderId, + source: GitProviderVendors.github, + organizationId, + url: 'https://github.com', + token: 'token', + }; + + expectedResult = { + id: createGitRepoId(uuidv4()), + owner: 'testowner', + repo: 'testrepo', + branch: 'main', + providerId: gitProviderId, + type: 'standard', + }; + + mockGitProviderService.findGitProviderById.mockResolvedValue( + mockProvider, + ); + + // GitRepoService.findGitRepoByOwnerRepoAndBranchInOrganization filters + // to type='standard' by default, so a same-coordinate marketplace row + // does NOT surface here and the duplicate check passes. + mockGitRepoService.findGitRepoByOwnerRepoAndBranchInOrganization.mockResolvedValue( + null, + ); + mockGitRepoService.addGitRepo.mockResolvedValue(expectedResult); + + result = await useCase.execute(command); + }); + + it('does not throw GitRepoAlreadyExistsError — standard and marketplace are tracked independently', () => { + expect(result).toEqual(expectedResult); + }); + }); + describe('when adding a repository with a different branch', () => { let command: AddGitRepoCommand; let mockProvider: GitProvider; @@ -193,6 +248,7 @@ describe('AddGitRepoUseCase', () => { repo: 'testrepo', branch: 'develop', providerId: gitProviderId, + type: 'standard', }; mockGitProviderService.findGitProviderById.mockResolvedValue( @@ -263,6 +319,7 @@ describe('AddGitRepoUseCase', () => { repo: 'testrepo', branch: 'main', providerId: gitProviderId, + type: 'standard', }; mockTarget = { @@ -503,6 +560,7 @@ describe('AddGitRepoUseCase', () => { repo: 'testrepo', branch: 'main', providerId: gitProviderId, + type: 'standard', }; mockGitProviderService.findGitProviderById.mockResolvedValue( @@ -545,6 +603,7 @@ describe('AddGitRepoUseCase', () => { repo: 'testrepo', branch: 'main', providerId: gitProviderId, + type: 'standard', }; mockGitProviderService.findGitProviderById.mockResolvedValue( diff --git a/packages/git/src/application/useCases/addGitRepo/AddGitRepoUseCase.ts b/packages/git/src/application/useCases/addGitRepo/AddGitRepoUseCase.ts index 71fae2e3d..1afb567db 100644 --- a/packages/git/src/application/useCases/addGitRepo/AddGitRepoUseCase.ts +++ b/packages/git/src/application/useCases/addGitRepo/AddGitRepoUseCase.ts @@ -118,12 +118,15 @@ export class AddGitRepoUseCase throw new GitRepoAlreadyExistsError(owner, repo, branch, organization.id); } - // Create the repository with provider association + // Create the repository with provider association. The standard type is + // explicit here so AddGitRepoUseCase never accidentally creates a + // marketplace-typed row. const gitRepoWithProvider = { owner, repo, branch, providerId: gitProviderId, + type: 'standard' as const, }; const createdRepo = diff --git a/packages/git/src/application/useCases/deleteGitRepo/DeleteGitRepoUseCase.ts b/packages/git/src/application/useCases/deleteGitRepo/DeleteGitRepoUseCase.ts index d2cd9000d..6b12f2126 100644 --- a/packages/git/src/application/useCases/deleteGitRepo/DeleteGitRepoUseCase.ts +++ b/packages/git/src/application/useCases/deleteGitRepo/DeleteGitRepoUseCase.ts @@ -36,8 +36,13 @@ export class DeleteGitRepoUseCase throw new Error('Repository ID is required'); } - // Business rule: repository must exist - const repository = await this.gitRepoService.findGitRepoById(repositoryId); + // Business rule: repository must exist. We use the type-ignoring finder + // here so this use case can serve both standard and marketplace deletion + // paths — `UnlinkMarketplaceUseCase` routes through `IGitPort.deleteGitRepo` + // to soft-delete the marketplace-typed `GitRepo`, and the default + // `findGitRepoById` filters those out. + const repository = + await this.gitRepoService.findGitRepoByIdIgnoringType(repositoryId); if (!repository) { throw new Error('Repository not found'); } diff --git a/packages/git/src/application/useCases/listProviders/ListProvidersUseCase.spec.ts b/packages/git/src/application/useCases/listProviders/ListProvidersUseCase.spec.ts index 17cb285ee..a3c963670 100644 --- a/packages/git/src/application/useCases/listProviders/ListProvidersUseCase.spec.ts +++ b/packages/git/src/application/useCases/listProviders/ListProvidersUseCase.spec.ts @@ -6,7 +6,7 @@ import { Organization, User, } from '@packmind/types'; -import { gitProviderFactory } from '../../../../test'; +import { gitProviderFactory, gitRepoFactory } from '../../../../test'; import { GitProviderService } from '../../GitProviderService'; import { ListProvidersUseCase } from './ListProvidersUseCase'; @@ -269,6 +269,33 @@ describe('ListProvidersUseCase', () => { }); }); + describe('repos projection', () => { + describe('when the provider eager-loads both standard and marketplace repos', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + const standardRepo = gitRepoFactory({ type: 'standard' }); + const marketplaceRepo = gitRepoFactory({ type: 'marketplace' }); + const provider = gitProviderFactory({ + organizationId, + token: 'valid-token', + repos: [standardRepo, marketplaceRepo], + }); + mockGitProviderService.findGitProvidersByOrganizationId.mockResolvedValue( + [provider], + ); + + result = await useCase.execute({ organizationId, userId }); + }); + + it('returns only standard-typed repos', () => { + expect(result.providers[0].repos).toEqual([ + expect.objectContaining({ type: 'standard' }), + ]); + }); + }); + }); + describe('when no providers exist', () => { it('returns empty providers array', async () => { mockGitProviderService.findGitProvidersByOrganizationId.mockResolvedValue( diff --git a/packages/git/src/application/useCases/listProviders/ListProvidersUseCase.ts b/packages/git/src/application/useCases/listProviders/ListProvidersUseCase.ts index 031b15b14..ee24c462b 100644 --- a/packages/git/src/application/useCases/listProviders/ListProvidersUseCase.ts +++ b/packages/git/src/application/useCases/listProviders/ListProvidersUseCase.ts @@ -50,6 +50,10 @@ export class ListProvidersUseCase !rest.revokedAt; return { ...rest, + // Exclude marketplace-typed repos so the "Repos" count and drawer + // fallback only reflect standard repositories; marketplaces are + // surfaced separately via the marketplaces API. + repos: (rest.repos ?? []).filter((repo) => repo.type === 'standard'), hasAuth: hasPatToken || hasActiveAppInstallation, authMethod: rest.authMethod, // The Deployments-aware enrichment happens at the API service diff --git a/packages/git/src/domain/errors/GitRepoAlreadyLinkedAsStandardError.ts b/packages/git/src/domain/errors/GitRepoAlreadyLinkedAsStandardError.ts new file mode 100644 index 000000000..00c0f57b3 --- /dev/null +++ b/packages/git/src/domain/errors/GitRepoAlreadyLinkedAsStandardError.ts @@ -0,0 +1,3 @@ +// Re-export from @packmind/types so domain code can import git domain errors +// from either the local barrel or the shared types package interchangeably. +export { GitRepoAlreadyLinkedAsStandardError } from '@packmind/types'; diff --git a/packages/git/src/domain/errors/index.ts b/packages/git/src/domain/errors/index.ts new file mode 100644 index 000000000..af3de683a --- /dev/null +++ b/packages/git/src/domain/errors/index.ts @@ -0,0 +1 @@ +export { GitRepoAlreadyLinkedAsStandardError } from './GitRepoAlreadyLinkedAsStandardError'; diff --git a/packages/git/src/domain/repositories/IGitRepo.ts b/packages/git/src/domain/repositories/IGitRepo.ts index 9b0102158..539a4da1e 100644 --- a/packages/git/src/domain/repositories/IGitRepo.ts +++ b/packages/git/src/domain/repositories/IGitRepo.ts @@ -35,4 +35,47 @@ export interface IGitRepo { path: string, branch: string, ): Promise<{ path: string }[]>; + + /** + * Ensure a target branch exists on the repository, creating it from the + * repository's configured base branch when missing. + * + * No-op when the target branch already exists. + * + * @param targetBranch - The branch name to ensure exists + */ + createBranchFromBase(targetBranch: string): Promise<void>; + + /** + * Open a pull request from `head` to the repository's configured base + * branch, or return the matching open PR when one already exists + * (rolling-PR semantics). + * + * @param command - PR head / title / body + * @returns The PR URL, provider-side number, and whether it was created + */ + openOrUpdatePullRequest(command: { + head: string; + title: string; + body?: string; + }): Promise<{ url: string; number: number; wasCreated: boolean }>; + + /** + * Find an OPEN pull request whose head is `head` targeting the repo's + * configured base branch. Returns `null` when none is open. Used by the + * marketplace reconcile to surface a pending "Packmind sync" PR. + */ + findOpenPullRequest( + head: string, + ): Promise<{ url: string; number: number } | null>; + + /** + * Probe whether the repository is currently reachable with the configured + * credentials. Distinguishes the three failure modes the marketplaces page + * surfaces, so the caller never has to infer them from raw exceptions. + */ + checkRepositoryExists(): Promise<{ + exists: boolean; + reason?: 'auth_failed' | 'repo_not_found' | 'network_transient'; + }>; } diff --git a/packages/git/src/domain/repositories/IGitRepoRepository.ts b/packages/git/src/domain/repositories/IGitRepoRepository.ts index f708dbb36..9e177cfbb 100644 --- a/packages/git/src/domain/repositories/IGitRepoRepository.ts +++ b/packages/git/src/domain/repositories/IGitRepoRepository.ts @@ -1,23 +1,56 @@ import { GitRepo } from '@packmind/types'; import { GitProviderId } from '@packmind/types'; +import { GitRepoType } from '@packmind/types'; import { OrganizationId } from '@packmind/types'; import { IRepository } from '@packmind/types'; import { QueryOption } from '@packmind/types'; +/** + * Type filter accepted by repository finders. + * + * - A concrete `GitRepoType` value (`'standard'` | `'marketplace'`) restricts + * the query to that type only. + * - `'any'` opts out of the type filter entirely — used by the marketplace + * link pre-flight collision check. + */ +export type GitRepoTypeFilter = GitRepoType | 'any'; + export interface IGitRepoRepository extends IRepository<GitRepo> { findByOwnerAndRepo( owner: string, repo: string, - opts?: Pick<QueryOption, 'includeDeleted'>, + opts?: Pick<QueryOption, 'includeDeleted'> & { + type?: GitRepoTypeFilter; + }, ): Promise<GitRepo | null>; findByOwnerRepoAndBranchInOrganization( owner: string, repo: string, branch: string, organizationId: OrganizationId, - opts?: Pick<QueryOption, 'includeDeleted'>, + opts?: Pick<QueryOption, 'includeDeleted'> & { + type?: GitRepoTypeFilter; + }, + ): Promise<GitRepo | null>; + findByOwnerAndRepoInOrganization( + owner: string, + repo: string, + organizationId: OrganizationId, + opts?: Pick<QueryOption, 'includeDeleted'> & { + type?: GitRepoTypeFilter; + providerId?: GitProviderId; + }, ): Promise<GitRepo | null>; - findByProviderId(providerId: GitProviderId): Promise<GitRepo[]>; - findByOrganizationId(organizationId: OrganizationId): Promise<GitRepo[]>; - list(organizationId?: OrganizationId): Promise<GitRepo[]>; + findByProviderId( + providerId: GitProviderId, + opts?: { type?: GitRepoTypeFilter }, + ): Promise<GitRepo[]>; + findByOrganizationId( + organizationId: OrganizationId, + opts?: { type?: GitRepoTypeFilter }, + ): Promise<GitRepo[]>; + list( + organizationId?: OrganizationId, + opts?: { type?: GitRepoTypeFilter }, + ): Promise<GitRepo[]>; } diff --git a/packages/git/src/index.ts b/packages/git/src/index.ts index 9513f442d..b76e6d1f6 100644 --- a/packages/git/src/index.ts +++ b/packages/git/src/index.ts @@ -2,6 +2,8 @@ export { GitHexa } from './GitHexa'; export * from './domain/jobs'; export * from './infra/schemas'; export * from './application/useCases'; +export { GitRepoService } from './application/GitRepoService'; +export { GitRepoRepository } from './infra/repositories/GitRepoRepository'; export { FetchFileContentCallback } from './application/jobs/FetchFileContentDelayedJob'; export { GithubTokenResolverFactory, diff --git a/packages/git/src/infra/repositories/GitRepoRepository.spec.ts b/packages/git/src/infra/repositories/GitRepoRepository.spec.ts index 31a7cfa48..4a0de411b 100644 --- a/packages/git/src/infra/repositories/GitRepoRepository.spec.ts +++ b/packages/git/src/infra/repositories/GitRepoRepository.spec.ts @@ -153,6 +153,55 @@ describe('GitRepoRepository', () => { expect(foundGitRepo).toBeNull(); }); + describe('findByOwnerAndRepoInOrganization with a providerId filter', () => { + let secondProvider: GitProvider; + + // Same owner/repo linked under two providers in one org — the + // marketplace collision check must distinguish them by provider. + beforeEach(async () => { + secondProvider = await gitProviderRepository.save( + gitProviderFactory({ organizationId: testOrganization.id }), + ); + + await gitRepoRepository.add( + gitRepoFactory({ + owner: 'acme', + repo: 'plugins', + providerId: testProvider.id, + type: 'marketplace', + }), + ); + await gitRepoRepository.add( + gitRepoFactory({ + owner: 'acme', + repo: 'plugins', + providerId: secondProvider.id, + type: 'standard', + }), + ); + }); + + it('returns the repo belonging to the requested provider', async () => { + const found = await gitRepoRepository.findByOwnerAndRepoInOrganization( + 'acme', + 'plugins', + testOrganization.id, + { type: 'any', providerId: testProvider.id }, + ); + expect(found?.providerId).toBe(testProvider.id); + }); + + it('returns the other provider repo once scoped to it', async () => { + const found = await gitRepoRepository.findByOwnerAndRepoInOrganization( + 'acme', + 'plugins', + testOrganization.id, + { type: 'any', providerId: secondProvider.id }, + ); + expect(found?.providerId).toBe(secondProvider.id); + }); + }); + describe('findByProviderId', () => { let gitRepo1: GitRepo; let gitRepo2: GitRepo; diff --git a/packages/git/src/infra/repositories/GitRepoRepository.ts b/packages/git/src/infra/repositories/GitRepoRepository.ts index 049535fd3..a4885cee8 100644 --- a/packages/git/src/infra/repositories/GitRepoRepository.ts +++ b/packages/git/src/infra/repositories/GitRepoRepository.ts @@ -1,6 +1,9 @@ import { GitRepo } from '@packmind/types'; import { GitProviderId } from '@packmind/types'; -import { IGitRepoRepository } from '../../domain/repositories/IGitRepoRepository'; +import { + IGitRepoRepository, + GitRepoTypeFilter, +} from '../../domain/repositories/IGitRepoRepository'; import { GitRepoSchema } from '../schemas/GitRepoSchema'; import { GitProviderSchema } from '../schemas/GitProviderSchema'; import { Repository } from 'typeorm'; @@ -36,18 +39,37 @@ export class GitRepoRepository async findByOwnerAndRepo( owner: string, repo: string, - opts?: Pick<QueryOption, 'includeDeleted'>, + opts?: Pick<QueryOption, 'includeDeleted'> & { + type?: GitRepoTypeFilter; + }, ): Promise<GitRepo | null> { - this.logger.info('Finding git repo by owner and repo', { owner, repo }); + const type: GitRepoTypeFilter = opts?.type ?? 'standard'; + + this.logger.info('Finding git repo by owner and repo', { + owner, + repo, + type, + }); try { - const gitRepo = await this.repository.findOne({ - where: { owner, repo }, - withDeleted: opts?.includeDeleted ?? false, - }); + const queryBuilder = this.repository + .createQueryBuilder('gitRepo') + .where('gitRepo.owner = :owner', { owner }) + .andWhere('gitRepo.repo = :repo', { repo }); + + if (type !== 'any') { + queryBuilder.andWhere('gitRepo.type = :type', { type }); + } + + if (opts?.includeDeleted) { + queryBuilder.withDeleted(); + } + + const gitRepo = await queryBuilder.getOne(); this.logger.info('Git repo found by owner and repo', { owner, repo, + type, found: !!gitRepo, }); return gitRepo; @@ -55,6 +77,7 @@ export class GitRepoRepository this.logger.error('Failed to find git repo by owner and repo', { owner, repo, + type, error: error instanceof Error ? error.message : String(error), }); throw error; @@ -66,8 +89,12 @@ export class GitRepoRepository repo: string, branch: string, organizationId: OrganizationId, - opts?: Pick<QueryOption, 'includeDeleted'>, + opts?: Pick<QueryOption, 'includeDeleted'> & { + type?: GitRepoTypeFilter; + }, ): Promise<GitRepo | null> { + const type: GitRepoTypeFilter = opts?.type ?? 'standard'; + this.logger.info( 'Finding git repo by owner, repo, branch, and organization', { @@ -75,6 +102,7 @@ export class GitRepoRepository repo, branch, organizationId, + type, }, ); @@ -93,6 +121,10 @@ export class GitRepoRepository organizationId, }); + if (type !== 'any') { + queryBuilder.andWhere('gitRepo.type = :type', { type }); + } + if (opts?.includeDeleted) { queryBuilder.withDeleted(); } @@ -106,6 +138,7 @@ export class GitRepoRepository repo, branch, organizationId, + type, found: !!gitRepo, }, ); @@ -118,6 +151,7 @@ export class GitRepoRepository repo, branch, organizationId, + type, error: error instanceof Error ? error.message : String(error), }, ); @@ -125,19 +159,111 @@ export class GitRepoRepository } } - async findByProviderId(providerId: GitProviderId): Promise<GitRepo[]> { - this.logger.info('Finding git repos by provider ID', { providerId }); + async findByOwnerAndRepoInOrganization( + owner: string, + repo: string, + organizationId: OrganizationId, + opts?: Pick<QueryOption, 'includeDeleted'> & { + type?: GitRepoTypeFilter; + providerId?: GitProviderId; + }, + ): Promise<GitRepo | null> { + const type: GitRepoTypeFilter = opts?.type ?? 'standard'; + + this.logger.info('Finding git repo by owner, repo, and organization', { + owner, + repo, + organizationId, + type, + providerId: opts?.providerId, + }); try { - const gitRepos = await this.repository.find({ where: { providerId } }); + const queryBuilder = this.repository + .createQueryBuilder('gitRepo') + .innerJoin( + GitProviderSchema.options.name, + 'provider', + 'gitRepo.providerId = provider.id', + ) + .where('gitRepo.owner = :owner', { owner }) + .andWhere('gitRepo.repo = :repo', { repo }) + .andWhere('provider.organizationId = :organizationId', { + organizationId, + }); + + if (type !== 'any') { + queryBuilder.andWhere('gitRepo.type = :type', { type }); + } + + if (opts?.providerId) { + queryBuilder.andWhere('gitRepo.providerId = :providerId', { + providerId: opts.providerId, + }); + } + + if (opts?.includeDeleted) { + queryBuilder.withDeleted(); + } + + const gitRepo = await queryBuilder.getOne(); + + this.logger.info('Git repo found by owner, repo, and organization', { + owner, + repo, + organizationId, + type, + providerId: opts?.providerId, + found: !!gitRepo, + }); + return gitRepo; + } catch (error) { + this.logger.error( + 'Failed to find git repo by owner, repo, and organization', + { + owner, + repo, + organizationId, + type, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async findByProviderId( + providerId: GitProviderId, + opts?: { type?: GitRepoTypeFilter }, + ): Promise<GitRepo[]> { + const type: GitRepoTypeFilter = opts?.type ?? 'standard'; + + this.logger.info('Finding git repos by provider ID', { + providerId, + type, + }); + + try { + const queryBuilder = this.repository + .createQueryBuilder('gitRepo') + .where('gitRepo.providerId = :providerId', { providerId }); + + if (type !== 'any') { + queryBuilder.andWhere('gitRepo.type = :type', { type }); + } + + const gitRepos = await queryBuilder.getMany(); + this.logger.info('Git repos found by provider ID', { providerId, + type, count: gitRepos.length, }); return gitRepos; } catch (error) { this.logger.error('Failed to find git repos by provider ID', { providerId, + type, error: error instanceof Error ? error.message : String(error), }); throw error; @@ -146,55 +272,79 @@ export class GitRepoRepository async findByOrganizationId( organizationId: OrganizationId, + opts?: { type?: GitRepoTypeFilter }, ): Promise<GitRepo[]> { + const type: GitRepoTypeFilter = opts?.type ?? 'standard'; + this.logger.info('Finding git repos by organization ID', { organizationId, + type, }); try { - const gitRepos = await this.repository + const queryBuilder = this.repository .createQueryBuilder('gitRepo') .innerJoin( GitProviderSchema.options.name, 'provider', 'gitRepo.providerId = provider.id', ) - .where('provider.organizationId = :organizationId', { organizationId }) - .getMany(); + .where('provider.organizationId = :organizationId', { + organizationId, + }); + + if (type !== 'any') { + queryBuilder.andWhere('gitRepo.type = :type', { type }); + } + + const gitRepos = await queryBuilder.getMany(); this.logger.info('Git repos found by organization ID', { organizationId, + type, count: gitRepos.length, }); return gitRepos; } catch (error) { this.logger.error('Failed to find git repos by organization ID', { organizationId, + type, error: error instanceof Error ? error.message : String(error), }); throw error; } } - async list(organizationId?: OrganizationId): Promise<GitRepo[]> { - this.logger.info('Listing git repos', { organizationId }); + async list( + organizationId?: OrganizationId, + opts?: { type?: GitRepoTypeFilter }, + ): Promise<GitRepo[]> { + const type: GitRepoTypeFilter = opts?.type ?? 'standard'; + + this.logger.info('Listing git repos', { organizationId, type }); try { let gitRepos: GitRepo[]; if (organizationId) { - gitRepos = await this.findByOrganizationId(organizationId); + gitRepos = await this.findByOrganizationId(organizationId, { type }); } else { - gitRepos = await this.repository.find(); + const queryBuilder = this.repository.createQueryBuilder('gitRepo'); + if (type !== 'any') { + queryBuilder.where('gitRepo.type = :type', { type }); + } + gitRepos = await queryBuilder.getMany(); } this.logger.info('Git repos listed successfully', { organizationId, + type, count: gitRepos.length, }); return gitRepos; } catch (error) { this.logger.error('Failed to list git repos', { organizationId, + type, error: error instanceof Error ? error.message : String(error), }); throw error; diff --git a/packages/git/src/infra/repositories/github/GithubRepository.spec.ts b/packages/git/src/infra/repositories/github/GithubRepository.spec.ts index 2197f657f..26c22b9c9 100644 --- a/packages/git/src/infra/repositories/github/GithubRepository.spec.ts +++ b/packages/git/src/infra/repositories/github/GithubRepository.spec.ts @@ -978,6 +978,304 @@ describe('GithubRepository', () => { }); }); + describe('createBranchFromBase', () => { + const baseBranchSha = 'base-branch-sha-abc'; + + describe('when the target branch already exists', () => { + beforeEach(async () => { + mockAxiosInstance.get = jest.fn().mockImplementation((url) => { + if ( + url === + `/repos/${options.owner}/${options.repo}/git/refs/heads/packmind/sync` + ) { + return Promise.resolve({ + data: { object: { sha: 'existing-sync-sha' } }, + }); + } + return Promise.reject(new Error(`Unexpected GET: ${url}`)); + }); + mockAxiosInstance.post = jest.fn(); + + await githubRepository.createBranchFromBase('packmind/sync'); + }); + + it('does not POST a new ref', () => { + expect(mockAxiosInstance.post).not.toHaveBeenCalled(); + }); + }); + + describe('when the target branch is missing', () => { + beforeEach(async () => { + mockAxiosInstance.get = jest.fn().mockImplementation((url) => { + if ( + url === + `/repos/${options.owner}/${options.repo}/git/refs/heads/packmind/sync` + ) { + return Promise.reject({ response: { status: 404 } }); + } + if ( + url === + `/repos/${options.owner}/${options.repo}/git/refs/heads/main` + ) { + return Promise.resolve({ + data: { object: { sha: baseBranchSha } }, + }); + } + return Promise.reject(new Error(`Unexpected GET: ${url}`)); + }); + mockAxiosInstance.post = jest.fn().mockResolvedValue({ data: {} }); + + await githubRepository.createBranchFromBase('packmind/sync'); + }); + + it('creates the target branch from the base SHA', () => { + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + `/repos/${options.owner}/${options.repo}/git/refs`, + { + ref: 'refs/heads/packmind/sync', + sha: baseBranchSha, + }, + ); + }); + }); + + describe('when fetching the base branch ref fails', () => { + beforeEach(() => { + mockAxiosInstance.get = jest.fn().mockImplementation((url) => { + if ( + url === + `/repos/${options.owner}/${options.repo}/git/refs/heads/packmind/sync` + ) { + return Promise.reject({ response: { status: 404 } }); + } + if ( + url === + `/repos/${options.owner}/${options.repo}/git/refs/heads/main` + ) { + return Promise.reject(new Error('Network down')); + } + return Promise.reject(new Error(`Unexpected GET: ${url}`)); + }); + mockAxiosInstance.post = jest.fn(); + }); + + it('propagates an error mentioning the base branch', async () => { + await expect( + githubRepository.createBranchFromBase('packmind/sync'), + ).rejects.toThrow( + `Failed to fetch base branch 'main' on GitHub: Network down`, + ); + }); + }); + }); + + describe('openOrUpdatePullRequest', () => { + const command = { + head: 'packmind/sync', + title: 'Packmind sync', + body: 'rolling PR body', + }; + + describe('when an open pull request already exists', () => { + let result: { url: string; number: number; wasCreated: boolean }; + + beforeEach(async () => { + mockAxiosInstance.get = jest.fn().mockResolvedValue({ + data: [ + { + number: 12, + html_url: 'https://github.com/test-owner/test-repo/pull/12', + }, + ], + }); + mockAxiosInstance.post = jest.fn(); + + result = await githubRepository.openOrUpdatePullRequest(command); + }); + + it('returns the existing pull request URL', () => { + expect(result.url).toBe( + 'https://github.com/test-owner/test-repo/pull/12', + ); + }); + + it('does not POST a new pull request', () => { + expect(mockAxiosInstance.post).not.toHaveBeenCalled(); + }); + }); + + describe('when no open pull request exists', () => { + let result: { url: string; number: number; wasCreated: boolean }; + + beforeEach(async () => { + mockAxiosInstance.get = jest.fn().mockResolvedValue({ data: [] }); + mockAxiosInstance.post = jest.fn().mockResolvedValue({ + data: { + number: 21, + html_url: 'https://github.com/test-owner/test-repo/pull/21', + }, + }); + + result = await githubRepository.openOrUpdatePullRequest(command); + }); + + it('POSTs a new pull request with the expected payload', () => { + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + `/repos/${options.owner}/${options.repo}/pulls`, + { + title: 'Packmind sync', + head: 'packmind/sync', + base: 'main', + body: 'rolling PR body', + }, + ); + }); + + it('reports wasCreated=true', () => { + expect(result.wasCreated).toBe(true); + }); + }); + + describe('when GitHub responds 422 "pull request already exists" on create', () => { + let result: { url: string; number: number; wasCreated: boolean }; + + beforeEach(async () => { + const lookupResponses: Array<{ data: unknown[] }> = [ + { data: [] }, + { + data: [ + { + number: 99, + html_url: 'https://github.com/test-owner/test-repo/pull/99', + }, + ], + }, + ]; + mockAxiosInstance.get = jest + .fn() + .mockImplementation(() => + Promise.resolve(lookupResponses.shift() ?? { data: [] }), + ); + mockAxiosInstance.post = jest.fn().mockRejectedValue({ + response: { + status: 422, + data: { message: 'A pull request already exists for foo:bar' }, + }, + }); + + result = await githubRepository.openOrUpdatePullRequest(command); + }); + + it('falls back to the existing pull request', () => { + expect(result.url).toBe( + 'https://github.com/test-owner/test-repo/pull/99', + ); + }); + }); + + describe('when GitHub responds with a non-422 error on create', () => { + beforeEach(() => { + mockAxiosInstance.get = jest.fn().mockResolvedValue({ data: [] }); + mockAxiosInstance.post = jest.fn().mockRejectedValue({ + response: { status: 500, data: { message: 'Server error' } }, + message: 'Server error', + }); + }); + + it('propagates the error', async () => { + await expect( + githubRepository.openOrUpdatePullRequest(command), + ).rejects.toThrow(/Failed to open pull request on GitHub/); + }); + }); + }); + + describe('findOpenPullRequest', () => { + describe('when an open pull request exists', () => { + let result: Awaited< + ReturnType<typeof githubRepository.findOpenPullRequest> + >; + + beforeEach(async () => { + mockAxiosInstance.get = jest.fn().mockResolvedValue({ + data: [ + { + number: 7, + html_url: 'https://github.com/test-owner/test-repo/pull/7', + }, + ], + }); + result = await githubRepository.findOpenPullRequest('packmind/sync'); + }); + + it('returns the first open pull request mapped to url and number', () => { + expect(result).toEqual({ + url: 'https://github.com/test-owner/test-repo/pull/7', + number: 7, + }); + }); + }); + + describe('when no open pull request exists', () => { + it('returns null', async () => { + mockAxiosInstance.get = jest.fn().mockResolvedValue({ data: [] }); + const result = + await githubRepository.findOpenPullRequest('packmind/sync'); + expect(result).toBeNull(); + }); + }); + }); + + describe('checkRepositoryExists', () => { + describe('when the repository endpoint returns 200', () => { + it('reports the repository exists', async () => { + mockAxiosInstance.get = jest.fn().mockResolvedValue({ data: {} }); + const result = await githubRepository.checkRepositoryExists(); + expect(result).toEqual({ exists: true }); + }); + }); + + describe('when the repository endpoint returns 404', () => { + it('classifies the failure as repo_not_found', async () => { + mockAxiosInstance.get = jest + .fn() + .mockRejectedValue({ response: { status: 404 } }); + const result = await githubRepository.checkRepositoryExists(); + expect(result).toEqual({ exists: false, reason: 'repo_not_found' }); + }); + }); + + describe('when the repository endpoint returns 401', () => { + it('classifies the failure as auth_failed', async () => { + mockAxiosInstance.get = jest + .fn() + .mockRejectedValue({ response: { status: 401 } }); + const result = await githubRepository.checkRepositoryExists(); + expect(result).toEqual({ exists: false, reason: 'auth_failed' }); + }); + }); + + describe('when the repository endpoint returns 403', () => { + it('classifies the failure as auth_failed', async () => { + mockAxiosInstance.get = jest + .fn() + .mockRejectedValue({ response: { status: 403 } }); + const result = await githubRepository.checkRepositoryExists(); + expect(result).toEqual({ exists: false, reason: 'auth_failed' }); + }); + }); + + describe('when the request fails with a network error', () => { + it('classifies the failure as network_transient', async () => { + mockAxiosInstance.get = jest + .fn() + .mockRejectedValue(new Error('socket hang up')); + const result = await githubRepository.checkRepositoryExists(); + expect(result).toEqual({ exists: false, reason: 'network_transient' }); + }); + }); + }); + describe('getFileOnRepo', () => { const filePath = 'test/file.txt'; const fileSha = 'test-sha-123'; diff --git a/packages/git/src/infra/repositories/github/GithubRepository.ts b/packages/git/src/infra/repositories/github/GithubRepository.ts index 2e2536757..f6326b4a7 100644 --- a/packages/git/src/infra/repositories/github/GithubRepository.ts +++ b/packages/git/src/infra/repositories/github/GithubRepository.ts @@ -358,6 +358,281 @@ export class GithubRepository implements IGitRepo { } } + async createBranchFromBase(targetBranch: string): Promise<void> { + const { owner, repo } = this.options; + const baseBranch = this.options.branch || 'main'; + + this.logger.info('Ensuring branch exists on GitHub repository', { + owner, + repo, + baseBranch, + targetBranch, + }); + + // Step 1: Check if the target branch already exists. If GitHub returns + // 2xx, the branch is present and no work is needed. + try { + await this.axiosInstance.get( + `/repos/${owner}/${repo}/git/refs/heads/${targetBranch}`, + ); + + this.logger.debug('Target branch already exists, skipping creation', { + owner, + repo, + targetBranch, + }); + return; + } catch (error) { + const status = this.extractHttpStatus(error); + if (status !== 404) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error('Failed to probe target branch existence on GitHub', { + owner, + repo, + targetBranch, + error: errorMessage, + }); + throw new Error( + `Failed to ensure branch '${targetBranch}' on GitHub: ${errorMessage}`, + ); + } + // 404 -> branch missing, proceed to create it from the base branch. + this.logger.debug('Target branch missing, will create from base', { + owner, + repo, + baseBranch, + targetBranch, + }); + } + + // Step 2: Fetch the base branch SHA so we know where to fork from. + let baseSha: string; + try { + const baseRefResponse = await this.axiosInstance.get( + `/repos/${owner}/${repo}/git/refs/heads/${baseBranch}`, + ); + baseSha = baseRefResponse.data.object.sha; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error('Failed to fetch base branch ref on GitHub', { + owner, + repo, + baseBranch, + error: errorMessage, + }); + throw new Error( + `Failed to fetch base branch '${baseBranch}' on GitHub: ${errorMessage}`, + ); + } + + // Step 3: Create the target branch ref pointing at the base SHA. + try { + await this.axiosInstance.post(`/repos/${owner}/${repo}/git/refs`, { + ref: `refs/heads/${targetBranch}`, + sha: baseSha, + }); + + this.logger.info('Created target branch on GitHub', { + owner, + repo, + baseBranch, + targetBranch, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error('Failed to create target branch on GitHub', { + owner, + repo, + baseBranch, + targetBranch, + error: errorMessage, + }); + throw new Error( + `Failed to create branch '${targetBranch}' on GitHub: ${errorMessage}`, + ); + } + } + + async openOrUpdatePullRequest(command: { + head: string; + title: string; + body?: string; + }): Promise<{ url: string; number: number; wasCreated: boolean }> { + const { owner, repo } = this.options; + const baseBranch = this.options.branch || 'main'; + const { head, title, body } = command; + + this.logger.info('Ensuring rolling pull request on GitHub repository', { + owner, + repo, + head, + base: baseBranch, + }); + + // Step 1: Look up any existing open PR matching head -> base. + const existing = await this.findOpenPullRequestForBase(head, baseBranch); + if (existing) { + this.logger.debug('Existing open pull request found, skipping creation', { + owner, + repo, + head, + base: baseBranch, + number: existing.number, + }); + return { url: existing.url, number: existing.number, wasCreated: false }; + } + + // Step 2: Create a new PR. GitHub may race a concurrent creator and + // respond with a 422 "A pull request already exists" — in that case we + // re-run the lookup and surface the existing PR. + try { + const createResponse = await this.axiosInstance.post( + `/repos/${owner}/${repo}/pulls`, + { + title, + head, + base: baseBranch, + body, + }, + ); + + this.logger.info('Created pull request on GitHub', { + owner, + repo, + head, + base: baseBranch, + number: createResponse.data.number, + }); + + return { + url: createResponse.data.html_url, + number: createResponse.data.number, + wasCreated: true, + }; + } catch (error) { + if (this.isPullRequestAlreadyExistsError(error)) { + this.logger.debug( + 'GitHub reported PR already exists, re-running lookup', + { owner, repo, head, base: baseBranch }, + ); + const racedExisting = await this.findOpenPullRequestForBase( + head, + baseBranch, + ); + if (racedExisting) { + return { + url: racedExisting.url, + number: racedExisting.number, + wasCreated: false, + }; + } + // Fallthrough: fall back to a generic error if the post-race lookup + // still finds nothing (extremely unlikely, but defensive). + } + + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error('Failed to open pull request on GitHub', { + owner, + repo, + head, + base: baseBranch, + error: errorMessage, + }); + throw new Error( + `Failed to open pull request on GitHub for '${head}' -> '${baseBranch}': ${errorMessage}`, + ); + } + } + + public async findOpenPullRequest( + head: string, + ): Promise<{ url: string; number: number } | null> { + const baseBranch = this.options.branch || 'main'; + return this.findOpenPullRequestForBase(head, baseBranch); + } + + public async checkRepositoryExists(): Promise<{ + exists: boolean; + reason?: 'auth_failed' | 'repo_not_found' | 'network_transient'; + }> { + const { owner, repo } = this.options; + try { + await this.axiosInstance.get(`/repos/${owner}/${repo}`); + return { exists: true }; + } catch (error) { + const status = this.extractHttpStatus(error); + if (status === 401 || status === 403) { + return { exists: false, reason: 'auth_failed' }; + } + if (status === 404) { + return { exists: false, reason: 'repo_not_found' }; + } + return { exists: false, reason: 'network_transient' }; + } + } + + private async findOpenPullRequestForBase( + head: string, + base: string, + ): Promise<{ url: string; number: number } | null> { + const { owner, repo } = this.options; + const response = await this.axiosInstance.get( + `/repos/${owner}/${repo}/pulls`, + { + params: { + head: `${owner}:${head}`, + base, + state: 'open', + }, + }, + ); + + if (Array.isArray(response.data) && response.data.length > 0) { + const first = response.data[0]; + return { url: first.html_url, number: first.number }; + } + return null; + } + + private isPullRequestAlreadyExistsError(error: unknown): boolean { + if (this.extractHttpStatus(error) !== 422) { + return false; + } + if ( + error && + typeof error === 'object' && + 'response' in error && + error.response && + typeof error.response === 'object' && + 'data' in error.response + ) { + const data = (error.response as { data: unknown }).data; + const serialized = + typeof data === 'string' ? data : JSON.stringify(data ?? ''); + return /pull request already exists/i.test(serialized); + } + return false; + } + + private extractHttpStatus(error: unknown): number | undefined { + if ( + error && + typeof error === 'object' && + 'response' in error && + error.response && + typeof error.response === 'object' && + 'status' in error.response && + typeof (error.response as { status: unknown }).status === 'number' + ) { + return (error.response as { status: number }).status; + } + return undefined; + } + async getFileOnRepo( path: string, branch?: string, diff --git a/packages/git/src/infra/repositories/gitlab/GitlabRepository.spec.ts b/packages/git/src/infra/repositories/gitlab/GitlabRepository.spec.ts index d80c4a8c1..fabcfe530 100644 --- a/packages/git/src/infra/repositories/gitlab/GitlabRepository.spec.ts +++ b/packages/git/src/infra/repositories/gitlab/GitlabRepository.spec.ts @@ -1309,4 +1309,264 @@ describe('GitlabRepository', () => { ).rejects.toThrow('Server error'); }); }); + + describe('createBranchFromBase', () => { + const encodedProjectPath = encodeURIComponent('testowner/testrepo'); + + describe('when the target branch already exists', () => { + beforeEach(async () => { + mockAxiosInstance.get.mockImplementation((url: string) => { + if ( + url.includes(`/projects/${encodedProjectPath}/repository/branches/`) + ) { + return Promise.resolve({ data: { name: 'packmind/sync' } }); + } + return Promise.reject(new Error(`Unexpected GET: ${url}`)); + }); + + await gitlabRepository.createBranchFromBase('packmind/sync'); + }); + + it('does not POST a new branch', () => { + expect(mockAxiosInstance.post).not.toHaveBeenCalled(); + }); + }); + + describe('when the target branch is missing', () => { + beforeEach(async () => { + mockAxiosInstance.get.mockImplementation((url: string) => { + if ( + url.includes(`/projects/${encodedProjectPath}/repository/branches/`) + ) { + return Promise.reject({ response: { status: 404 } }); + } + return Promise.reject(new Error(`Unexpected GET: ${url}`)); + }); + mockAxiosInstance.post.mockResolvedValue({ data: {} }); + + await gitlabRepository.createBranchFromBase('packmind/sync'); + }); + + it('creates the target branch from the base branch', () => { + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + `/projects/${encodedProjectPath}/repository/branches`, + null, + { + params: { + branch: 'packmind/sync', + ref: 'main', + }, + }, + ); + }); + }); + + describe('when probing the target branch fails with a non-404 error', () => { + beforeEach(() => { + mockAxiosInstance.get.mockImplementation((url: string) => { + if ( + url.includes(`/projects/${encodedProjectPath}/repository/branches/`) + ) { + return Promise.reject(new Error('Boom')); + } + return Promise.reject(new Error(`Unexpected GET: ${url}`)); + }); + }); + + it('propagates the error without attempting to create the branch', async () => { + await expect( + gitlabRepository.createBranchFromBase('packmind/sync'), + ).rejects.toThrow( + `Failed to ensure branch 'packmind/sync' on GitLab: Boom`, + ); + }); + }); + }); + + describe('openOrUpdatePullRequest', () => { + const encodedProjectPath = encodeURIComponent('testowner/testrepo'); + const command = { + head: 'packmind/sync', + title: 'Packmind sync', + body: 'rolling MR body', + }; + + describe('when an open merge request already exists', () => { + let result: { url: string; number: number; wasCreated: boolean }; + + beforeEach(async () => { + mockAxiosInstance.get.mockResolvedValue({ + data: [ + { + iid: 7, + web_url: + 'https://gitlab.com/testowner/testrepo/-/merge_requests/7', + }, + ], + }); + + result = await gitlabRepository.openOrUpdatePullRequest(command); + }); + + it('returns the existing merge request URL', () => { + expect(result.url).toBe( + 'https://gitlab.com/testowner/testrepo/-/merge_requests/7', + ); + }); + + it('does not POST a new merge request', () => { + expect(mockAxiosInstance.post).not.toHaveBeenCalled(); + }); + }); + + describe('when no open merge request exists', () => { + let result: { url: string; number: number; wasCreated: boolean }; + + beforeEach(async () => { + mockAxiosInstance.get.mockResolvedValue({ data: [] }); + mockAxiosInstance.post.mockResolvedValue({ + data: { + iid: 9, + web_url: 'https://gitlab.com/testowner/testrepo/-/merge_requests/9', + }, + }); + + result = await gitlabRepository.openOrUpdatePullRequest(command); + }); + + it('POSTs a new merge request with the expected payload', () => { + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + `/projects/${encodedProjectPath}/merge_requests`, + { + source_branch: 'packmind/sync', + target_branch: 'main', + title: 'Packmind sync', + description: 'rolling MR body', + }, + ); + }); + + it('reports wasCreated=true', () => { + expect(result.wasCreated).toBe(true); + }); + }); + + describe('when the lookup fails with a non-404 error', () => { + beforeEach(() => { + mockAxiosInstance.get.mockRejectedValue(new Error('GitLab 503')); + }); + + it('propagates the error without attempting to create the merge request', async () => { + await expect( + gitlabRepository.openOrUpdatePullRequest(command), + ).rejects.toThrow( + `Failed to look up merge request on GitLab for 'packmind/sync' -> 'main': GitLab 503`, + ); + }); + }); + }); + + describe('findOpenPullRequest', () => { + describe('when an open merge request exists', () => { + it('returns the first open merge request mapped to url and number', async () => { + mockAxiosInstance.get.mockResolvedValue({ + data: [ + { + iid: 7, + web_url: + 'https://gitlab.com/testowner/testrepo/-/merge_requests/7', + }, + ], + }); + + const result = + await gitlabRepository.findOpenPullRequest('packmind/sync'); + + expect(result).toEqual({ + url: 'https://gitlab.com/testowner/testrepo/-/merge_requests/7', + number: 7, + }); + }); + + it('queries the merge requests endpoint scoped to head and base branches', async () => { + mockAxiosInstance.get.mockResolvedValue({ data: [] }); + + await gitlabRepository.findOpenPullRequest('packmind/sync'); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + '/projects/testowner%2Ftestrepo/merge_requests', + { + params: { + source_branch: 'packmind/sync', + target_branch: 'main', + state: 'opened', + }, + }, + ); + }); + }); + + describe('when no open merge request exists', () => { + it('returns null', async () => { + mockAxiosInstance.get.mockResolvedValue({ data: [] }); + + const result = + await gitlabRepository.findOpenPullRequest('packmind/sync'); + + expect(result).toBeNull(); + }); + }); + }); + + describe('checkRepositoryExists', () => { + describe('when the project endpoint returns 200', () => { + it('reports the repository exists', async () => { + mockAxiosInstance.get.mockResolvedValue({ data: {} }); + + const result = await gitlabRepository.checkRepositoryExists(); + + expect(result).toEqual({ exists: true }); + }); + }); + + describe('when the project endpoint returns 404', () => { + it('classifies the failure as repo_not_found', async () => { + mockAxiosInstance.get.mockRejectedValue({ response: { status: 404 } }); + + const result = await gitlabRepository.checkRepositoryExists(); + + expect(result).toEqual({ exists: false, reason: 'repo_not_found' }); + }); + }); + + describe('when the project endpoint returns 401', () => { + it('classifies the failure as auth_failed', async () => { + mockAxiosInstance.get.mockRejectedValue({ response: { status: 401 } }); + + const result = await gitlabRepository.checkRepositoryExists(); + + expect(result).toEqual({ exists: false, reason: 'auth_failed' }); + }); + }); + + describe('when the project endpoint returns 403', () => { + it('classifies the failure as auth_failed', async () => { + mockAxiosInstance.get.mockRejectedValue({ response: { status: 403 } }); + + const result = await gitlabRepository.checkRepositoryExists(); + + expect(result).toEqual({ exists: false, reason: 'auth_failed' }); + }); + }); + + describe('when the request fails with a network error', () => { + it('classifies the failure as network_transient', async () => { + mockAxiosInstance.get.mockRejectedValue(new Error('socket hang up')); + + const result = await gitlabRepository.checkRepositoryExists(); + + expect(result).toEqual({ exists: false, reason: 'network_transient' }); + }); + }); + }); }); diff --git a/packages/git/src/infra/repositories/gitlab/GitlabRepository.ts b/packages/git/src/infra/repositories/gitlab/GitlabRepository.ts index 27424e66c..cb6747d8e 100644 --- a/packages/git/src/infra/repositories/gitlab/GitlabRepository.ts +++ b/packages/git/src/infra/repositories/gitlab/GitlabRepository.ts @@ -532,6 +532,240 @@ export class GitlabRepository implements IGitRepo { } } + async createBranchFromBase(targetBranch: string): Promise<void> { + const baseBranch = this.options.branch || 'main'; + + this.logger.info('Ensuring branch exists on GitLab repository', { + projectPath: this.projectPath, + baseBranch, + targetBranch, + }); + + // Step 1: Check if the target branch already exists. If GitLab returns + // 2xx, the branch is present and no work is needed. + const encodedTargetBranch = encodeURIComponent(targetBranch); + try { + await this.axiosInstance.get( + `/projects/${this.encodedProjectPath}/repository/branches/${encodedTargetBranch}`, + ); + + this.logger.debug('Target branch already exists, skipping creation', { + projectPath: this.projectPath, + targetBranch, + }); + return; + } catch (error) { + const status = this.extractHttpStatus(error); + if (status !== 404) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error('Failed to probe target branch existence on GitLab', { + projectPath: this.projectPath, + targetBranch, + error: errorMessage, + }); + throw new Error( + `Failed to ensure branch '${targetBranch}' on GitLab: ${errorMessage}`, + ); + } + // 404 -> branch missing, proceed to create it from the base branch. + this.logger.debug('Target branch missing, will create from base', { + projectPath: this.projectPath, + baseBranch, + targetBranch, + }); + } + + // Step 2: Create the target branch from the base branch. GitLab's branch + // creation endpoint validates that `ref` (the base) exists; propagate any + // failure verbatim so the caller can surface it. + try { + await this.axiosInstance.post( + `/projects/${this.encodedProjectPath}/repository/branches`, + null, + { + params: { + branch: targetBranch, + ref: baseBranch, + }, + }, + ); + + this.logger.info('Created target branch on GitLab', { + projectPath: this.projectPath, + baseBranch, + targetBranch, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error('Failed to create target branch on GitLab', { + projectPath: this.projectPath, + baseBranch, + targetBranch, + error: errorMessage, + }); + throw new Error( + `Failed to create branch '${targetBranch}' on GitLab: ${errorMessage}`, + ); + } + } + + async openOrUpdatePullRequest(command: { + head: string; + title: string; + body?: string; + }): Promise<{ url: string; number: number; wasCreated: boolean }> { + const baseBranch = this.options.branch || 'main'; + const { head, title, body } = command; + + this.logger.info('Ensuring rolling merge request on GitLab repository', { + projectPath: this.projectPath, + head, + base: baseBranch, + }); + + // Step 1: Look up any open MR matching source -> target. + try { + const lookupResponse = await this.axiosInstance.get( + `/projects/${this.encodedProjectPath}/merge_requests`, + { + params: { + source_branch: head, + target_branch: baseBranch, + state: 'opened', + }, + }, + ); + + if ( + Array.isArray(lookupResponse.data) && + lookupResponse.data.length > 0 + ) { + const first = lookupResponse.data[0]; + this.logger.debug( + 'Existing open merge request found, skipping creation', + { + projectPath: this.projectPath, + head, + base: baseBranch, + iid: first.iid, + }, + ); + return { + url: first.web_url, + number: first.iid, + wasCreated: false, + }; + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error('Failed to look up merge request on GitLab', { + projectPath: this.projectPath, + head, + base: baseBranch, + error: errorMessage, + }); + throw new Error( + `Failed to look up merge request on GitLab for '${head}' -> '${baseBranch}': ${errorMessage}`, + ); + } + + // Step 2: Create a new MR. + try { + const createResponse = await this.axiosInstance.post( + `/projects/${this.encodedProjectPath}/merge_requests`, + { + source_branch: head, + target_branch: baseBranch, + title, + description: body, + }, + ); + + this.logger.info('Created merge request on GitLab', { + projectPath: this.projectPath, + head, + base: baseBranch, + iid: createResponse.data.iid, + }); + + return { + url: createResponse.data.web_url, + number: createResponse.data.iid, + wasCreated: true, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error('Failed to create merge request on GitLab', { + projectPath: this.projectPath, + head, + base: baseBranch, + error: errorMessage, + }); + throw new Error( + `Failed to open merge request on GitLab for '${head}' -> '${baseBranch}': ${errorMessage}`, + ); + } + } + + public async findOpenPullRequest( + head: string, + ): Promise<{ url: string; number: number } | null> { + const baseBranch = this.options.branch || 'main'; + const response = await this.axiosInstance.get( + `/projects/${this.encodedProjectPath}/merge_requests`, + { + params: { + source_branch: head, + target_branch: baseBranch, + state: 'opened', + }, + }, + ); + if (Array.isArray(response.data) && response.data.length > 0) { + const first = response.data[0]; + return { url: first.web_url, number: first.iid }; + } + return null; + } + + public async checkRepositoryExists(): Promise<{ + exists: boolean; + reason?: 'auth_failed' | 'repo_not_found' | 'network_transient'; + }> { + try { + await this.axiosInstance.get(`/projects/${this.encodedProjectPath}`); + return { exists: true }; + } catch (error) { + const status = this.extractHttpStatus(error); + if (status === 401 || status === 403) { + return { exists: false, reason: 'auth_failed' }; + } + if (status === 404) { + return { exists: false, reason: 'repo_not_found' }; + } + return { exists: false, reason: 'network_transient' }; + } + } + + private extractHttpStatus(error: unknown): number | undefined { + if ( + error && + typeof error === 'object' && + 'response' in error && + error.response && + typeof error.response === 'object' && + 'status' in error.response && + typeof (error.response as { status: unknown }).status === 'number' + ) { + return (error.response as { status: number }).status; + } + return undefined; + } + async getFileOnRepo( path: string, branch?: string, diff --git a/packages/git/src/infra/schemas/GitRepoSchema.ts b/packages/git/src/infra/schemas/GitRepoSchema.ts index 9c88f60db..3ed12e4e2 100644 --- a/packages/git/src/infra/schemas/GitRepoSchema.ts +++ b/packages/git/src/infra/schemas/GitRepoSchema.ts @@ -28,6 +28,11 @@ export const GitRepoSchema = new EntitySchema< name: 'provider_id', type: 'uuid', }, + type: { + type: 'varchar', + nullable: false, + default: 'standard', + }, ...uuidSchema, ...timestampsSchemas, ...softDeleteSchemas, diff --git a/packages/git/test/gitRepoFactory.ts b/packages/git/test/gitRepoFactory.ts index dbc85cfbf..f43343b2c 100644 --- a/packages/git/test/gitRepoFactory.ts +++ b/packages/git/test/gitRepoFactory.ts @@ -11,6 +11,7 @@ export const gitRepoFactory: Factory<GitRepo> = ( repo: 'test-repo', branch: 'main', providerId: createGitProviderId(uuidv4()), + type: 'standard', ...gitRepo, }; }; diff --git a/packages/import-practices-legacy/.swcrc b/packages/import-practices-legacy/.swcrc new file mode 100644 index 000000000..83bfbeb3f --- /dev/null +++ b/packages/import-practices-legacy/.swcrc @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "jsc": { + "parser": { "syntax": "typescript", "decorators": true }, + "transform": { "legacyDecorator": true, "decoratorMetadata": true }, + "target": "es2022", + "keepClassNames": true + }, + "module": { "type": "commonjs" }, + "sourceMaps": true +} diff --git a/packages/import-practices-legacy/README.md b/packages/import-practices-legacy/README.md new file mode 100644 index 000000000..347589cc0 --- /dev/null +++ b/packages/import-practices-legacy/README.md @@ -0,0 +1,11 @@ +# import-practices-legacy + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build import-practices-legacy` to build the library. + +## Running unit tests + +Run `nx test import-practices-legacy` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/packages/import-practices-legacy/eslint.config.mjs b/packages/import-practices-legacy/eslint.config.mjs new file mode 100644 index 000000000..c334bc0bc --- /dev/null +++ b/packages/import-practices-legacy/eslint.config.mjs @@ -0,0 +1,19 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, +]; diff --git a/packages/import-practices-legacy/jest.config.ts b/packages/import-practices-legacy/jest.config.ts new file mode 100644 index 000000000..af4f94d46 --- /dev/null +++ b/packages/import-practices-legacy/jest.config.ts @@ -0,0 +1,22 @@ +const { compilerOptions } = require('../../tsconfig.base.effective.json'); + +const { + pathsToModuleNameMapper, + swcTransformWithDecorators, + standardTransformIgnorePatterns, + standardModuleFileExtensions, +} = require('../../jest-utils.ts'); + +module.exports = { + displayName: 'import-practices-legacy', + preset: '../../jest.preset.ts', + testEnvironment: 'node', + transform: swcTransformWithDecorators, + transformIgnorePatterns: standardTransformIgnorePatterns, + moduleFileExtensions: standardModuleFileExtensions, + coverageDirectory: '../../coverage/packages/import-practices-legacy', + moduleNameMapper: pathsToModuleNameMapper( + compilerOptions.paths, + '<rootDir>/../../', + ), +}; diff --git a/packages/import-practices-legacy/package.json b/packages/import-practices-legacy/package.json new file mode 100644 index 000000000..cd814ccac --- /dev/null +++ b/packages/import-practices-legacy/package.json @@ -0,0 +1,18 @@ +{ + "name": "@packmind/import-practices-legacy", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "typeorm": "0.3.28", + "@nestjs/common": "^11.1.6", + "express": "^5.1.0", + "@packmind/logger": "workspace:*", + "@packmind/node-utils": "workspace:*", + "@packmind/types": "workspace:*", + "@packmind/standards": "workspace:*", + "@packmind/test-utils": "workspace:*" + } +} diff --git a/packages/import-practices-legacy/project.json b/packages/import-practices-legacy/project.json new file mode 100644 index 000000000..fa54fa85e --- /dev/null +++ b/packages/import-practices-legacy/project.json @@ -0,0 +1,26 @@ +{ + "name": "import-practices-legacy", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/import-practices-legacy/src", + "projectType": "library", + "tags": ["env:node"], + "targets": { + "typecheck": { + "executor": "nx:run-commands", + "options": { + "command": "tsc --noEmit --project packages/import-practices-legacy/tsconfig.lib.json" + } + }, + "build": { + "executor": "@nx/js:swc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/import-practices-legacy", + "tsConfig": "packages/import-practices-legacy/tsconfig.lib.json", + "packageJson": "packages/import-practices-legacy/package.json", + "main": "packages/import-practices-legacy/src/index.ts", + "assets": ["packages/import-practices-legacy/*.md"] + } + } + } +} diff --git a/packages/import-practices-legacy/src/ImportPracticeLegacyHexa.ts b/packages/import-practices-legacy/src/ImportPracticeLegacyHexa.ts new file mode 100644 index 000000000..b8b1aef84 --- /dev/null +++ b/packages/import-practices-legacy/src/ImportPracticeLegacyHexa.ts @@ -0,0 +1,125 @@ +import { DataSource } from 'typeorm'; +import { PackmindLogger } from '@packmind/logger'; +import { BaseHexa, HexaRegistry, BaseHexaOpts } from '@packmind/node-utils'; +import { + IAccountsPort, + IAccountsPortName, + ILinterPort, + ILinterPortName, + ISpacesPort, + ISpacesPortName, + IStandardsPortName, +} from '@packmind/types'; +import { StandardsAdapter } from '@packmind/standards'; +import { ImportPracticeLegacyAdapter } from './application/adapter/ImportPracticeLegacyAdapter'; +import { + IImportPracticeLegacyPort, + IImportPracticeLegacyPortName, +} from './types'; + +const origin = 'ImportPracticeLegacyHexa'; + +/** + * ImportPracticeLegacyHexa - Facade for the Import Practices Legacy domain following the Port/Adapter pattern. + * + * This class serves as the main entry point for legacy practice import functionality. + * It exposes the ImportPracticeLegacy adapter for cross-domain access following DDD standards. + * + * The Hexa pattern: + * - Constructor: Instantiates the adapter + * - initialize(): Retrieves and sets ports from registry + * - getAdapter(): Exposes the domain adapter for cross-domain access + */ +export class ImportPracticeLegacyHexa extends BaseHexa< + BaseHexaOpts, + IImportPracticeLegacyPort +> { + private readonly adapter: ImportPracticeLegacyAdapter; + public isInitialized = false; + + constructor( + dataSource: DataSource, + opts: Partial<BaseHexaOpts> = { logger: new PackmindLogger(origin) }, + ) { + super(dataSource, opts); + this.logger.info('Constructing ImportPracticeLegacyHexa'); + + try { + // Instantiate adapter (dependencies will be injected in initialize()) + this.adapter = new ImportPracticeLegacyAdapter(); + + this.logger.info('ImportPracticeLegacyHexa construction completed'); + } catch (error) { + this.logger.error('Failed to construct ImportPracticeLegacyHexa', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * Initialize the hexa with access to the registry for adapter retrieval. + */ + public async initialize(registry: HexaRegistry): Promise<void> { + if (this.isInitialized) { + this.logger.info('ImportPracticeLegacyHexa already initialized'); + return; + } + + this.logger.info( + 'Initializing ImportPracticeLegacyHexa (adapter retrieval phase)', + ); + + try { + // Get all required ports + const accountsPort = + registry.getAdapter<IAccountsPort>(IAccountsPortName); + const spacesPort = registry.getAdapter<ISpacesPort>(ISpacesPortName); + const standardsAdapter = + registry.getAdapter<StandardsAdapter>(IStandardsPortName); + const linterPort = registry.getAdapter<ILinterPort>(ILinterPortName); + + this.logger.info('All required ports retrieved from registry'); + + // Initialize adapter with ports + await this.adapter.initialize({ + [IAccountsPortName]: accountsPort, + [ISpacesPortName]: spacesPort, + [IStandardsPortName]: standardsAdapter, + [ILinterPortName]: linterPort, + }); + + this.isInitialized = true; + this.logger.info('ImportPracticeLegacyHexa initialized successfully'); + } catch (error) { + this.logger.error('Failed to initialize ImportPracticeLegacyHexa', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * Clean up resources when the hexa is being destroyed. + */ + public destroy(): void { + this.logger.info('Destroying ImportPracticeLegacyHexa'); + // Add any cleanup logic here if needed + this.logger.info('ImportPracticeLegacyHexa destroyed'); + } + + /** + * Get the ImportPracticeLegacy adapter for cross-domain access. + * Following DDD monorepo architecture standard. + */ + public getAdapter(): IImportPracticeLegacyPort { + return this.adapter.getPort(); + } + + /** + * Get the port name for this hexa. + */ + public getPortName(): string { + return IImportPracticeLegacyPortName; + } +} diff --git a/packages/import-practices-legacy/src/application/adapter/ImportPracticeLegacyAdapter.ts b/packages/import-practices-legacy/src/application/adapter/ImportPracticeLegacyAdapter.ts new file mode 100644 index 000000000..739ac3575 --- /dev/null +++ b/packages/import-practices-legacy/src/application/adapter/ImportPracticeLegacyAdapter.ts @@ -0,0 +1,114 @@ +import { PackmindLogger } from '@packmind/logger'; +import { IBaseAdapter } from '@packmind/node-utils'; +import { + IAccountsPort, + IAccountsPortName, + ILinterPort, + ILinterPortName, + ISpacesPort, + ISpacesPortName, + IStandardsPortName, +} from '@packmind/types'; +import { StandardsAdapter } from '@packmind/standards'; +import { ImportPracticeLegacyUseCase } from '../useCases/importPracticeLegacy/ImportPracticeLegacyUseCase'; +import { + IImportPracticeLegacyPort, + ImportPracticeLegacyCommand, + ImportPracticeLegacyResponse, +} from '../../types'; + +const origin = 'ImportPracticeLegacyAdapter'; + +/** + * ImportPracticeLegacyAdapter - Implements the IImportPracticeLegacyPort interface for cross-domain access. + * Following the Port/Adapter pattern from DDD monorepo architecture standard. + */ +export class ImportPracticeLegacyAdapter + implements IBaseAdapter<IImportPracticeLegacyPort>, IImportPracticeLegacyPort +{ + private accountsPort: IAccountsPort | null = null; + private spacesPort: ISpacesPort | null = null; + private standardsAdapter: StandardsAdapter | null = null; + private linterPort: ILinterPort | null = null; + private importPracticeLegacyUseCase: ImportPracticeLegacyUseCase | null = + null; + + constructor( + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) { + this.logger.info('ImportPracticeLegacyAdapter constructed'); + } + + /** + * Initialize the adapter with ports from registry. + */ + public async initialize(ports: { + [IAccountsPortName]: IAccountsPort; + [ISpacesPortName]: ISpacesPort; + [IStandardsPortName]: StandardsAdapter; + [ILinterPortName]: ILinterPort; + }): Promise<void> { + this.logger.info('Initializing ImportPracticeLegacyAdapter with ports'); + + this.accountsPort = ports[IAccountsPortName]; + this.spacesPort = ports[ISpacesPortName]; + this.standardsAdapter = ports[IStandardsPortName]; + this.linterPort = ports[ILinterPortName]; + + if ( + !this.accountsPort || + !this.spacesPort || + !this.standardsAdapter || + !this.linterPort + ) { + throw new Error( + 'ImportPracticeLegacyAdapter: Required ports not provided', + ); + } + + // Create the use case with all dependencies + this.importPracticeLegacyUseCase = new ImportPracticeLegacyUseCase( + this.accountsPort, + this.spacesPort, + this.standardsAdapter, + this.linterPort, + ); + + this.logger.info('ImportPracticeLegacyAdapter initialized successfully'); + } + + /** + * Check if the adapter is ready to use. + */ + public isReady(): boolean { + return ( + this.accountsPort !== null && + this.spacesPort !== null && + this.standardsAdapter !== null && + this.linterPort !== null && + this.importPracticeLegacyUseCase !== null + ); + } + + /** + * Get the port interface this adapter implements. + */ + public getPort(): IImportPracticeLegacyPort { + return this as IImportPracticeLegacyPort; + } + + /** + * Import legacy practices into Packmind standards. + */ + async importPracticeLegacy( + command: ImportPracticeLegacyCommand, + ): Promise<ImportPracticeLegacyResponse> { + if (!this.importPracticeLegacyUseCase) { + throw new Error( + 'ImportPracticeLegacyAdapter not initialized. Call initialize() first.', + ); + } + + return this.importPracticeLegacyUseCase.execute(command); + } +} diff --git a/packages/import-practices-legacy/src/application/adapter/index.ts b/packages/import-practices-legacy/src/application/adapter/index.ts new file mode 100644 index 000000000..a8feb22ad --- /dev/null +++ b/packages/import-practices-legacy/src/application/adapter/index.ts @@ -0,0 +1 @@ +export * from './ImportPracticeLegacyAdapter'; diff --git a/packages/import-practices-legacy/src/application/useCases/importPracticeLegacy/ImportPracticeLegacyUseCase.spec.ts b/packages/import-practices-legacy/src/application/useCases/importPracticeLegacy/ImportPracticeLegacyUseCase.spec.ts new file mode 100644 index 000000000..96a5171ea --- /dev/null +++ b/packages/import-practices-legacy/src/application/useCases/importPracticeLegacy/ImportPracticeLegacyUseCase.spec.ts @@ -0,0 +1,1384 @@ +import { stubLogger } from '@packmind/test-utils'; +import { + createOrganizationId, + createRuleId, + createSpaceId, + createStandardId, + createStandardVersionId, + createUserId, + DetectionModeEnum, + DetectionStatus, + IAccountsPort, + ILinterPort, + ISpacesPort, + ProgrammingLanguage, + Rule, + RuleDetectionAssessmentStatus, + Space, + SpaceType, + Standard, +} from '@packmind/types'; +import { StandardsAdapter } from '@packmind/standards'; +import { ImportPracticeLegacyUseCase } from './ImportPracticeLegacyUseCase'; +import { LegacyPracticeInput } from '../../../types'; + +describe('ImportPracticeLegacyUseCase', () => { + let usecase: ImportPracticeLegacyUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked<ISpacesPort>; + let standardsAdapter: jest.Mocked<StandardsAdapter>; + let linterPort: jest.Mocked<ILinterPort>; + + const organizationId = createOrganizationId('org-1'); + const userId = createUserId('user-1'); + const spaceId = createSpaceId('space-1'); + const standardVersionId = createStandardVersionId('sv-1'); + + const mockSpace: Space = { + id: spaceId, + name: 'Global', + slug: 'global', + type: SpaceType.open, + organizationId, + isDefaultSpace: true, + }; + + const mockUser = { + id: userId, + email: 'test@example.com', + passwordHash: 'hash', + active: true, + memberships: [ + { + userId, + organizationId, + role: 'member' as const, + }, + ], + }; + + const mockOrganization = { + id: organizationId, + name: 'Test Org', + slug: 'test-org', + }; + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(mockUser), + getOrganizationById: jest.fn().mockResolvedValue(mockOrganization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort = { + listSpacesByOrganization: jest.fn().mockResolvedValue([mockSpace]), + } as unknown as jest.Mocked<ISpacesPort>; + + standardsAdapter = { + createStandardWithExamples: jest.fn(), + listStandardsBySpace: jest.fn().mockResolvedValue([]), + getRulesByStandardId: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked<StandardsAdapter>; + + linterPort = { + createDetectionProgram: jest.fn().mockResolvedValue({}), + createEmptyRuleDetectionAssessment: jest.fn().mockResolvedValue({}), + } as unknown as jest.Mocked<ILinterPort>; + + usecase = new ImportPracticeLegacyUseCase( + accountsPort, + spacesPort, + standardsAdapter, + linterPort, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('executeForMembers', () => { + describe('when importing a standard with rules and examples', () => { + it('creates standard with paired examples', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Test Standard', + description: 'A test standard description', + rules: [ + { + name: 'Test Rule', + positiveExamples: [ + { code: 'const x: number = 5;', language: 'TYPESCRIPT' }, + ], + negativeExamples: [ + { code: 'const x = 5;', language: 'TYPESCRIPT' }, + ], + }, + ], + }, + ], + }; + + const mockStandard: Standard = { + id: createStandardId('std-1'), + name: 'Test Standard', + slug: 'test-standard', + description: 'A test standard description', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }; + + standardsAdapter.createStandardWithExamples.mockResolvedValue( + mockStandard, + ); + + const result = await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect(result.importedStandards).toHaveLength(1); + }); + + it('calls createStandardWithExamples with disableTriggerAssessment true', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Test Standard', + description: 'A test description', + rules: [ + { + name: 'Rule 1', + positiveExamples: [], + negativeExamples: [], + }, + ], + }, + ], + }; + + const mockStandard: Standard = { + id: createStandardId('std-1'), + name: 'Test Standard', + slug: 'test-standard', + description: 'A test description', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }; + + standardsAdapter.createStandardWithExamples.mockResolvedValue( + mockStandard, + ); + + await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect( + standardsAdapter.createStandardWithExamples, + ).toHaveBeenCalledWith( + expect.objectContaining({ + disableTriggerAssessment: true, + }), + ); + }); + }); + + describe('when examples have different languages', () => { + it('creates separate pairs for each language', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Test Standard', + description: 'Description', + rules: [ + { + name: 'Rule with mixed languages', + positiveExamples: [ + { code: 'const x = 5;', language: 'JAVASCRIPT' }, + ], + negativeExamples: [{ code: 'int x = 5;', language: 'JAVA' }], + }, + ], + }, + ], + }; + + const mockStandard: Standard = { + id: createStandardId('std-1'), + name: 'Test Standard', + slug: 'test-standard', + description: 'Description', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }; + + standardsAdapter.createStandardWithExamples.mockResolvedValue( + mockStandard, + ); + + await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect( + standardsAdapter.createStandardWithExamples, + ).toHaveBeenCalledWith( + expect.objectContaining({ + rules: [ + { + content: 'Rule with mixed languages', + examples: expect.arrayContaining([ + { + positive: 'const x = 5;', + negative: '', + language: ProgrammingLanguage.JAVASCRIPT, + }, + { + positive: '', + negative: 'int x = 5;', + language: ProgrammingLanguage.JAVA, + }, + ]), + }, + ], + }), + ); + }); + + it('groups examples by language before pairing', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Test Standard', + description: 'Description', + rules: [ + { + name: 'Rule with multiple languages', + positiveExamples: [ + { code: 'js positive 1', language: 'JAVASCRIPT' }, + { code: 'java positive 1', language: 'JAVA' }, + ], + negativeExamples: [ + { code: 'js negative 1', language: 'JAVASCRIPT' }, + { code: 'java negative 1', language: 'JAVA' }, + ], + }, + ], + }, + ], + }; + + const mockStandard: Standard = { + id: createStandardId('std-1'), + name: 'Test Standard', + slug: 'test-standard', + description: 'Description', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }; + + standardsAdapter.createStandardWithExamples.mockResolvedValue( + mockStandard, + ); + + await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect( + standardsAdapter.createStandardWithExamples, + ).toHaveBeenCalledWith( + expect.objectContaining({ + rules: [ + { + content: 'Rule with multiple languages', + examples: expect.arrayContaining([ + { + positive: 'js positive 1', + negative: 'js negative 1', + language: ProgrammingLanguage.JAVASCRIPT, + }, + { + positive: 'java positive 1', + negative: 'java negative 1', + language: ProgrammingLanguage.JAVA, + }, + ]), + }, + ], + }), + ); + }); + }); + + describe('when pairing examples with unequal lengths within same language', () => { + it('pairs with empty string for missing positive example', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Test Standard', + description: 'Description', + rules: [ + { + name: 'Rule with more negatives', + positiveExamples: [ + { code: 'positive1', language: 'TYPESCRIPT' }, + ], + negativeExamples: [ + { code: 'negative1', language: 'TYPESCRIPT' }, + { code: 'negative2', language: 'TYPESCRIPT' }, + ], + }, + ], + }, + ], + }; + + const mockStandard: Standard = { + id: createStandardId('std-1'), + name: 'Test Standard', + slug: 'test-standard', + description: 'Description', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }; + + standardsAdapter.createStandardWithExamples.mockResolvedValue( + mockStandard, + ); + + await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect( + standardsAdapter.createStandardWithExamples, + ).toHaveBeenCalledWith( + expect.objectContaining({ + rules: [ + { + content: 'Rule with more negatives', + examples: [ + { + positive: 'positive1', + negative: 'negative1', + language: ProgrammingLanguage.TYPESCRIPT, + }, + { + positive: '', + negative: 'negative2', + language: ProgrammingLanguage.TYPESCRIPT, + }, + ], + }, + ], + }), + ); + }); + + it('pairs with empty string for missing negative example', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Test Standard', + description: 'Description', + rules: [ + { + name: 'Rule with more positives', + positiveExamples: [ + { code: 'positive1', language: 'JAVA' }, + { code: 'positive2', language: 'JAVA' }, + ], + negativeExamples: [{ code: 'negative1', language: 'JAVA' }], + }, + ], + }, + ], + }; + + const mockStandard: Standard = { + id: createStandardId('std-1'), + name: 'Test Standard', + slug: 'test-standard', + description: 'Description', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }; + + standardsAdapter.createStandardWithExamples.mockResolvedValue( + mockStandard, + ); + + await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect( + standardsAdapter.createStandardWithExamples, + ).toHaveBeenCalledWith( + expect.objectContaining({ + rules: [ + { + content: 'Rule with more positives', + examples: [ + { + positive: 'positive1', + negative: 'negative1', + language: ProgrammingLanguage.JAVA, + }, + { + positive: 'positive2', + negative: '', + language: ProgrammingLanguage.JAVA, + }, + ], + }, + ], + }), + ); + }); + }); + + describe('when standard creation fails', () => { + it('adds standard to skipped list with error reason', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Failing Standard', + description: 'This will fail', + rules: [], + }, + ], + }; + + standardsAdapter.createStandardWithExamples.mockRejectedValue( + new Error('Database error'), + ); + + const result = await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect(result.skippedStandards).toHaveLength(1); + }); + + it('includes error message in skipped standard reason', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Failing Standard', + description: 'This will fail', + rules: [], + }, + ], + }; + + standardsAdapter.createStandardWithExamples.mockRejectedValue( + new Error('Database error'), + ); + + const result = await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect(result.skippedStandards[0].reason).toBe('Database error'); + }); + }); + + describe('when no spaces exist in organization', () => { + it('throws error', async () => { + spacesPort.listSpacesByOrganization.mockResolvedValue([]); + + const legacyData: LegacyPracticeInput = { + standards: [], + }; + + await expect( + usecase.execute({ + userId, + organizationId, + legacyData, + }), + ).rejects.toThrow( + 'No spaces found in organization. Please create a space first.', + ); + }); + }); + + describe('when importing multiple standards', () => { + it('returns all successfully imported standards', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Standard 1', + description: 'Description 1', + rules: [], + }, + { + name: 'Standard 2', + description: 'Description 2', + rules: [], + }, + ], + }; + + standardsAdapter.createStandardWithExamples + .mockResolvedValueOnce({ + id: createStandardId('std-1'), + name: 'Standard 1', + slug: 'standard-1', + description: 'Description 1', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }) + .mockResolvedValueOnce({ + id: createStandardId('std-2'), + name: 'Standard 2', + slug: 'standard-2', + description: 'Description 2', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }); + + const result = await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect(result.importedStandards).toHaveLength(2); + }); + + describe('when one standard fails to import', () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Standard 1', + description: 'Description 1', + rules: [], + }, + { + name: 'Standard 2', + description: 'Description 2', + rules: [], + }, + ], + }; + + beforeEach(() => { + standardsAdapter.createStandardWithExamples + .mockRejectedValueOnce(new Error('Failed')) + .mockResolvedValueOnce({ + id: createStandardId('std-2'), + name: 'Standard 2', + slug: 'standard-2', + description: 'Description 2', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }); + }); + + it('imports the successful standard', async () => { + const result = await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect(result.importedStandards).toHaveLength(1); + }); + + it('adds failed standard to skipped list', async () => { + const result = await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect(result.skippedStandards).toHaveLength(1); + }); + }); + }); + + describe('when example has invalid language', () => { + it('creates standard with empty examples for invalid language', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Test Standard', + description: 'Description', + rules: [ + { + name: 'Rule with invalid language', + positiveExamples: [ + { code: 'valid code', language: 'INVALID_LANG' }, + ], + negativeExamples: [], + }, + ], + }, + ], + }; + + const mockStandard: Standard = { + id: createStandardId('std-1'), + name: 'Test Standard', + slug: 'test-standard', + description: 'Description', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }; + + standardsAdapter.createStandardWithExamples.mockResolvedValue( + mockStandard, + ); + + const result = await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect(result.importedStandards).toHaveLength(1); + }); + + it('passes empty examples array for rules with only invalid languages', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Test Standard', + description: 'Description', + rules: [ + { + name: 'Rule with invalid language', + positiveExamples: [ + { code: 'valid code', language: 'INVALID_LANG' }, + ], + negativeExamples: [], + }, + ], + }, + ], + }; + + const mockStandard: Standard = { + id: createStandardId('std-1'), + name: 'Test Standard', + slug: 'test-standard', + description: 'Description', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }; + + standardsAdapter.createStandardWithExamples.mockResolvedValue( + mockStandard, + ); + + await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect( + standardsAdapter.createStandardWithExamples, + ).toHaveBeenCalledWith( + expect.objectContaining({ + rules: [ + { + content: 'Rule with invalid language', + examples: [], + }, + ], + }), + ); + }); + }); + + describe('when rule has detection program', () => { + const mockRule: Rule = { + id: createRuleId('rule-1'), + content: 'Rule with detection program', + standardVersionId, + }; + + const mockStandard: Standard = { + id: createStandardId('std-1'), + name: 'Test Standard', + slug: 'test-standard', + description: 'Description', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }; + + beforeEach(() => { + standardsAdapter.createStandardWithExamples.mockResolvedValue( + mockStandard, + ); + standardsAdapter.getRulesByStandardId.mockResolvedValue([mockRule]); + }); + + it('imports standard successfully', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Test Standard', + description: 'Description', + rules: [ + { + name: 'Rule with detection program', + positiveExamples: [], + negativeExamples: [], + detectionProgram: { + code: 'function detect() {}', + description: 'Detects violations', + language: 'KOTLIN', + mode: 'AST', + }, + }, + ], + }, + ], + }; + + const result = await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect(result.importedStandards).toHaveLength(1); + }); + + it('calls createDetectionProgram with correct parameters', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Test Standard', + description: 'Description', + rules: [ + { + name: 'Rule with detection program', + positiveExamples: [], + negativeExamples: [], + detectionProgram: { + code: 'function detect() {}', + description: 'Detects violations', + language: 'KOTLIN', + mode: 'AST', + }, + }, + ], + }, + ], + }; + + await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect(linterPort.createDetectionProgram).toHaveBeenCalledWith({ + ruleId: mockRule.id, + code: 'function detect() {}', + language: ProgrammingLanguage.KOTLIN, + mode: DetectionModeEnum.SINGLE_AST, + status: DetectionStatus.READY, + organizationId, + userId, + mustBeDraftVersion: false, + sourceCodeState: 'AST', + }); + }); + + it('calls createEmptyRuleDetectionAssessment with correct parameters', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Test Standard', + description: 'Description', + rules: [ + { + name: 'Rule with detection program', + positiveExamples: [], + negativeExamples: [], + detectionProgram: { + code: 'function detect() {}', + description: 'Detects violations', + language: 'KOTLIN', + mode: 'AST', + }, + }, + ], + }, + ], + }; + + await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect( + linterPort.createEmptyRuleDetectionAssessment, + ).toHaveBeenCalledWith({ + ruleId: mockRule.id, + language: ProgrammingLanguage.KOTLIN, + organizationId, + userId, + status: RuleDetectionAssessmentStatus.SUCCESS, + details: '', + }); + }); + + describe('when rule has no detection program', () => { + beforeEach(() => { + standardsAdapter.getRulesByStandardId.mockResolvedValue([ + { + id: createRuleId('rule-no-program'), + content: 'Rule without detection program', + standardVersionId, + }, + ]); + }); + + const legacyDataWithoutDetectionProgram: LegacyPracticeInput = { + standards: [ + { + name: 'Test Standard', + description: 'Description', + rules: [ + { + name: 'Rule without detection program', + positiveExamples: [], + negativeExamples: [], + }, + ], + }, + ], + }; + + it('does not call createDetectionProgram', async () => { + await usecase.execute({ + userId, + organizationId, + legacyData: legacyDataWithoutDetectionProgram, + }); + + expect(linterPort.createDetectionProgram).not.toHaveBeenCalled(); + }); + + it('does not call createEmptyRuleDetectionAssessment', async () => { + await usecase.execute({ + userId, + organizationId, + legacyData: legacyDataWithoutDetectionProgram, + }); + + expect( + linterPort.createEmptyRuleDetectionAssessment, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when detection program language is invalid', () => { + it('skips detection program creation', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Test Standard', + description: 'Description', + rules: [ + { + name: 'Rule with detection program', + positiveExamples: [], + negativeExamples: [], + detectionProgram: { + code: 'function detect() {}', + description: 'Detects violations', + language: 'INVALID_LANGUAGE', + mode: 'AST', + }, + }, + ], + }, + ], + }; + + await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect(linterPort.createDetectionProgram).not.toHaveBeenCalled(); + }); + }); + + describe('when detection program import fails', () => { + it('continues importing and returns standard as imported', async () => { + linterPort.createDetectionProgram.mockRejectedValue( + new Error('Detection program creation failed'), + ); + + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Test Standard', + description: 'Description', + rules: [ + { + name: 'Rule with detection program', + positiveExamples: [], + negativeExamples: [], + detectionProgram: { + code: 'function detect() {}', + description: 'Detects violations', + language: 'KOTLIN', + mode: 'AST', + }, + }, + ], + }, + ], + }; + + const result = await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect(result.importedStandards).toHaveLength(1); + }); + }); + + describe('when importing multiple rules with detection programs', () => { + const rule1: Rule = { + id: createRuleId('rule-1'), + content: 'Rule 1', + standardVersionId, + }; + const rule2: Rule = { + id: createRuleId('rule-2'), + content: 'Rule 2', + standardVersionId, + }; + + const legacyDataWithMultipleRules: LegacyPracticeInput = { + standards: [ + { + name: 'Test Standard', + description: 'Description', + rules: [ + { + name: 'Rule 1', + positiveExamples: [], + negativeExamples: [], + detectionProgram: { + code: 'function detect1() {}', + description: 'Detects violations 1', + language: 'KOTLIN', + mode: 'AST', + }, + }, + { + name: 'Rule 2', + positiveExamples: [], + negativeExamples: [], + detectionProgram: { + code: 'function detect2() {}', + description: 'Detects violations 2', + language: 'JAVA', + mode: 'AST', + }, + }, + ], + }, + ], + }; + + beforeEach(() => { + standardsAdapter.getRulesByStandardId.mockResolvedValue([ + rule1, + rule2, + ]); + }); + + it('calls createDetectionProgram for each rule', async () => { + await usecase.execute({ + userId, + organizationId, + legacyData: legacyDataWithMultipleRules, + }); + + expect(linterPort.createDetectionProgram).toHaveBeenCalledTimes(2); + }); + + it('calls createEmptyRuleDetectionAssessment for each rule', async () => { + await usecase.execute({ + userId, + organizationId, + legacyData: legacyDataWithMultipleRules, + }); + + expect( + linterPort.createEmptyRuleDetectionAssessment, + ).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('when checking for existing standards', () => { + describe('when no standards exist in space', () => { + beforeEach(() => { + standardsAdapter.listStandardsBySpace.mockResolvedValue([]); + }); + + it('imports the standard successfully', async () => { + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'New Standard', + description: 'Description', + rules: [], + }, + ], + }; + + const mockStandard: Standard = { + id: createStandardId('std-1'), + name: 'New Standard', + slug: 'new-standard', + description: 'Description', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }; + + standardsAdapter.createStandardWithExamples.mockResolvedValue( + mockStandard, + ); + + const result = await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect(result.importedStandards).toHaveLength(1); + }); + }); + + describe('when existing standards have different names', () => { + it('imports the standard successfully', async () => { + const existingStandard: Standard = { + id: createStandardId('existing-1'), + name: 'Existing Standard', + slug: 'existing-standard', + description: 'Already exists', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }; + + standardsAdapter.listStandardsBySpace.mockResolvedValue([ + existingStandard, + ]); + + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Different Standard', + description: 'Description', + rules: [], + }, + ], + }; + + const mockStandard: Standard = { + id: createStandardId('std-1'), + name: 'Different Standard', + slug: 'different-standard', + description: 'Description', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }; + + standardsAdapter.createStandardWithExamples.mockResolvedValue( + mockStandard, + ); + + const result = await usecase.execute({ + userId, + organizationId, + legacyData, + }); + + expect(result.importedStandards).toHaveLength(1); + }); + }); + + describe('when one standard already exists', () => { + it('throws error with standard name', async () => { + const existingStandard: Standard = { + id: createStandardId('existing-1'), + name: 'Existing Standard', + slug: 'existing-standard', + description: 'Already exists', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }; + + standardsAdapter.listStandardsBySpace.mockResolvedValue([ + existingStandard, + ]); + + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Existing Standard', + description: 'Duplicate', + rules: [], + }, + ], + }; + + await expect( + usecase.execute({ + userId, + organizationId, + legacyData, + }), + ).rejects.toThrow( + 'Cannot import: the following standards already exist: Existing Standard', + ); + }); + }); + + describe('when multiple standards already exist', () => { + it('throws error listing all conflicting standard names', async () => { + const existingStandards: Standard[] = [ + { + id: createStandardId('existing-1'), + name: 'First Standard', + slug: 'first-standard', + description: 'Already exists', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }, + { + id: createStandardId('existing-2'), + name: 'Second Standard', + slug: 'second-standard', + description: 'Also exists', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }, + ]; + + standardsAdapter.listStandardsBySpace.mockResolvedValue( + existingStandards, + ); + + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'First Standard', + description: 'Duplicate 1', + rules: [], + }, + { + name: 'Second Standard', + description: 'Duplicate 2', + rules: [], + }, + { + name: 'Third Standard', + description: 'New one', + rules: [], + }, + ], + }; + + await expect( + usecase.execute({ + userId, + organizationId, + legacyData, + }), + ).rejects.toThrow( + 'Cannot import: the following standards already exist: First Standard, Second Standard', + ); + }); + }); + + describe('when standard names differ only by case', () => { + it('matches case-insensitively and throws error', async () => { + const existingStandard: Standard = { + id: createStandardId('existing-1'), + name: 'My Standard', + slug: 'my-standard', + description: 'Already exists', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }; + + standardsAdapter.listStandardsBySpace.mockResolvedValue([ + existingStandard, + ]); + + const legacyData: LegacyPracticeInput = { + standards: [ + { + name: 'MY STANDARD', + description: 'Same name different case', + rules: [], + }, + ], + }; + + await expect( + usecase.execute({ + userId, + organizationId, + legacyData, + }), + ).rejects.toThrow( + 'Cannot import: the following standards already exist: My Standard', + ); + }); + }); + + describe('when standards already exist', () => { + const existingStandard: Standard = { + id: createStandardId('existing-1'), + name: 'Existing Standard', + slug: 'existing-standard', + description: 'Already exists', + version: 1, + gitCommit: undefined, + userId, + scope: null, + spaceId, + }; + + const duplicateLegacyData: LegacyPracticeInput = { + standards: [ + { + name: 'Existing Standard', + description: 'Duplicate', + rules: [], + }, + ], + }; + + beforeEach(() => { + standardsAdapter.listStandardsBySpace.mockResolvedValue([ + existingStandard, + ]); + }); + + it('throws error before creating any standard', async () => { + await expect( + usecase.execute({ + userId, + organizationId, + legacyData: duplicateLegacyData, + }), + ).rejects.toThrow(); + }); + + it('does not call createStandardWithExamples', async () => { + try { + await usecase.execute({ + userId, + organizationId, + legacyData: duplicateLegacyData, + }); + } catch { + // Expected to throw + } + + expect( + standardsAdapter.createStandardWithExamples, + ).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/packages/import-practices-legacy/src/application/useCases/importPracticeLegacy/ImportPracticeLegacyUseCase.ts b/packages/import-practices-legacy/src/application/useCases/importPracticeLegacy/ImportPracticeLegacyUseCase.ts new file mode 100644 index 000000000..19db813b0 --- /dev/null +++ b/packages/import-practices-legacy/src/application/useCases/importPracticeLegacy/ImportPracticeLegacyUseCase.ts @@ -0,0 +1,448 @@ +import { PackmindLogger } from '@packmind/logger'; +import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; +import { + IAccountsPort, + ISpacesPort, + ILinterPort, + RuleWithExamples, + stringToProgrammingLanguage, + createOrganizationId, + createUserId, + createStandardId, + createSpaceId, + OrganizationId, + SpaceId, + Rule, + DetectionModeEnum, + DetectionStatus, + ProgrammingLanguage, + RuleDetectionAssessmentStatus, +} from '@packmind/types'; +import { StandardsAdapter } from '@packmind/standards'; +import { + IImportPracticeLegacyUseCase, + ImportPracticeLegacyCommand, + ImportPracticeLegacyResponse, + ImportedStandard, + SkippedStandard, + LegacyCodeExample, + LegacyDetectionProgram, + LegacyRule, + LegacyStandard, +} from '../../../types'; + +const origin = 'ImportPracticeLegacyUseCase'; + +/** + * Use case for importing legacy practice data into Packmind standards. + * This use case transforms legacy practice JSON format into standards with rules and examples. + */ +export class ImportPracticeLegacyUseCase + extends AbstractMemberUseCase< + ImportPracticeLegacyCommand, + ImportPracticeLegacyResponse + > + implements IImportPracticeLegacyUseCase +{ + constructor( + accountsPort: IAccountsPort, + private readonly spacesPort: ISpacesPort, + private readonly standardsAdapter: StandardsAdapter, + private readonly linterPort: ILinterPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + this.logger.info('ImportPracticeLegacyUseCase initialized'); + } + + async executeForMembers( + command: ImportPracticeLegacyCommand & MemberContext, + ): Promise<ImportPracticeLegacyResponse> { + this.logger.info('Starting legacy practice import', { + standardsCount: command.legacyData.standards.length, + organizationId: command.organizationId, + }); + + const importedStandards: ImportedStandard[] = []; + const skippedStandards: SkippedStandard[] = []; + + // Get the global space for the organization + const spaces = await this.spacesPort.listSpacesByOrganization( + createOrganizationId(command.organizationId), + ); + + if (!spaces || spaces.length === 0) { + throw new Error( + 'No spaces found in organization. Please create a space first.', + ); + } + + const globalSpace = spaces[0]; + this.logger.info('Using global space for import', { + spaceId: globalSpace.id, + spaceName: globalSpace.name, + }); + + // Check for existing standards before importing + await this.checkForExistingStandards( + command.legacyData.standards, + globalSpace.id, + createOrganizationId(command.organizationId), + command.userId, + ); + + // Process each legacy standard + for (const legacyStandard of command.legacyData.standards) { + try { + const result = await this.importStandard( + legacyStandard, + command.organizationId, + command.userId, + globalSpace.id, + ); + importedStandards.push(result); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.logger.error('Failed to import standard', { + standardName: legacyStandard.name, + reason, + }); + skippedStandards.push({ + name: legacyStandard.name, + reason, + }); + } + } + + this.logger.info('Legacy practice import completed', { + importedCount: importedStandards.length, + skippedCount: skippedStandards.length, + }); + + return { + importedStandards, + skippedStandards, + }; + } + + private async importStandard( + legacyStandard: LegacyStandard, + organizationId: string, + userId: string, + spaceId: string, + ): Promise<ImportedStandard> { + this.logger.info('Importing standard', { + name: legacyStandard.name, + rulesCount: legacyStandard.rules.length, + }); + + // Transform legacy rules to RuleWithExamples format + const rules = this.transformRules(legacyStandard.rules); + + // Create the standard using the adapter with assessment disabled + const standard = await this.standardsAdapter.createStandardWithExamples({ + name: legacyStandard.name, + description: legacyStandard.description, + summary: null, // Will be auto-generated + rules, + organizationId: createOrganizationId(organizationId), + userId: createUserId(userId), + scope: null, + spaceId: createSpaceId(spaceId), + disableTriggerAssessment: true, + }); + + this.logger.info('Standard imported successfully', { + standardId: standard.id, + slug: standard.slug, + }); + + // Import detection programs for rules that have them + await this.importDetectionPrograms( + legacyStandard.rules, + standard.id, + organizationId, + userId, + ); + + return { + name: standard.name, + slug: standard.slug, + }; + } + + /** + * Checks if any of the legacy standards already exist in the target space. + * Throws an error listing all conflicting standard names if any exist. + */ + private async checkForExistingStandards( + legacyStandards: LegacyStandard[], + spaceId: SpaceId, + organizationId: OrganizationId, + userId: string, + ): Promise<void> { + // Get existing standards in the space + const existingStandards = await this.standardsAdapter.listStandardsBySpace( + spaceId, + organizationId, + userId, + ); + + // Create a map of existing names (lowercase for case-insensitive comparison) + const existingNameMap = new Map( + existingStandards.map((s) => [s.name.toLowerCase(), s.name]), + ); + + // Find all conflicting standards + const conflictingStandards = legacyStandards + .filter((legacy) => existingNameMap.has(legacy.name.toLowerCase())) + .map((legacy) => existingNameMap.get(legacy.name.toLowerCase())!); + + if (conflictingStandards.length > 0) { + throw new Error( + `Cannot import: the following standards already exist: ${conflictingStandards.join(', ')}`, + ); + } + } + + /** + * Import detection programs for rules that have them defined in the legacy data. + */ + private async importDetectionPrograms( + legacyRules: LegacyRule[], + standardId: string, + organizationId: string, + userId: string, + ): Promise<void> { + // Get the rules that were created for this standard + const createdRules = await this.standardsAdapter.getRulesByStandardId( + createStandardId(standardId), + ); + + // Create a map from rule content (name) to created rule + const rulesByContent = new Map<string, Rule>(); + for (const rule of createdRules) { + rulesByContent.set(rule.content, rule); + } + + // Count how many detection programs we have to import + const rulesWithPrograms = legacyRules.filter( + (rule) => rule.detectionProgram, + ); + this.logger.info('Starting detection program import', { + standardId, + rulesWithDetectionPrograms: rulesWithPrograms.length, + totalRules: legacyRules.length, + }); + + // Import each detection program + for (const legacyRule of legacyRules) { + if (!legacyRule.detectionProgram) { + continue; + } + + const createdRule = rulesByContent.get(legacyRule.name); + if (!createdRule) { + this.logger.warn('Could not find created rule for legacy rule', { + legacyRuleName: legacyRule.name, + }); + continue; + } + + await this.importDetectionProgramForRule( + createdRule, + legacyRule.detectionProgram, + organizationId, + userId, + ); + } + + this.logger.info('Detection program import completed', { + standardId, + importedCount: rulesWithPrograms.length, + }); + } + + /** + * Import a single detection program for a rule. + */ + private async importDetectionProgramForRule( + rule: Rule, + detectionProgram: LegacyDetectionProgram, + organizationId: string, + userId: string, + ): Promise<void> { + // Convert language string to ProgrammingLanguage + let language: ProgrammingLanguage; + try { + language = stringToProgrammingLanguage(detectionProgram.language); + } catch { + this.logger.warn( + 'Invalid programming language for detection program, skipping', + { + ruleId: rule.id, + language: detectionProgram.language, + }, + ); + return; + } + + if (!language) { + this.logger.warn( + 'Unknown programming language for detection program, skipping', + { + ruleId: rule.id, + language: detectionProgram.language, + }, + ); + return; + } + + this.logger.info('Importing detection program for rule', { + ruleId: rule.id, + ruleContent: rule.content.substring(0, 50), + language, + }); + + try { + // Create the detection program (this also creates ActiveDetectionProgram) + await this.linterPort.createDetectionProgram({ + ruleId: rule.id, + code: detectionProgram.code, + language, + mode: DetectionModeEnum.SINGLE_AST, + status: DetectionStatus.READY, + organizationId, + userId, + mustBeDraftVersion: false, + sourceCodeState: detectionProgram.mode, + }); + + this.logger.info('Detection program created successfully', { + ruleId: rule.id, + language, + }); + + // Create RuleDetectionAssessment with SUCCESS status for imported detection program + await this.linterPort.createEmptyRuleDetectionAssessment({ + ruleId: rule.id, + language, + organizationId, + userId, + status: RuleDetectionAssessmentStatus.SUCCESS, + details: '', + }); + + this.logger.info( + 'Rule detection assessment created with SUCCESS status', + { + ruleId: rule.id, + language, + }, + ); + } catch (error) { + this.logger.error('Failed to import detection program for rule', { + ruleId: rule.id, + language, + error: error instanceof Error ? error.message : String(error), + }); + // Continue with other detection programs even if one fails + } + } + + private transformRules(legacyRules: LegacyRule[]): RuleWithExamples[] { + return legacyRules.map((rule) => { + // Pair positive and negative examples sequentially + const examples = this.pairExamples( + rule.positiveExamples, + rule.negativeExamples, + ); + + return { + content: rule.name, + examples, + }; + }); + } + + /** + * Groups examples by programming language and pairs positive/negative examples within each language. + * If there are more of one type than the other within a language, creates pairs with empty strings for missing examples. + * + * Example: 1 JS positive + 1 JAVA negative = 2 pairs (JS pair with empty negative, JAVA pair with empty positive) + */ + private pairExamples( + positiveExamples: LegacyCodeExample[], + negativeExamples: LegacyCodeExample[], + ): RuleWithExamples['examples'] { + const pairs: NonNullable<RuleWithExamples['examples']> = []; + + // Group examples by language + const positiveByLanguage = this.groupExamplesByLanguage(positiveExamples); + const negativeByLanguage = this.groupExamplesByLanguage(negativeExamples); + + // Get all unique languages from both positive and negative examples + const allLanguages = new Set([ + ...positiveByLanguage.keys(), + ...negativeByLanguage.keys(), + ]); + + // For each language, pair positive and negative examples sequentially + for (const languageString of allLanguages) { + let language; + try { + language = stringToProgrammingLanguage(languageString); + } catch { + this.logger.warn('Invalid programming language, skipping examples', { + language: languageString, + }); + continue; + } + + if (!language) { + this.logger.warn('Unknown programming language, skipping examples', { + language: languageString, + }); + continue; + } + + const positives = positiveByLanguage.get(languageString) || []; + const negatives = negativeByLanguage.get(languageString) || []; + const maxLength = Math.max(positives.length, negatives.length); + + for (let i = 0; i < maxLength; i++) { + pairs.push({ + positive: positives[i]?.code || '', + negative: negatives[i]?.code || '', + language, + }); + } + } + + return pairs; + } + + /** + * Groups code examples by their language. + */ + private groupExamplesByLanguage( + examples: LegacyCodeExample[], + ): Map<string, LegacyCodeExample[]> { + const grouped = new Map<string, LegacyCodeExample[]>(); + + for (const example of examples) { + if (!example.language) { + this.logger.warn('Example has no language, skipping', { + codePreview: example.code.substring(0, 50), + }); + continue; + } + + const existing = grouped.get(example.language) || []; + existing.push(example); + grouped.set(example.language, existing); + } + + return grouped; + } +} diff --git a/packages/import-practices-legacy/src/application/useCases/importPracticeLegacy/index.ts b/packages/import-practices-legacy/src/application/useCases/importPracticeLegacy/index.ts new file mode 100644 index 000000000..aebe7af3b --- /dev/null +++ b/packages/import-practices-legacy/src/application/useCases/importPracticeLegacy/index.ts @@ -0,0 +1 @@ +export * from './ImportPracticeLegacyUseCase'; diff --git a/packages/import-practices-legacy/src/application/useCases/index.ts b/packages/import-practices-legacy/src/application/useCases/index.ts new file mode 100644 index 000000000..870b560e4 --- /dev/null +++ b/packages/import-practices-legacy/src/application/useCases/index.ts @@ -0,0 +1 @@ +export * from './importPracticeLegacy'; diff --git a/packages/import-practices-legacy/src/index.ts b/packages/import-practices-legacy/src/index.ts new file mode 100644 index 000000000..6683454db --- /dev/null +++ b/packages/import-practices-legacy/src/index.ts @@ -0,0 +1,5 @@ +export { ImportPracticeLegacyHexa } from './ImportPracticeLegacyHexa'; +export { ImportPracticeLegacyAdapter } from './application/adapter/ImportPracticeLegacyAdapter'; +export { ImportPracticeLegacyUseCase } from './application/useCases/importPracticeLegacy/ImportPracticeLegacyUseCase'; +export { ImportLegacyModule } from './nest-api/import-legacy/import-legacy.module'; +export * from './types'; diff --git a/packages/import-practices-legacy/src/nest-api/guards/ip-filter.guard.ts b/packages/import-practices-legacy/src/nest-api/guards/ip-filter.guard.ts new file mode 100644 index 000000000..a4ecdf4dd --- /dev/null +++ b/packages/import-practices-legacy/src/nest-api/guards/ip-filter.guard.ts @@ -0,0 +1,61 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { PackmindLogger } from '@packmind/logger'; +import { Configuration } from '@packmind/node-utils'; +import { Request } from 'express'; + +const origin = 'IpFilterGuard'; + +/** + * Guard that filters requests based on IP address. + * If PACKMIND_IMPORT_LEGACY_IP_ALLOWED env var is set, only requests from that IP are allowed. + * If not set, all requests are allowed (no IP filtering). + */ +@Injectable() +export class IpFilterGuard implements CanActivate { + constructor( + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async canActivate(context: ExecutionContext): Promise<boolean> { + const allowedIp = await Configuration.getConfig( + 'PACKMIND_IMPORT_LEGACY_IP_ALLOWED', + ); + + // If no IP restriction is configured, allow all requests + if (!allowedIp) { + this.logger.info('No IP restriction configured, allowing request'); + return true; + } + + const request = context.switchToHttp().getRequest<Request>(); + + // Extract client IP from request + // Check x-forwarded-for header first (for proxied requests), then fall back to request.ip + const forwardedFor = request.headers['x-forwarded-for']; + const clientIp = + (typeof forwardedFor === 'string' + ? forwardedFor.split(',')[0]?.trim() + : undefined) || request.ip; + + this.logger.info('Checking IP address', { + clientIp, + allowedIp, + }); + + if (clientIp !== allowedIp) { + this.logger.error('IP address not allowed', { + clientIp, + allowedIp, + }); + throw new ForbiddenException('IP address not allowed'); + } + + this.logger.info('IP address allowed', { clientIp }); + return true; + } +} diff --git a/packages/import-practices-legacy/src/nest-api/import-legacy/import-legacy.controller.ts b/packages/import-practices-legacy/src/nest-api/import-legacy/import-legacy.controller.ts new file mode 100644 index 000000000..cf5aa6bf3 --- /dev/null +++ b/packages/import-practices-legacy/src/nest-api/import-legacy/import-legacy.controller.ts @@ -0,0 +1,104 @@ +import { + BadRequestException, + Body, + Controller, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { PackmindLogger } from '@packmind/logger'; +import { AuthenticatedRequest } from '@packmind/node-utils'; +import { IpFilterGuard } from '../guards/ip-filter.guard'; +import { ImportLegacyService } from './import-legacy.service'; +import { + ImportPracticeLegacyCommand, + ImportPracticeLegacyResponse, + LegacyPracticeInput, +} from '../../types'; + +const origin = 'ImportLegacyController'; + +@Controller('') +export class ImportLegacyController { + constructor( + private readonly importLegacyService: ImportLegacyService, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) { + this.logger.info('ImportLegacyController initialized'); + } + + @Post('import-legacy') + @UseGuards(IpFilterGuard) + async importLegacy( + @Body() body: LegacyPracticeInput, + @Req() request: AuthenticatedRequest, + ): Promise<ImportPracticeLegacyResponse> { + this.logger.info('POST /import-legacy - Importing legacy practices', { + standardsCount: body?.standards?.length, + organizationId: request.organization?.id, + userId: request.user?.userId, + }); + + try { + const organizationId = request.organization?.id; + const userId = request.user?.userId; + + if (!organizationId || !userId) { + this.logger.error( + 'POST /import-legacy - Missing user or organization context', + { + organizationId, + userId, + }, + ); + throw new BadRequestException('User authentication required'); + } + + // Validate body + if (!body || !body.standards || !Array.isArray(body.standards)) { + this.logger.error( + 'POST /import-legacy - Invalid body: standards array required', + ); + throw new BadRequestException( + 'Invalid body: standards array is required', + ); + } + + if (body.standards.length === 0) { + this.logger.error( + 'POST /import-legacy - Invalid body: standards array is empty', + ); + throw new BadRequestException( + 'Invalid body: standards array cannot be empty', + ); + } + + const command: ImportPracticeLegacyCommand = { + organizationId, + userId, + legacyData: body, + }; + + const result = await this.importLegacyService.importLegacy(command); + + this.logger.info('POST /import-legacy - Import completed successfully', { + importedCount: result.importedStandards.length, + skippedCount: result.skippedStandards.length, + }); + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error('POST /import-legacy - Import failed', { + error: errorMessage, + }); + + if (error instanceof BadRequestException) { + throw error; + } + + throw new BadRequestException(errorMessage); + } + } +} diff --git a/packages/import-practices-legacy/src/nest-api/import-legacy/import-legacy.module.ts b/packages/import-practices-legacy/src/nest-api/import-legacy/import-legacy.module.ts new file mode 100644 index 000000000..0fcc3426a --- /dev/null +++ b/packages/import-practices-legacy/src/nest-api/import-legacy/import-legacy.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { PackmindLogger } from '@packmind/logger'; +import { ImportLegacyController } from './import-legacy.controller'; +import { ImportLegacyService } from './import-legacy.service'; + +@Module({ + imports: [], + controllers: [ImportLegacyController], + providers: [ + ImportLegacyService, + { + provide: PackmindLogger, + useFactory: () => new PackmindLogger('ImportLegacyModule'), + }, + ], +}) +export class ImportLegacyModule {} diff --git a/packages/import-practices-legacy/src/nest-api/import-legacy/import-legacy.service.ts b/packages/import-practices-legacy/src/nest-api/import-legacy/import-legacy.service.ts new file mode 100644 index 000000000..76cc2d44e --- /dev/null +++ b/packages/import-practices-legacy/src/nest-api/import-legacy/import-legacy.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { ImportPracticeLegacyHexa } from '../../ImportPracticeLegacyHexa'; +import { + IImportPracticeLegacyPort, + ImportPracticeLegacyCommand, + ImportPracticeLegacyResponse, +} from '../../types'; + +@Injectable() +export class ImportLegacyService { + constructor(private readonly hexa: ImportPracticeLegacyHexa) {} + + private get adapter(): IImportPracticeLegacyPort { + return this.hexa.getAdapter(); + } + + async importLegacy( + command: ImportPracticeLegacyCommand, + ): Promise<ImportPracticeLegacyResponse> { + return this.adapter.importPracticeLegacy(command); + } +} diff --git a/packages/import-practices-legacy/src/types/LegacyPractice.ts b/packages/import-practices-legacy/src/types/LegacyPractice.ts new file mode 100644 index 000000000..fa7d348ac --- /dev/null +++ b/packages/import-practices-legacy/src/types/LegacyPractice.ts @@ -0,0 +1,46 @@ +/** + * Represents a code example from the legacy practice format. + */ +export interface LegacyCodeExample { + code: string; + language: string; +} + +/** + * Represents a detection program from the legacy practice format. + * Detection programs contain JavaScript code that can detect violations of a rule. + */ +export interface LegacyDetectionProgram { + code: string; + description: string; + language: string; + mode: 'AST' | 'RAW'; +} + +/** + * Represents a rule from the legacy practice format. + * Rules have a name and separate arrays of positive and negative examples. + */ +export interface LegacyRule { + name: string; + positiveExamples: LegacyCodeExample[]; + negativeExamples: LegacyCodeExample[]; + detectionProgram?: LegacyDetectionProgram; +} + +/** + * Represents a standard from the legacy practice format. + * A standard contains a name, description, and a list of rules. + */ +export interface LegacyStandard { + name: string; + description: string; + rules: LegacyRule[]; +} + +/** + * Represents the root structure of the legacy practice input JSON. + */ +export interface LegacyPracticeInput { + standards: LegacyStandard[]; +} diff --git a/packages/import-practices-legacy/src/types/contracts/IImportPracticeLegacyUseCase.ts b/packages/import-practices-legacy/src/types/contracts/IImportPracticeLegacyUseCase.ts new file mode 100644 index 000000000..fbd949c72 --- /dev/null +++ b/packages/import-practices-legacy/src/types/contracts/IImportPracticeLegacyUseCase.ts @@ -0,0 +1,41 @@ +import { IUseCase, PackmindCommand } from '@packmind/types'; +import { LegacyPracticeInput } from '../LegacyPractice'; + +/** + * Command for importing legacy practices into Packmind standards. + */ +export type ImportPracticeLegacyCommand = PackmindCommand & { + legacyData: LegacyPracticeInput; +}; + +/** + * Represents a successfully imported standard. + */ +export interface ImportedStandard { + name: string; + slug: string; +} + +/** + * Represents a standard that was skipped during import. + */ +export interface SkippedStandard { + name: string; + reason: string; +} + +/** + * Response from the import legacy practices use case. + */ +export type ImportPracticeLegacyResponse = { + importedStandards: ImportedStandard[]; + skippedStandards: SkippedStandard[]; +}; + +/** + * Use case interface for importing legacy practices. + */ +export type IImportPracticeLegacyUseCase = IUseCase< + ImportPracticeLegacyCommand, + ImportPracticeLegacyResponse +>; diff --git a/packages/import-practices-legacy/src/types/contracts/index.ts b/packages/import-practices-legacy/src/types/contracts/index.ts new file mode 100644 index 000000000..5b59d21c1 --- /dev/null +++ b/packages/import-practices-legacy/src/types/contracts/index.ts @@ -0,0 +1 @@ +export * from './IImportPracticeLegacyUseCase'; diff --git a/packages/import-practices-legacy/src/types/index.ts b/packages/import-practices-legacy/src/types/index.ts new file mode 100644 index 000000000..2272db98f --- /dev/null +++ b/packages/import-practices-legacy/src/types/index.ts @@ -0,0 +1,3 @@ +export * from './LegacyPractice'; +export * from './contracts'; +export * from './ports'; diff --git a/packages/import-practices-legacy/src/types/ports/IImportPracticeLegacyPort.ts b/packages/import-practices-legacy/src/types/ports/IImportPracticeLegacyPort.ts new file mode 100644 index 000000000..b299e7997 --- /dev/null +++ b/packages/import-practices-legacy/src/types/ports/IImportPracticeLegacyPort.ts @@ -0,0 +1,22 @@ +import { + ImportPracticeLegacyCommand, + ImportPracticeLegacyResponse, +} from '../contracts/IImportPracticeLegacyUseCase'; + +/** + * Port interface for cross-domain access to Import Practices Legacy functionality. + * Following DDD monorepo architecture standard. + */ +export const IImportPracticeLegacyPortName = + 'IImportPracticeLegacyPort' as const; + +export interface IImportPracticeLegacyPort { + /** + * Import legacy practices into Packmind standards. + * @param command - Command containing legacy data and user context + * @returns Summary of imported and skipped standards + */ + importPracticeLegacy( + command: ImportPracticeLegacyCommand, + ): Promise<ImportPracticeLegacyResponse>; +} diff --git a/packages/import-practices-legacy/src/types/ports/index.ts b/packages/import-practices-legacy/src/types/ports/index.ts new file mode 100644 index 000000000..504460e72 --- /dev/null +++ b/packages/import-practices-legacy/src/types/ports/index.ts @@ -0,0 +1 @@ +export * from './IImportPracticeLegacyPort'; diff --git a/packages/import-practices-legacy/tsconfig.json b/packages/import-practices-legacy/tsconfig.json new file mode 100644 index 000000000..19b9eece4 --- /dev/null +++ b/packages/import-practices-legacy/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/import-practices-legacy/tsconfig.lib.json b/packages/import-practices-legacy/tsconfig.lib.json new file mode 100644 index 000000000..33eca2c2c --- /dev/null +++ b/packages/import-practices-legacy/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/import-practices-legacy/tsconfig.spec.json b/packages/import-practices-legacy/tsconfig.spec.json new file mode 100644 index 000000000..0d3c604ea --- /dev/null +++ b/packages/import-practices-legacy/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/packages/integration-tests/.claude/rules/packmind/standard-integration-tests-structure-and-patterns.md b/packages/integration-tests/.claude/rules/packmind/standard-integration-tests-structure-and-patterns.md index 4f3789c18..de9256699 100644 --- a/packages/integration-tests/.claude/rules/packmind/standard-integration-tests-structure-and-patterns.md +++ b/packages/integration-tests/.claude/rules/packmind/standard-integration-tests-structure-and-patterns.md @@ -3,12 +3,12 @@ name: 'Integration Tests Structure and Patterns' paths: - "Integration test files in packages/integration-tests/src/**/*.spec.ts" alwaysApply: false -description: 'Standardize Packmind monorepo integration tests in packages/integration-tests/src/**/*.spec.ts using integrationTestWithUser, typed entities from @packmind/types, hexagonal adapter access via testContext.testApp.<domain>Hexa.getAdapter(), Jest spies with proper cleanup, factories, and structured describe/beforeEach patterns to ensure consistent organization, reliable resource management, and comprehensive coverage.' +description: 'Define testing patterns for integration tests in the Packmind monorepo to ensure consistent organization, proper resource management, and comprehensive test coverage using hexagonal architecture principles.' --- # Standard: Integration Tests Structure and Patterns -Standardize Packmind monorepo integration tests in packages/integration-tests/src/**/*.spec.ts using integrationTestWithUser, typed entities from @packmind/types, hexagonal adapter access via testContext.testApp.<domain>Hexa.getAdapter(), Jest spies with proper cleanup, factories, and structured describe/beforeEach patterns to ensure consistent organization, reliable resource management, and comprehensive coverage. : +Define testing patterns for integration tests in the Packmind monorepo to ensure consistent organization, proper resource management, and comprehensive test coverage using hexagonal architecture princ... : * Access domain adapters through testContext.testApp.<domain>Hexa.getAdapter() hexagonal architecture pattern * Clean up database connections in afterEach with datasource.destroy() when datasource is initialized in beforeEach * Clear all mocks in afterEach using jest.clearAllMocks() to prevent inter-test pollution diff --git a/packages/integration-tests/.cursor/rules/packmind/standard-integration-tests-structure-and-patterns.mdc b/packages/integration-tests/.cursor/rules/packmind/standard-integration-tests-structure-and-patterns.mdc index 35e4f9695..6f1d35906 100644 --- a/packages/integration-tests/.cursor/rules/packmind/standard-integration-tests-structure-and-patterns.mdc +++ b/packages/integration-tests/.cursor/rules/packmind/standard-integration-tests-structure-and-patterns.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: Integration Tests Structure and Patterns -Standardize Packmind monorepo integration tests in packages/integration-tests/src/**/*.spec.ts using integrationTestWithUser, typed entities from @packmind/types, hexagonal adapter access via testContext.testApp.<domain>Hexa.getAdapter(), Jest spies with proper cleanup, factories, and structured describe/beforeEach patterns to ensure consistent organization, reliable resource management, and comprehensive coverage. : +Define testing patterns for integration tests in the Packmind monorepo to ensure consistent organization, proper resource management, and comprehensive test coverage using hexagonal architecture princ... : * Access domain adapters through testContext.testApp.<domain>Hexa.getAdapter() hexagonal architecture pattern * Clean up database connections in afterEach with datasource.destroy() when datasource is initialized in beforeEach * Clear all mocks in afterEach using jest.clearAllMocks() to prevent inter-test pollution diff --git a/packages/integration-tests/.github/instructions/packmind-integration-tests-structure-and-patterns.instructions.md b/packages/integration-tests/.github/instructions/packmind-integration-tests-structure-and-patterns.instructions.md index 08e5afdaf..80a10f9b1 100644 --- a/packages/integration-tests/.github/instructions/packmind-integration-tests-structure-and-patterns.instructions.md +++ b/packages/integration-tests/.github/instructions/packmind-integration-tests-structure-and-patterns.instructions.md @@ -3,7 +3,7 @@ applyTo: 'Integration test files in packages/integration-tests/src/**/*.spec.ts' --- # Standard: Integration Tests Structure and Patterns -Standardize Packmind monorepo integration tests in packages/integration-tests/src/**/*.spec.ts using integrationTestWithUser, typed entities from @packmind/types, hexagonal adapter access via testContext.testApp.<domain>Hexa.getAdapter(), Jest spies with proper cleanup, factories, and structured describe/beforeEach patterns to ensure consistent organization, reliable resource management, and comprehensive coverage. : +Define testing patterns for integration tests in the Packmind monorepo to ensure consistent organization, proper resource management, and comprehensive test coverage using hexagonal architecture princ... : * Access domain adapters through testContext.testApp.<domain>Hexa.getAdapter() hexagonal architecture pattern * Clean up database connections in afterEach with datasource.destroy() when datasource is initialized in beforeEach * Clear all mocks in afterEach using jest.clearAllMocks() to prevent inter-test pollution diff --git a/packages/integration-tests/.gitlab/duo/chat-rules.md b/packages/integration-tests/.gitlab/duo/chat-rules.md index ba78c567a..18801c861 100644 --- a/packages/integration-tests/.gitlab/duo/chat-rules.md +++ b/packages/integration-tests/.gitlab/duo/chat-rules.md @@ -11,7 +11,7 @@ Failure to follow these standards may lead to inconsistencies, errors, or rework # Standard: Integration Tests Structure and Patterns -Standardize Packmind monorepo integration tests in packages/integration-tests/src/**/*.spec.ts using integrationTestWithUser, typed entities from @packmind/types, hexagonal adapter access via testContext.testApp.<domain>Hexa.getAdapter(), Jest spies with proper cleanup, factories, and structured describe/beforeEach patterns to ensure consistent organization, reliable resource management, and comprehensive coverage. : +Define testing patterns for integration tests in the Packmind monorepo to ensure consistent organization, proper resource management, and comprehensive test coverage using hexagonal architecture princ... : * Access domain adapters through testContext.testApp.<domain>Hexa.getAdapter() hexagonal architecture pattern * Clean up database connections in afterEach with datasource.destroy() when datasource is initialized in beforeEach * Clear all mocks in afterEach using jest.clearAllMocks() to prevent inter-test pollution diff --git a/packages/integration-tests/.packmind/standards-index.md b/packages/integration-tests/.packmind/standards-index.md index 52b42dec5..2807a5709 100644 --- a/packages/integration-tests/.packmind/standards-index.md +++ b/packages/integration-tests/.packmind/standards-index.md @@ -4,7 +4,7 @@ This standards index contains all available coding standards that can be used by ## Available Standards -- [Integration Tests Structure and Patterns](./standards/integration-tests-structure-and-patterns.md) : Standardize Packmind monorepo integration tests in packages/integration-tests/src/**/*.spec.ts using integrationTestWithUser, typed entities from @packmind/types, hexagonal adapter access via testContext.testApp.<domain>Hexa.getAdapter(), Jest spies with proper cleanup, factories, and structured describe/beforeEach patterns to ensure consistent organization, reliable resource management, and comprehensive coverage. +- [Integration Tests Structure and Patterns](./standards/integration-tests-structure-and-patterns.md) : Define testing patterns for integration tests in the Packmind monorepo to ensure consistent organization, proper resource management, and comprehensive test coverage using hexagonal architecture principles. --- diff --git a/packages/integration-tests/AGENTS.md b/packages/integration-tests/AGENTS.md index 9cee02fbf..613de1d17 100644 --- a/packages/integration-tests/AGENTS.md +++ b/packages/integration-tests/AGENTS.md @@ -11,7 +11,7 @@ Failure to follow these standards may lead to inconsistencies, errors, or rework # Standard: Integration Tests Structure and Patterns -Standardize Packmind monorepo integration tests in packages/integration-tests/src/**/*.spec.ts using integrationTestWithUser, typed entities from @packmind/types, hexagonal adapter access via testContext.testApp.<domain>Hexa.getAdapter(), Jest spies with proper cleanup, factories, and structured describe/beforeEach patterns to ensure consistent organization, reliable resource management, and comprehensive coverage. : +Define testing patterns for integration tests in the Packmind monorepo to ensure consistent organization, proper resource management, and comprehensive test coverage using hexagonal architecture princ... : * Access domain adapters through testContext.testApp.<domain>Hexa.getAdapter() hexagonal architecture pattern * Clean up database connections in afterEach with datasource.destroy() when datasource is initialized in beforeEach * Clear all mocks in afterEach using jest.clearAllMocks() to prevent inter-test pollution diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index a72c999b5..f89507e8c 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -6,8 +6,11 @@ "main": "./src/index.js", "types": "./src/index.d.ts", "dependencies": { + "@nestjs/common": "^11.1.17", + "bullmq": "^5.58.2", + "jsonwebtoken": "^9.0.2", "@packmind/accounts": "workspace:*", - "@packmind/editions": "workspace:*", + "@packmind/amplitude": "workspace:*", "@packmind/recipes": "workspace:*", "@packmind/git": "workspace:*", "@packmind/node-utils": "workspace:*", @@ -17,12 +20,12 @@ "@packmind/standards": "workspace:*", "@packmind/coding-agent": "workspace:*", "@packmind/deployments": "workspace:*", + "@packmind/playbook-change-management": "workspace:*", + "uuid": "^11.1.0", "@packmind/test-utils": "workspace:*", "@packmind/llm": "workspace:*", - "uuid": "^11.1.0", + "@packmind/linter": "workspace:*", "typeorm": "0.3.28", - "jsonwebtoken": "^9.0.3", - "jest-stage": "^1.0.0", - "bullmq": "^5.58.2" + "jest-stage": "^1.0.0" } } diff --git a/packages/integration-tests/packmind-lock.json b/packages/integration-tests/packmind-lock.json index 6cdfbb164..b2f80e078 100644 --- a/packages/integration-tests/packmind-lock.json +++ b/packages/integration-tests/packmind-lock.json @@ -6,12 +6,14 @@ "agents": [ "agents_md", "claude", + "codex", "copilot", "cursor", "gitlab_duo", + "opencode", "packmind" ], - "installedAt": "2026-06-16T03:12:28.363Z", + "installedAt": "2026-07-10T03:07:54.639Z", "artifacts": { "user:standard:integration-tests-structure-and-patterns": { "name": "Integration Tests Structure and Patterns", diff --git a/packages/integration-tests/src/deployments/publishPackageOnMarketplace.bad-format.spec.ts b/packages/integration-tests/src/deployments/publishPackageOnMarketplace.bad-format.spec.ts new file mode 100644 index 000000000..eb54c45fc --- /dev/null +++ b/packages/integration-tests/src/deployments/publishPackageOnMarketplace.bad-format.spec.ts @@ -0,0 +1,183 @@ +import { + MarketplaceDistributionSchema, + MarketplaceSchema, +} from '@packmind/deployments'; +import { gitProviderFactory } from '@packmind/git/test'; +import { + DistributionStatus, + GitProvider, + IGitPort, + LinkMarketplaceResponse, + Marketplace, + MarketplaceDescriptorNotFoundError, + MarketplaceDistribution, + Package, +} from '@packmind/types'; +import { createIntegrationTestFixture } from '../helpers/createIntegrationTestFixture'; +import { DataFactory } from '../helpers/DataFactory'; +import { integrationTestSchemas } from '../helpers/makeIntegrationTestDataSource'; +import { TestApp } from '../helpers/TestApp'; + +const ANTHROPIC_MARKETPLACE_DESCRIPTOR_JSON = JSON.stringify({ + name: 'Anthropic Marketplace', + vendor: 'anthropic', + version: '1.0.0', + plugins: [], +}); + +describe('publishPackageOnMarketplace — descriptor missing maps to bad_format', () => { + const fixture = createIntegrationTestFixture(integrationTestSchemas); + + let testApp: TestApp; + let dataFactory: DataFactory; + let gitProvider: GitProvider; + let gitPort: IGitPort; + let marketplace: LinkMarketplaceResponse; + let pkg: Package; + let getFileFromRepoSpy: jest.SpyInstance; + let commitToGitSpy: jest.SpyInstance; + let openOrUpdatePullRequestSpy: jest.SpyInstance; + + beforeAll(() => fixture.initialize()); + + beforeEach(async () => { + testApp = new TestApp(fixture.datasource); + await testApp.initialize(); + + dataFactory = new DataFactory(testApp); + await dataFactory.withUserAndOrganization({ email: 'admin@example.com' }); + + ({ gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + )); + + gitPort = testApp.gitHexa.getAdapter(); + + // Serve a valid descriptor during link-time, then flip the stub to + // return null inside the publish test so the descriptor is missing only + // at publish time. The standalone packmind-lock.json is reported + // missing so first-publish wiring stays consistent. + getFileFromRepoSpy = jest + .spyOn(gitPort, 'getFileFromRepo') + .mockImplementation(async (_repo, path) => { + if (path === 'packmind-lock.json') { + return null; + } + return { + sha: 'mock-sha', + content: ANTHROPIC_MARKETPLACE_DESCRIPTOR_JSON, + }; + }); + + const deploymentsAdapter = testApp.deploymentsHexa.getAdapter(); + const adapterAny = deploymentsAdapter as unknown as { + _linkMarketplaceUseCase: { + reconciliationJob: { + scheduleRecurring: () => Promise<void>; + addJob: () => Promise<string>; + }; + }; + }; + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'scheduleRecurring', + ) + .mockResolvedValue(undefined); + jest + .spyOn(adapterAny._linkMarketplaceUseCase.reconciliationJob, 'addJob') + .mockResolvedValue('mock-reconciliation-job-id'); + + commitToGitSpy = jest.spyOn(gitPort, 'commitToGit'); + openOrUpdatePullRequestSpy = jest.spyOn(gitPort, 'openOrUpdatePullRequest'); + jest.spyOn(gitPort, 'createBranchFromBase').mockResolvedValue(undefined); + // Bad-format test path never reaches the publish job — `checkBranchExists` + // is still called by the use case preflight. Default to "absent" so the + // descriptor probe targets the default branch (matching the original + // intent of this spec). + jest.spyOn(gitPort, 'checkBranchExists').mockResolvedValue(false); + + marketplace = await testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace', + }); + + const recipe = await dataFactory.withRecipe({ name: 'Hello recipe' }); + const createPackageResponse = await testApp.deploymentsHexa + .getAdapter() + .createPackage({ + ...dataFactory.packmindCommand(), + spaceId: dataFactory.space.id, + name: 'Security', + description: 'Security curated package', + recipeIds: [recipe.id], + standardIds: [], + }); + pkg = createPackageResponse.package; + + // Flip the descriptor probe to "not found" so the publish use case + // surfaces the missing-descriptor failure. + getFileFromRepoSpy.mockResolvedValue(null); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + describe('when the marketplace descriptor is missing at publish time', () => { + let thrown: unknown; + + beforeEach(async () => { + try { + await testApp.deploymentsHexa.getAdapter().publishPackageOnMarketplace({ + ...dataFactory.packmindCommand(), + marketplaceId: marketplace.id, + packageId: pkg.id, + }); + } catch (error) { + thrown = error; + } + }); + + it('throws MarketplaceDescriptorNotFoundError synchronously', () => { + expect(thrown).toBeInstanceOf(MarketplaceDescriptorNotFoundError); + }); + + it('flips the marketplace state to bad_format', async () => { + const marketplaceRepo = + fixture.datasource.getRepository(MarketplaceSchema); + const persisted = (await marketplaceRepo.findOne({ + where: { id: marketplace.id }, + })) as Marketplace | null; + expect(persisted?.state).toBe('bad_format'); + }); + + it('does not push any commit on the marketplace repo', () => { + expect(commitToGitSpy).not.toHaveBeenCalled(); + }); + + it('does not open or amend a pull request on the marketplace repo', () => { + expect(openOrUpdatePullRequestSpy).not.toHaveBeenCalled(); + }); + + it('does not persist any distribution row in success state', async () => { + const distributionRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + const successRows = (await distributionRepo.find({ + where: { + marketplaceId: marketplace.id, + status: DistributionStatus.success, + }, + })) as MarketplaceDistribution[]; + expect(successRows).toHaveLength(0); + }); + }); +}); diff --git a/packages/integration-tests/src/deployments/publishPackageOnMarketplace.concurrent.spec.ts b/packages/integration-tests/src/deployments/publishPackageOnMarketplace.concurrent.spec.ts new file mode 100644 index 000000000..2765d215f --- /dev/null +++ b/packages/integration-tests/src/deployments/publishPackageOnMarketplace.concurrent.spec.ts @@ -0,0 +1,392 @@ +import { MarketplaceDistributionSchema } from '@packmind/deployments'; +import { gitProviderFactory } from '@packmind/git/test'; +import { + DistributionStatus, + FileModification, + GitCommit, + GitProvider, + GitRepo, + IGitPort, + LinkMarketplaceResponse, + MarketplaceDistribution, + Package, + User, + UserOrganizationMembership, + createUserId, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; +import { createIntegrationTestFixture } from '../helpers/createIntegrationTestFixture'; +import { DataFactory } from '../helpers/DataFactory'; +import { integrationTestSchemas } from '../helpers/makeIntegrationTestDataSource'; +import { runMarketplaceReconciliation } from '../helpers/marketplaceReconciliation'; +import { TestApp } from '../helpers/TestApp'; + +const INITIAL_DESCRIPTOR_JSON = JSON.stringify({ + name: 'Anthropic Marketplace', + vendor: 'anthropic', + version: '1.0.0', + plugins: [], +}); + +const ROLLING_PR_URL = 'https://github.com/anthropic/marketplace/pull/11'; +const DESCRIPTOR_PATH = '.claude-plugin/marketplace.json'; +const PACKMIND_LOCK_PATH = 'packmind-lock.json'; + +describe('publishPackageOnMarketplace — two concurrent publishers', () => { + const fixture = createIntegrationTestFixture(integrationTestSchemas); + + let testApp: TestApp; + let dataFactory: DataFactory; + let gitProvider: GitProvider; + let gitPort: IGitPort; + let marketplace: LinkMarketplaceResponse; + let packageA: Package; + let packageB: Package; + let secondMember: User; + let commitToGitSpy: jest.SpyInstance; + /** + * Tracks the descriptor content "on disk" so the second publish sees the + * changes the first publish committed — mirroring the real Git host's + * behaviour and exercising the rolling-PR merge semantics. + */ + let currentDescriptorContent: string; + /** + * Tracks the standalone packmind-lock.json content "on disk" so each + * subsequent publish reads the previously committed lock state. Starts + * `null` so the first publish exercises the empty-lock first-publish + * path. + */ + let currentLockContent: string | null; + + beforeAll(() => fixture.initialize()); + + beforeEach(async () => { + testApp = new TestApp(fixture.datasource); + await testApp.initialize(); + + dataFactory = new DataFactory(testApp); + await dataFactory.withUserAndOrganization({ email: 'admin@example.com' }); + + ({ gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + )); + + gitPort = testApp.gitHexa.getAdapter(); + + currentDescriptorContent = INITIAL_DESCRIPTOR_JSON; + currentLockContent = null; + + // Serve the descriptor or the lock file depending on the requested + // path. The publish job overwrites these contents after each + // successful commit so the next publish reads the latest state — + // same convergence as a real upstream repo. + jest + .spyOn(gitPort, 'getFileFromRepo') + .mockImplementation(async (_repo, path) => { + if (path === PACKMIND_LOCK_PATH) { + return currentLockContent === null + ? null + : { sha: `lock-sha-${Math.random()}`, content: currentLockContent }; + } + return { + sha: `sha-${Math.random()}`, + content: currentDescriptorContent, + }; + }); + + const deploymentsAdapter = testApp.deploymentsHexa.getAdapter(); + const adapterAny = deploymentsAdapter as unknown as { + _linkMarketplaceUseCase: { + reconciliationJob: { + scheduleRecurring: () => Promise<void>; + addJob: () => Promise<string>; + }; + }; + }; + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'scheduleRecurring', + ) + .mockResolvedValue(undefined); + jest + .spyOn(adapterAny._linkMarketplaceUseCase.reconciliationJob, 'addJob') + .mockResolvedValue('mock-reconciliation-job-id'); + + // Capture committed files and update the in-memory descriptor + lock + // so the next publish reads the converged state. + commitToGitSpy = jest + .spyOn(gitPort, 'commitToGit') + .mockImplementation( + async ( + _repo: GitRepo, + files: FileModification[], + ): Promise<GitCommit> => { + const descriptorUpdate = files.find( + (f) => f.path === DESCRIPTOR_PATH || f.path === 'marketplace.json', + ); + if (descriptorUpdate) { + currentDescriptorContent = descriptorUpdate.content; + } + const lockUpdate = files.find((f) => f.path === PACKMIND_LOCK_PATH); + if (lockUpdate) { + currentLockContent = lockUpdate.content; + } + return { + sha: `commit-sha-${commitToGitSpy.mock.calls.length}`, + } as GitCommit; + }, + ); + + jest.spyOn(gitPort, 'createBranchFromBase').mockResolvedValue(undefined); + jest.spyOn(gitPort, 'openOrUpdatePullRequest').mockResolvedValue({ + url: ROLLING_PR_URL, + number: 11, + wasCreated: true, + }); + + // First publish: rolling sync branch absent. Second publish: present + // because the first commit landed on it. Flag flips inside the + // `commitToGit` mock above (via tracking by `currentLockContent` + // becoming non-null is equivalent here, but using an explicit flag is + // clearer). + jest + .spyOn(gitPort, 'checkBranchExists') + .mockImplementation(async (_providerId, _owner, _repo, branch) => { + if (branch === 'packmind/sync') { + // The rolling sync branch becomes "present" once the first commit + // has landed and updated either the descriptor or the lock. + return currentLockContent !== null; + } + return false; + }); + + marketplace = await testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace', + }); + + // Two distinct packages so each publish lands a different plugin entry. + const recipeA = await dataFactory.withRecipe({ name: 'Recipe for A' }); + const recipeB = await dataFactory.withRecipe({ name: 'Recipe for B' }); + + const createA = await testApp.deploymentsHexa.getAdapter().createPackage({ + ...dataFactory.packmindCommand(), + spaceId: dataFactory.space.id, + name: 'Security', + description: 'Security curated package', + recipeIds: [recipeA.id], + standardIds: [], + }); + packageA = createA.package; + + const createB = await testApp.deploymentsHexa.getAdapter().createPackage({ + ...dataFactory.packmindCommand(), + spaceId: dataFactory.space.id, + name: 'Observability', + description: 'Observability curated package', + recipeIds: [recipeB.id], + standardIds: [], + }); + packageB = createB.package; + + secondMember = await seedNonAdminMember(); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + /** + * Seeds a second org member so the two publishers are distinct identities. + * Direct DB seeding mirrors the pattern used in + * `marketplaces-lifecycle.spec.ts` — going through the sign-up flow would + * create a second organization, which is not what we want here. + */ + async function seedNonAdminMember(): Promise<User> { + const userId = createUserId(uuidv4()); + const userRepo = fixture.datasource.getRepository('User'); + const membershipRepo = fixture.datasource.getRepository( + 'UserOrganizationMembership', + ); + + const user = (await userRepo.save({ + id: userId, + email: 'second-member@example.com', + displayName: 'Second Member', + passwordHash: null, + active: true, + trial: false, + })) as User; + + const membership: UserOrganizationMembership = { + userId, + organizationId: dataFactory.organization.id, + role: 'member', + }; + await membershipRepo.save(membership); + return user; + } + + describe('when two distinct members publish two different packages onto the same marketplace', () => { + let firstResponse: Awaited< + ReturnType< + ReturnType< + typeof testApp.deploymentsHexa.getAdapter + >['publishPackageOnMarketplace'] + > + >; + let secondResponse: Awaited< + ReturnType< + ReturnType< + typeof testApp.deploymentsHexa.getAdapter + >['publishPackageOnMarketplace'] + > + >; + let firstRow: MarketplaceDistribution | null; + let secondRow: MarketplaceDistribution | null; + let finalDescriptor: { + plugins: Array<{ slug: string; name: string }>; + }; + let finalLock: { + schemaVersion: number; + plugins: Record<string, unknown>; + }; + + beforeEach(async () => { + const adapter = testApp.deploymentsHexa.getAdapter(); + + // Admin publishes package A. The SyncJob harness runs each publish's + // BullMQ job inline, so the two publish calls effectively serialize + // — exactly the single-worker concurrency the production queue + // enforces. + firstResponse = await adapter.publishPackageOnMarketplace({ + ...dataFactory.packmindCommand(), + marketplaceId: marketplace.id, + packageId: packageA.id, + }); + + // Second member publishes package B onto the same marketplace. + secondResponse = await adapter.publishPackageOnMarketplace({ + userId: secondMember.id, + organizationId: dataFactory.organization.id, + marketplaceId: marketplace.id, + packageId: packageB.id, + }); + + // Both publishes land in `pending_merge`; drive a reconciliation so the + // rows are confirmed to `success` — the in-memory lock now holds both + // plugin entries with their published content hashes. + await runMarketplaceReconciliation(testApp, marketplace.id); + + const distributionRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + firstRow = (await distributionRepo.findOne({ + where: { id: firstResponse.marketplaceDistributionId }, + })) as MarketplaceDistribution | null; + secondRow = (await distributionRepo.findOne({ + where: { id: secondResponse.marketplaceDistributionId }, + })) as MarketplaceDistribution | null; + + finalDescriptor = JSON.parse( + currentDescriptorContent, + ) as typeof finalDescriptor; + finalLock = JSON.parse(currentLockContent ?? '{}') as typeof finalLock; + }); + + it('records status=success on the first publish row', () => { + expect(firstRow?.status).toBe(DistributionStatus.success); + }); + + it('records status=success on the second publish row', () => { + expect(secondRow?.status).toBe(DistributionStatus.success); + }); + + it('lands two commits on the rolling sync branch', () => { + expect(commitToGitSpy).toHaveBeenCalledTimes(2); + }); + + it('targets the rolling sync branch on every commit (ordered)', () => { + const branches = commitToGitSpy.mock.calls.map( + (call) => (call[0] as { branch: string }).branch, + ); + expect(branches).toEqual(['packmind/sync', 'packmind/sync']); + }); + + it('points both rows at the same rolling PR URL', () => { + expect(firstRow?.prUrl).toBe(secondRow?.prUrl); + }); + + it('records distinct authors on the two rows', () => { + expect(firstRow?.authorId).not.toBe(secondRow?.authorId); + }); + + describe('the resulting marketplace.json descriptor', () => { + it('lists both plugin slugs', () => { + const slugs = finalDescriptor.plugins.map((p) => p.slug).sort(); + expect(slugs).toEqual(['observability', 'security']); + }); + + it('does not embed a legacy packmindLock field in the descriptor', () => { + expect( + (finalDescriptor as Record<string, unknown>)['packmindLock'], + ).toBeUndefined(); + }); + + describe('the per-plugin source blocks', () => { + let pluginsBySlug: Record< + string, + { source?: { source?: string; url?: string; path?: string } } + >; + + beforeEach(() => { + const pluginsWithSource = finalDescriptor.plugins as Array< + (typeof finalDescriptor.plugins)[number] & { + source?: { source?: string; url?: string; path?: string }; + } + >; + pluginsBySlug = Object.fromEntries( + pluginsWithSource.map((plugin) => [plugin.slug, plugin]), + ); + }); + + it('writes a git-subdir source on the security entry', () => { + expect(pluginsBySlug['security'].source).toEqual({ + source: 'git-subdir', + url: 'https://github.com/anthropic/marketplace.git', + path: 'plugins/security', + }); + }); + + it('writes a git-subdir source on the observability entry', () => { + expect(pluginsBySlug['observability'].source).toEqual({ + source: 'git-subdir', + url: 'https://github.com/anthropic/marketplace.git', + path: 'plugins/observability', + }); + }); + }); + }); + + describe('the resulting standalone packmind-lock.json', () => { + it('contains both plugin slugs after the second publish', () => { + expect(Object.keys(finalLock.plugins).sort()).toEqual([ + 'observability', + 'security', + ]); + }); + + it('uses schemaVersion 1', () => { + expect(finalLock.schemaVersion).toBe(1); + }); + }); + }); +}); diff --git a/packages/integration-tests/src/deployments/publishPackageOnMarketplace.invalid-token.spec.ts b/packages/integration-tests/src/deployments/publishPackageOnMarketplace.invalid-token.spec.ts new file mode 100644 index 000000000..fc8d78fcc --- /dev/null +++ b/packages/integration-tests/src/deployments/publishPackageOnMarketplace.invalid-token.spec.ts @@ -0,0 +1,203 @@ +import { MarketplaceDistributionSchema } from '@packmind/deployments'; +import { gitProviderFactory } from '@packmind/git/test'; +import { + GitProvider, + GitProviderTokenInvalidError, + IGitPort, + LinkMarketplaceResponse, + MarketplaceDistribution, + Package, +} from '@packmind/types'; +import { createIntegrationTestFixture } from '../helpers/createIntegrationTestFixture'; +import { DataFactory } from '../helpers/DataFactory'; +import { integrationTestSchemas } from '../helpers/makeIntegrationTestDataSource'; +import { TestApp } from '../helpers/TestApp'; + +const ANTHROPIC_MARKETPLACE_DESCRIPTOR_JSON = JSON.stringify({ + name: 'Anthropic Marketplace', + vendor: 'anthropic', + version: '1.0.0', + plugins: [], +}); + +describe('publishPackageOnMarketplace — expired token preflight', () => { + const fixture = createIntegrationTestFixture(integrationTestSchemas); + + let testApp: TestApp; + let dataFactory: DataFactory; + let gitProvider: GitProvider; + let gitPort: IGitPort; + let marketplace: LinkMarketplaceResponse; + let pkg: Package; + let publishJobAddSpy: jest.SpyInstance; + let commitToGitSpy: jest.SpyInstance; + let openOrUpdatePullRequestSpy: jest.SpyInstance; + + beforeAll(() => fixture.initialize()); + + beforeEach(async () => { + testApp = new TestApp(fixture.datasource); + await testApp.initialize(); + + dataFactory = new DataFactory(testApp); + await dataFactory.withUserAndOrganization({ email: 'admin@example.com' }); + + ({ gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + )); + + gitPort = testApp.gitHexa.getAdapter(); + + // Serve a valid descriptor at link time so the linkMarketplace flow can + // succeed and seed the marketplace + git repo rows. The standalone + // packmind-lock.json is absent — irrelevant here because the publish + // never reaches the lock fetch (token preflight fails first), but + // keeping the mock realistic. + jest + .spyOn(gitPort, 'getFileFromRepo') + .mockImplementation(async (_repo, path) => { + if (path === 'packmind-lock.json') { + return null; + } + return { + sha: 'mock-sha', + content: ANTHROPIC_MARKETPLACE_DESCRIPTOR_JSON, + }; + }); + + const deploymentsAdapter = testApp.deploymentsHexa.getAdapter(); + const adapterAny = deploymentsAdapter as unknown as { + _linkMarketplaceUseCase: { + reconciliationJob: { + scheduleRecurring: () => Promise<void>; + addJob: () => Promise<string>; + }; + }; + _publishPackageOnMarketplaceUseCase: { + publishJob: { + addJob: (...args: unknown[]) => Promise<string>; + }; + }; + }; + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'scheduleRecurring', + ) + .mockResolvedValue(undefined); + jest + .spyOn(adapterAny._linkMarketplaceUseCase.reconciliationJob, 'addJob') + .mockResolvedValue('mock-reconciliation-job-id'); + + commitToGitSpy = jest.spyOn(gitPort, 'commitToGit'); + openOrUpdatePullRequestSpy = jest.spyOn(gitPort, 'openOrUpdatePullRequest'); + jest.spyOn(gitPort, 'createBranchFromBase').mockResolvedValue(undefined); + // The invalid-token path rejects at preflight before any branch probe + // would meaningfully matter; default the stub so unrelated paths stay + // deterministic. + jest.spyOn(gitPort, 'checkBranchExists').mockResolvedValue(false); + + marketplace = await testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace', + }); + + const recipe = await dataFactory.withRecipe({ name: 'Hello recipe' }); + const createPackageResponse = await testApp.deploymentsHexa + .getAdapter() + .createPackage({ + ...dataFactory.packmindCommand(), + spaceId: dataFactory.space.id, + name: 'Security', + description: 'Security curated package', + recipeIds: [recipe.id], + standardIds: [], + }); + pkg = createPackageResponse.package; + + publishJobAddSpy = jest.spyOn( + adapterAny._publishPackageOnMarketplaceUseCase.publishJob, + 'addJob', + ); + + // Simulate an expired / invalid token: the preflight listProviders call + // returns a provider whose `hasAuth` flag is false. The use case must + // treat this as a synchronous failure before persisting any row or + // enqueuing the BullMQ job. + jest.spyOn(gitPort, 'listProviders').mockResolvedValue({ + providers: [ + { + id: gitProvider.id, + source: gitProvider.source, + organizationId: gitProvider.organizationId, + url: gitProvider.url, + hasAuth: false, + authMethod: 'token', + }, + ], + }); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + describe('when the marketplace git provider has no usable token', () => { + let thrown: unknown; + + beforeEach(async () => { + try { + await testApp.deploymentsHexa.getAdapter().publishPackageOnMarketplace({ + ...dataFactory.packmindCommand(), + marketplaceId: marketplace.id, + packageId: pkg.id, + }); + } catch (error) { + thrown = error; + } + }); + + it('throws GitProviderTokenInvalidError synchronously', () => { + expect(thrown).toBeInstanceOf(GitProviderTokenInvalidError); + }); + + it('exposes the verbatim user-facing message on the error', () => { + expect((thrown as Error).message).toBe( + GitProviderTokenInvalidError.USER_FACING_MESSAGE, + ); + }); + + it('never echoes the token in the error message', () => { + expect((thrown as Error).message).not.toContain('gh-pat-test-token'); + }); + + it('does not create any MarketplaceDistribution row', async () => { + const distributionRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + const rows = (await distributionRepo.find({ + where: { marketplaceId: marketplace.id }, + })) as MarketplaceDistribution[]; + expect(rows).toHaveLength(0); + }); + + it('does not enqueue the publish BullMQ job', () => { + expect(publishJobAddSpy).not.toHaveBeenCalled(); + }); + + it('does not push any commit on the marketplace repo', () => { + expect(commitToGitSpy).not.toHaveBeenCalled(); + }); + + it('does not open a pull request on the marketplace repo', () => { + expect(openOrUpdatePullRequestSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/integration-tests/src/deployments/publishPackageOnMarketplace.name-conflict.spec.ts b/packages/integration-tests/src/deployments/publishPackageOnMarketplace.name-conflict.spec.ts new file mode 100644 index 000000000..2c85567b7 --- /dev/null +++ b/packages/integration-tests/src/deployments/publishPackageOnMarketplace.name-conflict.spec.ts @@ -0,0 +1,170 @@ +import { MarketplaceDistributionSchema } from '@packmind/deployments'; +import { gitProviderFactory } from '@packmind/git/test'; +import { + GitProvider, + IGitPort, + LinkMarketplaceResponse, + MarketplaceDistribution, + MarketplacePluginNameConflictError, + Package, +} from '@packmind/types'; +import { createIntegrationTestFixture } from '../helpers/createIntegrationTestFixture'; +import { DataFactory } from '../helpers/DataFactory'; +import { integrationTestSchemas } from '../helpers/makeIntegrationTestDataSource'; +import { TestApp } from '../helpers/TestApp'; + +/** + * Marketplace descriptor that already exposes an unmanaged plugin named + * "Security" (slug `security`). Publishing a Packmind package whose slug + * also collapses to `security` must fail with a name-conflict error. + */ +const COLLIDING_MARKETPLACE_DESCRIPTOR_JSON = JSON.stringify({ + name: 'Anthropic Marketplace', + vendor: 'anthropic', + version: '1.0.0', + plugins: [{ name: 'Security' }], +}); + +describe('publishPackageOnMarketplace — name collision with unmanaged plugin', () => { + const fixture = createIntegrationTestFixture(integrationTestSchemas); + + let testApp: TestApp; + let dataFactory: DataFactory; + let gitProvider: GitProvider; + let gitPort: IGitPort; + let marketplace: LinkMarketplaceResponse; + let pkg: Package; + let commitToGitSpy: jest.SpyInstance; + let openOrUpdatePullRequestSpy: jest.SpyInstance; + + beforeAll(() => fixture.initialize()); + + beforeEach(async () => { + testApp = new TestApp(fixture.datasource); + await testApp.initialize(); + + dataFactory = new DataFactory(testApp); + await dataFactory.withUserAndOrganization({ email: 'admin@example.com' }); + + ({ gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + )); + + gitPort = testApp.gitHexa.getAdapter(); + + // Descriptor exposes a colliding unmanaged plugin; the standalone + // packmind-lock.json is absent so the slug stays unmanaged. + jest + .spyOn(gitPort, 'getFileFromRepo') + .mockImplementation(async (_repo, path) => { + if (path === 'packmind-lock.json') { + return null; + } + return { + sha: 'mock-sha', + content: COLLIDING_MARKETPLACE_DESCRIPTOR_JSON, + }; + }); + + const deploymentsAdapter = testApp.deploymentsHexa.getAdapter(); + const adapterAny = deploymentsAdapter as unknown as { + _linkMarketplaceUseCase: { + reconciliationJob: { + scheduleRecurring: () => Promise<void>; + addJob: () => Promise<string>; + }; + }; + }; + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'scheduleRecurring', + ) + .mockResolvedValue(undefined); + jest + .spyOn(adapterAny._linkMarketplaceUseCase.reconciliationJob, 'addJob') + .mockResolvedValue('mock-reconciliation-job-id'); + + commitToGitSpy = jest.spyOn(gitPort, 'commitToGit'); + openOrUpdatePullRequestSpy = jest.spyOn(gitPort, 'openOrUpdatePullRequest'); + jest.spyOn(gitPort, 'createBranchFromBase').mockResolvedValue(undefined); + // Name-conflict path rejects on the descriptor preflight; default the + // branch probe to "absent" so the descriptor read targets the default + // branch (matches this spec's expectation of an unmanaged collision on + // the marketplace's main). + jest.spyOn(gitPort, 'checkBranchExists').mockResolvedValue(false); + + marketplace = await testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace', + }); + + // Tiny package — no artifacts needed since the collision check fires + // before rendering. Slug derives from name → 'security' which collides + // with the unmanaged "Security" plugin already in the descriptor. + const createPackageResponse = await testApp.deploymentsHexa + .getAdapter() + .createPackage({ + ...dataFactory.packmindCommand(), + spaceId: dataFactory.space.id, + name: 'Security', + description: 'Security curated package', + recipeIds: [], + standardIds: [], + }); + pkg = createPackageResponse.package; + }); + + afterEach(async () => { + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + describe('when the package slug collides with an unmanaged plugin entry', () => { + let thrown: unknown; + + beforeEach(async () => { + try { + await testApp.deploymentsHexa.getAdapter().publishPackageOnMarketplace({ + ...dataFactory.packmindCommand(), + marketplaceId: marketplace.id, + packageId: pkg.id, + }); + } catch (error) { + thrown = error; + } + }); + + it('throws MarketplacePluginNameConflictError synchronously', () => { + expect(thrown).toBeInstanceOf(MarketplacePluginNameConflictError); + }); + + it('mentions the conflicting plugin slug in the error message', () => { + expect((thrown as Error).message).toContain('security'); + }); + + it('does not push any commit on the marketplace repo', () => { + expect(commitToGitSpy).not.toHaveBeenCalled(); + }); + + it('does not open a pull request on the marketplace repo', () => { + expect(openOrUpdatePullRequestSpy).not.toHaveBeenCalled(); + }); + + it('does not persist any MarketplaceDistribution row for the package', async () => { + const distributionRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + const rows = (await distributionRepo.find({ + where: { marketplaceId: marketplace.id, packageId: pkg.id }, + })) as MarketplaceDistribution[]; + expect(rows).toHaveLength(0); + }); + }); +}); diff --git a/packages/integration-tests/src/deployments/publishPackageOnMarketplace.no-op.spec.ts b/packages/integration-tests/src/deployments/publishPackageOnMarketplace.no-op.spec.ts new file mode 100644 index 000000000..b5d5a8437 --- /dev/null +++ b/packages/integration-tests/src/deployments/publishPackageOnMarketplace.no-op.spec.ts @@ -0,0 +1,322 @@ +import { MarketplaceDistributionSchema } from '@packmind/deployments'; +import { gitProviderFactory } from '@packmind/git/test'; +import { + DistributionStatus, + GitCommit, + GitProvider, + IGitPort, + LinkMarketplaceResponse, + MarketplaceDistribution, + Package, + PluginPublishedEvent, +} from '@packmind/types'; +import { + PackmindEventEmitterService, + PackmindListener, +} from '@packmind/node-utils'; +import { createIntegrationTestFixture } from '../helpers/createIntegrationTestFixture'; +import { DataFactory } from '../helpers/DataFactory'; +import { integrationTestSchemas } from '../helpers/makeIntegrationTestDataSource'; +import { runMarketplaceReconciliation } from '../helpers/marketplaceReconciliation'; +import { TestApp } from '../helpers/TestApp'; + +const ANTHROPIC_MARKETPLACE_DESCRIPTOR_JSON = JSON.stringify({ + name: 'Anthropic Marketplace', + vendor: 'anthropic', + version: '1.0.0', + plugins: [], +}); + +/** + * Capture every PluginPublishedEvent emitted during the test so we can + * assert the wasNoop flag on the second republish without depending on + * BullMQ event delivery internals. + */ +interface StubPublishedAdapter { + onPluginPublished(event: PluginPublishedEvent): void; +} + +class StubPublishedListener extends PackmindListener<StubPublishedAdapter> { + protected registerHandlers(): void { + this.subscribe(PluginPublishedEvent, this.handlePluginPublished); + } + + private handlePluginPublished = (event: PluginPublishedEvent): void => { + this.adapter.onPluginPublished(event); + }; +} + +describe('publishPackageOnMarketplace — no-op idempotency', () => { + const fixture = createIntegrationTestFixture(integrationTestSchemas); + + let testApp: TestApp; + let dataFactory: DataFactory; + let gitProvider: GitProvider; + let gitPort: IGitPort; + let marketplace: LinkMarketplaceResponse; + let pkg: Package; + let commitToGitSpy: jest.SpyInstance; + /** + * The `packmind-lock.json` the first publish commits. Captured here but NOT + * served back during the publishes (the lock stays absent so both publishes + * keep their original code paths); it is only served to the reconciliation + * sweep, which mirrors the lock being merged to the default branch and + * confirms the pending publish to `success`. + */ + let committedLockContent: string | null; + let currentLockContent: string | null; + let eventEmitterService: PackmindEventEmitterService; + let stubPublishedAdapter: jest.Mocked<StubPublishedAdapter>; + let stubPublishedListener: StubPublishedListener; + + beforeAll(() => fixture.initialize()); + + beforeEach(async () => { + testApp = new TestApp(fixture.datasource); + await testApp.initialize(); + + dataFactory = new DataFactory(testApp); + await dataFactory.withUserAndOrganization({ email: 'admin@example.com' }); + + ({ gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + )); + + gitPort = testApp.gitHexa.getAdapter(); + + committedLockContent = null; + currentLockContent = null; + + // Descriptor served on the marketplace repo. The standalone + // packmind-lock.json is reported missing during the publishes (so the + // first publish exercises the empty-lock first-publish path); it is only + // served once `currentLockContent` is set, just before the reconciliation + // sweep below. + jest + .spyOn(gitPort, 'getFileFromRepo') + .mockImplementation(async (_repo, path) => { + if (path === 'packmind-lock.json') { + return currentLockContent === null + ? null + : { sha: 'mock-lock-sha', content: currentLockContent }; + } + return { + sha: 'mock-sha', + content: ANTHROPIC_MARKETPLACE_DESCRIPTOR_JSON, + }; + }); + + const deploymentsAdapter = testApp.deploymentsHexa.getAdapter(); + const adapterAny = deploymentsAdapter as unknown as { + _linkMarketplaceUseCase: { + reconciliationJob: { + scheduleRecurring: () => Promise<void>; + addJob: () => Promise<string>; + }; + }; + }; + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'scheduleRecurring', + ) + .mockResolvedValue(undefined); + jest + .spyOn(adapterAny._linkMarketplaceUseCase.reconciliationJob, 'addJob') + .mockResolvedValue('mock-reconciliation-job-id'); + + // Simulate the git side effect for the no-op scenario: first publish + // commits successfully; the second publish hits `NO_CHANGES_DETECTED` + // because the rendered content is byte-identical to what is already on + // the rolling sync branch. This mirrors the production code path — + // `commitToGit` is what surfaces the no-op signal to the job. + commitToGitSpy = jest + .spyOn(gitPort, 'commitToGit') + .mockImplementation(async (_repo, files) => { + if (commitToGitSpy.mock.calls.length === 1) { + const lockFile = ( + files as Array<{ path: string; content: string }> + ).find((file) => file.path === 'packmind-lock.json'); + committedLockContent = lockFile?.content ?? null; + return { + sha: 'commit-sha-1', + } as GitCommit; + } + throw new Error('NO_CHANGES_DETECTED'); + }); + jest.spyOn(gitPort, 'createBranchFromBase').mockResolvedValue(undefined); + jest.spyOn(gitPort, 'openOrUpdatePullRequest').mockResolvedValue({ + url: 'https://github.com/anthropic/marketplace/pull/9', + number: 9, + wasCreated: true, + }); + + // The rolling sync branch presence has no impact on this no-op scenario + // — the descriptor mock returns the same content on any branch. Default + // to "absent" so both publishes read from the marketplace default + // branch. + jest.spyOn(gitPort, 'checkBranchExists').mockResolvedValue(false); + + marketplace = await testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace', + }); + + const recipe = await dataFactory.withRecipe({ name: 'Hello recipe' }); + const createPackageResponse = await testApp.deploymentsHexa + .getAdapter() + .createPackage({ + ...dataFactory.packmindCommand(), + spaceId: dataFactory.space.id, + name: 'Security', + description: 'Security curated package', + recipeIds: [recipe.id], + standardIds: [], + }); + pkg = createPackageResponse.package; + + eventEmitterService = testApp.registry.getService( + PackmindEventEmitterService, + ); + stubPublishedAdapter = { + onPluginPublished: jest.fn(), + }; + stubPublishedListener = new StubPublishedListener(stubPublishedAdapter); + stubPublishedListener.initialize(eventEmitterService); + }); + + afterEach(async () => { + eventEmitterService.removeAllListeners(); + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + describe('when the same package is republished without content changes', () => { + let firstResponse: Awaited< + ReturnType< + ReturnType< + typeof testApp.deploymentsHexa.getAdapter + >['publishPackageOnMarketplace'] + > + >; + let secondResponse: Awaited< + ReturnType< + ReturnType< + typeof testApp.deploymentsHexa.getAdapter + >['publishPackageOnMarketplace'] + > + >; + let firstRow: MarketplaceDistribution | null; + let secondRow: MarketplaceDistribution | null; + + beforeEach(async () => { + const adapter = testApp.deploymentsHexa.getAdapter(); + firstResponse = await adapter.publishPackageOnMarketplace({ + ...dataFactory.packmindCommand(), + marketplaceId: marketplace.id, + packageId: pkg.id, + }); + // No content change between the two publishes — second one must + // short-circuit on the content hash. + secondResponse = await adapter.publishPackageOnMarketplace({ + ...dataFactory.packmindCommand(), + marketplaceId: marketplace.id, + packageId: pkg.id, + }); + + // Serve the lock the first publish committed, then drive a + // reconciliation so the (pending_merge) first row is confirmed to + // `success`. The no-op second row stays `no_changes`. + currentLockContent = committedLockContent; + await runMarketplaceReconciliation(testApp, marketplace.id); + + const distributionRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + firstRow = (await distributionRepo.findOne({ + where: { id: firstResponse.marketplaceDistributionId }, + })) as MarketplaceDistribution | null; + secondRow = (await distributionRepo.findOne({ + where: { id: secondResponse.marketplaceDistributionId }, + })) as MarketplaceDistribution | null; + }); + + it('records status=success on the first distribution row', () => { + expect(firstRow?.status).toBe(DistributionStatus.success); + }); + + it('records status=no_changes on the second distribution row', () => { + expect(secondRow?.status).toBe(DistributionStatus.no_changes); + }); + + it('persists a single successful commit (the second call surfaces NO_CHANGES_DETECTED and lands nothing)', () => { + expect(firstRow?.gitCommit).toBe('commit-sha-1'); + }); + + it('does not record a gitCommit on the no-op row', () => { + expect(secondRow?.gitCommit).toBeFalsy(); + }); + + it('preserves the content hash across both rows', () => { + expect(secondRow?.contentHash).toBe(firstRow?.contentHash); + }); + + describe('the descriptor committed by the first publish', () => { + let pluginEntry: { + slug?: string; + source?: { source?: string; url?: string; path?: string }; + }; + + beforeEach(() => { + const committedFiles = commitToGitSpy.mock.calls[0][1] as Array<{ + path: string; + content: string; + }>; + const descriptorCommit = committedFiles.find( + (f) => f.path === '.claude-plugin/marketplace.json', + ); + const parsed = JSON.parse(descriptorCommit?.content ?? '{}') as { + plugins: Array<typeof pluginEntry>; + }; + pluginEntry = parsed.plugins.find((p) => p.slug === 'security') ?? {}; + }); + + it('attaches a git-subdir source block on the plugin entry', () => { + expect(pluginEntry.source).toEqual({ + source: 'git-subdir', + url: 'https://github.com/anthropic/marketplace.git', + path: 'plugins/security', + }); + }); + }); + + describe('PluginPublishedEvent for the no-op publish', () => { + let noopEvent: PluginPublishedEvent | undefined; + + beforeEach(() => { + const calls = stubPublishedAdapter.onPluginPublished.mock.calls.map( + (call) => call[0], + ); + noopEvent = calls.find( + (event) => + event.payload.marketplaceDistributionId === + secondResponse.marketplaceDistributionId, + ); + }); + + it('emits a PluginPublishedEvent for the second publish', () => { + expect(noopEvent).toBeDefined(); + }); + + it('flags the no-op event with wasNoop=true', () => { + expect(noopEvent?.payload.wasNoop).toBe(true); + }); + }); + }); +}); diff --git a/packages/integration-tests/src/deployments/publishPackageOnMarketplace.rolling-pr.spec.ts b/packages/integration-tests/src/deployments/publishPackageOnMarketplace.rolling-pr.spec.ts new file mode 100644 index 000000000..d461c3d60 --- /dev/null +++ b/packages/integration-tests/src/deployments/publishPackageOnMarketplace.rolling-pr.spec.ts @@ -0,0 +1,378 @@ +import { MarketplaceDistributionSchema } from '@packmind/deployments'; +import { gitProviderFactory } from '@packmind/git/test'; +import { + DistributionStatus, + GitCommit, + GitProvider, + IGitPort, + LinkMarketplaceResponse, + MarketplaceDistribution, + Package, +} from '@packmind/types'; +import { createIntegrationTestFixture } from '../helpers/createIntegrationTestFixture'; +import { DataFactory } from '../helpers/DataFactory'; +import { integrationTestSchemas } from '../helpers/makeIntegrationTestDataSource'; +import { runMarketplaceReconciliation } from '../helpers/marketplaceReconciliation'; +import { TestApp } from '../helpers/TestApp'; + +const ANTHROPIC_MARKETPLACE_DESCRIPTOR_JSON = JSON.stringify({ + name: 'Anthropic Marketplace', + vendor: 'anthropic', + version: '1.0.0', + plugins: [], +}); + +const ROLLING_PR_URL = 'https://github.com/anthropic/marketplace/pull/7'; + +describe('publishPackageOnMarketplace — rolling PR amend', () => { + const fixture = createIntegrationTestFixture(integrationTestSchemas); + + let testApp: TestApp; + let dataFactory: DataFactory; + let gitProvider: GitProvider; + let gitPort: IGitPort; + let marketplace: LinkMarketplaceResponse; + let pkg: Package; + let commitToGitSpy: jest.SpyInstance; + let openOrUpdatePullRequestSpy: jest.SpyInstance; + let checkBranchExistsSpy: jest.SpyInstance; + let getFileFromRepoSpy: jest.SpyInstance; + /** + * Tracks the descriptor content "on disk" so the second publish reads the + * accumulated state from the rolling sync branch, mirroring how a real Git + * host serves the latest commit on that branch. + */ + let currentDescriptorContent: string; + /** + * Tracks the standalone packmind-lock.json content "on disk" so each + * subsequent publish sees the previous publish's lock entries. Starts as + * `null` so the first publish exercises the empty-lock first-publish path. + */ + let currentLockContent: string | null; + + beforeAll(() => fixture.initialize()); + + beforeEach(async () => { + testApp = new TestApp(fixture.datasource); + await testApp.initialize(); + + dataFactory = new DataFactory(testApp); + await dataFactory.withUserAndOrganization({ email: 'admin@example.com' }); + + ({ gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + )); + + gitPort = testApp.gitHexa.getAdapter(); + + currentDescriptorContent = ANTHROPIC_MARKETPLACE_DESCRIPTOR_JSON; + currentLockContent = null; + + // Serve descriptor + lock from the in-memory "repo" state so the second + // publish sees the first publish's accumulated entries, matching how the + // upstream Git host returns the latest commit on the rolling sync + // branch. + getFileFromRepoSpy = jest + .spyOn(gitPort, 'getFileFromRepo') + .mockImplementation(async (_repo, path) => { + if (path === 'packmind-lock.json') { + return currentLockContent === null + ? null + : { sha: 'mock-lock-sha', content: currentLockContent }; + } + return { + sha: 'mock-sha', + content: currentDescriptorContent, + }; + }); + + // First publish: rolling sync branch doesn't exist yet; second publish: + // first publish created it so the resolver returns the rolling branch. + // The flag flips after the first commit lands on `packmind/sync`. + let rollingBranchExists = false; + checkBranchExistsSpy = jest + .spyOn(gitPort, 'checkBranchExists') + .mockImplementation(async (_providerId, _owner, _repo, branch) => { + if (branch === 'packmind/sync') { + return rollingBranchExists; + } + return false; + }); + + // Stub the reconciliation job so a successful link does not try to + // contact a real queue (link-time concern only). + const deploymentsAdapter = testApp.deploymentsHexa.getAdapter(); + const adapterAny = deploymentsAdapter as unknown as { + _linkMarketplaceUseCase: { + reconciliationJob: { + scheduleRecurring: () => Promise<void>; + addJob: () => Promise<string>; + }; + }; + }; + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'scheduleRecurring', + ) + .mockResolvedValue(undefined); + jest + .spyOn(adapterAny._linkMarketplaceUseCase.reconciliationJob, 'addJob') + .mockResolvedValue('mock-reconciliation-job-id'); + + // Git side effects executed by the publish job — captured rather than + // performed. The in-memory descriptor + lock are updated so the next + // publish reads the accumulated state, and the rolling-branch flag is + // flipped on so `checkBranchExists` reports it as present from then on. + commitToGitSpy = jest + .spyOn(gitPort, 'commitToGit') + .mockImplementation(async (_repo, files) => { + for (const file of files as Array<{ path: string; content: string }>) { + if (file.path === '.claude-plugin/marketplace.json') { + currentDescriptorContent = file.content; + } else if (file.path === 'packmind-lock.json') { + currentLockContent = file.content; + } + } + rollingBranchExists = true; + return { + sha: `commit-sha-${commitToGitSpy.mock.calls.length}`, + } as GitCommit; + }); + jest.spyOn(gitPort, 'createBranchFromBase').mockResolvedValue(undefined); + // First call returns wasCreated=true; subsequent calls match GitHub's + // semantics for an already-open PR on the same head/base. + openOrUpdatePullRequestSpy = jest + .spyOn(gitPort, 'openOrUpdatePullRequest') + .mockImplementation(async () => ({ + url: ROLLING_PR_URL, + number: 7, + wasCreated: openOrUpdatePullRequestSpy.mock.calls.length === 1, + })); + + // Seed: link a marketplace through the real flow so all DB rows are + // populated consistently. + marketplace = await testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace', + }); + + // Seed: a recipe + a package containing it so the renderer has content + // to commit on the first publish. + const recipe = await dataFactory.withRecipe({ name: 'Hello recipe' }); + const createPackageResponse = await testApp.deploymentsHexa + .getAdapter() + .createPackage({ + ...dataFactory.packmindCommand(), + spaceId: dataFactory.space.id, + name: 'Security', + description: 'Security curated package', + recipeIds: [recipe.id], + standardIds: [], + }); + pkg = createPackageResponse.package; + }); + + afterEach(async () => { + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + describe('when the same package is published twice with content changes between', () => { + let firstResponse: Awaited< + ReturnType< + ReturnType< + typeof testApp.deploymentsHexa.getAdapter + >['publishPackageOnMarketplace'] + > + >; + let secondResponse: Awaited< + ReturnType< + ReturnType< + typeof testApp.deploymentsHexa.getAdapter + >['publishPackageOnMarketplace'] + > + >; + let firstRow: MarketplaceDistribution | null; + let secondRow: MarketplaceDistribution | null; + + beforeEach(async () => { + const adapter = testApp.deploymentsHexa.getAdapter(); + firstResponse = await adapter.publishPackageOnMarketplace({ + ...dataFactory.packmindCommand(), + marketplaceId: marketplace.id, + packageId: pkg.id, + }); + + // Add a second artifact to the package so the rendered plugin content + // differs from the first publish — otherwise the content-hash gate + // short-circuits the second publish to `no_changes`. + const newStandard = await dataFactory.withStandard({ + name: 'Extra standard', + }); + await adapter.addArtefactsToPackage({ + ...dataFactory.packmindCommand(), + spaceId: dataFactory.space.id, + packageId: pkg.id, + standardIds: [newStandard.id], + }); + + secondResponse = await adapter.publishPackageOnMarketplace({ + ...dataFactory.packmindCommand(), + marketplaceId: marketplace.id, + packageId: pkg.id, + }); + + // Both publishes land in `pending_merge`; drive a reconciliation so the + // rows are confirmed to `success` once their lock entries are present on + // the default branch — the in-memory lock now holds both publishes. + await runMarketplaceReconciliation(testApp, marketplace.id); + + const distributionRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + firstRow = (await distributionRepo.findOne({ + where: { id: firstResponse.marketplaceDistributionId }, + })) as MarketplaceDistribution | null; + secondRow = (await distributionRepo.findOne({ + where: { id: secondResponse.marketplaceDistributionId }, + })) as MarketplaceDistribution | null; + }); + + it('lands two commits on the rolling sync branch', () => { + expect(commitToGitSpy).toHaveBeenCalledTimes(2); + }); + + it('targets the same rolling sync branch on every commit', () => { + const branches = commitToGitSpy.mock.calls.map( + (call) => (call[0] as { branch: string }).branch, + ); + expect(branches).toEqual(['packmind/sync', 'packmind/sync']); + }); + + it('opens (or amends) the rolling PR with the canonical title', () => { + const titles = openOrUpdatePullRequestSpy.mock.calls.map( + (call) => (call[1] as { title: string }).title, + ); + expect(titles).toEqual(['Packmind sync', 'Packmind sync']); + }); + + it('opens (or amends) the rolling PR on the canonical sync branch', () => { + const heads = openOrUpdatePullRequestSpy.mock.calls.map( + (call) => (call[1] as { head: string }).head, + ); + expect(heads).toEqual(['packmind/sync', 'packmind/sync']); + }); + + it('creates the PR on the first publish and amends it on the second', async () => { + const results = await Promise.all( + openOrUpdatePullRequestSpy.mock.results.map( + (result) => result.value as Promise<{ wasCreated: boolean }>, + ), + ); + const wasCreatedFlags = results.map((r) => r.wasCreated); + expect(wasCreatedFlags).toEqual([true, false]); + }); + + it('records status=success on the first distribution row', () => { + expect(firstRow?.status).toBe(DistributionStatus.success); + }); + + it('records status=success on the second distribution row', () => { + expect(secondRow?.status).toBe(DistributionStatus.success); + }); + + it('stores the same rolling PR URL on the first distribution row', () => { + expect(firstRow?.prUrl).toBe(ROLLING_PR_URL); + }); + + it('stores the same rolling PR URL on the second distribution row', () => { + expect(secondRow?.prUrl).toBe(ROLLING_PR_URL); + }); + + describe('the source block on the committed descriptor', () => { + let firstPluginEntry: { + slug?: string; + source?: { source?: string; url?: string; path?: string }; + }; + let secondPluginEntry: { + slug?: string; + source?: { source?: string; url?: string; path?: string }; + }; + + beforeEach(() => { + const firstCallFiles = commitToGitSpy.mock.calls[0][1] as Array<{ + path: string; + content: string; + }>; + const secondCallFiles = commitToGitSpy.mock.calls[1][1] as Array<{ + path: string; + content: string; + }>; + const firstDescriptorFile = firstCallFiles.find( + (f) => f.path === '.claude-plugin/marketplace.json', + ); + const secondDescriptorFile = secondCallFiles.find( + (f) => f.path === '.claude-plugin/marketplace.json', + ); + const firstParsed = JSON.parse( + firstDescriptorFile?.content ?? '{}', + ) as { + plugins: Array<typeof firstPluginEntry>; + }; + const secondParsed = JSON.parse( + secondDescriptorFile?.content ?? '{}', + ) as { + plugins: Array<typeof secondPluginEntry>; + }; + firstPluginEntry = + firstParsed.plugins.find((p) => p.slug === 'security') ?? {}; + secondPluginEntry = + secondParsed.plugins.find((p) => p.slug === 'security') ?? {}; + }); + + it('writes a git-subdir source on the first publish entry', () => { + expect(firstPluginEntry.source).toEqual({ + source: 'git-subdir', + url: 'https://github.com/anthropic/marketplace.git', + path: 'plugins/security', + }); + }); + + it('writes a git-subdir source on the second publish entry', () => { + expect(secondPluginEntry.source).toEqual({ + source: 'git-subdir', + url: 'https://github.com/anthropic/marketplace.git', + path: 'plugins/security', + }); + }); + }); + + describe('branch resolution', () => { + it('probes the rolling sync branch presence on both publishes', () => { + const probedBranches = checkBranchExistsSpy.mock.calls.map( + (call) => call[3] as string, + ); + expect(probedBranches.every((b) => b === 'packmind/sync')).toBe(true); + }); + + it('reads the descriptor + lock from the rolling sync branch after the first publish has landed', () => { + const branchesUsedToFetchAfterFirstCommit = + getFileFromRepoSpy.mock.calls + .filter( + (call) => + call[1] === '.claude-plugin/marketplace.json' || + call[1] === 'packmind-lock.json', + ) + .map((call) => call[2] as string); + expect(branchesUsedToFetchAfterFirstCommit).toContain('packmind/sync'); + }); + }); + }); +}); diff --git a/packages/integration-tests/src/gitrepo-marketplace-isolation.spec.ts b/packages/integration-tests/src/gitrepo-marketplace-isolation.spec.ts new file mode 100644 index 000000000..c8b2879b0 --- /dev/null +++ b/packages/integration-tests/src/gitrepo-marketplace-isolation.spec.ts @@ -0,0 +1,254 @@ +import { + GitProvider, + GitRepo, + GitRepoAlreadyLinkedAsStandardError, + IGitPort, + createGitRepoId, +} from '@packmind/types'; +import { gitProviderFactory } from '@packmind/git/test'; +import { GitRepoSchema } from '@packmind/git'; +import { v4 as uuidv4 } from 'uuid'; +import { createIntegrationTestFixture } from './helpers/createIntegrationTestFixture'; +import { DataFactory } from './helpers/DataFactory'; +import { integrationTestSchemas } from './helpers/makeIntegrationTestDataSource'; +import { TestApp } from './helpers/TestApp'; + +const ANTHROPIC_MARKETPLACE_DESCRIPTOR_JSON = JSON.stringify({ + name: 'Anthropic Marketplace', + vendor: 'anthropic', + plugins: [{ name: 'plugin-alpha' }], +}); + +describe('GitRepo finder isolation (AC-15)', () => { + const fixture = createIntegrationTestFixture(integrationTestSchemas); + + let testApp: TestApp; + let dataFactory: DataFactory; + let gitProvider: GitProvider; + let standardGitRepo: GitRepo; + let marketplaceGitRepo: GitRepo; + let gitPort: IGitPort; + let scheduleRecurringSpy: jest.SpyInstance; + let addJobSpy: jest.SpyInstance; + + beforeAll(() => fixture.initialize()); + + beforeEach(async () => { + testApp = new TestApp(fixture.datasource); + await testApp.initialize(); + + dataFactory = new DataFactory(testApp); + await dataFactory.withUserAndOrganization({ email: 'admin@example.com' }); + + ({ gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + )); + + // Standard-typed repo via the normal AddGitRepo flow (which also creates + // a default Target). + ({ gitRepo: standardGitRepo } = await dataFactory.withGitRepo({ + owner: 'octocat', + repo: 'hello-world', + branch: 'main', + providerId: gitProvider.id, + })); + + gitPort = testApp.gitHexa.getAdapter(); + + // Marketplace-typed repo — seed directly into the DB to keep the test + // focused on finder isolation (i.e. don't drive LinkMarketplaceUseCase + // here; that's covered in marketplaces-lifecycle.spec.ts). + const gitRepoRepo = fixture.datasource.getRepository(GitRepoSchema); + marketplaceGitRepo = (await gitRepoRepo.save({ + id: createGitRepoId(uuidv4()), + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + providerId: gitProvider.id, + type: 'marketplace', + })) as GitRepo; + + // Stub the file fetch + reconciliation job for the LinkMarketplaceUseCase + // collision path exercised at the end of this file. + jest.spyOn(gitPort, 'getFileFromRepo').mockResolvedValue({ + sha: 'mock-sha', + content: ANTHROPIC_MARKETPLACE_DESCRIPTOR_JSON, + }); + const deploymentsAdapter = testApp.deploymentsHexa.getAdapter(); + const adapterAny = deploymentsAdapter as unknown as { + _linkMarketplaceUseCase: { + reconciliationJob: { + scheduleRecurring: () => Promise<void>; + addJob: () => Promise<string>; + }; + }; + }; + scheduleRecurringSpy = jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'scheduleRecurring', + ) + .mockResolvedValue(undefined); + addJobSpy = jest + .spyOn(adapterAny._linkMarketplaceUseCase.reconciliationJob, 'addJob') + .mockResolvedValue('mock-job-id'); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + describe('GitRepo finder defaults filter type=standard', () => { + describe('getOrganizationRepositories', () => { + let ids: string[]; + + beforeEach(async () => { + const repos = await gitPort.getOrganizationRepositories( + dataFactory.organization.id, + ); + + ids = repos.map((r) => r.id); + }); + + it('includes the standard repo', () => { + expect(ids).toContain(standardGitRepo.id); + }); + + it('excludes the marketplace repo', () => { + expect(ids).not.toContain(marketplaceGitRepo.id); + }); + }); + + describe('listRepos for the provider', () => { + let ids: string[]; + + beforeEach(async () => { + const repos = await gitPort.listRepos(gitProvider.id); + + ids = repos.map((r) => r.id); + }); + + it('includes the standard repo', () => { + expect(ids).toContain(standardGitRepo.id); + }); + + it('excludes the marketplace repo', () => { + expect(ids).not.toContain(marketplaceGitRepo.id); + }); + }); + + it('findGitRepoByOwnerAndRepo never returns the marketplace row by its coords', async () => { + const found = await gitPort.findGitRepoByOwnerAndRepo( + marketplaceGitRepo.owner, + marketplaceGitRepo.repo, + ); + + expect(found).toBeNull(); + }); + + it('getRepositoryById on a marketplace id returns null (filtered)', async () => { + const found = await gitPort.getRepositoryById(marketplaceGitRepo.id); + + expect(found).toBeNull(); + }); + }); + + describe('AddTarget refuses a marketplace-typed gitRepoId', () => { + // AddTargetUseCase resolves the gitRepo via GitRepoService.findGitRepoById + // (standard-only). Passing a marketplace id should be treated as + // "repository not found", confirming the marketplace cannot accidentally + // become a deployment target. + describe('when the gitRepoId is marketplace-typed', () => { + it('throws', async () => { + await expect( + testApp.deploymentsHexa.getAdapter().addTarget({ + ...dataFactory.packmindCommand(), + name: 'Should not be allowed', + path: '/', + gitRepoId: marketplaceGitRepo.id, + }), + ).rejects.toThrow(/Repository with id .* not found/); + }); + }); + + it('still allows AddTarget on the standard-typed gitRepoId', async () => { + const target = await testApp.deploymentsHexa.getAdapter().addTarget({ + ...dataFactory.packmindCommand(), + name: 'Extra Standard Target', + path: '/packages/foo', + gitRepoId: standardGitRepo.id, + }); + + expect(target.gitRepoId).toBe(standardGitRepo.id); + }); + }); + + describe('Marketplace-aware finders see only marketplace rows', () => { + describe('the marketplace row reached via the underlying GitRepo schema query', () => { + // Sanity check that the marketplace row really exists, so the + // assertions above are meaningful (rather than passing because the + // fixture is empty). + let found: GitRepo | null; + + beforeEach(async () => { + const gitRepoRepo = fixture.datasource.getRepository(GitRepoSchema); + found = (await gitRepoRepo.findOne({ + where: { id: marketplaceGitRepo.id }, + })) as GitRepo | null; + }); + + it('is reachable', () => { + expect(found).not.toBeNull(); + }); + + it('has type marketplace', () => { + expect(found?.type).toBe('marketplace'); + }); + }); + }); + + describe('LinkMarketplace rejects collision with an existing standard repo', () => { + describe('when the same coords are already standard', () => { + it('throws GitRepoAlreadyLinkedAsStandardError', async () => { + await expect( + testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: standardGitRepo.owner, + repo: standardGitRepo.repo, + branch: standardGitRepo.branch, + name: 'Should-be-rejected Marketplace', + }), + ).rejects.toThrow(GitRepoAlreadyLinkedAsStandardError); + }); + }); + + describe('when the link is rejected', () => { + beforeEach(async () => { + try { + await testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: standardGitRepo.owner, + repo: standardGitRepo.repo, + branch: standardGitRepo.branch, + name: 'Should-be-rejected Marketplace', + }); + } catch { + // expected + } + }); + + it('does not schedule a recurring reconciliation job', () => { + expect(scheduleRecurringSpy).not.toHaveBeenCalled(); + }); + + it('does not enqueue a reconciliation job', () => { + expect(addJobSpy).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/integration-tests/src/helpers/TestApp.ts b/packages/integration-tests/src/helpers/TestApp.ts index 4409a2c3a..92b8b8c0d 100644 --- a/packages/integration-tests/src/helpers/TestApp.ts +++ b/packages/integration-tests/src/helpers/TestApp.ts @@ -1,7 +1,8 @@ import { AccountsHexa, AccountsHexaOpts } from '@packmind/accounts'; import { CodingAgentHexa } from '@packmind/coding-agent'; import { DeploymentsHexa } from '@packmind/deployments'; -import { AmplitudeHexa, LinterHexa } from '@packmind/editions'; +import { AmplitudeHexa } from '@packmind/amplitude'; +import { LinterHexa } from '@packmind/linter'; import { GitHexa } from '@packmind/git'; import { LlmHexa } from '@packmind/llm'; import { diff --git a/packages/integration-tests/src/helpers/marketplaceReconciliation.ts b/packages/integration-tests/src/helpers/marketplaceReconciliation.ts new file mode 100644 index 000000000..2bb86dd5e --- /dev/null +++ b/packages/integration-tests/src/helpers/marketplaceReconciliation.ts @@ -0,0 +1,43 @@ +import { MarketplaceId } from '@packmind/types'; +import { TestApp } from './TestApp'; + +/** + * Minimal shape of the reconciliation job the integration tests drive inline. + */ +interface ReconciliationJobHandle { + reconcileNow: (marketplaceId: MarketplaceId) => Promise<unknown>; +} + +/** + * Reaches the `MarketplaceReconciliationDelayedJob` instance hanging off the + * deployments adapter's link use case — the same instance specs already stub + * `scheduleRecurring`/`addJob` on. + */ +function getReconciliationJob(testApp: TestApp): ReconciliationJobHandle { + return ( + testApp.deploymentsHexa.getAdapter() as unknown as { + _linkMarketplaceUseCase: { reconciliationJob: ReconciliationJobHandle }; + } + )._linkMarketplaceUseCase.reconciliationJob; +} + +/** + * Runs a reconciliation sweep inline for a marketplace, mirroring the + * member-triggered "Sync now" action and the BullMQ worker. + * + * Since the Jun-2026 refactor, `publishPackageOnMarketplace` lands the + * distribution in `pending_merge`; the reconciliation job confirms it to + * `success` once the published lock entry's content hash is present on the + * default branch. Specs that assert the post-merge `success` state must + * therefore drive a reconciliation after publishing, exactly as production + * does once the rolling sync PR merges. + * + * The served `packmind-lock.json` must contain the published plugin's entry + * (matching the distribution's `contentHash`) for the confirmation to fire. + */ +export async function runMarketplaceReconciliation( + testApp: TestApp, + marketplaceId: MarketplaceId, +): Promise<void> { + await getReconciliationJob(testApp).reconcileNow(marketplaceId); +} diff --git a/packages/integration-tests/src/helpers/marketplaceRemovalJob.ts b/packages/integration-tests/src/helpers/marketplaceRemovalJob.ts new file mode 100644 index 000000000..8683ff326 --- /dev/null +++ b/packages/integration-tests/src/helpers/marketplaceRemovalJob.ts @@ -0,0 +1,95 @@ +import { + GitCommit, + IGitPort, + MarketplaceDistributionId, + MarketplaceId, + OrganizationId, + PackageId, + UserId, +} from '@packmind/types'; +import { TestApp } from './TestApp'; + +/** + * Shape of the removal job exposed by the deployments adapter. Only the two + * methods the integration tests drive are typed here. + */ +interface RemovalJobHandle { + addJob: (...args: unknown[]) => Promise<string>; + runJob: ( + jobId: string, + input: { + marketplaceDistributionId: MarketplaceDistributionId; + marketplaceId: MarketplaceId; + packageId: PackageId; + organizationId: OrganizationId; + userId: UserId; + }, + controller: AbortController, + ) => Promise<unknown>; +} + +/** + * Prepares the `RemovePluginFromMarketplaceDelayedJob` for deterministic + * in-test driving and returns its handle. + * + * Since the Jun-2026 refactor, `markPluginForRemoval` no longer flips the + * distribution to `to_be_removed`; that flip is owned by the removal job once + * the deletion lands on the rolling `packmind/sync` branch. Tests that need a + * `to_be_removed` row must therefore run the job explicitly, mirroring what the + * BullMQ worker does in production. + * + * This stubs: + * - `addJob` to a no-op so the enqueue triggered by `markPluginForRemoval` + * does not also reach a real queue / async worker (mirrors the + * reconciliation-job stub pattern), and + * - the write-side git operations the job performs on the sync branch so + * `runJob` commits successfully and reaches the status flip. Descriptor + * reads (`getFileFromRepo`) are left to the caller, which controls the + * descriptor content per scenario. + * + * Call this in `beforeEach` BEFORE invoking `markPluginForRemoval`, then call + * `runMarketplaceRemovalJob` once the plugin has been marked. + */ +export function prepareMarketplaceRemovalJob( + testApp: TestApp, + gitPort: IGitPort, +): RemovalJobHandle { + jest.spyOn(gitPort, 'checkBranchExists').mockResolvedValue(false); + jest.spyOn(gitPort, 'createBranchFromBase').mockResolvedValue(undefined); + jest + .spyOn(gitPort, 'commitToGit') + .mockResolvedValue({ sha: 'removal-commit-sha' } as GitCommit); + jest.spyOn(gitPort, 'openOrUpdatePullRequest').mockResolvedValue({ + url: 'https://github.com/anthropic/marketplace/pull/1', + number: 1, + wasCreated: true, + }); + + const job = ( + testApp.deploymentsHexa.getAdapter() as unknown as { + getRemovePluginFromMarketplaceJob: () => RemovalJobHandle; + } + ).getRemovePluginFromMarketplaceJob(); + + jest.spyOn(job, 'addJob').mockResolvedValue('mock-removal-job-id'); + + return job; +} + +/** + * Runs the removal job inline for a single distribution, flipping it from + * `success` to `to_be_removed` exactly as the production worker does after the + * deletion lands on the sync branch. + */ +export async function runMarketplaceRemovalJob( + job: RemovalJobHandle, + input: { + marketplaceDistributionId: MarketplaceDistributionId; + marketplaceId: MarketplaceId; + packageId: PackageId; + organizationId: OrganizationId; + userId: UserId; + }, +): Promise<void> { + await job.runJob('manual-removal-job-id', input, new AbortController()); +} diff --git a/packages/integration-tests/src/marketplace-plugin-removal-drift.spec.ts b/packages/integration-tests/src/marketplace-plugin-removal-drift.spec.ts new file mode 100644 index 000000000..0745c4aff --- /dev/null +++ b/packages/integration-tests/src/marketplace-plugin-removal-drift.spec.ts @@ -0,0 +1,199 @@ +import { + DistributionStatus, + GitProvider, + IGitPort, + Marketplace, + MarketplaceDistribution, + MarketplaceId, + Package, +} from '@packmind/types'; +import { gitProviderFactory } from '@packmind/git/test'; +import { + MarketplaceDistributionSchema, + MarketplaceSchema, +} from '@packmind/deployments'; +import { v4 as uuidv4 } from 'uuid'; +import { createIntegrationTestFixture } from './helpers/createIntegrationTestFixture'; +import { DataFactory } from './helpers/DataFactory'; +import { integrationTestSchemas } from './helpers/makeIntegrationTestDataSource'; +import { TestApp } from './helpers/TestApp'; + +const INITIAL_DESCRIPTOR_JSON = JSON.stringify({ + name: 'Anthropic Marketplace', + vendor: 'anthropic', + version: '1.0.0', + plugins: [ + { name: 'plugin-alpha', version: '0.1.0' }, + { name: 'plugin-beta', version: '0.2.0' }, + ], +}); + +/** + * Same descriptor with `plugin-alpha` removed directly by the marketplace + * owner — no prior `to_be_removed` row exists in Packmind. The + * reconciliation job must flag drift and surface the missing slug in + * `driftedPluginSlugs` so the UI can warn admins (AC9). + */ +const DESCRIPTOR_WITHOUT_ALPHA_JSON = JSON.stringify({ + name: 'Anthropic Marketplace', + vendor: 'anthropic', + version: '1.0.0', + plugins: [{ name: 'plugin-beta', version: '0.2.0' }], +}); + +describe('Marketplace plugin removal: drift detection on direct repo delete', () => { + const fixture = createIntegrationTestFixture(integrationTestSchemas); + + let testApp: TestApp; + let dataFactory: DataFactory; + let gitProvider: GitProvider; + let gitPort: IGitPort; + let marketplaceId: MarketplaceId; + let pkg: Package; + let liveDistributionRow: MarketplaceDistribution; + + beforeAll(() => fixture.initialize()); + + beforeEach(async () => { + testApp = new TestApp(fixture.datasource); + await testApp.initialize(); + + dataFactory = new DataFactory(testApp); + await dataFactory.withUserAndOrganization({ email: 'admin@example.com' }); + + ({ gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + )); + + gitPort = testApp.gitHexa.getAdapter(); + jest.spyOn(gitPort, 'getFileFromRepo').mockResolvedValue({ + sha: 'mock-sha', + content: INITIAL_DESCRIPTOR_JSON, + }); + + const deploymentsAdapter = testApp.deploymentsHexa.getAdapter(); + const adapterAny = deploymentsAdapter as unknown as { + _linkMarketplaceUseCase: { + reconciliationJob: { + scheduleRecurring: () => Promise<void>; + addJob: () => Promise<string>; + cancelRecurring: () => Promise<void>; + runJob: ( + jobId: string, + input: { marketplaceId: MarketplaceId }, + controller: AbortController, + ) => Promise<unknown>; + }; + }; + }; + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'scheduleRecurring', + ) + .mockResolvedValue(undefined); + jest + .spyOn(adapterAny._linkMarketplaceUseCase.reconciliationJob, 'addJob') + .mockResolvedValue('mock-job-id'); + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'cancelRecurring', + ) + .mockResolvedValue(undefined); + + const linkResponse = await testApp.deploymentsHexa + .getAdapter() + .linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace', + }); + marketplaceId = linkResponse.id; + + const createPackageResponse = await testApp.deploymentsHexa + .getAdapter() + .createPackage({ + ...dataFactory.packmindCommand(), + spaceId: dataFactory.space.id, + name: 'Plugin Alpha Package', + description: 'Backing package for the alpha plugin', + recipeIds: [], + standardIds: [], + }); + pkg = createPackageResponse.package; + + // Seed a live `success` distribution for `plugin-alpha`. Crucially, + // we do NOT mark it for removal — the marketplace owner deletes the + // slug directly outside Packmind. + const marketplaceDistributionRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + const inserted = await marketplaceDistributionRepo.save({ + id: uuidv4(), + organizationId: dataFactory.organization.id, + marketplaceId, + packageId: pkg.id, + pluginSlug: 'plugin-alpha', + authorId: dataFactory.user.id, + status: DistributionStatus.success, + source: 'app', + }); + liveDistributionRow = inserted as unknown as MarketplaceDistribution; + + // Descriptor now omits the slug — direct repo delete simulation. + jest.spyOn(gitPort, 'getFileFromRepo').mockResolvedValue({ + sha: 'mock-sha-2', + content: DESCRIPTOR_WITHOUT_ALPHA_JSON, + }); + + await adapterAny._linkMarketplaceUseCase.reconciliationJob.runJob( + 'manual-job-id', + { marketplaceId }, + new AbortController(), + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + describe('after the reconciliation sweep', () => { + let reconciledMarketplace: Marketplace | null; + + beforeEach(async () => { + const marketplaceRepo = + fixture.datasource.getRepository(MarketplaceSchema); + reconciledMarketplace = (await marketplaceRepo.findOne({ + where: { id: marketplaceId }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + })) as any; + }); + + it('flags the marketplace as drift', () => { + expect(reconciledMarketplace?.state).toBe('drift'); + }); + + it('persists the missing slug in driftedPluginSlugs', () => { + expect(reconciledMarketplace?.descriptor?.driftedPluginSlugs).toEqual([ + 'plugin-alpha', + ]); + }); + + it('keeps the live distribution row in success status', async () => { + const distRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + const stillSuccess = (await distRepo.findOne({ + where: { id: liveDistributionRow.id }, + })) as MarketplaceDistribution | null; + expect(stillSuccess?.status).toBe(DistributionStatus.success); + }); + }); +}); diff --git a/packages/integration-tests/src/marketplace-plugin-removal-package-delete-cascade.spec.ts b/packages/integration-tests/src/marketplace-plugin-removal-package-delete-cascade.spec.ts new file mode 100644 index 000000000..67645c279 --- /dev/null +++ b/packages/integration-tests/src/marketplace-plugin-removal-package-delete-cascade.spec.ts @@ -0,0 +1,338 @@ +import { + DistributionStatus, + GitProvider, + IGitPort, + MarketplaceDistribution, + MarketplaceId, + MarketplacePluginRemovalInitiatedEvent, + MarketplacePluginRemovalInitiatedPayload, + Package, +} from '@packmind/types'; +import { gitProviderFactory } from '@packmind/git/test'; +import { MarketplaceDistributionSchema } from '@packmind/deployments'; +import { + PackmindEventEmitterService, + PackmindListener, +} from '@packmind/node-utils'; +import { v4 as uuidv4 } from 'uuid'; +import { createIntegrationTestFixture } from './helpers/createIntegrationTestFixture'; +import { DataFactory } from './helpers/DataFactory'; +import { integrationTestSchemas } from './helpers/makeIntegrationTestDataSource'; +import { TestApp } from './helpers/TestApp'; + +const ANTHROPIC_DESCRIPTOR_JSON_A = JSON.stringify({ + name: 'Marketplace A', + vendor: 'anthropic', + version: '1.0.0', + plugins: [{ name: 'plugin-alpha', version: '0.1.0' }], +}); + +const ANTHROPIC_DESCRIPTOR_JSON_B = JSON.stringify({ + name: 'Marketplace B', + vendor: 'anthropic', + version: '1.0.0', + plugins: [{ name: 'plugin-alpha', version: '0.1.0' }], +}); + +interface StubRemovalAdapter { + onMarketplacePluginRemovalInitiated( + payload: MarketplacePluginRemovalInitiatedPayload, + ): void; +} + +/** + * Polls `predicate` until it returns true or the timeout elapses. + * + * The package-delete cascade is dispatched fire-and-forget: the use case emits + * `PackagesDeletedEvent` via a synchronous event emit whose async handler then + * settles on its own microtask chain. A deterministic test must therefore wait + * on the observable outcome, not a guessed sleep duration. + */ +async function waitFor( + predicate: () => Promise<boolean> | boolean, + { + timeoutMs = 2000, + intervalMs = 10, + }: { timeoutMs?: number; intervalMs?: number } = {}, +): Promise<void> { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (await predicate()) { + return; + } + if (Date.now() >= deadline) { + throw new Error(`waitFor: condition not met within ${timeoutMs}ms`); + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} + +class StubRemovalListener extends PackmindListener<StubRemovalAdapter> { + protected registerHandlers(): void { + this.subscribe( + MarketplacePluginRemovalInitiatedEvent, + this.handleRemovalInitiated, + ); + } + + private handleRemovalInitiated = ( + event: MarketplacePluginRemovalInitiatedEvent, + ): void => { + this.adapter.onMarketplacePluginRemovalInitiated(event.payload); + }; +} + +describe('Marketplace plugin removal: package-delete cascade', () => { + const fixture = createIntegrationTestFixture(integrationTestSchemas); + + let testApp: TestApp; + let dataFactory: DataFactory; + let gitProvider: GitProvider; + let gitPort: IGitPort; + let marketplaceAId: MarketplaceId; + let marketplaceBId: MarketplaceId; + let pkg: Package; + let distributionRowA: MarketplaceDistribution; + let distributionRowB: MarketplaceDistribution; + let eventEmitterService: PackmindEventEmitterService; + let stubAdapter: jest.Mocked<StubRemovalAdapter>; + let stubListener: StubRemovalListener; + + beforeAll(() => fixture.initialize()); + + beforeEach(async () => { + testApp = new TestApp(fixture.datasource); + await testApp.initialize(); + + dataFactory = new DataFactory(testApp); + await dataFactory.withUserAndOrganization({ email: 'admin@example.com' }); + + ({ gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + )); + + gitPort = testApp.gitHexa.getAdapter(); + + // Both link calls hit the same stub, so we mock per-call to seed + // both marketplaces with distinct descriptors. + jest + .spyOn(gitPort, 'getFileFromRepo') + .mockResolvedValueOnce({ + sha: 'mock-sha-a', + content: ANTHROPIC_DESCRIPTOR_JSON_A, + }) + .mockResolvedValueOnce({ + sha: 'mock-sha-b', + content: ANTHROPIC_DESCRIPTOR_JSON_B, + }); + + const deploymentsAdapter = testApp.deploymentsHexa.getAdapter(); + const adapterAny = deploymentsAdapter as unknown as { + _linkMarketplaceUseCase: { + reconciliationJob: { + scheduleRecurring: () => Promise<void>; + addJob: () => Promise<string>; + cancelRecurring: () => Promise<void>; + }; + }; + getRemovePluginFromMarketplaceJob: () => { + addJob: () => Promise<string>; + }; + }; + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'scheduleRecurring', + ) + .mockResolvedValue(undefined); + jest + .spyOn(adapterAny._linkMarketplaceUseCase.reconciliationJob, 'addJob') + .mockResolvedValue('mock-job-id'); + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'cancelRecurring', + ) + .mockResolvedValue(undefined); + + // Stub the Git side-effect job the cascade enqueues per flipped + // distribution. Under the integration-test harness `addJob` runs the job + // inline (SyncJob), which would fire real, unmocked git operations + // mid-cascade — slow, non-deterministic, and prone to leaking past + // teardown. This test only asserts the DB transition + event emission, so + // stub it to a no-op (mirrors the reconciliation-job stub above). + jest + .spyOn(adapterAny.getRemovePluginFromMarketplaceJob(), 'addJob') + .mockResolvedValue('mock-removal-job-id'); + + const linkA = await testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace-a', + branch: 'main', + name: 'Marketplace A', + }); + marketplaceAId = linkA.id; + + const linkB = await testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace-b', + branch: 'main', + name: 'Marketplace B', + }); + marketplaceBId = linkB.id; + + const createPackageResponse = await testApp.deploymentsHexa + .getAdapter() + .createPackage({ + ...dataFactory.packmindCommand(), + spaceId: dataFactory.space.id, + name: 'Plugin Alpha Package', + description: 'Backing package published to two marketplaces', + recipeIds: [], + standardIds: [], + }); + pkg = createPackageResponse.package; + + // Seed `success` distributions in both marketplaces — the cascade + // listener must flip BOTH to `to_be_removed` and emit one event + // per affected distribution. + const marketplaceDistributionRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + const insertedA = await marketplaceDistributionRepo.save({ + id: uuidv4(), + organizationId: dataFactory.organization.id, + marketplaceId: marketplaceAId, + packageId: pkg.id, + pluginSlug: 'plugin-alpha', + authorId: dataFactory.user.id, + status: DistributionStatus.success, + source: 'app', + }); + const insertedB = await marketplaceDistributionRepo.save({ + id: uuidv4(), + organizationId: dataFactory.organization.id, + marketplaceId: marketplaceBId, + packageId: pkg.id, + pluginSlug: 'plugin-alpha', + authorId: dataFactory.user.id, + status: DistributionStatus.success, + source: 'app', + }); + distributionRowA = insertedA as unknown as MarketplaceDistribution; + distributionRowB = insertedB as unknown as MarketplaceDistribution; + + // Subscribe a stub listener AFTER the link path so we don't pick + // up unrelated events from the link flow. + eventEmitterService = testApp.registry.getService( + PackmindEventEmitterService, + ); + stubAdapter = { + onMarketplacePluginRemovalInitiated: jest.fn(), + }; + stubListener = new StubRemovalListener(stubAdapter); + stubListener.initialize(eventEmitterService); + + // Trigger the cascade via the public delete-batch use case. + await testApp.deploymentsHexa.getAdapter().deletePackagesBatch({ + ...dataFactory.packmindCommand(), + spaceId: dataFactory.space.id, + packageIds: [pkg.id], + }); + + // Wait for the fire-and-forget cascade to settle on its observable + // outcome — both seeded distributions flipped to `to_be_removed` and one + // removal event emitted per distribution — rather than guessing a sleep + // duration. The iteration order of the two distributions is not + // deterministic, so a fixed sleep would race whichever row settles last. + const cascadeDistRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + await waitFor(async () => { + const [rowA, rowB] = await Promise.all([ + cascadeDistRepo.findOne({ where: { id: distributionRowA.id } }), + cascadeDistRepo.findOne({ where: { id: distributionRowB.id } }), + ]); + return ( + rowA?.status === DistributionStatus.to_be_removed && + rowB?.status === DistributionStatus.to_be_removed && + stubAdapter.onMarketplacePluginRemovalInitiated.mock.calls.length === 2 + ); + }); + }); + + afterEach(async () => { + eventEmitterService.removeAllListeners(); + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + describe('after the package-delete cascade', () => { + describe('distribution row A', () => { + let cascadedRow: MarketplaceDistribution | null; + + beforeEach(async () => { + const distRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + cascadedRow = (await distRepo.findOne({ + where: { id: distributionRowA.id }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + })) as any; + }); + + it('transitions to to_be_removed', () => { + expect(cascadedRow?.status).toBe(DistributionStatus.to_be_removed); + }); + }); + + describe('distribution row B', () => { + let cascadedRow: MarketplaceDistribution | null; + + beforeEach(async () => { + const distRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + cascadedRow = (await distRepo.findOne({ + where: { id: distributionRowB.id }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + })) as any; + }); + + it('transitions to to_be_removed', () => { + expect(cascadedRow?.status).toBe(DistributionStatus.to_be_removed); + }); + }); + + describe('MarketplacePluginRemovalInitiatedEvent emissions', () => { + it('fires exactly twice — one per affected distribution', () => { + expect( + stubAdapter.onMarketplacePluginRemovalInitiated, + ).toHaveBeenCalledTimes(2); + }); + + describe('every emitted payload', () => { + let payloads: MarketplacePluginRemovalInitiatedPayload[]; + + beforeEach(() => { + payloads = + stubAdapter.onMarketplacePluginRemovalInitiated.mock.calls.map( + ([payload]) => payload, + ); + }); + + it('carries trigger="from_packmind_package" on every emission', () => { + expect( + payloads.every((p) => p.trigger === 'from_packmind_package'), + ).toBe(true); + }); + }); + }); + }); +}); diff --git a/packages/integration-tests/src/marketplace-plugin-removal-routes.spec.ts b/packages/integration-tests/src/marketplace-plugin-removal-routes.spec.ts new file mode 100644 index 000000000..29aa647f5 --- /dev/null +++ b/packages/integration-tests/src/marketplace-plugin-removal-routes.spec.ts @@ -0,0 +1,320 @@ +import { + DistributionStatus, + GitProvider, + IGitPort, + MarketplaceDistribution, + MarketplaceId, + MarketplacePluginRemovalInitiatedEvent, + MarketplacePluginRemovalInitiatedPayload, + Package, +} from '@packmind/types'; +import { gitProviderFactory } from '@packmind/git/test'; +import { MarketplaceDistributionSchema } from '@packmind/deployments'; +import { + PackmindEventEmitterService, + PackmindListener, +} from '@packmind/node-utils'; +import { v4 as uuidv4 } from 'uuid'; +import { createIntegrationTestFixture } from './helpers/createIntegrationTestFixture'; +import { DataFactory } from './helpers/DataFactory'; +import { integrationTestSchemas } from './helpers/makeIntegrationTestDataSource'; +import { + prepareMarketplaceRemovalJob, + runMarketplaceRemovalJob, +} from './helpers/marketplaceRemovalJob'; +import { TestApp } from './helpers/TestApp'; + +type RemovalJobHandle = ReturnType<typeof prepareMarketplaceRemovalJob>; + +const DESCRIPTOR_JSON = JSON.stringify({ + name: 'Anthropic Marketplace', + vendor: 'anthropic', + version: '1.0.0', + plugins: [{ name: 'plugin-alpha', version: '0.1.0' }], +}); + +interface StubRemovalAdapter { + onMarketplacePluginRemovalInitiated( + payload: MarketplacePluginRemovalInitiatedPayload, + ): void; +} + +class StubRemovalListener extends PackmindListener<StubRemovalAdapter> { + protected registerHandlers(): void { + this.subscribe( + MarketplacePluginRemovalInitiatedEvent, + this.handleRemovalInitiated, + ); + } + + private handleRemovalInitiated = ( + event: MarketplacePluginRemovalInitiatedEvent, + ): void => { + this.adapter.onMarketplacePluginRemovalInitiated(event.payload); + }; +} + +describe('Marketplace plugin removal: manual-trigger routes', () => { + const fixture = createIntegrationTestFixture(integrationTestSchemas); + + let testApp: TestApp; + let dataFactory: DataFactory; + let gitProvider: GitProvider; + let gitPort: IGitPort; + let marketplaceId: MarketplaceId; + let pkg: Package; + let eventEmitterService: PackmindEventEmitterService; + let stubAdapter: jest.Mocked<StubRemovalAdapter>; + let removalJob: RemovalJobHandle; + + beforeAll(() => fixture.initialize()); + + beforeEach(async () => { + testApp = new TestApp(fixture.datasource); + await testApp.initialize(); + + dataFactory = new DataFactory(testApp); + await dataFactory.withUserAndOrganization({ email: 'admin@example.com' }); + + ({ gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + )); + + gitPort = testApp.gitHexa.getAdapter(); + jest + .spyOn(gitPort, 'getFileFromRepo') + .mockImplementation(async (_repo, path) => + path === 'packmind-lock.json' + ? null + : { sha: 'mock-sha', content: DESCRIPTOR_JSON }, + ); + + const deploymentsAdapter = testApp.deploymentsHexa.getAdapter(); + const adapterAny = deploymentsAdapter as unknown as { + _linkMarketplaceUseCase: { + reconciliationJob: { + scheduleRecurring: () => Promise<void>; + addJob: () => Promise<string>; + cancelRecurring: () => Promise<void>; + }; + }; + }; + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'scheduleRecurring', + ) + .mockResolvedValue(undefined); + jest + .spyOn(adapterAny._linkMarketplaceUseCase.reconciliationJob, 'addJob') + .mockResolvedValue('mock-job-id'); + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'cancelRecurring', + ) + .mockResolvedValue(undefined); + + const linkResponse = await testApp.deploymentsHexa + .getAdapter() + .linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace', + }); + marketplaceId = linkResponse.id; + + const createPackageResponse = await testApp.deploymentsHexa + .getAdapter() + .createPackage({ + ...dataFactory.packmindCommand(), + spaceId: dataFactory.space.id, + name: 'Plugin Alpha Package', + description: 'Backing package for the alpha plugin', + recipeIds: [], + standardIds: [], + }); + pkg = createPackageResponse.package; + + // Stub listener subscribes BEFORE either manual route is invoked + // so we capture every emitted event. + eventEmitterService = testApp.registry.getService( + PackmindEventEmitterService, + ); + stubAdapter = { + onMarketplacePluginRemovalInitiated: jest.fn(), + }; + new StubRemovalListener(stubAdapter).initialize(eventEmitterService); + + // Since the removal-status refactor, `markPluginForRemoval` leaves the row + // at `success` and enqueues the job that flips it to `to_be_removed` after + // committing to the sync branch. Prepare the job so each scenario can drive + // it inline after marking. + removalJob = prepareMarketplaceRemovalJob(testApp, gitPort); + }); + + afterEach(async () => { + eventEmitterService.removeAllListeners(); + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + describe('by-distributionId trigger', () => { + let distributionRow: MarketplaceDistribution; + + beforeEach(async () => { + const marketplaceDistributionRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + const inserted = await marketplaceDistributionRepo.save({ + id: uuidv4(), + organizationId: dataFactory.organization.id, + marketplaceId, + packageId: pkg.id, + pluginSlug: 'plugin-alpha', + authorId: dataFactory.user.id, + status: DistributionStatus.success, + source: 'app', + }); + distributionRow = inserted as unknown as MarketplaceDistribution; + + await testApp.deploymentsHexa.getAdapter().markPluginForRemoval({ + ...dataFactory.packmindCommand(), + marketplaceId, + distributionId: distributionRow.id, + }); + + // The status flip now happens in the removal job once the deletion + // lands on the sync branch — drive it inline. + await runMarketplaceRemovalJob(removalJob, { + marketplaceDistributionId: distributionRow.id, + marketplaceId, + packageId: pkg.id, + organizationId: dataFactory.organization.id, + userId: dataFactory.user.id, + }); + }); + + describe('mutated distribution row', () => { + let mutated: MarketplaceDistribution | null; + + beforeEach(async () => { + const distRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + mutated = (await distRepo.findOne({ + where: { id: distributionRow.id }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + })) as any; + }); + + it('lands in to_be_removed', () => { + expect(mutated?.status).toBe(DistributionStatus.to_be_removed); + }); + }); + + describe('the emitted event', () => { + it('fires exactly once', () => { + expect( + stubAdapter.onMarketplacePluginRemovalInitiated, + ).toHaveBeenCalledTimes(1); + }); + + describe('payload', () => { + let payload: MarketplacePluginRemovalInitiatedPayload; + + beforeEach(() => { + payload = + stubAdapter.onMarketplacePluginRemovalInitiated.mock.calls[0][0]; + }); + + it('carries trigger="from_marketplace"', () => { + expect(payload.trigger).toBe('from_marketplace'); + }); + }); + }); + }); + + describe('by-packageId trigger', () => { + let distributionRow: MarketplaceDistribution; + + beforeEach(async () => { + const marketplaceDistributionRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + const inserted = await marketplaceDistributionRepo.save({ + id: uuidv4(), + organizationId: dataFactory.organization.id, + marketplaceId, + packageId: pkg.id, + pluginSlug: 'plugin-alpha', + authorId: dataFactory.user.id, + status: DistributionStatus.success, + source: 'app', + }); + distributionRow = inserted as unknown as MarketplaceDistribution; + + // No `distributionId` — only `packageId`. The use case resolves + // the latest successful (package, marketplace) distribution. + await testApp.deploymentsHexa.getAdapter().markPluginForRemoval({ + ...dataFactory.packmindCommand(), + marketplaceId, + packageId: pkg.id, + }); + + // The status flip now happens in the removal job once the deletion + // lands on the sync branch — drive it inline. + await runMarketplaceRemovalJob(removalJob, { + marketplaceDistributionId: distributionRow.id, + marketplaceId, + packageId: pkg.id, + organizationId: dataFactory.organization.id, + userId: dataFactory.user.id, + }); + }); + + describe('mutated distribution row', () => { + let mutated: MarketplaceDistribution | null; + + beforeEach(async () => { + const distRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + mutated = (await distRepo.findOne({ + where: { id: distributionRow.id }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + })) as any; + }); + + it('lands in to_be_removed', () => { + expect(mutated?.status).toBe(DistributionStatus.to_be_removed); + }); + }); + + describe('the emitted event', () => { + it('fires exactly once', () => { + expect( + stubAdapter.onMarketplacePluginRemovalInitiated, + ).toHaveBeenCalledTimes(1); + }); + + describe('payload', () => { + let payload: MarketplacePluginRemovalInitiatedPayload; + + beforeEach(() => { + payload = + stubAdapter.onMarketplacePluginRemovalInitiated.mock.calls[0][0]; + }); + + it('carries trigger="from_marketplace"', () => { + expect(payload.trigger).toBe('from_marketplace'); + }); + }); + }); + }); +}); diff --git a/packages/integration-tests/src/marketplace-plugin-removal.spec.ts b/packages/integration-tests/src/marketplace-plugin-removal.spec.ts new file mode 100644 index 000000000..b974a9291 --- /dev/null +++ b/packages/integration-tests/src/marketplace-plugin-removal.spec.ts @@ -0,0 +1,256 @@ +import { + DistributionStatus, + GitProvider, + IGitPort, + Marketplace, + MarketplaceDistribution, + MarketplaceId, + Package, +} from '@packmind/types'; +import { gitProviderFactory } from '@packmind/git/test'; +import { + MarketplaceDistributionSchema, + MarketplaceSchema, +} from '@packmind/deployments'; +import { v4 as uuidv4 } from 'uuid'; +import { createIntegrationTestFixture } from './helpers/createIntegrationTestFixture'; +import { DataFactory } from './helpers/DataFactory'; +import { integrationTestSchemas } from './helpers/makeIntegrationTestDataSource'; +import { + prepareMarketplaceRemovalJob, + runMarketplaceRemovalJob, +} from './helpers/marketplaceRemovalJob'; +import { TestApp } from './helpers/TestApp'; + +/** + * Anthropic-shape descriptor seeded by the link path. Plugin slug + * `plugin-alpha` is the one we will mark for removal, then reconcile. + */ +const INITIAL_DESCRIPTOR_JSON = JSON.stringify({ + name: 'Anthropic Marketplace', + vendor: 'anthropic', + version: '1.0.0', + plugins: [ + { name: 'plugin-alpha', version: '0.1.0' }, + { name: 'plugin-beta', version: '0.2.0' }, + ], +}); + +/** + * Post-removal descriptor where the slug for `plugin-alpha` has been + * deleted by the marketplace owner (their cleanup PR landed). The + * reconciliation job should transition the `to_be_removed` row to + * terminal `removed` and keep the marketplace `healthy`. + */ +const POST_REMOVAL_DESCRIPTOR_JSON = JSON.stringify({ + name: 'Anthropic Marketplace', + vendor: 'anthropic', + version: '1.0.0', + plugins: [{ name: 'plugin-beta', version: '0.2.0' }], +}); + +type RemovalJobHandle = ReturnType<typeof prepareMarketplaceRemovalJob>; + +describe('Marketplace plugin removal: reconciliation success path', () => { + const fixture = createIntegrationTestFixture(integrationTestSchemas); + + let testApp: TestApp; + let dataFactory: DataFactory; + let gitProvider: GitProvider; + let gitPort: IGitPort; + let marketplaceId: MarketplaceId; + let pkg: Package; + let distributionRow: MarketplaceDistribution; + let removalJob: RemovalJobHandle; + + beforeAll(() => fixture.initialize()); + + beforeEach(async () => { + testApp = new TestApp(fixture.datasource); + await testApp.initialize(); + + dataFactory = new DataFactory(testApp); + await dataFactory.withUserAndOrganization({ email: 'admin@example.com' }); + + ({ gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + )); + + gitPort = testApp.gitHexa.getAdapter(); + jest + .spyOn(gitPort, 'getFileFromRepo') + .mockImplementation(async (_repo, path) => + path === 'packmind-lock.json' + ? null + : { sha: 'mock-sha', content: INITIAL_DESCRIPTOR_JSON }, + ); + + // Stub the BullMQ-backed reconciliation job so the link path does + // not try to reach a real queue. We invoke `runJob` directly later. + const deploymentsAdapter = testApp.deploymentsHexa.getAdapter(); + const adapterAny = deploymentsAdapter as unknown as { + _linkMarketplaceUseCase: { + reconciliationJob: { + scheduleRecurring: () => Promise<void>; + addJob: () => Promise<string>; + cancelRecurring: () => Promise<void>; + }; + }; + }; + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'scheduleRecurring', + ) + .mockResolvedValue(undefined); + jest + .spyOn(adapterAny._linkMarketplaceUseCase.reconciliationJob, 'addJob') + .mockResolvedValue('mock-job-id'); + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'cancelRecurring', + ) + .mockResolvedValue(undefined); + + // Step 1 — link the marketplace through the real use case so the + // descriptor + state ('healthy') are persisted as in production. + const linkResponse = await testApp.deploymentsHexa + .getAdapter() + .linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace', + }); + marketplaceId = linkResponse.id; + + // Step 2 — create a real Packmind package so the cascade listener + // can resolve a slug (used in the mark-for-removal event payload). + const createPackageResponse = await testApp.deploymentsHexa + .getAdapter() + .createPackage({ + ...dataFactory.packmindCommand(), + spaceId: dataFactory.space.id, + name: 'Plugin Alpha Package', + description: 'Backing package for the alpha plugin', + recipeIds: [], + standardIds: [], + }); + pkg = createPackageResponse.package; + + // Step 3 — seed a `success`-state MarketplaceDistribution row for + // (marketplace, package, 'plugin-alpha'). Inserting directly via + // TypeORM keeps the test focused on the removal/reconciliation + // pipeline (the render flow is exercised elsewhere). + const marketplaceDistributionRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + const inserted = await marketplaceDistributionRepo.save({ + id: uuidv4(), + organizationId: dataFactory.organization.id, + marketplaceId, + packageId: pkg.id, + pluginSlug: 'plugin-alpha', + authorId: dataFactory.user.id, + status: DistributionStatus.success, + source: 'app', + }); + distributionRow = inserted as unknown as MarketplaceDistribution; + + removalJob = prepareMarketplaceRemovalJob(testApp, gitPort); + + // Step 4 — admin marks the plugin for removal (manual trigger), then the + // removal job commits the deletion to the sync branch and flips the row to + // `to_be_removed`. `markPluginForRemoval` no longer flips it directly, so + // the job must be driven for the reconciliation cross-check to find a + // `to_be_removed` row. + await testApp.deploymentsHexa.getAdapter().markPluginForRemoval({ + ...dataFactory.packmindCommand(), + marketplaceId, + distributionId: distributionRow.id, + }); + await runMarketplaceRemovalJob(removalJob, { + marketplaceDistributionId: distributionRow.id, + marketplaceId, + packageId: pkg.id, + organizationId: dataFactory.organization.id, + userId: dataFactory.user.id, + }); + + // Step 5 — descriptor now reflects the marketplace cleanup PR (the + // slug has been dropped). Run the reconciliation job directly so + // the cross-check transitions the row to `removed`. + jest.spyOn(gitPort, 'getFileFromRepo').mockResolvedValue({ + sha: 'mock-sha-2', + content: POST_REMOVAL_DESCRIPTOR_JSON, + }); + + const reconciliationJob = adapterAny._linkMarketplaceUseCase + .reconciliationJob as unknown as { + runJob: ( + jobId: string, + input: { marketplaceId: MarketplaceId }, + controller: AbortController, + ) => Promise<unknown>; + }; + await reconciliationJob.runJob( + 'manual-job-id', + { marketplaceId }, + new AbortController(), + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + describe('after the reconciliation sweep', () => { + describe('the distribution row', () => { + let reconciledRow: MarketplaceDistribution | null; + + beforeEach(async () => { + const distRepo = fixture.datasource.getRepository( + MarketplaceDistributionSchema, + ); + reconciledRow = (await distRepo.findOne({ + where: { id: distributionRow.id }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + })) as any; + }); + + it('transitions to terminal removed status', () => { + expect(reconciledRow?.status).toBe(DistributionStatus.removed); + }); + }); + + describe('the marketplace state', () => { + let reconciledMarketplace: Marketplace | null; + let storedDescriptorDriftedSlugs: string[] | undefined; + + beforeEach(async () => { + const marketplaceRepo = + fixture.datasource.getRepository(MarketplaceSchema); + reconciledMarketplace = (await marketplaceRepo.findOne({ + where: { id: marketplaceId }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + })) as any; + storedDescriptorDriftedSlugs = + reconciledMarketplace?.descriptor?.driftedPluginSlugs; + }); + + // The reconciliation job picks `drift` whenever the on-disk + // descriptor differs from what we last persisted, but the AC10 + // de-dup means the `to_be_removed` slug must NOT surface as a + // drifted slug — it was an expected removal. + it('omits the expected removal from driftedPluginSlugs', () => { + expect(storedDescriptorDriftedSlugs).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/integration-tests/src/marketplaces-errors.spec.ts b/packages/integration-tests/src/marketplaces-errors.spec.ts new file mode 100644 index 000000000..3167a979d --- /dev/null +++ b/packages/integration-tests/src/marketplaces-errors.spec.ts @@ -0,0 +1,537 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { + GitProviderMissingTokenError, + GitProviderNotFoundError, + GitProviderOrganizationMismatchError, + GitRepoAlreadyLinkedAsStandardError, + IGitPort, + MarketplaceAlreadyLinkedError, + MarketplaceDescriptorNotFoundError, + MarketplaceDescriptorParseError, + MarketplaceNotFoundError, + MarketplaceUrlNotReachableError, + UnknownMarketplaceDescriptorError, + UserOrganizationMembership, + createGitProviderId, + createMarketplaceId, + createUserId, +} from '@packmind/types'; +import { OrganizationAdminRequiredError } from '@packmind/node-utils'; +import { gitProviderFactory } from '@packmind/git/test'; +import { v4 as uuidv4 } from 'uuid'; +import { createIntegrationTestFixture } from './helpers/createIntegrationTestFixture'; +import { DataFactory } from './helpers/DataFactory'; +import { integrationTestSchemas } from './helpers/makeIntegrationTestDataSource'; +import { TestApp } from './helpers/TestApp'; + +/** + * Replicates `MarketplacesController.mapError` so this integration test can + * verify that the typed domain errors thrown by the real backend stack are + * mapped to the right NestJS HTTP exception. Keep in sync with + * `apps/api/src/app/organizations/marketplaces/marketplaces.controller.ts`. + */ +function mapMarketplaceError(error: unknown): unknown { + if (error instanceof MarketplaceAlreadyLinkedError) { + return new ConflictException(error.message); + } + if (error instanceof GitRepoAlreadyLinkedAsStandardError) { + return new ConflictException(error.message); + } + if (error instanceof MarketplaceDescriptorNotFoundError) { + return new BadRequestException(error.message); + } + if (error instanceof UnknownMarketplaceDescriptorError) { + return new BadRequestException(error.message); + } + if (error instanceof MarketplaceDescriptorParseError) { + return new BadRequestException(error.message); + } + if (error instanceof MarketplaceUrlNotReachableError) { + return new BadRequestException(error.message); + } + if (error instanceof MarketplaceNotFoundError) { + return new NotFoundException(error.message); + } + if (error instanceof OrganizationAdminRequiredError) { + return new ForbiddenException(error.message); + } + if (error instanceof GitProviderNotFoundError) { + return new NotFoundException(error.message); + } + if (error instanceof GitProviderOrganizationMismatchError) { + return new ForbiddenException(error.message); + } + if (error instanceof GitProviderMissingTokenError) { + return new BadRequestException(error.message); + } + return error; +} + +async function captureMappedError<T>( + promise: Promise<T>, +): Promise<{ raw: unknown; mapped: unknown }> { + try { + await promise; + throw new Error('Expected the operation to throw, but it resolved.'); + } catch (raw) { + return { raw, mapped: mapMarketplaceError(raw) }; + } +} + +describe('Marketplace HTTP boundary error mapping', () => { + const fixture = createIntegrationTestFixture(integrationTestSchemas); + + let testApp: TestApp; + let dataFactory: DataFactory; + let gitPort: IGitPort; + + beforeAll(() => fixture.initialize()); + + beforeEach(async () => { + testApp = new TestApp(fixture.datasource); + await testApp.initialize(); + + dataFactory = new DataFactory(testApp); + await dataFactory.withUserAndOrganization({ email: 'admin@example.com' }); + + gitPort = testApp.gitHexa.getAdapter(); + + // Stub the reconciliation job so a successful link does not try to + // contact a real queue. + const deploymentsAdapter = testApp.deploymentsHexa.getAdapter(); + const adapterAny = deploymentsAdapter as unknown as { + _linkMarketplaceUseCase: { + reconciliationJob: { + scheduleRecurring: () => Promise<void>; + addJob: () => Promise<string>; + cancelRecurring: () => Promise<void>; + }; + }; + }; + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'scheduleRecurring', + ) + .mockResolvedValue(undefined); + jest + .spyOn(adapterAny._linkMarketplaceUseCase.reconciliationJob, 'addJob') + .mockResolvedValue('mock-job-id'); + jest + .spyOn( + adapterAny._linkMarketplaceUseCase.reconciliationJob, + 'cancelRecurring', + ) + .mockResolvedValue(undefined); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + /** + * Seeds a non-admin (member) user on the same organization so the use + * cases can exercise the `OrganizationAdminRequiredError` denial path. + */ + async function seedNonAdminMember(): Promise< + ReturnType<typeof createUserId> + > { + const userId = createUserId(uuidv4()); + const userRepo = fixture.datasource.getRepository('User'); + const membershipRepo = fixture.datasource.getRepository( + 'UserOrganizationMembership', + ); + + await userRepo.save({ + id: userId, + email: 'member@example.com', + displayName: 'Member User', + passwordHash: null, + active: true, + trial: false, + }); + + const membership: UserOrganizationMembership = { + userId, + organizationId: dataFactory.organization.id, + role: 'member', + }; + await membershipRepo.save(membership); + return userId; + } + + describe('linkMarketplace', () => { + describe('non-admin denial (AC-4)', () => { + let raw: unknown; + let mapped: unknown; + + beforeEach(async () => { + const memberUserId = await seedNonAdminMember(); + // Use a real provider; the admin check fires before the use case + // resolves it, so no further fixtures are needed. + const { gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + ); + + ({ raw, mapped } = await captureMappedError( + testApp.deploymentsHexa.getAdapter().linkMarketplace({ + userId: memberUserId, + organizationId: dataFactory.organization.id, + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace', + }), + )); + }); + + it('throws OrganizationAdminRequiredError', () => { + expect(raw).toBeInstanceOf(OrganizationAdminRequiredError); + }); + + it('maps to 403 ForbiddenException', () => { + expect(mapped).toBeInstanceOf(ForbiddenException); + }); + }); + + describe('GitProviderNotFoundError → 404', () => { + let raw: unknown; + let mapped: unknown; + + beforeEach(async () => { + const missingProviderId = createGitProviderId(uuidv4()); + + ({ raw, mapped } = await captureMappedError( + testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: missingProviderId, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace', + }), + )); + }); + + it('throws GitProviderNotFoundError', () => { + expect(raw).toBeInstanceOf(GitProviderNotFoundError); + }); + + it('maps to NotFoundException', () => { + expect(mapped).toBeInstanceOf(NotFoundException); + }); + }); + + describe('GitProviderMissingTokenError → 400', () => { + describe('when the provider has no token', () => { + let raw: unknown; + let mapped: unknown; + + beforeEach(async () => { + // Create a token-bearing provider through the normal flow, then null + // the token at the DB level so the link use case sees a tokenless + // provider — the `addGitProvider` use case rejects empty tokens up + // front, so we cannot construct this state via the public API. + const { gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-temp' }), + ); + await fixture.datasource + .createQueryRunner() + .query('UPDATE git_providers SET token = NULL WHERE id = $1', [ + gitProvider.id, + ]); + + ({ raw, mapped } = await captureMappedError( + testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace', + }), + )); + }); + + it('throws GitProviderMissingTokenError', () => { + expect(raw).toBeInstanceOf(GitProviderMissingTokenError); + }); + + it('maps to BadRequestException', () => { + expect(mapped).toBeInstanceOf(BadRequestException); + }); + }); + }); + + describe('MarketplaceDescriptorNotFoundError → 400', () => { + describe('when marketplace.json is missing', () => { + let raw: unknown; + let mapped: unknown; + + beforeEach(async () => { + const { gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + ); + // Use a fresh git port spy that returns null (no descriptor file). + jest.spyOn(gitPort, 'getFileFromRepo').mockResolvedValue(null); + + ({ raw, mapped } = await captureMappedError( + testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'no-marketplace-json-here', + branch: 'main', + name: 'Anthropic Marketplace', + }), + )); + }); + + it('throws MarketplaceDescriptorNotFoundError', () => { + expect(raw).toBeInstanceOf(MarketplaceDescriptorNotFoundError); + }); + + it('maps to BadRequestException', () => { + expect(mapped).toBeInstanceOf(BadRequestException); + }); + }); + }); + + describe('UnknownMarketplaceDescriptorError → 400', () => { + describe('when no registered parser claims the descriptor', () => { + let raw: unknown; + let mapped: unknown; + + beforeEach(async () => { + const { gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + ); + // No `plugins` array → AnthropicParser declines → registry throws + // UnknownMarketplaceDescriptorError. + jest.spyOn(gitPort, 'getFileFromRepo').mockResolvedValue({ + sha: 'mock-sha', + content: JSON.stringify({ name: 'foreign-format', other: 'shape' }), + }); + + ({ raw, mapped } = await captureMappedError( + testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'foreign', + repo: 'descriptor', + branch: 'main', + name: 'Foreign Format Marketplace', + }), + )); + }); + + it('throws UnknownMarketplaceDescriptorError', () => { + expect(raw).toBeInstanceOf(UnknownMarketplaceDescriptorError); + }); + + it('maps to BadRequestException', () => { + expect(mapped).toBeInstanceOf(BadRequestException); + }); + }); + }); + + describe('MarketplaceDescriptorParseError → 400', () => { + describe('when the descriptor is malformed JSON', () => { + let raw: unknown; + let mapped: unknown; + + beforeEach(async () => { + const { gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + ); + jest.spyOn(gitPort, 'getFileFromRepo').mockResolvedValue({ + sha: 'mock-sha', + content: '{ this is not valid json', + }); + + ({ raw, mapped } = await captureMappedError( + testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'broken', + repo: 'descriptor', + branch: 'main', + name: 'Broken Descriptor Marketplace', + }), + )); + }); + + it('throws MarketplaceDescriptorParseError', () => { + expect(raw).toBeInstanceOf(MarketplaceDescriptorParseError); + }); + + it('maps to BadRequestException', () => { + expect(mapped).toBeInstanceOf(BadRequestException); + }); + }); + }); + + describe('MarketplaceAlreadyLinkedError → 409 with verbatim contract message (AC-5)', () => { + let raw: unknown; + let mapped: unknown; + + beforeEach(async () => { + const { gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + ); + jest.spyOn(gitPort, 'getFileFromRepo').mockResolvedValue({ + sha: 'mock-sha', + content: JSON.stringify({ + name: 'Anthropic Marketplace', + vendor: 'anthropic', + plugins: [{ name: 'plugin-alpha' }], + }), + }); + + // First link — succeeds. + await testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace', + }); + + // Second link with the same coords — should be rejected. + ({ raw, mapped } = await captureMappedError( + testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace (again)', + }), + )); + }); + + it('throws MarketplaceAlreadyLinkedError', () => { + expect(raw).toBeInstanceOf(MarketplaceAlreadyLinkedError); + }); + + it('carries the exact contract message on the raw error', () => { + expect((raw as Error).message).toBe( + 'The marketplace anthropic/marketplace has already been linked to your organization', + ); + }); + + it('maps to ConflictException', () => { + expect(mapped).toBeInstanceOf(ConflictException); + }); + + it('carries the exact contract message on the mapped exception', () => { + expect((mapped as ConflictException).message).toBe( + 'The marketplace anthropic/marketplace has already been linked to your organization', + ); + }); + }); + + describe('GitRepoAlreadyLinkedAsStandardError → 409', () => { + describe('when the coords are already a standard repo', () => { + let raw: unknown; + let mapped: unknown; + + beforeEach(async () => { + const { gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + ); + // Seed a standard repo via the normal flow. + await dataFactory.withGitRepo({ + owner: 'octocat', + repo: 'hello-world', + branch: 'main', + providerId: gitProvider.id, + }); + + ({ raw, mapped } = await captureMappedError( + testApp.deploymentsHexa.getAdapter().linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'octocat', + repo: 'hello-world', + branch: 'main', + name: 'Trying to link as marketplace', + }), + )); + }); + + it('throws GitRepoAlreadyLinkedAsStandardError', () => { + expect(raw).toBeInstanceOf(GitRepoAlreadyLinkedAsStandardError); + }); + + it('maps to ConflictException', () => { + expect(mapped).toBeInstanceOf(ConflictException); + }); + }); + }); + }); + + describe('unlinkMarketplace', () => { + describe('MarketplaceNotFoundError → 404 (AC-10)', () => { + describe('when the marketplace does not exist', () => { + let raw: unknown; + let mapped: unknown; + + beforeEach(async () => { + const unknownMarketplaceId = createMarketplaceId(uuidv4()); + + ({ raw, mapped } = await captureMappedError( + testApp.deploymentsHexa.getAdapter().unlinkMarketplace({ + ...dataFactory.packmindCommand(), + marketplaceId: unknownMarketplaceId, + }), + )); + }); + + it('throws MarketplaceNotFoundError', () => { + expect(raw).toBeInstanceOf(MarketplaceNotFoundError); + }); + + it('maps to NotFoundException', () => { + expect(mapped).toBeInstanceOf(NotFoundException); + }); + }); + }); + + describe('non-admin denial (AC-4)', () => { + let raw: unknown; + let mapped: unknown; + + beforeEach(async () => { + const memberUserId = await seedNonAdminMember(); + const someMarketplaceId = createMarketplaceId(uuidv4()); + + ({ raw, mapped } = await captureMappedError( + testApp.deploymentsHexa.getAdapter().unlinkMarketplace({ + userId: memberUserId, + organizationId: dataFactory.organization.id, + marketplaceId: someMarketplaceId, + }), + )); + }); + + it('throws OrganizationAdminRequiredError', () => { + expect(raw).toBeInstanceOf(OrganizationAdminRequiredError); + }); + + it('maps to 403 ForbiddenException', () => { + expect(mapped).toBeInstanceOf(ForbiddenException); + }); + }); + }); +}); diff --git a/packages/integration-tests/src/marketplaces-lifecycle.spec.ts b/packages/integration-tests/src/marketplaces-lifecycle.spec.ts new file mode 100644 index 000000000..cdc205dc2 --- /dev/null +++ b/packages/integration-tests/src/marketplaces-lifecycle.spec.ts @@ -0,0 +1,546 @@ +import { + GitProvider, + IGitPort, + MarketplaceId, + MarketplaceLinkedEvent, + MarketplaceLinkedPayload, + MarketplaceUnlinkedEvent, + MarketplaceUnlinkedPayload, + User, + UserOrganizationMembership, + createUserId, +} from '@packmind/types'; +import { gitProviderFactory } from '@packmind/git/test'; +import { GitRepoSchema } from '@packmind/git'; +import { MarketplaceSchema } from '@packmind/deployments'; +import { + PackmindEventEmitterService, + PackmindListener, +} from '@packmind/node-utils'; +import { v4 as uuidv4 } from 'uuid'; +import { createIntegrationTestFixture } from './helpers/createIntegrationTestFixture'; +import { DataFactory } from './helpers/DataFactory'; +import { integrationTestSchemas } from './helpers/makeIntegrationTestDataSource'; +import { TestApp } from './helpers/TestApp'; + +/** + * Stub adapter capturing emitted marketplace events. The integration test + * subscribes a `PackmindListener` to confirm that the link/unlink flow emits + * the expected domain events with the right payloads. + */ +interface StubMarketplaceAdapter { + onMarketplaceLinked(payload: MarketplaceLinkedPayload): void; + onMarketplaceUnlinked(payload: MarketplaceUnlinkedPayload): void; +} + +class StubMarketplaceListener extends PackmindListener<StubMarketplaceAdapter> { + protected registerHandlers(): void { + this.subscribe(MarketplaceLinkedEvent, this.handleMarketplaceLinked); + this.subscribe(MarketplaceUnlinkedEvent, this.handleMarketplaceUnlinked); + } + + private handleMarketplaceLinked = (event: MarketplaceLinkedEvent): void => { + this.adapter.onMarketplaceLinked(event.payload); + }; + + private handleMarketplaceUnlinked = ( + event: MarketplaceUnlinkedEvent, + ): void => { + this.adapter.onMarketplaceUnlinked(event.payload); + }; +} + +const ANTHROPIC_MARKETPLACE_DESCRIPTOR_JSON = JSON.stringify({ + name: 'Anthropic Marketplace', + vendor: 'anthropic', + version: '1.0.0', + plugins: [ + { name: 'plugin-alpha', version: '0.1.0' }, + { name: 'plugin-beta', version: '0.2.0' }, + { name: 'plugin-gamma' }, + ], +}); + +describe('Marketplace lifecycle integration', () => { + const fixture = createIntegrationTestFixture(integrationTestSchemas); + + let testApp: TestApp; + let dataFactory: DataFactory; + let gitProvider: GitProvider; + let gitPort: IGitPort; + let getFileFromRepoSpy: jest.SpyInstance; + let scheduleRecurringSpy: jest.SpyInstance; + let addJobSpy: jest.SpyInstance; + let cancelRecurringSpy: jest.SpyInstance; + let eventEmitterService: PackmindEventEmitterService; + let stubMarketplaceAdapter: jest.Mocked<StubMarketplaceAdapter>; + let stubMarketplaceListener: StubMarketplaceListener; + + beforeAll(() => fixture.initialize()); + + beforeEach(async () => { + testApp = new TestApp(fixture.datasource); + await testApp.initialize(); + + dataFactory = new DataFactory(testApp); + await dataFactory.withUserAndOrganization({ email: 'admin@example.com' }); + + // A token-bearing GitProvider — `LinkMarketplaceUseCase` requires + // `hasAuth=true` so it can fetch `marketplace.json`. We seed via the + // existing factory (token: 'test-token') so the live use case sees a + // realistic provider. + ({ gitProvider } = await dataFactory.withGitProvider( + gitProviderFactory({ token: 'gh-pat-test-token' }), + )); + + gitPort = testApp.gitHexa.getAdapter(); + + // Stub the file fetch — we never reach a real Git host in the test + // stack. The descriptor JSON has the Anthropic shape so the parser + // registry's `AnthropicMarketplaceDescriptorParser` claims it. + getFileFromRepoSpy = jest + .spyOn(gitPort, 'getFileFromRepo') + .mockResolvedValue({ + sha: 'mock-sha', + content: ANTHROPIC_MARKETPLACE_DESCRIPTOR_JSON, + }); + + // Spy on the reconciliation job scheduler. The link use case enqueues + // both the recurring job and an immediate run; the unlink use case + // cancels the recurring job. We assert call counts without depending on + // BullMQ internals. + const deploymentsAdapter = testApp.deploymentsHexa.getAdapter(); + // Cast through the adapter's private field to spy on the job — + // production wiring stores the same job instance for both use cases. + const adapterAny = deploymentsAdapter as unknown as { + _linkMarketplaceUseCase: { + // The link use case keeps a reference to the reconciliation job. + reconciliationJob: { + scheduleRecurring: (id: MarketplaceId) => Promise<void>; + addJob: (params: { marketplaceId: MarketplaceId }) => Promise<string>; + cancelRecurring: (id: MarketplaceId) => Promise<void>; + }; + }; + }; + const reconciliationJob = + adapterAny._linkMarketplaceUseCase.reconciliationJob; + scheduleRecurringSpy = jest + .spyOn(reconciliationJob, 'scheduleRecurring') + .mockResolvedValue(undefined); + addJobSpy = jest + .spyOn(reconciliationJob, 'addJob') + .mockResolvedValue('mock-job-id'); + cancelRecurringSpy = jest + .spyOn(reconciliationJob, 'cancelRecurring') + .mockResolvedValue(undefined); + + // Subscribe a stub listener so we can confirm domain events fired. + eventEmitterService = testApp.registry.getService( + PackmindEventEmitterService, + ); + stubMarketplaceAdapter = { + onMarketplaceLinked: jest.fn(), + onMarketplaceUnlinked: jest.fn(), + }; + stubMarketplaceListener = new StubMarketplaceListener( + stubMarketplaceAdapter, + ); + stubMarketplaceListener.initialize(eventEmitterService); + }); + + afterEach(async () => { + eventEmitterService.removeAllListeners(); + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + /** + * Seeds an inactive (non-admin) second user with a *member* membership on + * the same organization, so list-marketplaces tests can confirm members + * see the marketplace too. + */ + async function seedNonAdminMember(): Promise<User> { + const userId = createUserId(uuidv4()); + const userRepo = fixture.datasource.getRepository('User'); + const membershipRepo = fixture.datasource.getRepository( + 'UserOrganizationMembership', + ); + + const user = (await userRepo.save({ + id: userId, + email: 'member@example.com', + displayName: 'Member User', + passwordHash: null, + active: true, + trial: false, + })) as User; + + const membership: UserOrganizationMembership = { + userId, + organizationId: dataFactory.organization.id, + role: 'member', + }; + await membershipRepo.save(membership); + + return user; + } + + describe('link → list → unlink happy path', () => { + let linkResponse: Awaited< + ReturnType< + ReturnType<typeof testApp.deploymentsHexa.getAdapter>['linkMarketplace'] + > + >; + + beforeEach(async () => { + linkResponse = await testApp.deploymentsHexa + .getAdapter() + .linkMarketplace({ + ...dataFactory.packmindCommand(), + gitProviderId: gitProvider.id, + owner: 'anthropic', + repo: 'marketplace', + branch: 'main', + name: 'Anthropic Marketplace', + }); + }); + + describe('after linkMarketplace', () => { + describe('the persisted Marketplace row', () => { + let stored: Awaited< + ReturnType< + ReturnType<typeof fixture.datasource.getRepository>['findOne'] + > + >; + + beforeEach(async () => { + const marketplaceRepo = + fixture.datasource.getRepository(MarketplaceSchema); + stored = await marketplaceRepo.findOne({ + where: { id: linkResponse.id }, + }); + }); + + it('exists', () => { + expect(stored).not.toBeNull(); + }); + + it('belongs to the organization', () => { + expect(stored?.organizationId).toBe(dataFactory.organization.id); + }); + + it('keeps the marketplace name', () => { + expect(stored?.name).toBe('Anthropic Marketplace'); + }); + + it('records the vendor', () => { + expect(stored?.vendor).toBe('anthropic'); + }); + + it('records the plugin count', () => { + expect(stored?.pluginCount).toBe(3); + }); + }); + + describe('the persisted GitRepo', () => { + let storedRepo: Awaited< + ReturnType< + ReturnType<typeof fixture.datasource.getRepository>['findOne'] + > + >; + + beforeEach(async () => { + const gitRepoRepo = fixture.datasource.getRepository(GitRepoSchema); + storedRepo = await gitRepoRepo.findOne({ + where: { id: linkResponse.gitRepoId }, + }); + }); + + it('exists', () => { + expect(storedRepo).not.toBeNull(); + }); + + it('has type=marketplace', () => { + expect(storedRepo?.type).toBe('marketplace'); + }); + + it('keeps the owner', () => { + expect(storedRepo?.owner).toBe('anthropic'); + }); + + it('keeps the repo name', () => { + expect(storedRepo?.repo).toBe('marketplace'); + }); + }); + + it('fetched the descriptor exactly once via the git port', () => { + expect(getFileFromRepoSpy).toHaveBeenCalledTimes(1); + }); + + it('probed .claude-plugin/marketplace.json as the descriptor path', () => { + const [, filePath] = getFileFromRepoSpy.mock.calls[0]; + expect(filePath).toBe('.claude-plugin/marketplace.json'); + }); + + it('emits MarketplaceLinkedEvent exactly once', () => { + expect( + stubMarketplaceAdapter.onMarketplaceLinked, + ).toHaveBeenCalledTimes(1); + }); + + describe('the emitted MarketplaceLinkedEvent payload', () => { + let payload: MarketplaceLinkedPayload; + + beforeEach(() => { + payload = stubMarketplaceAdapter.onMarketplaceLinked.mock.calls[0][0]; + }); + + it('carries the marketplaceId', () => { + expect(payload.marketplaceId).toBe(linkResponse.id); + }); + + it('carries the organizationId', () => { + expect(payload.organizationId).toBe(dataFactory.organization.id); + }); + + it('carries the gitRepoId', () => { + expect(payload.gitRepoId).toBe(linkResponse.gitRepoId); + }); + + it('carries addedBy', () => { + expect(payload.addedBy).toBe(dataFactory.user.id); + }); + + it('carries the userId', () => { + expect(payload.userId).toBe(dataFactory.user.id); + }); + }); + + describe('enqueues the recurring reconciliation job', () => { + it('calls scheduleRecurring exactly once', () => { + expect(scheduleRecurringSpy).toHaveBeenCalledTimes(1); + }); + + it('schedules with the marketplace id', () => { + expect(scheduleRecurringSpy).toHaveBeenCalledWith(linkResponse.id); + }); + }); + + describe('enqueues an immediate reconciliation run', () => { + it('calls addJob exactly once', () => { + expect(addJobSpy).toHaveBeenCalledTimes(1); + }); + + it('adds the job with the marketplace id', () => { + expect(addJobSpy).toHaveBeenCalledWith({ + marketplaceId: linkResponse.id, + }); + }); + }); + }); + + describe('listMarketplaces', () => { + describe('for org members', () => { + let items: Awaited< + ReturnType< + ReturnType< + typeof testApp.deploymentsHexa.getAdapter + >['listMarketplaces'] + > + >; + + beforeEach(async () => { + const memberUser = await seedNonAdminMember(); + + items = await testApp.deploymentsHexa.getAdapter().listMarketplaces({ + userId: memberUser.id, + organizationId: dataFactory.organization.id, + }); + }); + + it('returns a single marketplace', () => { + expect(items).toHaveLength(1); + }); + + it('returns the linked marketplace', () => { + expect(items[0].id).toBe(linkResponse.id); + }); + }); + + it('denormalizes addedByUserName onto each list item', async () => { + const items = await testApp.deploymentsHexa + .getAdapter() + .listMarketplaces(dataFactory.packmindCommand()); + + // DataFactory seeds the admin with email `admin@example.com` and no + // displayName, so `addedByUserName` should fall back to the email. + expect(items[0].addedByUserName).toBe('admin@example.com'); + }); + + it('returns the denormalized pluginCount from the row', async () => { + const items = await testApp.deploymentsHexa + .getAdapter() + .listMarketplaces(dataFactory.packmindCommand()); + + expect(items[0].pluginCount).toBe(3); + }); + + it('enriches each list item with its backing repository', async () => { + const items = await testApp.deploymentsHexa + .getAdapter() + .listMarketplaces(dataFactory.packmindCommand()); + + expect(items[0].repository).toMatchObject({ + owner: 'anthropic', + repo: 'marketplace', + url: 'https://github.com/anthropic/marketplace', + }); + }); + }); + + describe('unlinkMarketplace', () => { + let marketplaceId: MarketplaceId; + + beforeEach(async () => { + marketplaceId = linkResponse.id; + + await testApp.deploymentsHexa.getAdapter().unlinkMarketplace({ + ...dataFactory.packmindCommand(), + marketplaceId, + }); + }); + + describe('soft-deletes the Marketplace row', () => { + let liveRow: Awaited< + ReturnType< + ReturnType<typeof fixture.datasource.getRepository>['findOne'] + > + >; + let softDeletedRow: Awaited< + ReturnType< + ReturnType<typeof fixture.datasource.getRepository>['findOne'] + > + >; + + beforeEach(async () => { + const marketplaceRepo = + fixture.datasource.getRepository(MarketplaceSchema); + + liveRow = await marketplaceRepo.findOne({ + where: { id: marketplaceId }, + }); + + softDeletedRow = await marketplaceRepo.findOne({ + where: { id: marketplaceId }, + withDeleted: true, + }); + }); + + it('hides the row from live queries', () => { + expect(liveRow).toBeNull(); + }); + + it('keeps the row available with withDeleted', () => { + expect(softDeletedRow).not.toBeNull(); + }); + + it('stamps deletedAt on the row', () => { + expect(softDeletedRow?.deletedAt).not.toBeNull(); + }); + }); + + describe('soft-deletes the underlying marketplace-typed GitRepo', () => { + let liveRow: Awaited< + ReturnType< + ReturnType<typeof fixture.datasource.getRepository>['findOne'] + > + >; + let softDeletedRow: Awaited< + ReturnType< + ReturnType<typeof fixture.datasource.getRepository>['findOne'] + > + >; + + beforeEach(async () => { + const gitRepoRepo = fixture.datasource.getRepository(GitRepoSchema); + + liveRow = await gitRepoRepo.findOne({ + where: { id: linkResponse.gitRepoId }, + }); + + softDeletedRow = await gitRepoRepo.findOne({ + where: { id: linkResponse.gitRepoId }, + withDeleted: true, + }); + }); + + it('hides the row from live queries', () => { + expect(liveRow).toBeNull(); + }); + + it('keeps the row available with withDeleted', () => { + expect(softDeletedRow).not.toBeNull(); + }); + + it('stamps deletedAt on the row', () => { + expect(softDeletedRow?.deletedAt).not.toBeNull(); + }); + + it('keeps the type as marketplace', () => { + expect(softDeletedRow?.type).toBe('marketplace'); + }); + }); + + it('emits MarketplaceUnlinkedEvent exactly once', () => { + expect( + stubMarketplaceAdapter.onMarketplaceUnlinked, + ).toHaveBeenCalledTimes(1); + }); + + describe('the emitted MarketplaceUnlinkedEvent payload', () => { + let payload: MarketplaceUnlinkedPayload; + + beforeEach(() => { + payload = + stubMarketplaceAdapter.onMarketplaceUnlinked.mock.calls[0][0]; + }); + + it('carries the marketplaceId', () => { + expect(payload.marketplaceId).toBe(marketplaceId); + }); + + it('carries the organizationId', () => { + expect(payload.organizationId).toBe(dataFactory.organization.id); + }); + + it('carries the gitRepoId', () => { + expect(payload.gitRepoId).toBe(linkResponse.gitRepoId); + }); + + it('carries the userId', () => { + expect(payload.userId).toBe(dataFactory.user.id); + }); + }); + + describe('cancels the recurring reconciliation job', () => { + it('calls cancelRecurring exactly once', () => { + expect(cancelRecurringSpy).toHaveBeenCalledTimes(1); + }); + + it('cancels with the marketplace id', () => { + expect(cancelRecurringSpy).toHaveBeenCalledWith(marketplaceId); + }); + }); + + it('removes the marketplace from listMarketplaces results', async () => { + const items = await testApp.deploymentsHexa + .getAdapter() + .listMarketplaces(dataFactory.packmindCommand()); + + expect(items).toEqual([]); + }); + }); + }); +}); diff --git a/packages/integration-tests/src/playbook-change-managment/apply-multiple-changes.spec.ts b/packages/integration-tests/src/playbook-change-managment/apply-multiple-changes.spec.ts new file mode 100644 index 000000000..105bda307 --- /dev/null +++ b/packages/integration-tests/src/playbook-change-managment/apply-multiple-changes.spec.ts @@ -0,0 +1,277 @@ +import { + ChangeProposal, + ChangeProposalDecision, + ChangeProposalStatus, + ChangeProposalType, + Recipe, +} from '@packmind/types'; + +import { changeProposalFactory } from '@packmind/playbook-change-management/test'; + +import { + integrationTestWithUser, + IntegrationTestWithUserContext, +} from '../helpers/integrationTest'; + +describe( + 'Applying multiple changes', + integrationTestWithUser((getContext) => { + let command: Recipe; + let testContext: IntegrationTestWithUserContext; + + beforeEach(async () => { + testContext = await getContext(); + + command = await testContext.testApp.recipesHexa + .getAdapter() + .captureRecipe({ + ...testContext.basePackmindCommand, + name: 'My command', + content: 'With some description', + }); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + describe('when there is no conflicting changes', () => { + let changeProposals: ChangeProposal[]; + const updatedName = 'My updated name'; + const updatedDescription = 'My new description'; + + beforeEach(async () => { + await testContext.testApp.playbookManagementHexa + .getAdapter() + .batchCreateChangeProposals({ + ...testContext.basePackmindCommand, + proposals: [ + changeProposalFactory({ + artefactId: command.id, + artefactVersion: command.version, + type: ChangeProposalType.updateCommandName, + payload: { + oldValue: command.name, + newValue: updatedName, + }, + }), + changeProposalFactory({ + artefactId: command.id, + artefactVersion: command.version, + type: ChangeProposalType.updateCommandDescription, + payload: { + oldValue: command.content, + newValue: updatedDescription, + }, + }), + ], + }); + + const listChangeProposalsResponse = + await testContext.testApp.playbookManagementHexa + .getAdapter() + .listChangeProposalsByArtefact({ + ...testContext.basePackmindCommand, + artefactId: command.id, + }); + changeProposals = listChangeProposalsResponse.changeProposals; + }); + + describe('when rejecting all change proposals', () => { + beforeEach(async () => { + await testContext.testApp.playbookManagementHexa + .getAdapter() + .applyChangeProposals({ + ...testContext.basePackmindCommand, + artefactId: command.id, + accepted: [], + rejected: changeProposals.map((cp) => cp.id), + }); + }); + + it('does not create new recipe version', async () => { + const queriedCommand = await testContext.testApp.recipesHexa + .getAdapter() + .getRecipeById({ + ...testContext.basePackmindCommand, + recipeId: command.id, + }); + + expect(queriedCommand?.version).toEqual(command.version); + }); + + it('marks all changes as rejected', async () => { + const { changeProposals: queriedProposals } = + await testContext.testApp.playbookManagementHexa + .getAdapter() + .listChangeProposalsByArtefact({ + ...testContext.basePackmindCommand, + artefactId: command.id, + pendingOnly: false, + }); + + expect(queriedProposals).toEqual([ + expect.objectContaining({ + status: ChangeProposalStatus.rejected, + }), + expect.objectContaining({ + status: ChangeProposalStatus.rejected, + }), + ]); + }); + }); + + describe('when accepting all change proposals', () => { + beforeEach(async () => { + await testContext.testApp.playbookManagementHexa + .getAdapter() + .applyChangeProposals({ + ...testContext.basePackmindCommand, + artefactId: command.id, + accepted: changeProposals.map((cp) => ({ + ...cp, + status: ChangeProposalStatus.applied, + decision: cp.payload as ChangeProposalDecision, + })), + rejected: [], + }); + }); + + it('udpates the recipes and creates a new version', async () => { + const queriedCommand = await testContext.testApp.recipesHexa + .getAdapter() + .getRecipeById({ + ...testContext.basePackmindCommand, + recipeId: command.id, + }); + + expect(queriedCommand).toEqual( + expect.objectContaining({ + name: updatedName, + content: updatedDescription, + version: command.version + 1, + }), + ); + }); + + it('marks all changes as applied', async () => { + const { changeProposals: queriedProposals } = + await testContext.testApp.playbookManagementHexa + .getAdapter() + .listChangeProposalsByArtefact({ + ...testContext.basePackmindCommand, + artefactId: command.id, + pendingOnly: false, + }); + + expect(queriedProposals).toEqual([ + expect.objectContaining({ + status: ChangeProposalStatus.applied, + }), + expect.objectContaining({ + status: ChangeProposalStatus.applied, + }), + ]); + }); + }); + }); + + describe('when there is conflicting changes', () => { + let changeProposals: ChangeProposal[]; + + beforeEach(async () => { + await testContext.testApp.playbookManagementHexa + .getAdapter() + .batchCreateChangeProposals({ + ...testContext.basePackmindCommand, + proposals: [ + changeProposalFactory({ + artefactId: command.id, + artefactVersion: command.version, + type: ChangeProposalType.updateCommandDescription, + payload: { + oldValue: command.content, + newValue: 'My new description', + }, + }), + changeProposalFactory({ + artefactId: command.id, + artefactVersion: command.version, + type: ChangeProposalType.updateCommandDescription, + payload: { + oldValue: command.content, + newValue: 'My newer description', + }, + }), + ], + }); + + const listChangeProposalsResponse = + await testContext.testApp.playbookManagementHexa + .getAdapter() + .listChangeProposalsByArtefact({ + ...testContext.basePackmindCommand, + artefactId: command.id, + }); + changeProposals = listChangeProposalsResponse.changeProposals; + }); + + describe('when accepting all change proposals', () => { + beforeEach(async () => { + try { + await testContext.testApp.playbookManagementHexa + .getAdapter() + .applyChangeProposals({ + ...testContext.basePackmindCommand, + artefactId: command.id, + accepted: changeProposals.map((cp) => ({ + ...cp, + status: ChangeProposalStatus.applied, + decision: cp.payload as ChangeProposalDecision, + })), + rejected: [], + }); + } catch (error) { + console.error(error); + } + }); + + it('does not update the command', async () => { + const queriedCommand = await testContext.testApp.recipesHexa + .getAdapter() + .getRecipeById({ + ...testContext.basePackmindCommand, + recipeId: command.id, + }); + + expect(queriedCommand).toEqual( + expect.objectContaining({ + content: command.content, + version: command.version, + }), + ); + }); + + it('leaves all changes as pending', async () => { + const { changeProposals: queriedProposals } = + await testContext.testApp.playbookManagementHexa + .getAdapter() + .listChangeProposalsByArtefact({ + ...testContext.basePackmindCommand, + artefactId: command.id, + pendingOnly: false, + }); + + expect(queriedProposals).toEqual([ + expect.objectContaining({ + status: ChangeProposalStatus.pending, + }), + expect.objectContaining({ + status: ChangeProposalStatus.pending, + }), + ]); + }); + }); + }); + }), +); diff --git a/packages/integration-tests/src/test-setup.ts b/packages/integration-tests/src/test-setup.ts index af46aeb0c..e1da88c9b 100644 --- a/packages/integration-tests/src/test-setup.ts +++ b/packages/integration-tests/src/test-setup.ts @@ -38,6 +38,16 @@ class SyncJob<Input, Output> implements IQueue<Input, Output> { // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars async cancelJob(_jobId: string): Promise<void> {} + + async removeRepeatable( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _name: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _pattern: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _jobId: string, + // eslint-disable-next-line @typescript-eslint/no-empty-function + ): Promise<void> {} } // Mock the queueFactory, Configuration, and SSEEventPublisher from @packmind/node-utils diff --git a/packages/linter/.swcrc b/packages/linter/.swcrc new file mode 100644 index 000000000..83bfbeb3f --- /dev/null +++ b/packages/linter/.swcrc @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "jsc": { + "parser": { "syntax": "typescript", "decorators": true }, + "transform": { "legacyDecorator": true, "decoratorMetadata": true }, + "target": "es2022", + "keepClassNames": true + }, + "module": { "type": "commonjs" }, + "sourceMaps": true +} diff --git a/packages/linter/README.md b/packages/linter/README.md new file mode 100644 index 000000000..901227d37 --- /dev/null +++ b/packages/linter/README.md @@ -0,0 +1,11 @@ +# linter + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build linter` to build the library. + +## Running unit tests + +Run `nx test linter` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/packages/linter/eslint.config.mjs b/packages/linter/eslint.config.mjs new file mode 100644 index 000000000..5a6508581 --- /dev/null +++ b/packages/linter/eslint.config.mjs @@ -0,0 +1,23 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + ignores: ['js-playground/**/*', 'js-playground-local/**/*'], + }, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + ignoredDependencies: ['@packmind/linter-ast'], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, +]; diff --git a/packages/linter/jest.config.ts b/packages/linter/jest.config.ts new file mode 100644 index 000000000..303db09c4 --- /dev/null +++ b/packages/linter/jest.config.ts @@ -0,0 +1,22 @@ +const { compilerOptions } = require('../../tsconfig.base.effective.json'); + +const { + pathsToModuleNameMapper, + swcTransformWithDecorators, + standardTransformIgnorePatterns, + standardModuleFileExtensions, +} = require('../../jest-utils.ts'); + +module.exports = { + displayName: 'linter', + preset: '../../jest.preset.ts', + testEnvironment: 'node', + transform: swcTransformWithDecorators, + transformIgnorePatterns: standardTransformIgnorePatterns, + moduleFileExtensions: standardModuleFileExtensions, + coverageDirectory: '../../coverage/packages/linter', + moduleNameMapper: pathsToModuleNameMapper( + compilerOptions.paths, + '<rootDir>/../../', + ), +}; diff --git a/packages/linter/js-playground/index.js b/packages/linter/js-playground/index.js new file mode 100644 index 000000000..898e80549 --- /dev/null +++ b/packages/linter/js-playground/index.js @@ -0,0 +1,39 @@ +const util = require('util'); + +// Set maxArrayLength to null globally, so that all violations are printed +util.inspect.defaultOptions.maxArrayLength = null; + +function checkSourceCode(sourceCode) { + const lines = sourceCode.split('\n'); + const violations = []; + + lines.forEach((line, index) => { + const pipeCount = (line.match(/\|/g) || []).length; + if (pipeCount > 7) { + violations.push(index); + } + }); + + return violations; +} +function processInput(sourceCode) { + const violations = checkSourceCode(sourceCode); + console.log(violations); +} + +// Check if there are command line arguments +if (process.argv.length > 2) { + // Command line arguments provided + const sourceCode = process.argv.slice(2).join(' '); + processInput(sourceCode); +} else { + // No command line arguments, read from stdin + let sourceCode = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => { + sourceCode += chunk; + }); + process.stdin.on('end', () => { + processInput(sourceCode); + }); +} diff --git a/packages/linter/js-playground/suffix_ast.js b/packages/linter/js-playground/suffix_ast.js new file mode 100644 index 000000000..d3551b19c --- /dev/null +++ b/packages/linter/js-playground/suffix_ast.js @@ -0,0 +1,28 @@ +const util = require('util'); + +// Set maxArrayLength to null globally, so that all violations are printed +util.inspect.defaultOptions.maxArrayLength = null; + +function processInput(sourceCode) { + const json = JSON.parse(sourceCode); + //delete json.text; + const violations = checkSourceCode(json); + console.log(violations); +} + +// Check if there are command line arguments +if (process.argv.length > 2) { + // Command line arguments provided + const sourceCode = process.argv.slice(2).join(' '); + processInput(sourceCode); +} else { + // No command line arguments, read from stdin + let sourceCode = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => { + sourceCode += chunk; + }); + process.stdin.on('end', () => { + processInput(sourceCode); + }); +} diff --git a/packages/linter/js-playground/suffix_ast_multi.js b/packages/linter/js-playground/suffix_ast_multi.js new file mode 100644 index 000000000..9c9897aeb --- /dev/null +++ b/packages/linter/js-playground/suffix_ast_multi.js @@ -0,0 +1,58 @@ +const util = require('util'); + +// Set maxArrayLength to null globally, so that all violations are printed +util.inspect.defaultOptions.maxArrayLength = null; + +// This section will be replaced dynamically with program functions for each practice +// BEGIN_PROGRAM_FUNCTIONS +// END_PROGRAM_FUNCTIONS + +// Map to store the practice IDs and their corresponding program functions +const practicePrograms = { + // This section will be replaced dynamically with mappings + // BEGIN_PROGRAM_MAPPINGS + // END_PROGRAM_MAPPINGS +}; + +function processInput(sourceCode) { + const json = JSON.parse(sourceCode); + + // Execute each program and collect results + const violationsMap = {}; + + for (const [practiceId, programFunction] of Object.entries( + practicePrograms, + )) { + try { + // Execute the program function and store the result + const violations = programFunction(json); + violationsMap[practiceId] = violations; + } catch (error) { + console.error( + `Error executing program for practice ${practiceId}:`, + error, + ); + violationsMap[practiceId] = []; + } + } + + // Output the results as a JSON object + console.log(JSON.stringify(violationsMap)); +} + +// Check if there are command line arguments +if (process.argv.length > 2) { + // Command line arguments provided + const sourceCode = process.argv.slice(2).join(' '); + processInput(sourceCode); +} else { + // No command line arguments, read from stdin + let sourceCode = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => { + sourceCode += chunk; + }); + process.stdin.on('end', () => { + processInput(sourceCode); + }); +} diff --git a/packages/linter/js-playground/suffix_code.js b/packages/linter/js-playground/suffix_code.js new file mode 100644 index 000000000..e058375f0 --- /dev/null +++ b/packages/linter/js-playground/suffix_code.js @@ -0,0 +1,26 @@ +const util = require('util'); + +// Set maxArrayLength to null globally, so that all violations are printed +util.inspect.defaultOptions.maxArrayLength = null; + +function processInput(sourceCode) { + const violations = checkSourceCode(sourceCode); + console.log(violations); +} + +// Check if there are command line arguments +if (process.argv.length > 2) { + // Command line arguments provided + const sourceCode = process.argv.slice(2).join(' '); + processInput(sourceCode); +} else { + // No command line arguments, read from stdin + let sourceCode = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => { + sourceCode += chunk; + }); + process.stdin.on('end', () => { + processInput(sourceCode); + }); +} diff --git a/packages/linter/js-playground/suffix_code_multi.js b/packages/linter/js-playground/suffix_code_multi.js new file mode 100644 index 000000000..853e7da6b --- /dev/null +++ b/packages/linter/js-playground/suffix_code_multi.js @@ -0,0 +1,58 @@ +const util = require('util'); + +// Set maxArrayLength to null globally, so that all violations are printed +util.inspect.defaultOptions.maxArrayLength = null; + +// This section will be replaced dynamically with program functions for each practice +// BEGIN_PROGRAM_FUNCTIONS +// END_PROGRAM_FUNCTIONS + +// Map to store the practice IDs and their corresponding program functions +const practicePrograms = { + // This section will be replaced dynamically with mappings + // BEGIN_PROGRAM_MAPPINGS + // END_PROGRAM_MAPPINGS +}; + +function processInput(input) { + const sourceCode = input; + + // Execute each program and collect results + const violationsMap = {}; + + for (const practiceId in practicePrograms) { + try { + // Call the specific program function for this practice + const violations = practicePrograms[practiceId](sourceCode); + violationsMap[practiceId] = violations; + } catch (error) { + console.error( + `Error executing program for practice ${practiceId}:`, + error, + ); + violationsMap[practiceId] = []; + } + } + + // Print the combined violations map + console.log(JSON.stringify(violationsMap)); +} + +// Check if there are command line arguments +if (process.argv.length > 2) { + // Command line arguments provided + const sourceCode = process.argv.slice(2).join(' '); + processInput(sourceCode); +} else { + // No command line arguments, read from stdin + let sourceCode = ''; + process.stdin.setEncoding('utf-8'); + + process.stdin.on('data', (chunk) => { + sourceCode += chunk; + }); + + process.stdin.on('end', () => { + processInput(sourceCode); + }); +} diff --git a/packages/linter/package.json b/packages/linter/package.json new file mode 100644 index 000000000..c74210f9d --- /dev/null +++ b/packages/linter/package.json @@ -0,0 +1,25 @@ +{ + "name": "@packmind/linter", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "@packmind/deployments": "workspace:*", + "@packmind/git": "workspace:*", + "@packmind/linter-ast": "workspace:*", + "@packmind/linter-execution": "workspace:*", + "@packmind/logger": "workspace:*", + "@packmind/types": "workspace:*", + "@packmind/node-utils": "workspace:*", + "@packmind/standards": "workspace:*", + "bullmq": "^5.23.6", + "typeorm": "0.3.28", + "uuid": "^11.1.0", + "@nestjs/common": "^11.1.6", + "@packmind/test-utils": "workspace:*", + "@packmind/llm": "workspace:*", + "yaml": "^2.8.1" + } +} diff --git a/packages/linter/project.json b/packages/linter/project.json new file mode 100644 index 000000000..2aa87f8db --- /dev/null +++ b/packages/linter/project.json @@ -0,0 +1,20 @@ +{ + "name": "linter", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/linter/src", + "projectType": "library", + "tags": ["env:node"], + "targets": { + "build": { + "executor": "@nx/js:swc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/linter", + "tsConfig": "packages/linter/tsconfig.lib.json", + "packageJson": "packages/linter/package.json", + "main": "packages/linter/src/index.ts", + "assets": ["packages/linter/*.md"] + } + } + } +} diff --git a/packages/linter/src/LinterHexa.ts b/packages/linter/src/LinterHexa.ts new file mode 100644 index 000000000..85f0e6516 --- /dev/null +++ b/packages/linter/src/LinterHexa.ts @@ -0,0 +1,363 @@ +import { JobsService, PackmindEventEmitterService } from '@packmind/node-utils'; +import { LinterAstAdapter } from '@packmind/linter-ast'; +import { ExecuteLinterProgramsUseCase } from '@packmind/linter-execution'; +import { PackmindLogger } from '@packmind/logger'; +import { BaseHexa, BaseHexaOpts, HexaRegistry } from '@packmind/node-utils'; +import { + IAccountsPort, + IAccountsPortName, + IDeploymentPort, + IDeploymentPortName, + IEventTrackingPort, + IEventTrackingPortName, + IGitPort, + IGitPortName, + ILinterAstPort, + ILinterPort, + ILinterPortName, + ILlmPort, + ILlmPortName, + ISpacesPort, + ISpacesPortName, + IStandardsPort, + IStandardsPortName, +} from '@packmind/types'; +import { DataSource } from 'typeorm'; +import { LinterAdapter } from './application/adapter/LinterAdapter'; +import { LinterListener } from './application/listeners/LinterListener'; +import { DetectionProgramService } from './application/services/DetectionProgramService'; +import { ILinterDelayedJobs } from './domain/jobs/ILinterDelayedJobs'; +import { AssessRuleDetectionJobFactory } from './infra/AssessRuleDetectionJobFactory'; +import { GenerateProgramJobFactory } from './infra/GenerateProgramJobFactory'; +import { MoveLinterArtefactsJobFactory } from './infra/MoveLinterArtefactsJobFactory'; +import { LinterRepositories } from './infra/repositories/LinterRepositories'; + +const origin = 'LinterHexa'; + +/** + * LinterHexa - Facade for the Linter domain following hexagonal architecture. + * + * This class serves as the main entry point for linter-related functionality. + * It handles the generation of detection programs for rules within standards. + * + * The constructor instantiates repositories, services, and adapter (but not initialized). + * The initialize method retrieves ports from registry, initializes delayed jobs, and completes async setup. + * + * Note: JobsHexa can be referenced directly (infrastructure, no port exposed) - only for delayed job access. + */ +export type LinterHexaOpts = BaseHexaOpts; + +const baseLinterHexaOpts = { logger: new PackmindLogger(origin) }; + +export class LinterHexa extends BaseHexa<LinterHexaOpts, ILinterPort> { + private readonly linterRepositories: LinterRepositories; + private readonly detectionProgramService: DetectionProgramService; + private readonly adapter: LinterAdapter; + private readonly linterListener: LinterListener; + private linterAstAdapter: ILinterAstPort | null = null; + private standardsPort: IStandardsPort | null = null; + + constructor(dataSource: DataSource, opts?: Partial<LinterHexaOpts>) { + super(dataSource, { ...baseLinterHexaOpts, ...opts }); + this.logger.info('Constructing LinterHexa'); + + try { + this.logger.debug( + 'Creating repository and service aggregators with DataSource', + ); + + // Instantiate repositories + this.linterRepositories = new LinterRepositories(this.dataSource); + + // Instantiate services + this.detectionProgramService = new DetectionProgramService( + this.linterRepositories, + ); + + // Initialize linter-ast adapter early + try { + this.linterAstAdapter = new LinterAstAdapter(); + this.logger.info('LinterAstAdapter initialized successfully', { + adapter: this.linterAstAdapter ? 'present' : 'null', + availableLanguages: + this.linterAstAdapter?.getAvailableLanguages?.() || [], + }); + } catch (error) { + this.logger.error( + 'Failed to initialize LinterAstAdapter - will fall back to js-playground', + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }, + ); + } + + // Create ExecuteLinterProgramsUseCase + const executeLinterProgramsUseCase = new ExecuteLinterProgramsUseCase( + this.linterAstAdapter || undefined, + ); + + // Create adapter with minimal dependencies (ports will be set in initialize) + const hexaFactory: { + getDetectionProgramService(): DetectionProgramService; + getRepositories(): LinterRepositories; + } = { + getDetectionProgramService: () => this.detectionProgramService, + getRepositories: () => this.linterRepositories, + }; + + // Create adapter in constructor (ports will be set in initialize) + this.adapter = new LinterAdapter({ + hexaFactory, + executeLinterProgramsUseCase, + }); + + this.linterListener = new LinterListener(this.adapter.getPort()); + + this.logger.debug( + 'Repository aggregator, service aggregator, and adapter created successfully', + ); + + this.logger.info('LinterHexa construction completed'); + } catch (error) { + this.logger.error('Failed to construct LinterHexa', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * Async initialization phase - retrieves ports and initializes delayed jobs. + * Called by HexaRegistry after all hexas are constructed. + */ + async initialize(registry: HexaRegistry): Promise<void> { + this.logger.info('Initializing LinterHexa (adapter retrieval phase)'); + + try { + // Get all required ports - let errors propagate + const gitPort = registry.getAdapter<IGitPort>(IGitPortName); + this.logger.debug('Retrieved GitAdapter from registry'); + + this.standardsPort = + registry.getAdapter<IStandardsPort>(IStandardsPortName); + const standardsPort = this.standardsPort; + this.logger.debug('Retrieved StandardsAdapter from registry'); + + const accountsPort = + registry.getAdapter<IAccountsPort>(IAccountsPortName); + this.logger.debug('Retrieved AccountsAdapter from registry'); + + // Get optional ports + let deploymentsPort: IDeploymentPort | undefined; + try { + deploymentsPort = + registry.getAdapter<IDeploymentPort>(IDeploymentPortName); + this.logger.debug('Retrieved DeploymentsAdapter from registry'); + } catch (error) { + this.logger.debug('DeploymentsHexa not available in registry', { + error: error instanceof Error ? error.message : String(error), + }); + } + + let spacesPort: ISpacesPort | undefined; + try { + spacesPort = registry.getAdapter<ISpacesPort>(ISpacesPortName); + this.logger.debug('Retrieved SpacesAdapter from registry'); + } catch (error) { + this.logger.debug('SpacesHexa not available in registry', { + error: error instanceof Error ? error.message : String(error), + }); + } + + let eventTrackingPort: IEventTrackingPort | null = null; + try { + eventTrackingPort = registry.getAdapter<IEventTrackingPort>( + IEventTrackingPortName, + ); + this.logger.debug('Retrieved EventTrackingAdapter from registry'); + } catch (error) { + this.logger.debug('EventTrackingPort not available in registry', { + error: error instanceof Error ? error.message : String(error), + }); + } + + // Get llm port (required for AI features) + const llmPort = registry.getAdapter<ILlmPort>(ILlmPortName); + this.logger.debug('Retrieved LlmAdapter from registry'); + + // Get JobsHexa (required) for delayed job registration + const jobsService = registry.getService(JobsService); + if (!jobsService) { + throw new Error('JobsHexa not found in registry'); + } + + // Get PackmindEventEmitterService (required) - for domain event emission + const eventEmitterService = registry.getService( + PackmindEventEmitterService, + ); + + // Build delayed jobs + this.logger.debug('Building linter delayed jobs'); + const linterDelayedJobs = await this.buildLinterDelayedJobs( + jobsService, + () => standardsPort, + () => this.getAdapter(), + () => llmPort, + ); + + // Initialize adapter once with all ports and delayed jobs + this.logger.debug('Initializing LinterAdapter with all ports'); + await this.adapter.initialize({ + [IGitPortName]: gitPort, + [IStandardsPortName]: standardsPort, + [IAccountsPortName]: accountsPort, + [IDeploymentPortName]: deploymentsPort, + [ISpacesPortName]: spacesPort, + [IEventTrackingPortName]: eventTrackingPort, + llmPort, + linterAstPort: this.linterAstAdapter, + linterDelayedJobs, + eventEmitterService, + }); + + this.linterListener.initialize(eventEmitterService); + + this.logger.info('LinterHexa initialized successfully'); + + // Start the job workers + await this.initializeJobQueues(jobsService); + } catch (error) { + this.logger.error('Failed to initialize LinterHexa', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * Build delayed jobs for linter operations + */ + private async buildLinterDelayedJobs( + jobsService: JobsService, + getStandardsAdapter: () => IStandardsPort, + getLinterAdapter: () => ILinterPort, + getLlmPort: () => ILlmPort, + ): Promise<ILinterDelayedJobs> { + // Register generate program job queue with JobsService + const generateProgramJobFactory = new GenerateProgramJobFactory( + this.linterRepositories, + getStandardsAdapter, + () => this.linterAstAdapter, + getLinterAdapter, + getLlmPort, + ); + + jobsService.registerJobQueue( + generateProgramJobFactory.getQueueName(), + generateProgramJobFactory, + ); + + await generateProgramJobFactory.createQueue(); + + if (!generateProgramJobFactory.delayedJob) { + throw new Error('DelayedJob not found for GenerateProgramJobFactory'); + } + + // Register assess rule detection job queue with JobsService + const assessRuleDetectionJobFactory = new AssessRuleDetectionJobFactory( + this.linterRepositories, + getStandardsAdapter, + getLinterAdapter, + getLlmPort, + ); + + jobsService.registerJobQueue( + assessRuleDetectionJobFactory.getQueueName(), + assessRuleDetectionJobFactory, + ); + + await assessRuleDetectionJobFactory.createQueue(); + + if (!assessRuleDetectionJobFactory.delayedJob) { + throw new Error('DelayedJob not found for AssessRuleDetectionJobFactory'); + } + + // Register move linter artefacts job queue with JobsService + const moveLinterArtefactsJobFactory = new MoveLinterArtefactsJobFactory( + this.linterRepositories, + getLinterAdapter, + ); + + jobsService.registerJobQueue( + moveLinterArtefactsJobFactory.getQueueName(), + moveLinterArtefactsJobFactory, + ); + + await moveLinterArtefactsJobFactory.createQueue(); + + if (!moveLinterArtefactsJobFactory.delayedJob) { + throw new Error('DelayedJob not found for MoveLinterArtefactsJobFactory'); + } + + return { + generateProgramDelayedJob: generateProgramJobFactory.delayedJob, + assessRuleDetectionDelayedJob: assessRuleDetectionJobFactory.delayedJob, + moveLinterArtefactsDelayedJob: moveLinterArtefactsJobFactory.delayedJob, + }; + } + + /** + * Initialize job queues - starts the workers for processing delayed jobs. + * This is automatically called during the async initialization phase. + */ + private async initializeJobQueues(jobsService: JobsService): Promise<void> { + this.logger.info('Initializing job queues for LinterHexa'); + + try { + await jobsService.initJobQueues(); + this.logger.info('Job queues initialized successfully for LinterHexa'); + } catch (error) { + this.logger.error('Failed to initialize job queues for LinterHexa', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + destroy(): void { + this.logger.info('Destroying LinterHexa'); + // Add any cleanup logic here if needed + this.logger.info('LinterHexa destroyed'); + } + + /** + * Get the Linter adapter for cross-domain access. + * This adapter implements ILinterPort and can be injected into other domains. + * The adapter is created during construction, and ports are set during initialization. + */ + public getAdapter(): ILinterPort { + return this.adapter.getPort(); + } + + /** + * Get the port name constant for registry mapping. + * Used by HexaRegistry to build the port-to-hexa map. + */ + public getPortName(): string { + return ILinterPortName; + } + + /** + * Get the Standards port for accessing rule data. + * This is used by the service layer to fetch rules when starting assessments. + */ + public getStandardsPort(): IStandardsPort { + if (!this.standardsPort) { + throw new Error( + 'StandardsPort not initialized. Call initialize() first.', + ); + } + return this.standardsPort; + } +} diff --git a/packages/linter/src/application/adapter/LinterAdapter.spec.ts b/packages/linter/src/application/adapter/LinterAdapter.spec.ts new file mode 100644 index 000000000..eeb8e45a9 --- /dev/null +++ b/packages/linter/src/application/adapter/LinterAdapter.spec.ts @@ -0,0 +1,191 @@ +import { + IAccountsPort, + IAccountsPortName, + IDeploymentPort, + IDeploymentPortName, + IEventTrackingPortName, + IExecuteLinterProgramsUseCase, + IGitPort, + IGitPortName, + ISpacesPort, + ISpacesPortName, + IStandardsPort, + IStandardsPortName, +} from '@packmind/types'; +import { ILinterDelayedJobs } from '../../domain/jobs/ILinterDelayedJobs'; +import { ILinterRepositories } from '../../domain/repositories/ILinterRepositories'; +import { DetectionProgramService } from '../services/DetectionProgramService'; +import { LinterAdapter } from './LinterAdapter'; + +describe('LinterAdapter', () => { + let adapter: LinterAdapter; + let mockDetectionProgramService: DetectionProgramService; + let mockRepositories: ILinterRepositories; + let mockStandardsPort: IStandardsPort; + let mockGitPort: IGitPort; + let mockAccountsPort: IAccountsPort; + let mockLinterDelayedJobs: ILinterDelayedJobs; + let mockDeploymentsPort: IDeploymentPort; + let mockSpacesPort: ISpacesPort; + + beforeEach(() => { + // Create mocks + mockDetectionProgramService = {} as DetectionProgramService; + mockRepositories = { + getDetectionProgramRepository: jest.fn(), + getActiveDetectionProgramRepository: jest.fn(), + getRuleDetectionAssessmentRepository: jest.fn(), + getDetectionHeuristicsRepository: jest.fn(), + } as unknown as ILinterRepositories; + + mockStandardsPort = {} as IStandardsPort; + mockGitPort = {} as IGitPort; + mockAccountsPort = {} as IAccountsPort; + mockLinterDelayedJobs = { + generateProgramDelayedJob: {}, + assessRuleDetectionDelayedJob: {}, + } as ILinterDelayedJobs; + mockDeploymentsPort = {} as IDeploymentPort; + mockSpacesPort = {} as ISpacesPort; + + // Create adapter + adapter = new LinterAdapter({ + hexaFactory: { + getDetectionProgramService: () => mockDetectionProgramService, + getRepositories: () => mockRepositories, + }, + executeLinterProgramsUseCase: {} as IExecuteLinterProgramsUseCase, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('isReady', () => { + describe('when adapter not initialized', () => { + it('returns false', () => { + expect(adapter.isReady()).toBe(false); + }); + }); + + describe('when all required ports are set', () => { + it('returns true', async () => { + await adapter.initialize({ + [IGitPortName]: mockGitPort, + [IStandardsPortName]: mockStandardsPort, + [IAccountsPortName]: mockAccountsPort, + [IEventTrackingPortName]: null, + llmPort: null, + linterDelayedJobs: mockLinterDelayedJobs, + }); + + expect(adapter.isReady()).toBe(true); + }); + }); + + describe('when missing required port', () => { + it('returns false', async () => { + const adapterPartial = new LinterAdapter({ + hexaFactory: { + getDetectionProgramService: () => mockDetectionProgramService, + getRepositories: () => mockRepositories, + }, + executeLinterProgramsUseCase: {} as IExecuteLinterProgramsUseCase, + }); + + // Don't initialize - should be false + expect(adapterPartial.isReady()).toBe(false); + }); + }); + }); + + describe('initialize', () => { + describe('when required ports not provided', () => { + it('throws', async () => { + await expect( + adapter.initialize({ + [IGitPortName]: mockGitPort, + // Missing IStandardsPortName and IAccountsPortName + linterDelayedJobs: mockLinterDelayedJobs, + } as Parameters<typeof adapter.initialize>[0]), + ).rejects.toThrow('LinterAdapter: Required ports not provided'); + }); + }); + + describe('when all required ports are provided', () => { + it('completes without error', async () => { + await expect( + adapter.initialize({ + [IGitPortName]: mockGitPort, + [IStandardsPortName]: mockStandardsPort, + [IAccountsPortName]: mockAccountsPort, + [IEventTrackingPortName]: null, + llmPort: null, + linterDelayedJobs: mockLinterDelayedJobs, + }), + ).resolves.not.toThrow(); + }); + + it('sets adapter to ready state', async () => { + await adapter.initialize({ + [IGitPortName]: mockGitPort, + [IStandardsPortName]: mockStandardsPort, + [IAccountsPortName]: mockAccountsPort, + [IEventTrackingPortName]: null, + llmPort: null, + linterDelayedJobs: mockLinterDelayedJobs, + }); + + expect(adapter.isReady()).toBe(true); + }); + }); + + describe('when optional ports are also provided', () => { + it('completes without error', async () => { + await expect( + adapter.initialize({ + [IGitPortName]: mockGitPort, + [IStandardsPortName]: mockStandardsPort, + [IAccountsPortName]: mockAccountsPort, + [IDeploymentPortName]: mockDeploymentsPort, + [ISpacesPortName]: mockSpacesPort, + [IEventTrackingPortName]: null, + llmPort: null, + linterDelayedJobs: mockLinterDelayedJobs, + }), + ).resolves.not.toThrow(); + }); + + it('sets adapter to ready state', async () => { + await adapter.initialize({ + [IGitPortName]: mockGitPort, + [IStandardsPortName]: mockStandardsPort, + [IAccountsPortName]: mockAccountsPort, + [IDeploymentPortName]: mockDeploymentsPort, + [ISpacesPortName]: mockSpacesPort, + [IEventTrackingPortName]: null, + llmPort: null, + linterDelayedJobs: mockLinterDelayedJobs, + }); + + expect(adapter.isReady()).toBe(true); + }); + }); + }); + + describe('getPort', () => { + it('returns the adapter as port interface', async () => { + await adapter.initialize({ + [IGitPortName]: mockGitPort, + [IStandardsPortName]: mockStandardsPort, + [IAccountsPortName]: mockAccountsPort, + [IEventTrackingPortName]: null, + llmPort: null, + linterDelayedJobs: mockLinterDelayedJobs, + }); + + expect(adapter.getPort()).toBe(adapter); + }); + }); +}); diff --git a/packages/linter/src/application/adapter/LinterAdapter.ts b/packages/linter/src/application/adapter/LinterAdapter.ts new file mode 100644 index 000000000..7faf8bf21 --- /dev/null +++ b/packages/linter/src/application/adapter/LinterAdapter.ts @@ -0,0 +1,625 @@ +import { + IBaseAdapter, + PackmindEventEmitterService, +} from '@packmind/node-utils'; +import { + IAccountsPortName, + IDeploymentPortName, + IEventTrackingPortName, + IGitPortName, + ISpacesPortName, + IStandardsPortName, + TrackLinterExecutionCommand, +} from '@packmind/types'; +import type { + IAccountsPort, + IDeploymentPort, + IEventTrackingPort, + IGitPort, + ILinterAstPort, + ILinterPort, + ILlmPort, + ISpacesPort, + IStandardsPort, +} from '@packmind/types'; +import { + ActiveDetectionProgram, + ActiveDetectionProgramId, + DetectionProgram, + RuleDetectionAssessment, +} from '../../domain'; +import type { ILinterDelayedJobs } from '../../domain/jobs/ILinterDelayedJobs'; +import type { ILinterRepositories } from '../../domain/repositories/ILinterRepositories'; +import { + AssessRuleDetectionInput, + ComputeRuleLanguageDetectionStatusCommand, + ComputeRuleLanguageDetectionStatusResponse, + CopyDetectionHeuristicsCommand, + CopyDetectionHeuristicsResponse, + CopyDetectionProgramsToNewRuleCommand, + CopyDetectionProgramsToNewRuleResponse, + CopyLinterArtefactsCommand, + CopyLinterArtefactsResponse, + MoveLinterArtefactsToNewRulesCommand, + MoveLinterArtefactsToNewRulesResponse, + CopyRuleDetectionAssessmentsCommand, + CopyRuleDetectionAssessmentsResponse, + CreateDetectionHeuristicsCommand, + CreateDetectionHeuristicsResponse, + CreateDetectionProgramCommand, + CreateEmptyRuleDetectionAssessmentCommand, + CreateEmptyRuleDetectionAssessmentResponse, + CreateNewDetectionProgramVersionCommand, + GetActiveDetectionProgramCommand, + GetActiveDetectionProgramForRuleCommand, + GetActiveDetectionProgramForRuleResponse, + GetActiveDetectionProgramResponse, + GetAllDetectionProgramsByRuleCommand, + GetAllDetectionProgramsByRuleResponse, + GetDetectionHeuristicsCommand, + GetDetectionHeuristicsResponse, + GetDetectionProgramMetadataCommand, + GetDetectionProgramMetadataResponse, + GetDetectionProgramsForPackagesCommand, + GetDetectionProgramsForPackagesResponse, + GetDraftDetectionProgramForRuleCommand, + GetDraftDetectionProgramForRuleResponse, + GetRuleDetectionAssessmentCommand, + GetRuleDetectionAssessmentResponse, + GetStandardRulesDetectionStatusCommand, + GetStandardRulesDetectionStatusResponse, + IExecuteLinterProgramsUseCase, + ListDetectionProgramCommand, + ListDetectionProgramResponse, + StartProgramGenerationCommand, + StartProgramGenerationResponse, + TestProgramExecutionCommand, + TestProgramExecutionResponse, + UpdateActiveDetectionProgramCommand, + UpdateDetectionProgramCommand, + UpdateDetectionProgramStatusCommand, + UpdateRuleDetectionAssessmentAfterUpdateResponse, + UpdateRuleDetectionHeuristicsCommand, + UpdateRuleDetectionHeuristicsResponse, + UpdateHeuristicsFollowingChatbotInputCommand, + UpdateHeuristicsFollowingChatbotInputResponse, + UpdateRuleDetectionStatusAfterUpdateCommand, + UpdateActiveDetectionProgramSeverityCommand, +} from '@packmind/types'; +import type { DetectionProgramService } from '../services/DetectionProgramService'; +import { ComputeRuleLanguageDetectionStatusUseCase } from '../useCases/computeRuleLanguageDetectionStatus/ComputeRuleLanguageDetectionStatusUseCase'; +import { CopyDetectionHeuristicsUseCase } from '../useCases/copyDetectionHeuristics/CopyDetectionHeuristicsUseCase'; +import { CopyDetectionProgramsToNewRuleUseCase } from '../useCases/copyDetectionProgramsToNewRule/CopyDetectionProgramsToNewRuleUseCase'; +import { CopyLinterArtefactsUseCase } from '../useCases/copyLinterArtefacts/CopyLinterArtefactsUseCase'; +import { MoveLinterArtefactsToNewRulesUseCase } from '../useCases/moveLinterArtefactsToNewRules/MoveLinterArtefactsToNewRulesUseCase'; +import { SoftDeleteLinterArtefactsByRuleUseCase } from '../useCases/softDeleteLinterArtefactsByRule/SoftDeleteLinterArtefactsByRuleUseCase'; +import { CopyRuleDetectionAssessmentsUseCase } from '../useCases/copyRuleDetectionAssessments/CopyRuleDetectionAssessmentsUseCase'; +import { CreateDetectionHeuristicsUseCase } from '../useCases/createDetectionHeuristics/CreateDetectionHeuristicsUseCase'; +import { CreateEmptyRuleDetectionAssessmentUseCase } from '../useCases/createEmptyRuleDetectionAssessment/CreateEmptyRuleDetectionAssessmentUseCase'; +import { GetDetectionProgramsForPackagesUseCase } from '../useCases/getDetectionProgramsForPackages/GetDetectionProgramsForPackagesUseCase'; +import { CreateDetectionProgramUseCase } from '../useCases/createDetectionProgram/CreateDetectionProgramUseCase'; +import { CreateNewDetectionProgramVersionUseCase } from '../useCases/createNewDetectionProgramVersion/CreateNewDetectionProgramVersionUseCase'; +import { GetActiveDetectionProgramUseCase } from '../useCases/getActiveDetectionProgram/GetActiveDetectionProgramUseCase'; +import { GetActiveDetectionProgramForRuleUseCase } from '../useCases/getActiveDetectionProgramForRule/GetActiveDetectionProgramForRuleUseCase'; +import { GetAllDetectionProgramsByRuleUseCase } from '../useCases/getAllDetectionProgramsByRule/GetAllDetectionProgramsByRuleUseCase'; +import { GetDetectionHeuristicsUseCase } from '../useCases/getDetectionHeuristics/GetDetectionHeuristicsUseCase'; +import { GetDetectionProgramMetadataUseCase } from '../useCases/getDetectionProgramMetadata/GetDetectionProgramMetadataUseCase'; +import { GetDraftDetectionProgramForRuleUseCase } from '../useCases/getDraftDetectionProgramForRule/GetDraftDetectionProgramForRuleUseCase'; +import { GetRuleDetectionAssessmentUseCase } from '../useCases/getRuleDetectionAssessment/GetRuleDetectionAssessmentUseCase'; +import { GetStandardRulesDetectionStatusUseCase } from '../useCases/getStandardRulesDetectionStatus/GetStandardRulesDetectionStatusUseCase'; +import { ListDetectionProgramUseCase } from '../useCases/listDetectionProgram/ListDetectionProgramUseCase'; +import { StartGenerationProgramUseCase } from '../useCases/startProgramGeneration/StartGenerationProgramUseCase'; +import { StartRuleDetectionAssessmentUseCase } from '../useCases/startRuleDetectionAssessment/StartRuleDetectionAssessmentUseCase'; +import { TestProgramExecutionUseCase } from '../useCases/testProgramExecutionUseCase/TestProgramExecutionUseCase'; +import { UpdateActiveDetectionProgramUseCase } from '../useCases/updateActiveDetectionProgram/UpdateActiveDetectionProgramUseCase'; +import { UpdateActiveDetectionProgramSeverityUseCase } from '../useCases/updateActiveDetectionProgramSeverity/UpdateActiveDetectionProgramSeverityUseCase'; +import { UpdateDetectionProgramUseCase } from '../useCases/updateDetectionProgram/UpdateDetectionProgramUseCase'; +import { UpdateDetectionProgramStatusUseCase } from '../useCases/updateDetectionProgramStatus/UpdateDetectionProgramStatusUseCase'; +import { TrackLinterExecutionUseCase } from '../useCases/trackLinterExecution/TrackLinterExecutionUseCase'; +import { UpdateRuleDetectionHeuristicsUseCase } from '../useCases/updateRuleDetectionHeuristics/UpdateRuleDetectionHeuristicsUseCase'; +import { GenerateHeuristicFollowingChatbotInputUseCase } from '../useCases/generateHeuristicFollowingChatbotInput/GenerateHeuristicFollowingChatbotInputUseCase'; +import { UpdateRuleDetectionStatusAfterUpdateUseCase } from '../useCases/updateRuleDetectionStatusAfterUpdate/UpdateRuleDetectionStatusAfterUpdateUseCase'; + +type LinterAdapterDependencies = { + hexaFactory: { + getDetectionProgramService(): DetectionProgramService; + getRepositories(): ILinterRepositories; + }; + executeLinterProgramsUseCase: IExecuteLinterProgramsUseCase; +}; + +/** + * LinterAdapter - Adapter for the Linter domain following hexagonal architecture. + * + * This adapter implements the ILinterPort interface and exposes linter functionality + * to other domains. It manages detection programs, assessments, and heuristics for rules. + * + * Required ports: + * - IStandardsPort: Access to standards and rules + * - IGitPort: Access to git repositories + * - IAccountsPort: Access to user and organization data + * + * Optional ports (set to undefined if not needed): + * - IDeploymentPort: Access to deployment information (for listDetectionPrograms) + * - ISpacesPort: Access to space information (for listDetectionPrograms) + * + * The adapter also requires ILinterDelayedJobs which is built in LinterHexa + * and passed during initialization. + */ +export class LinterAdapter implements IBaseAdapter<ILinterPort>, ILinterPort { + private readonly detectionProgramService: DetectionProgramService; + private readonly repositories: ILinterRepositories; + private readonly executeLinterProgramsUseCase: IExecuteLinterProgramsUseCase; + + // Required ports - set in initialize() + private standardsPort!: IStandardsPort; + private gitPort!: IGitPort; + private accountsPort!: IAccountsPort; + private linterDelayedJobs!: ILinterDelayedJobs; + + // Optional ports - may be null + private deploymentsPort: IDeploymentPort | undefined; + private spacesPort: ISpacesPort | undefined; + private eventTrackingPort: IEventTrackingPort | null = null; + private llmPort: ILlmPort | null = null; + private linterAstPort: ILinterAstPort | null = null; + + // Use cases - created in initialize() after ports are set + private _createDetectionProgramUseCase!: CreateDetectionProgramUseCase; + private _getActiveDetectionProgramUseCase!: GetActiveDetectionProgramUseCase; + private _createNewDetectionProgramVersionUseCase!: CreateNewDetectionProgramVersionUseCase; + private _listDetectionProgramUseCase!: ListDetectionProgramUseCase; + private _updateDetectionProgramUseCase!: UpdateDetectionProgramUseCase; + private _updateActiveDetectionProgramUseCase!: UpdateActiveDetectionProgramUseCase; + private _updateActiveDetectionProgramSeverityUseCase!: UpdateActiveDetectionProgramSeverityUseCase; + private _startGenerationProgramUseCase!: StartGenerationProgramUseCase; + private _getAllDetectionProgramsByRuleUseCase!: GetAllDetectionProgramsByRuleUseCase; + private _getDetectionProgramMetadataUseCase!: GetDetectionProgramMetadataUseCase; + private _copyDetectionProgramsToNewRuleUseCase!: CopyDetectionProgramsToNewRuleUseCase; + private _copyRuleDetectionAssessmentsUseCase!: CopyRuleDetectionAssessmentsUseCase; + private _copyDetectionHeuristicsUseCase!: CopyDetectionHeuristicsUseCase; + private _copyLinterArtefactsUseCase!: CopyLinterArtefactsUseCase; + private _getDraftDetectionProgramForRuleUseCase!: GetDraftDetectionProgramForRuleUseCase; + private _getActiveDetectionProgramForRuleUseCase!: GetActiveDetectionProgramForRuleUseCase; + private _updateDetectionProgramStatusUseCase!: UpdateDetectionProgramStatusUseCase; + private _updateRuleDetectionStatusAfterUpdateUseCase!: UpdateRuleDetectionStatusAfterUpdateUseCase; + private _startRuleDetectionAssessmentUseCase!: StartRuleDetectionAssessmentUseCase; + private _getRuleDetectionAssessmentUseCase!: GetRuleDetectionAssessmentUseCase; + private _computeRuleLanguageDetectionStatusUseCase!: ComputeRuleLanguageDetectionStatusUseCase; + private _getStandardRulesDetectionStatusUseCase!: GetStandardRulesDetectionStatusUseCase; + private _testProgramExecutionUseCase!: TestProgramExecutionUseCase; + private _updateRuleDetectionHeuristicsUseCase!: UpdateRuleDetectionHeuristicsUseCase; + private _generateHeuristicFollowingChatbotInputUseCase!: GenerateHeuristicFollowingChatbotInputUseCase; + private _getDetectionHeuristicsUseCase!: GetDetectionHeuristicsUseCase; + private _createDetectionHeuristicsUseCase!: CreateDetectionHeuristicsUseCase; + private _getDetectionProgramsForPackagesUseCase!: GetDetectionProgramsForPackagesUseCase; + private _createEmptyRuleDetectionAssessmentUseCase!: CreateEmptyRuleDetectionAssessmentUseCase; + private _trackLinterExecutionUseCase!: TrackLinterExecutionUseCase; + private _softDeleteLinterArtefactsByRuleUseCase!: SoftDeleteLinterArtefactsByRuleUseCase; + private _moveLinterArtefactsToNewRulesUseCase!: MoveLinterArtefactsToNewRulesUseCase; + + constructor({ + hexaFactory, + executeLinterProgramsUseCase, + }: LinterAdapterDependencies) { + this.detectionProgramService = hexaFactory.getDetectionProgramService(); + this.repositories = hexaFactory.getRepositories(); + this.executeLinterProgramsUseCase = executeLinterProgramsUseCase; + } + + /** + * Initialize adapter with ports from registry. + * All ports in signature are REQUIRED except deploymentsPort, spacesPort, and llmPort. + */ + public async initialize(ports: { + [IStandardsPortName]: IStandardsPort; + [IGitPortName]: IGitPort; + [IAccountsPortName]: IAccountsPort; + [IDeploymentPortName]?: IDeploymentPort; + [ISpacesPortName]?: ISpacesPort; + [IEventTrackingPortName]: IEventTrackingPort | null; + llmPort: ILlmPort | null; + linterAstPort: ILinterAstPort | null; + linterDelayedJobs: ILinterDelayedJobs; + eventEmitterService: PackmindEventEmitterService; + }): Promise<void> { + // Step 1: Set all ports + this.standardsPort = ports[IStandardsPortName]; + this.gitPort = ports[IGitPortName]; + this.accountsPort = ports[IAccountsPortName]; + this.linterDelayedJobs = ports.linterDelayedJobs; + this.deploymentsPort = ports[IDeploymentPortName]; + this.spacesPort = ports[ISpacesPortName]; + this.eventTrackingPort = ports[IEventTrackingPortName]; + this.llmPort = ports.llmPort; + this.linterAstPort = ports.linterAstPort; + + // Step 2: Validate all required ports are set + if (!this.isReady()) { + throw new Error('LinterAdapter: Required ports not provided'); + } + + // Step 3: Create all use cases with non-null ports + this._createDetectionProgramUseCase = new CreateDetectionProgramUseCase( + this.detectionProgramService, + this.standardsPort, + this.linterAstPort, + ); + + this._getActiveDetectionProgramUseCase = + new GetActiveDetectionProgramUseCase( + this.detectionProgramService, + this.standardsPort, + ); + + this._createNewDetectionProgramVersionUseCase = + new CreateNewDetectionProgramVersionUseCase( + this.repositories.getDetectionProgramRepository(), + this.repositories.getActiveDetectionProgramRepository(), + this.standardsPort, + ); + + this._listDetectionProgramUseCase = new ListDetectionProgramUseCase( + this.detectionProgramService, + this.deploymentsPort, + this.standardsPort, + this.spacesPort, + this.gitPort, + ); + + this._updateDetectionProgramUseCase = new UpdateDetectionProgramUseCase( + this.repositories.getDetectionProgramRepository(), + ); + + this._updateActiveDetectionProgramUseCase = + new UpdateActiveDetectionProgramUseCase( + this.repositories.getActiveDetectionProgramRepository(), + this.repositories.getDetectionProgramRepository(), + ); + + this._updateActiveDetectionProgramSeverityUseCase = + new UpdateActiveDetectionProgramSeverityUseCase( + this.repositories.getActiveDetectionProgramRepository(), + ports.eventEmitterService, + ); + + this._startGenerationProgramUseCase = new StartGenerationProgramUseCase( + this.linterDelayedJobs.generateProgramDelayedJob, + this.standardsPort, + () => this, + ); + + this._getAllDetectionProgramsByRuleUseCase = + new GetAllDetectionProgramsByRuleUseCase(this.repositories); + + this._getDetectionProgramMetadataUseCase = + new GetDetectionProgramMetadataUseCase(this.repositories); + + this._copyDetectionProgramsToNewRuleUseCase = + new CopyDetectionProgramsToNewRuleUseCase(this.repositories); + + this._copyRuleDetectionAssessmentsUseCase = + new CopyRuleDetectionAssessmentsUseCase(this.repositories); + + this._copyDetectionHeuristicsUseCase = new CopyDetectionHeuristicsUseCase( + this.repositories, + ); + + this._copyLinterArtefactsUseCase = new CopyLinterArtefactsUseCase(this); + + this._getDraftDetectionProgramForRuleUseCase = + new GetDraftDetectionProgramForRuleUseCase( + this.detectionProgramService, + this.standardsPort, + ); + + this._getActiveDetectionProgramForRuleUseCase = + new GetActiveDetectionProgramForRuleUseCase( + this.detectionProgramService, + this.standardsPort, + ); + + this._updateDetectionProgramStatusUseCase = + new UpdateDetectionProgramStatusUseCase( + this.repositories, + this.standardsPort, + this.executeLinterProgramsUseCase, + ); + + this._updateRuleDetectionStatusAfterUpdateUseCase = + new UpdateRuleDetectionStatusAfterUpdateUseCase( + this.repositories, + this.standardsPort, + () => this, + ); + + this._startRuleDetectionAssessmentUseCase = + new StartRuleDetectionAssessmentUseCase( + this.repositories, + this.linterDelayedJobs, + this.standardsPort, + ); + + this._getRuleDetectionAssessmentUseCase = + new GetRuleDetectionAssessmentUseCase( + this.repositories, + this.accountsPort, + ); + + this._computeRuleLanguageDetectionStatusUseCase = + new ComputeRuleLanguageDetectionStatusUseCase(this.repositories); + + this._getStandardRulesDetectionStatusUseCase = + new GetStandardRulesDetectionStatusUseCase( + this.accountsPort, + this.standardsPort, + this._computeRuleLanguageDetectionStatusUseCase, + this.repositories.getActiveDetectionProgramRepository(), + ); + + this._testProgramExecutionUseCase = new TestProgramExecutionUseCase( + this.repositories, + this.executeLinterProgramsUseCase, + this.standardsPort, + ); + + this._generateHeuristicFollowingChatbotInputUseCase = + new GenerateHeuristicFollowingChatbotInputUseCase( + this.accountsPort, + this.repositories, + this.standardsPort, + this.llmPort, + ); + + this._updateRuleDetectionHeuristicsUseCase = + new UpdateRuleDetectionHeuristicsUseCase( + this.repositories, + this.standardsPort, + () => this, + ); + + this._getDetectionHeuristicsUseCase = new GetDetectionHeuristicsUseCase( + this.repositories, + ); + + this._createDetectionHeuristicsUseCase = + new CreateDetectionHeuristicsUseCase(this.repositories); + + this._getDetectionProgramsForPackagesUseCase = + new GetDetectionProgramsForPackagesUseCase( + this.detectionProgramService, + this.deploymentsPort, + this.standardsPort, + this.spacesPort, + ); + + this._createEmptyRuleDetectionAssessmentUseCase = + new CreateEmptyRuleDetectionAssessmentUseCase(this.repositories); + + this._trackLinterExecutionUseCase = new TrackLinterExecutionUseCase( + ports.eventEmitterService, + ); + + this._softDeleteLinterArtefactsByRuleUseCase = + new SoftDeleteLinterArtefactsByRuleUseCase(this.repositories); + + this._moveLinterArtefactsToNewRulesUseCase = + new MoveLinterArtefactsToNewRulesUseCase( + this, + this._softDeleteLinterArtefactsByRuleUseCase, + ); + } + + public isReady(): boolean { + return ( + this.standardsPort !== undefined && + this.gitPort !== undefined && + this.accountsPort !== undefined && + this.linterDelayedJobs !== undefined + ); + } + + public getPort(): ILinterPort { + return this as ILinterPort; + } + + // ILinterPort implementation + + async createDetectionProgram( + command: CreateDetectionProgramCommand, + ): Promise<DetectionProgram> { + return this._createDetectionProgramUseCase.execute(command); + } + + async getActiveDetectionProgram( + command: GetActiveDetectionProgramCommand, + ): Promise<GetActiveDetectionProgramResponse> { + return this._getActiveDetectionProgramUseCase.execute(command); + } + + async createNewDetectionProgramVersion( + command: CreateNewDetectionProgramVersionCommand, + ): Promise<DetectionProgram> { + return this._createNewDetectionProgramVersionUseCase.execute(command); + } + + async listDetectionPrograms( + command: ListDetectionProgramCommand, + ): Promise<ListDetectionProgramResponse> { + return this._listDetectionProgramUseCase.execute(command); + } + + async updateDetectionProgram( + command: UpdateDetectionProgramCommand, + ): Promise<DetectionProgram> { + return this._updateDetectionProgramUseCase.execute(command); + } + + async updateActiveDetectionProgram( + command: UpdateActiveDetectionProgramCommand, + ): Promise<ActiveDetectionProgram> { + return this._updateActiveDetectionProgramUseCase.execute(command); + } + + async updateActiveDetectionProgramSeverity( + command: UpdateActiveDetectionProgramSeverityCommand, + ): Promise<ActiveDetectionProgram> { + return this._updateActiveDetectionProgramSeverityUseCase.execute(command); + } + + async getActiveDetectionProgramById( + activeDetectionProgramId: ActiveDetectionProgramId, + ): Promise<ActiveDetectionProgram | null> { + return this.repositories + .getActiveDetectionProgramRepository() + .findById(activeDetectionProgramId); + } + + async startGenerateProgram( + command: StartProgramGenerationCommand, + ): Promise<StartProgramGenerationResponse> { + return this._startGenerationProgramUseCase.execute(command); + } + + async getAllDetectionProgramsByRule( + command: GetAllDetectionProgramsByRuleCommand, + ): Promise<GetAllDetectionProgramsByRuleResponse> { + return this._getAllDetectionProgramsByRuleUseCase.execute(command); + } + + async getDetectionProgramMetadata( + command: GetDetectionProgramMetadataCommand, + ): Promise<GetDetectionProgramMetadataResponse> { + return this._getDetectionProgramMetadataUseCase.execute(command); + } + + async copyDetectionProgramsToNewRule( + command: CopyDetectionProgramsToNewRuleCommand, + ): Promise<CopyDetectionProgramsToNewRuleResponse> { + return this._copyDetectionProgramsToNewRuleUseCase.execute(command); + } + + async copyRuleDetectionAssessments( + command: CopyRuleDetectionAssessmentsCommand, + ): Promise<CopyRuleDetectionAssessmentsResponse> { + return this._copyRuleDetectionAssessmentsUseCase.execute(command); + } + + async copyDetectionHeuristics( + command: CopyDetectionHeuristicsCommand, + ): Promise<CopyDetectionHeuristicsResponse> { + return this._copyDetectionHeuristicsUseCase.execute(command); + } + + async copyLinterArtefacts( + command: CopyLinterArtefactsCommand, + ): Promise<CopyLinterArtefactsResponse> { + return this._copyLinterArtefactsUseCase.execute(command); + } + + async getDraftDetectionProgramForRule( + command: GetDraftDetectionProgramForRuleCommand, + ): Promise<GetDraftDetectionProgramForRuleResponse> { + return this._getDraftDetectionProgramForRuleUseCase.execute(command); + } + + async getActiveDetectionProgramForRule( + command: GetActiveDetectionProgramForRuleCommand, + ): Promise<GetActiveDetectionProgramForRuleResponse> { + return this._getActiveDetectionProgramForRuleUseCase.execute(command); + } + + async startRuleDetectionAssessment( + command: Omit<AssessRuleDetectionInput, 'assessmentId'>, + ): Promise<RuleDetectionAssessment> { + return this._startRuleDetectionAssessmentUseCase.execute(command); + } + + async updateDetectionProgramStatus( + command: UpdateDetectionProgramStatusCommand, + ): Promise<UpdateRuleDetectionAssessmentAfterUpdateResponse> { + return this._updateDetectionProgramStatusUseCase.execute(command); + } + + async updateRuleDetectionAssessmentAfterUpdate( + command: UpdateRuleDetectionStatusAfterUpdateCommand, + ): Promise<UpdateRuleDetectionAssessmentAfterUpdateResponse> { + return this._updateRuleDetectionStatusAfterUpdateUseCase.execute(command); + } + + async getRuleDetectionAssessment( + command: GetRuleDetectionAssessmentCommand, + ): Promise<GetRuleDetectionAssessmentResponse> { + return this._getRuleDetectionAssessmentUseCase.execute(command); + } + + async computeRuleLanguageDetectionStatus( + command: ComputeRuleLanguageDetectionStatusCommand, + ): Promise<ComputeRuleLanguageDetectionStatusResponse> { + return this._computeRuleLanguageDetectionStatusUseCase.execute(command); + } + + async getStandardRulesDetectionStatus( + command: GetStandardRulesDetectionStatusCommand, + ): Promise<GetStandardRulesDetectionStatusResponse> { + return this._getStandardRulesDetectionStatusUseCase.execute(command); + } + + async testProgramExecution( + command: TestProgramExecutionCommand, + ): Promise<TestProgramExecutionResponse> { + return this._testProgramExecutionUseCase.execute(command); + } + + async updateRuleDetectionHeuristics( + command: UpdateRuleDetectionHeuristicsCommand, + ): Promise<UpdateRuleDetectionHeuristicsResponse> { + return this._updateRuleDetectionHeuristicsUseCase.execute(command); + } + + async updateHeuristicsFollowingChatbotInput( + command: UpdateHeuristicsFollowingChatbotInputCommand, + ): Promise<UpdateHeuristicsFollowingChatbotInputResponse> { + return this._generateHeuristicFollowingChatbotInputUseCase.execute(command); + } + + async getDetectionHeuristics( + command: GetDetectionHeuristicsCommand, + ): Promise<GetDetectionHeuristicsResponse> { + return this._getDetectionHeuristicsUseCase.execute(command); + } + + async createDetectionHeuristics( + command: CreateDetectionHeuristicsCommand, + ): Promise<CreateDetectionHeuristicsResponse> { + return this._createDetectionHeuristicsUseCase.execute(command); + } + + async getDetectionProgramsForPackages( + command: GetDetectionProgramsForPackagesCommand, + ): Promise<GetDetectionProgramsForPackagesResponse> { + return this._getDetectionProgramsForPackagesUseCase.execute(command); + } + + async createEmptyRuleDetectionAssessment( + command: CreateEmptyRuleDetectionAssessmentCommand, + ): Promise<CreateEmptyRuleDetectionAssessmentResponse> { + return this._createEmptyRuleDetectionAssessmentUseCase.execute(command); + } + + async trackLinterExecution(command: TrackLinterExecutionCommand) { + return this._trackLinterExecutionUseCase.execute(command); + } + + async moveLinterArtefactsToNewRules( + command: MoveLinterArtefactsToNewRulesCommand, + ): Promise<MoveLinterArtefactsToNewRulesResponse> { + return this._moveLinterArtefactsToNewRulesUseCase.execute(command); + } + + async dispatchMoveLinterArtefactsToNewRules( + command: MoveLinterArtefactsToNewRulesCommand, + ): Promise<void> { + await this.linterDelayedJobs.moveLinterArtefactsDelayedJob.addJob(command); + } +} diff --git a/packages/linter/src/application/listeners/LinterListener.spec.ts b/packages/linter/src/application/listeners/LinterListener.spec.ts new file mode 100644 index 000000000..c4f0d95ec --- /dev/null +++ b/packages/linter/src/application/listeners/LinterListener.spec.ts @@ -0,0 +1,161 @@ +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { stubLogger } from '@packmind/test-utils'; +import { + ILinterPort, + PlaybookArtefactMovedEvent, + createSpaceId, + createOrganizationId, + createUserId, + createRuleId, +} from '@packmind/types'; +import { DataSource } from 'typeorm'; +import { LinterListener } from './LinterListener'; + +describe('LinterListener', () => { + let eventService: PackmindEventEmitterService; + let mockLinterPort: jest.Mocked<ILinterPort>; + let listener: LinterListener; + let mockDataSource: DataSource; + + const sourceSpaceId = createSpaceId('source-space'); + const destinationSpaceId = createSpaceId('dest-space'); + const organizationId = createOrganizationId('org-123'); + const userId = createUserId('user-456'); + const oldRuleId = 'old-rule-id'; + const newRuleId = 'new-rule-id'; + + beforeEach(() => { + mockDataSource = { + isInitialized: true, + options: {}, + } as unknown as DataSource; + + eventService = new PackmindEventEmitterService(mockDataSource); + + mockLinterPort = { + dispatchMoveLinterArtefactsToNewRules: jest + .fn() + .mockResolvedValue(undefined), + } as unknown as jest.Mocked<ILinterPort>; + + listener = new LinterListener(mockLinterPort, stubLogger()); + listener.initialize(eventService); + }); + + afterEach(() => { + eventService.removeAllListeners(); + jest.clearAllMocks(); + }); + + describe('when PlaybookArtefactMovedEvent is emitted for a standard with rule mappings', () => { + let event: PlaybookArtefactMovedEvent; + + beforeEach(() => { + event = new PlaybookArtefactMovedEvent({ + artifactType: 'standard', + oldArtifactId: 'old-standard-id', + newArtifactId: 'new-standard-id', + sourceSpaceId, + destinationSpaceId, + userId, + organizationId, + source: 'ui', + ruleMappings: [{ oldRuleId, newRuleId }], + }); + }); + + it('calls dispatchMoveLinterArtefactsToNewRules on the adapter', async () => { + eventService.emit(event); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect( + mockLinterPort.dispatchMoveLinterArtefactsToNewRules, + ).toHaveBeenCalledWith({ + ruleMappings: [ + { + oldRuleId: createRuleId(oldRuleId), + newRuleId: createRuleId(newRuleId), + }, + ], + userId, + organizationId, + }); + }); + + it('calls dispatchMoveLinterArtefactsToNewRules exactly once', async () => { + eventService.emit(event); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect( + mockLinterPort.dispatchMoveLinterArtefactsToNewRules, + ).toHaveBeenCalledTimes(1); + }); + }); + + describe('when PlaybookArtefactMovedEvent is emitted for a non-standard artifact', () => { + it('does not call dispatchMoveLinterArtefactsToNewRules', async () => { + const event = new PlaybookArtefactMovedEvent({ + artifactType: 'skill', + oldArtifactId: 'old-skill-id', + newArtifactId: 'new-skill-id', + sourceSpaceId, + destinationSpaceId, + userId, + organizationId, + source: 'ui', + }); + + eventService.emit(event); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect( + mockLinterPort.dispatchMoveLinterArtefactsToNewRules, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when PlaybookArtefactMovedEvent is emitted without rule mappings', () => { + it('does not call dispatchMoveLinterArtefactsToNewRules', async () => { + const event = new PlaybookArtefactMovedEvent({ + artifactType: 'standard', + oldArtifactId: 'old-standard-id', + newArtifactId: 'new-standard-id', + sourceSpaceId, + destinationSpaceId, + userId, + organizationId, + source: 'ui', + }); + + eventService.emit(event); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect( + mockLinterPort.dispatchMoveLinterArtefactsToNewRules, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when PlaybookArtefactMovedEvent is emitted with empty rule mappings', () => { + it('does not call dispatchMoveLinterArtefactsToNewRules', async () => { + const event = new PlaybookArtefactMovedEvent({ + artifactType: 'standard', + oldArtifactId: 'old-standard-id', + newArtifactId: 'new-standard-id', + sourceSpaceId, + destinationSpaceId, + userId, + organizationId, + source: 'ui', + ruleMappings: [], + }); + + eventService.emit(event); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect( + mockLinterPort.dispatchMoveLinterArtefactsToNewRules, + ).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/linter/src/application/listeners/LinterListener.ts b/packages/linter/src/application/listeners/LinterListener.ts new file mode 100644 index 000000000..8f497f343 --- /dev/null +++ b/packages/linter/src/application/listeners/LinterListener.ts @@ -0,0 +1,60 @@ +import { PackmindLogger } from '@packmind/logger'; +import { PackmindListener } from '@packmind/node-utils'; +import { + createRuleId, + ILinterPort, + PlaybookArtefactMovedEvent, +} from '@packmind/types'; + +const origin = 'LinterListener'; + +export class LinterListener extends PackmindListener<ILinterPort> { + constructor( + adapter: ILinterPort, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(adapter); + } + + protected registerHandlers(): void { + this.subscribe( + PlaybookArtefactMovedEvent, + this.handlePlaybookArtefactMoved, + ); + } + + private handlePlaybookArtefactMoved = async ( + event: PlaybookArtefactMovedEvent, + ): Promise<void> => { + const { + artifactType, + oldArtifactId, + newArtifactId, + sourceSpaceId, + destinationSpaceId, + ruleMappings, + } = event.payload; + + this.logger.info('Handling PlaybookArtefactMovedEvent', { + artifactType, + oldArtifactId, + newArtifactId, + sourceSpaceId, + destinationSpaceId, + ruleMappingsCount: ruleMappings?.length ?? 0, + }); + + if (artifactType !== 'standard' || !ruleMappings?.length) { + return; + } + + await this.adapter.dispatchMoveLinterArtefactsToNewRules({ + ruleMappings: ruleMappings.map((m) => ({ + oldRuleId: createRuleId(m.oldRuleId), + newRuleId: createRuleId(m.newRuleId), + })), + userId: event.payload.userId, + organizationId: event.payload.organizationId, + }); + }; +} diff --git a/packages/linter/src/application/services/DetectionProgramService.ts b/packages/linter/src/application/services/DetectionProgramService.ts new file mode 100644 index 000000000..e8690fb31 --- /dev/null +++ b/packages/linter/src/application/services/DetectionProgramService.ts @@ -0,0 +1,122 @@ +import { PackmindLogger } from '@packmind/logger'; +import { RuleId } from '@packmind/types'; +import { ILinterRepositories } from '../../domain/repositories/ILinterRepositories'; +import type { + ActiveDetectionProgram, + DetectionProgram, + LanguageDetectionPrograms, +} from '@packmind/types'; + +const origin = 'DetectionProgramService'; + +export class DetectionProgramService { + constructor( + private readonly linterRepositories: ILinterRepositories, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) { + this.logger.info('DetectionProgramService initialized'); + } + + async findActiveByRuleIdAndLanguage(ruleId: RuleId, language: string) { + this.logger.info('Finding active detection program by rule and language', { + ruleId, + language, + }); + try { + const result = await this.linterRepositories + .getActiveDetectionProgramRepository() + .findByRuleIdAndLanguage(ruleId, language); + if (!result) { + this.logger.debug('Active detection program not found', { + ruleId, + language, + }); + } else { + this.logger.debug('Active detection program found', { + ruleId, + language, + }); + } + return result; + } catch (error) { + this.logger.error( + 'Error finding active detection program by rule and language', + { ruleId, language, error }, + ); + throw error; + } + } + + async findActiveByRuleIdWithPrograms( + ruleId: RuleId, + ): Promise<LanguageDetectionPrograms[]> { + this.logger.info( + 'Finding active detection programs with programs by rule', + { ruleId }, + ); + try { + const result = await this.linterRepositories + .getActiveDetectionProgramRepository() + .findByRuleIdWithPrograms(ruleId); + this.logger.debug('Active detection programs with programs fetched', { + ruleId, + count: Array.isArray(result) ? result.length : undefined, + }); + return result; + } catch (error) { + this.logger.error( + 'Error finding active detection programs with programs by rule', + { ruleId, error }, + ); + throw error; + } + } + + async addDetectionProgram(detectionProgram: DetectionProgram) { + this.logger.info('Adding detection program', { + ruleId: detectionProgram.ruleId, + version: detectionProgram.version, + }); + try { + const result = await this.linterRepositories + .getDetectionProgramRepository() + .add(detectionProgram); + this.logger.debug('Detection program added successfully', { + ruleId: detectionProgram.ruleId, + version: detectionProgram.version, + }); + return result; + } catch (error) { + this.logger.error('Error adding detection program', { + ruleId: detectionProgram.ruleId, + version: detectionProgram.version, + error, + }); + throw error; + } + } + + async addActiveDetectionProgram(active: ActiveDetectionProgram) { + this.logger.info('Adding active detection program', { + ruleId: active.ruleId, + language: active.language, + }); + try { + const result = await this.linterRepositories + .getActiveDetectionProgramRepository() + .add(active); + this.logger.debug('Active detection program added successfully', { + ruleId: active.ruleId, + language: active.language, + }); + return result; + } catch (error) { + this.logger.error('Error adding active detection program', { + ruleId: active.ruleId, + language: active.language, + error, + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/assessRuleDetection/AssessRuleDetectionUseCase.spec.ts b/packages/linter/src/application/useCases/assessRuleDetection/AssessRuleDetectionUseCase.spec.ts new file mode 100644 index 000000000..aaf3e0245 --- /dev/null +++ b/packages/linter/src/application/useCases/assessRuleDetection/AssessRuleDetectionUseCase.spec.ts @@ -0,0 +1,653 @@ +import { AssessRuleDetectionUseCase } from './AssessRuleDetectionUseCase'; +import { IRuleDetectionAssessmentRepository } from '../../../domain/repositories/IRuleDetectionAssessmentRepository'; +import { IRuleDetectionHeuristicsRepository } from '../../../domain/repositories/IRuleDetectionHeuristicsRepository'; +import { PackmindLogger } from '@packmind/logger'; +import { createOrganizationId, createUserId } from '@packmind/types'; +import { + createRuleId, + ProgrammingLanguage, + IStandardsPort, + ILinterPort, + ILlmPort, + AIService, + AiNotConfigured, + RuleDetectionAssessmentStatus, + createRuleDetectionAssessmentId, + DetectionModeEnum, + AssessRuleDetectionJobCommand, + createDetectionHeuristicsId, +} from '@packmind/types'; +import { ruleFactory } from '@packmind/standards/test'; +import { stubLogger } from '@packmind/test-utils'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { v4 as uuidv4 } from 'uuid'; +import { RuleDetectionAssessmentService } from './shared/RuleDetectionAssessmentService'; + +// Mock RuleDetectionAssessmentService +jest.mock('./shared/RuleDetectionAssessmentService'); + +describe('AssessRuleDetectionUseCase', () => { + let assessRuleDetectionUseCase: AssessRuleDetectionUseCase; + let ruleDetectionAssessmentRepository: jest.Mocked<IRuleDetectionAssessmentRepository>; + let ruleDetectionHeuristicsRepository: jest.Mocked<IRuleDetectionHeuristicsRepository>; + let linterRepositories: jest.Mocked<ILinterRepositories>; + let standardsAdapter: jest.Mocked<IStandardsPort>; + let linterAdapter: jest.Mocked<ILinterPort>; + let getLinterAdapter: jest.Mock<ILinterPort>; + let llmPort: jest.Mocked<ILlmPort>; + let mockAiService: jest.Mocked<AIService>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + beforeEach(() => { + ruleDetectionAssessmentRepository = { + add: jest.fn(), + findById: jest.fn(), + get: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as unknown as jest.Mocked<IRuleDetectionAssessmentRepository>; + + ruleDetectionHeuristicsRepository = { + upsertHeuristics: jest.fn(), + getHeuristicsForRule: jest.fn().mockResolvedValue(null), + updateHeuristics: jest.fn(), + getHeuristicsById: jest.fn(), + } as unknown as jest.Mocked<IRuleDetectionHeuristicsRepository>; + + linterRepositories = { + getRuleDetectionAssessmentRepository: jest + .fn() + .mockReturnValue(ruleDetectionAssessmentRepository), + getDetectionProgramRepository: jest.fn(), + getActiveDetectionProgramRepository: jest.fn(), + getDetectionProgramMetadataRepository: jest.fn(), + getRuleDetectionHeuristicsRepository: jest + .fn() + .mockReturnValue(ruleDetectionHeuristicsRepository), + } as unknown as jest.Mocked<ILinterRepositories>; + + standardsAdapter = { + getStandard: jest.fn(), + getRule: jest.fn(), + getStandardVersion: jest.fn(), + getStandardVersionById: jest.fn(), + listStandardVersions: jest.fn(), + getLatestRulesByStandardId: jest.fn(), + getRulesByStandardId: jest.fn(), + listStandardsBySpace: jest.fn(), + getRuleCodeExamples: jest.fn().mockResolvedValue([ + { + id: 'example-1', + positive: 'const x = 1;', + negative: 'var x = 1;', + lang: ProgrammingLanguage.TYPESCRIPT, + }, + { + id: 'example-2', + positive: 'const y = 2;', + negative: 'var y = 2;', + lang: ProgrammingLanguage.JAVASCRIPT, + }, + ]), + findStandardBySlug: jest.fn(), + getLatestStandardVersion: jest.fn(), + }; + + linterAdapter = { + createDetectionHeuristics: jest.fn(), + getDetectionHeuristics: jest.fn(), + updateRuleDetectionHeuristics: jest.fn(), + } as unknown as jest.Mocked<ILinterPort>; + + getLinterAdapter = jest.fn().mockReturnValue(linterAdapter); + + // Mock AI service + mockAiService = { + executePrompt: jest.fn(), + isConfigured: jest.fn().mockResolvedValue(true), + } as unknown as jest.Mocked<AIService>; + + // Mock LLM port + llmPort = { + getLlmForOrganization: jest + .fn() + .mockResolvedValue({ aiService: mockAiService }), + } as jest.Mocked<ILlmPort>; + + stubbedLogger = stubLogger(); + + assessRuleDetectionUseCase = new AssessRuleDetectionUseCase( + linterRepositories, + standardsAdapter, + getLinterAdapter, + llmPort, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when assessment is feasible', () => { + let rule: ReturnType<typeof ruleFactory>; + let assessmentId: ReturnType<typeof createRuleDetectionAssessmentId>; + let command: AssessRuleDetectionJobCommand; + + beforeEach(() => { + rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Use const instead of var', + }); + assessmentId = createRuleDetectionAssessmentId(uuidv4()); + command = { + rule, + jobId: 'job-123', + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + assessmentId, + }; + + const MockedService = RuleDetectionAssessmentService as jest.MockedClass< + typeof RuleDetectionAssessmentService + >; + MockedService.mockImplementation( + () => + ({ + runFeasibilityAssessment: jest.fn().mockResolvedValue({ + feasible: true, + reason: ['Rule is detectable with AST'], + }), + }) as unknown as RuleDetectionAssessmentService, + ); + + linterAdapter.createDetectionHeuristics.mockResolvedValue({ + detectionHeuristics: { + id: createDetectionHeuristicsId(uuidv4()), + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: [], + }, + }); + }); + + it('returns correct assessment id', async () => { + const result = await assessRuleDetectionUseCase.execute(command); + + expect(result.assessmentId).toBe(assessmentId); + }); + + it('returns SUCCESS status', async () => { + const result = await assessRuleDetectionUseCase.execute(command); + + expect(result.status).toBe(RuleDetectionAssessmentStatus.SUCCESS); + }); + + it('returns feasible as true', async () => { + const result = await assessRuleDetectionUseCase.execute(command); + + expect(result.feasible).toBe(true); + }); + + it('returns formatted details', async () => { + const result = await assessRuleDetectionUseCase.execute(command); + + expect(result.details).toBe('- Rule is detectable with AST'); + }); + + it('retrieves existing heuristics for rule', async () => { + await assessRuleDetectionUseCase.execute(command); + + expect( + ruleDetectionHeuristicsRepository.getHeuristicsForRule, + ).toHaveBeenCalledWith(rule.id, ProgrammingLanguage.TYPESCRIPT); + }); + + it('calls createDetectionHeuristics use case', async () => { + await assessRuleDetectionUseCase.execute(command); + + expect(linterAdapter.createDetectionHeuristics).toHaveBeenCalledWith({ + userId: command.userId, + organizationId: command.organizationId, + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + }); + }); + + it('filters examples by language before assessment', async () => { + const mockRunFeasibility = jest.fn().mockResolvedValue({ + feasible: true, + reason: [], + }); + const MockedService = RuleDetectionAssessmentService as jest.MockedClass< + typeof RuleDetectionAssessmentService + >; + MockedService.mockImplementation( + () => + ({ + runFeasibilityAssessment: mockRunFeasibility, + }) as unknown as RuleDetectionAssessmentService, + ); + + await assessRuleDetectionUseCase.execute(command); + + expect(mockRunFeasibility).toHaveBeenCalledWith( + expect.objectContaining({ + rule, + language: ProgrammingLanguage.TYPESCRIPT, + ruleExamples: [ + expect.objectContaining({ + lang: ProgrammingLanguage.TYPESCRIPT, + }), + ], + }), + null, + ); + }); + }); + + describe('when assessment is not feasible', () => { + let rule: ReturnType<typeof ruleFactory>; + let assessmentId: ReturnType<typeof createRuleDetectionAssessmentId>; + let command: AssessRuleDetectionJobCommand; + + beforeEach(() => { + rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Complex rule requiring human judgment', + }); + assessmentId = createRuleDetectionAssessmentId(uuidv4()); + command = { + rule, + jobId: 'job-456', + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + assessmentId, + }; + + const MockedService = RuleDetectionAssessmentService as jest.MockedClass< + typeof RuleDetectionAssessmentService + >; + MockedService.mockImplementation( + () => + ({ + runFeasibilityAssessment: jest.fn().mockResolvedValue({ + feasible: false, + reason: [ + 'Requires semantic understanding', + 'Cannot be detected with AST alone', + ], + }), + }) as unknown as RuleDetectionAssessmentService, + ); + + linterAdapter.createDetectionHeuristics.mockResolvedValue({ + detectionHeuristics: { + id: createDetectionHeuristicsId(uuidv4()), + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: [], + }, + }); + }); + + it('returns FAILED status', async () => { + const result = await assessRuleDetectionUseCase.execute(command); + + expect(result.status).toBe(RuleDetectionAssessmentStatus.FAILED); + }); + + it('returns feasible as false', async () => { + const result = await assessRuleDetectionUseCase.execute(command); + + expect(result.feasible).toBe(false); + }); + + it('formats reasons as bullet points', async () => { + const result = await assessRuleDetectionUseCase.execute(command); + + expect(result.details).toBe( + '- Requires semantic understanding\n- Cannot be detected with AST alone', + ); + }); + }); + + describe('when assessment service throws error', () => { + it('propagates the error', async () => { + const rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Test rule', + }); + const command: AssessRuleDetectionJobCommand = { + rule, + jobId: 'job-789', + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + assessmentId: createRuleDetectionAssessmentId(uuidv4()), + }; + + const MockedService = RuleDetectionAssessmentService as jest.MockedClass< + typeof RuleDetectionAssessmentService + >; + MockedService.mockImplementation( + () => + ({ + runFeasibilityAssessment: jest + .fn() + .mockRejectedValue(new Error('AI service unavailable')), + }) as unknown as RuleDetectionAssessmentService, + ); + + await expect(assessRuleDetectionUseCase.execute(command)).rejects.toThrow( + 'AI service unavailable', + ); + }); + }); + + describe('detection heuristics creation', () => { + it('does not create heuristics if they already exist', async () => { + const rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Use const instead of var', + }); + const assessmentId = createRuleDetectionAssessmentId(uuidv4()); + const command: AssessRuleDetectionJobCommand = { + rule, + jobId: 'job-existing', + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + assessmentId, + }; + + // Mock existing heuristics + ruleDetectionHeuristicsRepository.getHeuristicsForRule.mockResolvedValue({ + id: createDetectionHeuristicsId('existing-heuristics-id'), + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['existing heuristics'], + }); + + const MockedService = RuleDetectionAssessmentService as jest.MockedClass< + typeof RuleDetectionAssessmentService + >; + MockedService.mockImplementation( + () => + ({ + runFeasibilityAssessment: jest.fn().mockResolvedValue({ + feasible: true, + reason: [], + }), + }) as unknown as RuleDetectionAssessmentService, + ); + + await assessRuleDetectionUseCase.execute(command); + + expect(linterAdapter.createDetectionHeuristics).not.toHaveBeenCalled(); + }); + }); + + describe('assessment entity creation', () => { + it('creates assessment with SINGLE_AST detection mode', async () => { + const rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Use const instead of var', + }); + const assessmentId = createRuleDetectionAssessmentId(uuidv4()); + const command: AssessRuleDetectionJobCommand = { + rule, + jobId: 'job-123', + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + assessmentId, + }; + + const MockedService = RuleDetectionAssessmentService as jest.MockedClass< + typeof RuleDetectionAssessmentService + >; + MockedService.mockImplementation( + () => + ({ + runFeasibilityAssessment: jest.fn().mockResolvedValue({ + feasible: true, + reason: ['Detectable'], + }), + }) as unknown as RuleDetectionAssessmentService, + ); + + linterAdapter.createDetectionHeuristics.mockResolvedValue({ + detectionHeuristics: { + id: createDetectionHeuristicsId(uuidv4()), + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: [], + }, + }); + + await assessRuleDetectionUseCase.execute(command); + + expect(ruleDetectionAssessmentRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + id: assessmentId, + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + detectionMode: DetectionModeEnum.SINGLE_AST, + }), + ); + }); + }); + + describe('clarification question handling', () => { + describe('when provided by assessment', () => { + it('stores clarification question and answers', async () => { + const rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Use appropriate error handling', + }); + const assessmentId = createRuleDetectionAssessmentId(uuidv4()); + const command: AssessRuleDetectionJobCommand = { + rule, + jobId: 'job-clarification', + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + assessmentId, + }; + + const MockedService = + RuleDetectionAssessmentService as jest.MockedClass< + typeof RuleDetectionAssessmentService + >; + MockedService.mockImplementation( + () => + ({ + runFeasibilityAssessment: jest.fn().mockResolvedValue({ + feasible: false, + reason: ['Need more information about error handling approach'], + clarificationQuestion: { + question: 'What type of error handling should be enforced?', + answers: ['try-catch blocks', 'Promise rejection', 'Both'], + }, + }), + }) as unknown as RuleDetectionAssessmentService, + ); + + linterAdapter.createDetectionHeuristics.mockResolvedValue({ + detectionHeuristics: { + id: createDetectionHeuristicsId(uuidv4()), + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: [], + }, + }); + + await assessRuleDetectionUseCase.execute(command); + + expect(ruleDetectionAssessmentRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + clarificationQuestion: + 'What type of error handling should be enforced?', + clarificationAnswers: [ + 'try-catch blocks', + 'Promise rejection', + 'Both', + ], + }), + ); + }); + }); + + describe('when not provided', () => { + it('stores null for clarification fields', async () => { + const rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Use const instead of var', + }); + const assessmentId = createRuleDetectionAssessmentId(uuidv4()); + const command: AssessRuleDetectionJobCommand = { + rule, + jobId: 'job-no-clarification', + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + assessmentId, + }; + + const MockedService = + RuleDetectionAssessmentService as jest.MockedClass< + typeof RuleDetectionAssessmentService + >; + MockedService.mockImplementation( + () => + ({ + runFeasibilityAssessment: jest.fn().mockResolvedValue({ + feasible: true, + reason: ['Detectable with AST'], + }), + }) as unknown as RuleDetectionAssessmentService, + ); + + linterAdapter.createDetectionHeuristics.mockResolvedValue({ + detectionHeuristics: { + id: createDetectionHeuristicsId(uuidv4()), + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: [], + }, + }); + + await assessRuleDetectionUseCase.execute(command); + + expect(ruleDetectionAssessmentRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + clarificationQuestion: null, + clarificationAnswers: null, + }), + ); + }); + }); + }); + + describe('when AI service is not configured for organization', () => { + it('throws AiNotConfigured error', async () => { + const rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Test rule', + }); + const command: AssessRuleDetectionJobCommand = { + rule, + jobId: 'job-no-ai', + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + assessmentId: createRuleDetectionAssessmentId(uuidv4()), + }; + + // Mock LLM port returning undefined aiService + llmPort.getLlmForOrganization.mockResolvedValue({ aiService: undefined }); + + await expect(assessRuleDetectionUseCase.execute(command)).rejects.toThrow( + AiNotConfigured, + ); + }); + }); + + describe('when no negative examples exist for the language', () => { + let rule: ReturnType<typeof ruleFactory>; + let assessmentId: ReturnType<typeof createRuleDetectionAssessmentId>; + let command: AssessRuleDetectionJobCommand; + + beforeEach(() => { + rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Test rule with no negative examples', + }); + assessmentId = createRuleDetectionAssessmentId(uuidv4()); + command = { + rule, + jobId: 'job-no-negative', + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + assessmentId, + }; + + // Mock examples with empty negative fields + standardsAdapter.getRuleCodeExamples.mockResolvedValue([ + { + id: 'example-1', + positive: 'const x = 1;', + negative: '', + lang: ProgrammingLanguage.TYPESCRIPT, + }, + { + id: 'example-2', + positive: 'const y = 2;', + negative: ' ', + lang: ProgrammingLanguage.TYPESCRIPT, + }, + ]); + }); + + it('returns FAILED status', async () => { + const result = await assessRuleDetectionUseCase.execute(command); + + expect(result.status).toBe(RuleDetectionAssessmentStatus.FAILED); + }); + + it('returns feasible as false', async () => { + const result = await assessRuleDetectionUseCase.execute(command); + + expect(result.feasible).toBe(false); + }); + + it('returns appropriate error message in details', async () => { + const result = await assessRuleDetectionUseCase.execute(command); + + expect(result.details).toContain('No negative code examples found'); + }); + + it('saves assessment to repository', async () => { + await assessRuleDetectionUseCase.execute(command); + + expect(ruleDetectionAssessmentRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + id: assessmentId, + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + status: RuleDetectionAssessmentStatus.FAILED, + }), + ); + }); + + it('does not call the LLM service', async () => { + await assessRuleDetectionUseCase.execute(command); + + expect(llmPort.getLlmForOrganization).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/assessRuleDetection/AssessRuleDetectionUseCase.ts b/packages/linter/src/application/useCases/assessRuleDetection/AssessRuleDetectionUseCase.ts new file mode 100644 index 000000000..d3411f222 --- /dev/null +++ b/packages/linter/src/application/useCases/assessRuleDetection/AssessRuleDetectionUseCase.ts @@ -0,0 +1,336 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + IStandardsPort, + ILinterPort, + ILlmPort, + AiNotConfigured, + OrganizationId, + createOrganizationId, +} from '@packmind/types'; +import { + AssessRuleDetectionOutput, + RuleDetectionAssessment, + RuleDetectionAssessmentStatus, + DetectionModeEnum, + DetectionProgramRuleInput, + AssessRuleDetectionJobCommand, + IAssessRuleDetectionJob, + DetectionHeuristics, + RuleId, + ProgrammingLanguage, + AssessmentDetectionReadiness, + RuleExample, +} from '@packmind/types'; +import { RuleDetectionAssessmentService } from './shared/RuleDetectionAssessmentService'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; + +const origin = 'AssessRuleDetectionUseCase'; + +export class AssessRuleDetectionUseCase implements IAssessRuleDetectionJob { + constructor( + private readonly linterRepositories: ILinterRepositories, + private readonly standardsAdapter: IStandardsPort, + private readonly getLinterAdapter: () => ILinterPort, + private readonly llmPort: ILlmPort | null, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: AssessRuleDetectionJobCommand, + ): Promise<AssessRuleDetectionOutput> { + try { + this.logger.info('Job starting - running feasibility assessment', { + jobId: command.jobId, + assessmentId: command.assessmentId, + language: command.language, + }); + + const filteredExamples = await this.fetchAndFilterRuleExamples( + command.rule.id, + command.language, + command.jobId, + ); + + // Check if there is at least one negative example + const hasNegativeExample = this.hasNonEmptyNegativeExample( + filteredExamples, + command.jobId, + ); + + if (!hasNegativeExample) { + this.logger.info( + 'No negative examples found for language, skipping assessment', + { + jobId: command.jobId, + ruleId: command.rule.id, + language: command.language, + }, + ); + + // Save assessment as FAILED with appropriate message + const assessment = + await this.saveFailedAssessmentNoNegativeExamples(command); + + return { + assessmentId: command.assessmentId, + status: assessment.status, + feasible: false, + details: assessment.details, + }; + } + + const detectionProgramRuleInput = this.buildDetectionProgramRuleInput( + command, + filteredExamples, + ); + + const existingHeuristics = await this.getExistingHeuristics( + command.rule.id, + command.language, + ); + + const assessmentResult = await this.runFeasibilityAssessment( + detectionProgramRuleInput, + existingHeuristics, + createOrganizationId(command.organizationId), + command.jobId, + ); + + const assessment = await this.saveAssessment(command, assessmentResult); + + await this.ensureHeuristicsExist(command, existingHeuristics); + + const output = this.buildOutput(command, assessment, assessmentResult); + + this.logger.info('Job completed', { + jobId: command.jobId, + assessmentId: output.assessmentId, + status: output.status, + }); + + return output; + } catch (error) { + this.logger.error('Failed to assess rule detection', { + jobId: command.jobId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + private async fetchAndFilterRuleExamples( + ruleId: RuleId, + language: ProgrammingLanguage, + jobId: string, + ) { + const allRuleExamples = + await this.standardsAdapter.getRuleCodeExamples(ruleId); + + const filteredExamples = allRuleExamples.filter( + (example) => example.lang === language, + ); + + this.logger.info('Rule examples filtered for language', { + jobId, + language, + totalExamples: allRuleExamples.length, + filteredExamples: filteredExamples.length, + }); + + return filteredExamples; + } + + private hasNonEmptyNegativeExample( + examples: RuleExample[], + jobId: string, + ): boolean { + const hasNegative = examples.some( + (example) => example.negative && example.negative.trim() !== '', + ); + + this.logger.info('Checked for negative examples', { + jobId, + examplesCount: examples.length, + hasNegativeExample: hasNegative, + }); + + return hasNegative; + } + + private async saveFailedAssessmentNoNegativeExamples( + command: AssessRuleDetectionJobCommand, + ): Promise<RuleDetectionAssessment> { + const assessment: RuleDetectionAssessment = { + id: command.assessmentId, + ruleId: command.rule.id, + language: command.language, + detectionMode: DetectionModeEnum.SINGLE_AST, + status: RuleDetectionAssessmentStatus.FAILED, + details: + '- No negative code examples found for this language. Add at least one "Don\'t" example to enable detection assessment.', + clarificationQuestion: null, + clarificationAnswers: null, + }; + + await this.linterRepositories + .getRuleDetectionAssessmentRepository() + .add(assessment); + + this.logger.info( + 'Assessment saved as FAILED due to missing negative examples', + { + jobId: command.jobId, + assessmentId: assessment.id, + ruleId: command.rule.id, + language: command.language, + }, + ); + + return assessment; + } + + private buildDetectionProgramRuleInput( + command: AssessRuleDetectionJobCommand, + filteredExamples: RuleExample[], + ): DetectionProgramRuleInput { + return { + rule: command.rule, + ruleExamples: filteredExamples, + language: command.language, + }; + } + + private async getExistingHeuristics( + ruleId: RuleId, + language: ProgrammingLanguage, + ): Promise<DetectionHeuristics | null> { + const heuristicsRepo = + this.linterRepositories.getRuleDetectionHeuristicsRepository(); + + return await heuristicsRepo.getHeuristicsForRule(ruleId, language); + } + + private async runFeasibilityAssessment( + detectionProgramRuleInput: DetectionProgramRuleInput, + existingHeuristics: DetectionHeuristics | null, + organizationId: OrganizationId, + jobId: string, + ) { + if (!this.llmPort) { + throw new AiNotConfigured( + 'LLM port not configured for rule detection assessment', + ); + } + const response = await this.llmPort.getLlmForOrganization({ + organizationId, + }); + if (!response.aiService) { + this.logger.warn( + 'AI service not configured for organization - cannot assess rule detection', + { + organizationId: organizationId.toString(), + jobId, + }, + ); + throw new AiNotConfigured( + 'AI service not configured for this organization', + ); + } + const aiService = response.aiService; + const assessmentService = new RuleDetectionAssessmentService(aiService); + + const assessmentResult = await assessmentService.runFeasibilityAssessment( + detectionProgramRuleInput, + existingHeuristics, + ); + + this.logger.info('Feasibility assessment completed', { + jobId, + feasible: assessmentResult.feasible, + reasonsCount: assessmentResult.reason.length, + }); + + return assessmentResult; + } + + private async saveAssessment( + command: AssessRuleDetectionJobCommand, + assessmentResult: AssessmentDetectionReadiness, + ): Promise<RuleDetectionAssessment> { + const status = assessmentResult.feasible + ? RuleDetectionAssessmentStatus.SUCCESS + : RuleDetectionAssessmentStatus.FAILED; + + const details = assessmentResult.reason + .map((reason: string) => `- ${reason}`) + .join('\n'); + + const assessment: RuleDetectionAssessment = { + id: command.assessmentId, + ruleId: command.rule.id, + language: command.language, + detectionMode: DetectionModeEnum.SINGLE_AST, + status, + details, + clarificationQuestion: + assessmentResult.clarificationQuestion?.question ?? null, + clarificationAnswers: + assessmentResult.clarificationQuestion?.answers ?? null, + }; + + await this.linterRepositories + .getRuleDetectionAssessmentRepository() + .add(assessment); + + this.logger.info('Assessment updated in repository', { + jobId: command.jobId, + assessmentId: assessment.id, + status: assessment.status, + hasClarificationQuestion: !!assessmentResult.clarificationQuestion, + }); + + return assessment; + } + + private async ensureHeuristicsExist( + command: AssessRuleDetectionJobCommand, + existingHeuristics: DetectionHeuristics | null, + ): Promise<void> { + if (existingHeuristics) { + return; + } + + this.logger.info('Creating new detection heuristics', { + jobId: command.jobId, + ruleId: command.rule.id, + language: command.language, + }); + + const linterAdapter = this.getLinterAdapter(); + await linterAdapter.createDetectionHeuristics({ + userId: command.userId, + organizationId: command.organizationId, + ruleId: command.rule.id, + language: command.language, + }); + + this.logger.info('Detection heuristics created', { + jobId: command.jobId, + ruleId: command.rule.id, + language: command.language, + }); + } + + private buildOutput( + command: AssessRuleDetectionJobCommand, + assessment: RuleDetectionAssessment, + assessmentResult: AssessmentDetectionReadiness, + ): AssessRuleDetectionOutput { + return { + assessmentId: command.assessmentId, + status: assessment.status, + feasible: assessmentResult.feasible, + details: assessment.details, + }; + } +} diff --git a/packages/linter/src/application/useCases/assessRuleDetection/prompts/generate_rule_assessment.ts b/packages/linter/src/application/useCases/assessRuleDetection/prompts/generate_rule_assessment.ts new file mode 100644 index 000000000..20c4753a9 --- /dev/null +++ b/packages/linter/src/application/useCases/assessRuleDetection/prompts/generate_rule_assessment.ts @@ -0,0 +1,92 @@ +// NOTE : backported from promyze, DO NOT USE AS IT + +export const generate_rule_assessment = ` +You are a seasoned static-analysis expert who designs automated detectors for coding-standard violations in the Packmind platform. + +## Mission + +Your task is to evaluate whether the following coding rule contains sufficient low-level, language-specific detail to allow the development of an AST-based detector that works within a single source file (Packmind uses AST under the hood, but just think in terms of exact code patterns). + +**Important**: The coding rule you are evaluating is specific to a single programming language. Do not consider cross-language complexity or compatibility as a factor in your assessment - focus solely on whether the rule can be detected within that specific language. + +You are not required to assess the detector's performance or feasibility beyond this: just determine if the provided information is detailed and specific enough to begin development. + +Be overly pessimistic in your assessment. + +## Instructions: + +* Output a JSON object literal with the following keys: + * "feasible": true or false + - "true" means the coding rule provides enough detail to confidently write an AST-based detector that works in a single file. + - "false" means the coding rule is too vague, too high-level, or lacks the required low-level elements to begin writing a detector. + * "reason": + - If "feasible" is true, set "reason" to an empty array + - If "feasible" is false, set "reason" to an array of bullet-point strings. Each string should explain, in user-friendly terms, why Packmind cannot support this detection yet—avoid mentioning ASTs or static analysis directly. + - **Never mention "the detector" in reasons. Use "Packmind" instead.** For example, say "Packmind cannot detect this pattern" not "The detector cannot detect this pattern". + - Include only the top 1-3 most critical reasons that make the rule undetectable. Skip minor or medium-importance issues. + - Keep each reason concise and non-redundant. Avoid repeating the same concept in different words. + * "clarificationQuestion": (only when "feasible" is false) + - When the rule is not feasible, generate a clarification question that will help gather actionable information to make the rule detectable. + - The question should focus on identifying concrete code patterns that can be analyzed within a single file. + - Use beginner-friendly language: prefer terms like "source code element", "code pattern", "function call" instead of technical jargon like "AST node", "method_declaration node". + - **Never mention "the detector" in questions or answers. Use "Packmind" instead.** For example, say "Which patterns should Packmind flag?" not "Which patterns should the detector flag?" + - Provide 2-4 pre-defined answers (most impactful and relevant options). + - **CRITICAL**: Each answer must be directly actionable and complete. Never include placeholders like "N", "X", variables, or phrases like "I will supply" or "user-defined value". The user will select an answer via radio button and cannot provide additional input. + - Instead of "Flag classes with more than N methods (I will supply N)", provide concrete examples like: "Classes with more than 10 methods", "Classes with more than 20 methods", "Classes with more than 5 methods". + - **EXAMPLE CONSISTENCY**: When good examples (positive examples) and bad examples (negative examples) are provided below: + * The suggested answers MUST NOT contradict the good examples. If a good example demonstrates valid code, your suggested answers should not flag that same pattern as a violation. + * The suggested answers SHOULD align with and help detect the violations shown in the bad examples. Each answer should represent a specific detection strategy that would catch at least one of the bad examples. + * If the examples show specific patterns, use those patterns to inform your suggested answers rather than inventing unrelated detection strategies. + - Structure: {"question": "string", "answers": ["answer1", "answer2", ...]} + +* **Multi-file Analysis Limitation**: If the coding rule requires analyzing relationships across multiple files (such as Java inheritance trees where classes are typically split across multiple files), mark it as not feasible. In the reason array, include that "Packmind does not support yet static multi-file analysis." + +* **Example Contamination**: If the good examples (positive examples) contain the actual violation pattern within their code, mark the rule as not feasible. The detection algorithm analyzes code syntactically and cannot distinguish context, intent, or whether a pattern is shown for demonstration purposes versus being the actual recommended approach. This would cause the good examples to be incorrectly flagged as violations. For instance, if the rule is "Don't use console.log" and a good example shows both the bad pattern (console.log) and the good pattern (logger.info) side by side for comparison, the detector will flag the good example as a violation. Similarly, if a good example imports or references the forbidden pattern in any way, it will trigger false positives. In such cases, include in the reason array that "The good examples contain the violation pattern, which would cause false positives when validating the detection algorithm." + +* Each string in the 'reason' should explain why Packmind cannot support this detection in one file—do not propose modifications to the rule itself. + +## Output (JSON Object) +Provide your response in JSON format as follows: + +Do not even wrap the JSON object in triple backticks or add any explanation. + +{ + "feasible": true, + "reason": [] +} + +or + +{ + "feasible": false, + "reason": [ + "The rule is too subjective and depends on team preferences, making reliable automated detection difficult." + ], + "clarificationQuestion": { + "question": "What specific code pattern should we look for to detect this rule?", + "answers": [ + "Function calls to specific methods", + "Variable declarations with certain types", + "Control flow statements like if/else or loops", + "Import statements from specific libraries" + ] + } +} + + + +## Detection Heuristics + +**When present, these heuristics define the exact detection criteria for this rule.** If heuristics are provided below, evaluate the feasibility based on these specific patterns. The heuristics represent concrete, actionable detection logic that should be considered as part of the rule specification itself: + +""" +$DETECTION_HEURISTICS$ +""" + +## Input + +Here is the coding rule: + +""" +$CODING_RULE$ +"""`; diff --git a/packages/linter/src/application/useCases/assessRuleDetection/shared/AssessRuleDetectionDelayedJob.ts b/packages/linter/src/application/useCases/assessRuleDetection/shared/AssessRuleDetectionDelayedJob.ts new file mode 100644 index 000000000..fecc24bc3 --- /dev/null +++ b/packages/linter/src/application/useCases/assessRuleDetection/shared/AssessRuleDetectionDelayedJob.ts @@ -0,0 +1,273 @@ +import { PackmindLogger } from '@packmind/logger'; +import { SSEEventPublisher } from '@packmind/node-utils'; +import type { IStandardsPort, ILinterPort, ILlmPort } from '@packmind/types'; +import { + AbstractAIDelayedJob, + IQueue, + QueueListeners, + WorkerListeners, +} from '@packmind/node-utils'; +import { + AssessRuleDetectionInput, + AssessRuleDetectionOutput, + AssessRuleDetectionJobCommand, + RuleDetectionAssessmentStatus, +} from '@packmind/types'; +import { Job } from 'bullmq'; +import { AssessRuleDetectionUseCase } from '../AssessRuleDetectionUseCase'; +import { ILinterRepositories } from '../../../../domain/repositories/ILinterRepositories'; + +const origin = 'AssessRuleDetectionDelayedJob'; + +export class AssessRuleDetectionDelayedJob extends AbstractAIDelayedJob< + AssessRuleDetectionInput, + AssessRuleDetectionOutput +> { + readonly origin = 'AssessRuleDetectionJob'; + + constructor( + queueFactory: ( + queueListeners: Partial<QueueListeners>, + ) => Promise<IQueue<AssessRuleDetectionInput, AssessRuleDetectionOutput>>, + private readonly linterRepositories: ILinterRepositories, + private readonly getStandardsAdapter: () => IStandardsPort, + private readonly getLinterAdapter: () => ILinterPort, + private readonly getLlmPort: () => ILlmPort, + ) { + super(queueFactory, new PackmindLogger(origin)); + } + + async onFail(jobId: string): Promise<void> { + this.logger.error( + `[${this.origin}] Job ${jobId} failed - status will be updated in failed listener`, + ); + } + + async runJob( + jobId: string, + input: AssessRuleDetectionInput, + _controller: AbortController, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise<AssessRuleDetectionOutput> { + this.logger.info( + `[${this.origin}] Processing job ${jobId} for rule: ${input.rule.content}`, + ); + + // Convert AssessRuleDetectionInput to AssessRuleDetectionJobCommand + const command: AssessRuleDetectionJobCommand = { + rule: input.rule, + jobId, + organizationId: input.organizationId, + userId: input.userId, + language: input.language, + assessmentId: input.assessmentId, + }; + + const useCase = new AssessRuleDetectionUseCase( + this.linterRepositories, + this.getStandardsAdapter(), + this.getLinterAdapter, + this.getLlmPort(), + ); + + return await useCase.execute(command); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getJobName(_input: AssessRuleDetectionInput): string { + return `assess-rule-detection-${Date.now()}`; + } + + jobStartedInfo(input: AssessRuleDetectionInput): string { + return `rule: ${input.rule.content}, language: ${input.language}`; + } + + getWorkerListener(): Partial< + WorkerListeners<AssessRuleDetectionInput, AssessRuleDetectionOutput> + > { + return { + completed: async ( + job: Job<AssessRuleDetectionInput, AssessRuleDetectionOutput, string>, + result: AssessRuleDetectionOutput, + ) => { + this.logger.info( + `[${this.origin}] Job ${job.id} completed successfully`, + ); + + try { + // Get the input data from the job + const input = job.data; + + // Fetch the assessment + const assessment = await this.linterRepositories + .getRuleDetectionAssessmentRepository() + .findById(result.assessmentId); + + if (!assessment) { + this.logger.error( + `[${this.origin}] Assessment not found after job completion`, + { + assessmentId: result.assessmentId, + }, + ); + return; + } + + // Update status and details explicitly + const updatedAssessment = { + ...assessment, + status: result.status, + details: result.details, + }; + + await this.linterRepositories + .getRuleDetectionAssessmentRepository() + .update(updatedAssessment); + + this.logger.info( + `[${this.origin}] Assessment updated for job ${job.id}`, + { + assessmentId: result.assessmentId, + status: result.status, + }, + ); + + // Publish SSE event for assessment completion + await SSEEventPublisher.publishAssessmentStatusEvent( + updatedAssessment.ruleId, + updatedAssessment.language, + input.userId, + input.organizationId, + ); + + this.logger.info( + `[${this.origin}] SSE event published for assessment completion - job ${job.id}`, + { + assessmentId: result.assessmentId, + status: result.status, + userId: input.userId, + }, + ); + + // If assessment succeeded, automatically trigger program generation + if (result.status === RuleDetectionAssessmentStatus.SUCCESS) { + try { + await this.getLinterAdapter().startGenerateProgram({ + organizationId: input.organizationId, + userId: input.userId, + ruleId: input.rule.id, + language: input.language, + }); + + this.logger.info( + `[${this.origin}] Program generation triggered after successful assessment`, + { + ruleId: input.rule.id, + language: input.language, + }, + ); + } catch (programGenerationError) { + this.logger.error( + `[${this.origin}] Failed to trigger program generation`, + { + error: + programGenerationError instanceof Error + ? programGenerationError.message + : String(programGenerationError), + }, + ); + // Don't throw - assessment was successful + } + } + } catch (error) { + this.logger.error( + `[${this.origin}] Failed to handle completed job ${job.id}`, + { + error: error instanceof Error ? error.message : String(error), + }, + ); + // Note: We don't throw here to avoid marking the job as failed + // since the assessment itself was successful + } + }, + failed: async (job, error) => { + this.logger.error( + `[${this.origin}] Job ${job.id} failed with error: ${error.message}`, + ); + + try { + const input = job.data; + + // Try to get assessment ID from job return value or input + let assessmentId = input.assessmentId; + if (job.returnvalue?.assessmentId) { + assessmentId = job.returnvalue.assessmentId; + } + + if (!assessmentId) { + this.logger.warn( + `[${this.origin}] No assessment ID available for failed job ${job.id} - will be retried later`, + ); + return; + } + + // Fetch existing assessment + const existingAssessment = await this.linterRepositories + .getRuleDetectionAssessmentRepository() + .findById(assessmentId); + + if (!existingAssessment) { + this.logger.warn( + `[${this.origin}] Assessment not found for failed job ${job.id}`, + { + assessmentId, + }, + ); + return; + } + + // Update assessment status to FAILED + const updatedAssessment = { + ...existingAssessment, + status: RuleDetectionAssessmentStatus.FAILED, + }; + + await this.linterRepositories + .getRuleDetectionAssessmentRepository() + .update(updatedAssessment); + + this.logger.info( + `[${this.origin}] Assessment status updated to FAILED for failed job ${job.id}`, + { + assessmentId, + }, + ); + + // Publish SSE event for assessment failure + await SSEEventPublisher.publishAssessmentStatusEvent( + String(updatedAssessment.ruleId), + String(updatedAssessment.language), + input.userId, + input.organizationId, + ); + + this.logger.info( + `[${this.origin}] SSE event published for assessment failure - job ${job.id}`, + { + assessmentId, + status: RuleDetectionAssessmentStatus.FAILED, + userId: input.userId, + }, + ); + } catch (sseError) { + this.logger.error( + `[${this.origin}] Failed to handle failed job ${job.id}`, + { + error: + sseError instanceof Error ? sseError.message : String(sseError), + }, + ); + } + }, + }; + } +} diff --git a/packages/linter/src/application/useCases/assessRuleDetection/shared/RuleDetectionAssessmentService.ts b/packages/linter/src/application/useCases/assessRuleDetection/shared/RuleDetectionAssessmentService.ts new file mode 100644 index 000000000..773bbd696 --- /dev/null +++ b/packages/linter/src/application/useCases/assessRuleDetection/shared/RuleDetectionAssessmentService.ts @@ -0,0 +1,172 @@ +import { parseCodeOrJsonFromAIAnswer } from '../../generateProgramUseCase/shared/program/ProgramOutputUtils'; +import { generate_rule_assessment } from '../prompts/generate_rule_assessment'; +import { + DetectionProgramRuleInput, + AssessmentDetectionReadiness, + DetectionHeuristics, +} from '@packmind/types'; +import { PackmindLogger } from '@packmind/logger'; +import { + AI_RESPONSE_FORMAT, + AIService, + OpenAIServiceTier, + PromptConversationRole, +} from '@packmind/types'; +import AIRequestEmitter from '../../../../domain/entities/AIRequestEmitter'; +import { + getBadExamplesCode, + getGoodExamplesCode, +} from '../../generateProgramUseCase/shared/utils/PromptUtils'; + +const origin = 'RuleDetectionAssessmentService'; +export class RuleDetectionAssessmentService extends AIRequestEmitter { + constructor( + protected readonly _aiService: AIService, + protected readonly _logger = new PackmindLogger(origin), + ) { + super('assessment-detection-readiness', _aiService, _logger); + } + + public async runFeasibilityAssessment( + detectionProgramRuleInput: DetectionProgramRuleInput, + existingHeuristics: DetectionHeuristics | null = null, + ): Promise<AssessmentDetectionReadiness> { + const prompt = this.buildPromptWithRule( + generate_rule_assessment, + detectionProgramRuleInput, + existingHeuristics, + ); + const MAX_RETRY = 3; + let i = 0; + while (i < MAX_RETRY) { + try { + const response = await this.callAiProvider( + [ + { + role: PromptConversationRole.USER, + message: prompt, + }, + ], + { + responseFormat: AI_RESPONSE_FORMAT.JSON_MODE, + service_tier: OpenAIServiceTier.PRIORITY, // Will have no impact if not OpenAI. This is for optimizing response time for the ChatBot experience when assessment is refreshed in realtime + }, + ); + + if (!response?.data) { + throw new Error('No data provided by AI'); + } + + // Ensure response.data is a string before parsing + const responseData = + typeof response.data === 'string' + ? response.data + : JSON.stringify(response.data); + + // Try direct JSON parsing first (for JSON_MODE responses) + // This avoids parseCodeOrJsonFromAIAnswer incorrectly extracting inline code + // snippets from within JSON string values (e.g., code examples in reason text) + let assessment: AssessmentDetectionReadiness; + try { + assessment = JSON.parse(responseData); + } catch { + // Fallback to parseCodeOrJsonFromAIAnswer for non-JSON responses + assessment = JSON.parse(parseCodeOrJsonFromAIAnswer(responseData)); + } + + this._logger.info( + `Assessment result: ${JSON.stringify(assessment, null, 2)}`, + ); + + // Log clarification question if present + if (assessment.clarificationQuestion) { + this._logger.info( + `Clarification Question: ${assessment.clarificationQuestion.question}`, + ); + this._logger.info( + `Possible Answers: ${assessment.clarificationQuestion.answers.join(', ')}`, + ); + } + + return { + feasible: assessment.feasible || false, + reason: assessment.reason || [], + ...(assessment.clarificationQuestion && { + clarificationQuestion: assessment.clarificationQuestion, + }), + }; + } catch (error) { + this._logger.error( + `[TaskId=${this._taskId}] Error when running feasibility assessment for ruleId=${detectionProgramRuleInput.rule.id} - ${error instanceof Error ? error.message : String(error)}`, + ); + i++; + } + } + + if (i > MAX_RETRY) { + this._logger.error( + `[TaskId=${this._taskId}] Max retries reached for generating coding rule assessment for ruleId=${detectionProgramRuleInput.rule.id}`, + ); + return { + feasible: false, + reason: ['Assessment failed due to technical error'], + }; + } + + // If all retries failed, assume not feasible + return { + feasible: false, + reason: ['Assessment failed due to technical error'], + }; + } + + private buildPromptWithRule( + prompt: string, + rule: DetectionProgramRuleInput, + existingHeuristics: DetectionHeuristics | null, + ) { + const ruleText = this.getRuleText(rule); + let updatedPrompt = prompt.replace('$CODING_RULE$', ruleText); + + // Inject heuristics section if they exist and are not empty + if (existingHeuristics && existingHeuristics.heuristics.length > 0) { + const formattedHeuristics = existingHeuristics.heuristics + .map((h) => `* ${h}`) + .join('\n'); + const heuristicsSection = ` +## Detection Heuristics + +**When present, these heuristics define the exact detection criteria for this rule.** If heuristics are provided below, evaluate the feasibility based on these specific patterns. The heuristics represent concrete, actionable detection logic that should be considered as part of the rule specification itself: + +""" +${formattedHeuristics} +""" +`; + updatedPrompt = updatedPrompt.replace( + /## Detection Heuristics[\s\S]*?\$DETECTION_HEURISTICS\$[\s\S]*?"""\s*/, + heuristicsSection, + ); + } else { + // Remove the entire heuristics section if not available + updatedPrompt = updatedPrompt.replace( + /## Detection Heuristics[\s\S]*?\$DETECTION_HEURISTICS\$[\s\S]*?"""\s*/, + '', + ); + } + + return updatedPrompt; + } + + private getRuleText(rule: DetectionProgramRuleInput) { + return ` +Rule name: ${rule.rule.content} +Rule programming language: ${rule.language} +${getGoodExamplesCode(rule.ruleExamples)} +${getBadExamplesCode(rule.ruleExamples)} +`; + } + + getOperationType(): string { + return 'RULE_ASSESSMENT_FOR_DETECTION_READINESS'; + } +} diff --git a/packages/linter/src/application/useCases/computeRuleLanguageDetectionStatus/ComputeRuleLanguageDetectionStatusUseCase.spec.ts b/packages/linter/src/application/useCases/computeRuleLanguageDetectionStatus/ComputeRuleLanguageDetectionStatusUseCase.spec.ts new file mode 100644 index 000000000..682c8b41a --- /dev/null +++ b/packages/linter/src/application/useCases/computeRuleLanguageDetectionStatus/ComputeRuleLanguageDetectionStatusUseCase.spec.ts @@ -0,0 +1,662 @@ +import { + createRuleId, + DetectionStatus, + ProgrammingLanguage, + RuleLanguageDetectionStatus, + RuleDetectionAssessment, + RuleDetectionAssessmentStatus, + createRuleDetectionAssessmentId, + DetectionModeEnum, + createDetectionProgramId, + createActiveDetectionProgramId, + LanguageDetectionPrograms, +} from '@packmind/types'; +import { stubLogger } from '@packmind/test-utils'; +import { ComputeRuleLanguageDetectionStatusUseCase } from './ComputeRuleLanguageDetectionStatusUseCase'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { IRuleDetectionAssessmentRepository } from '../../../domain/repositories/IRuleDetectionAssessmentRepository'; +import { IActiveDetectionProgramRepository } from '../../../domain/repositories/IActiveDetectionProgramRepository'; + +describe('ComputeRuleLanguageDetectionStatusUseCase', () => { + let useCase: ComputeRuleLanguageDetectionStatusUseCase; + let mockLinterRepositories: jest.Mocked<ILinterRepositories>; + let mockRuleDetectionAssessmentRepository: jest.Mocked<IRuleDetectionAssessmentRepository>; + let mockActiveDetectionProgramRepository: jest.Mocked<IActiveDetectionProgramRepository>; + + const mockRuleId = createRuleId('rule-123'); + const language = ProgrammingLanguage.TYPESCRIPT; + + beforeEach(() => { + mockRuleDetectionAssessmentRepository = { + get: jest.fn(), + update: jest.fn(), + add: jest.fn(), + findById: jest.fn(), + list: jest.fn(), + delete: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as unknown as jest.Mocked<IRuleDetectionAssessmentRepository>; + + mockActiveDetectionProgramRepository = { + findByRuleIdWithPrograms: jest.fn(), + findByRuleId: jest.fn(), + findByRuleIdAndLanguage: jest.fn(), + updateActiveDetectionProgram: jest.fn(), + deleteByRuleId: jest.fn(), + add: jest.fn(), + findById: jest.fn(), + list: jest.fn(), + delete: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as unknown as jest.Mocked<IActiveDetectionProgramRepository>; + + mockLinterRepositories = { + getRuleDetectionAssessmentRepository: jest.fn( + () => mockRuleDetectionAssessmentRepository, + ), + getActiveDetectionProgramRepository: jest.fn( + () => mockActiveDetectionProgramRepository, + ), + getDetectionProgramRepository: jest.fn(), + getDetectionProgramMetadataRepository: jest.fn(), + getRuleDetectionHeuristicsRepository: jest.fn(), + } as unknown as jest.Mocked<ILinterRepositories>; + + useCase = new ComputeRuleLanguageDetectionStatusUseCase( + mockLinterRepositories, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('assessment phase (no programs)', () => { + beforeEach(() => { + mockActiveDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [], + ); + }); + + it('returns NONE for no assessment', async () => { + mockRuleDetectionAssessmentRepository.get.mockResolvedValue(null); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.NONE); + }); + + it('returns NONE for NOT_STARTED assessment', async () => { + const assessment: RuleDetectionAssessment = { + id: createRuleDetectionAssessmentId('assessment-1'), + ruleId: mockRuleId, + language, + detectionMode: DetectionModeEnum.SINGLE_AST, + status: RuleDetectionAssessmentStatus.NOT_STARTED, + details: '', + clarificationQuestion: '', + clarificationAnswers: [], + }; + mockRuleDetectionAssessmentRepository.get.mockResolvedValue(assessment); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.NONE); + }); + + it('returns NONE for FAILED assessment', async () => { + const assessment: RuleDetectionAssessment = { + id: createRuleDetectionAssessmentId('assessment-1'), + ruleId: mockRuleId, + language, + detectionMode: DetectionModeEnum.SINGLE_AST, + status: RuleDetectionAssessmentStatus.FAILED, + details: 'Assessment failed', + clarificationQuestion: '', + clarificationAnswers: [], + }; + mockRuleDetectionAssessmentRepository.get.mockResolvedValue(assessment); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.NONE); + }); + + it('returns NONE for IN_PROGRESS assessment', async () => { + const assessment: RuleDetectionAssessment = { + id: createRuleDetectionAssessmentId('assessment-1'), + ruleId: mockRuleId, + language, + detectionMode: DetectionModeEnum.SINGLE_AST, + status: RuleDetectionAssessmentStatus.IN_PROGRESS, + details: 'Assessment in progress', + clarificationQuestion: '', + clarificationAnswers: [], + }; + mockRuleDetectionAssessmentRepository.get.mockResolvedValue(assessment); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.NONE); + }); + + it('returns WIP for SUCCESS assessment (transitioning to program phase)', async () => { + const assessment: RuleDetectionAssessment = { + id: createRuleDetectionAssessmentId('assessment-1'), + ruleId: mockRuleId, + language, + detectionMode: DetectionModeEnum.SINGLE_AST, + status: RuleDetectionAssessmentStatus.SUCCESS, + details: 'Assessment completed', + clarificationQuestion: '', + clarificationAnswers: [], + }; + mockRuleDetectionAssessmentRepository.get.mockResolvedValue(assessment); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.WIP); + }); + }); + + describe('program phase (programs exist)', () => { + describe('when draft is IN_PROGRESS', () => { + it('returns OK for SUCCESS active program', async () => { + const programWithRelations: LanguageDetectionPrograms = { + id: createActiveDetectionProgramId('active-1'), + detectionProgramVersion: createDetectionProgramId('program-1'), + ruleId: mockRuleId, + language, + detectionProgramDraftVersion: createDetectionProgramId('draft-1'), + detectionProgram: { + id: createDetectionProgramId('program-1'), + code: 'detection code', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + }, + draftDetectionProgram: { + id: createDetectionProgramId('draft-1'), + code: 'draft code', + version: 2, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.IN_PROGRESS, + sourceCodeState: 'AST', + }, + }; + mockActiveDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [programWithRelations], + ); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.OK); + }); + + it('returns WIP for TO_REVIEW active program', async () => { + const programWithRelations: LanguageDetectionPrograms = { + id: createActiveDetectionProgramId('active-1'), + detectionProgramVersion: createDetectionProgramId('program-1'), + ruleId: mockRuleId, + language, + detectionProgramDraftVersion: createDetectionProgramId('draft-1'), + detectionProgram: { + id: createDetectionProgramId('program-1'), + code: 'detection code', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.TO_REVIEW, + sourceCodeState: 'AST', + }, + draftDetectionProgram: { + id: createDetectionProgramId('draft-1'), + code: 'draft code', + version: 2, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.IN_PROGRESS, + sourceCodeState: 'AST', + }, + }; + mockActiveDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [programWithRelations], + ); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.WIP); + }); + + it('returns WIP for no active program', async () => { + const programWithRelations: LanguageDetectionPrograms = { + id: createActiveDetectionProgramId('active-1'), + detectionProgramVersion: null, + ruleId: mockRuleId, + language, + detectionProgramDraftVersion: createDetectionProgramId('draft-1'), + detectionProgram: null, + draftDetectionProgram: { + id: createDetectionProgramId('draft-1'), + code: 'draft code', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.IN_PROGRESS, + sourceCodeState: 'AST', + }, + }; + mockActiveDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [programWithRelations], + ); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.WIP); + }); + }); + + describe('when draft is ERROR', () => { + it('returns OK for SUCCESS active program', async () => { + const programWithRelations: LanguageDetectionPrograms = { + id: createActiveDetectionProgramId('active-1'), + detectionProgramVersion: createDetectionProgramId('program-1'), + ruleId: mockRuleId, + language, + detectionProgramDraftVersion: createDetectionProgramId('draft-1'), + detectionProgram: { + id: createDetectionProgramId('program-1'), + code: 'detection code', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + }, + draftDetectionProgram: { + id: createDetectionProgramId('draft-1'), + code: 'draft code', + version: 2, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.ERROR, + sourceCodeState: 'AST', + }, + }; + mockActiveDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [programWithRelations], + ); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.OK); + }); + + it('returns WIP for TO_REVIEW active program', async () => { + const programWithRelations: LanguageDetectionPrograms = { + id: createActiveDetectionProgramId('active-1'), + detectionProgramVersion: createDetectionProgramId('program-1'), + ruleId: mockRuleId, + language, + detectionProgramDraftVersion: createDetectionProgramId('draft-1'), + detectionProgram: { + id: createDetectionProgramId('program-1'), + code: 'detection code', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.TO_REVIEW, + sourceCodeState: 'AST', + }, + draftDetectionProgram: { + id: createDetectionProgramId('draft-1'), + code: 'draft code', + version: 2, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.ERROR, + sourceCodeState: 'AST', + }, + }; + mockActiveDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [programWithRelations], + ); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.WIP); + }); + + it('returns WIP for no active program', async () => { + const programWithRelations: LanguageDetectionPrograms = { + id: createActiveDetectionProgramId('active-1'), + detectionProgramVersion: null, + ruleId: mockRuleId, + language, + detectionProgramDraftVersion: createDetectionProgramId('draft-1'), + detectionProgram: null, + draftDetectionProgram: { + id: createDetectionProgramId('draft-1'), + code: 'draft code', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.ERROR, + sourceCodeState: 'AST', + }, + }; + mockActiveDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [programWithRelations], + ); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.WIP); + }); + }); + + describe('when draft is SUCCESS', () => { + it('returns OK for SUCCESS active program', async () => { + const programWithRelations: LanguageDetectionPrograms = { + id: createActiveDetectionProgramId('active-1'), + detectionProgramVersion: createDetectionProgramId('program-1'), + ruleId: mockRuleId, + language, + detectionProgramDraftVersion: createDetectionProgramId('draft-1'), + detectionProgram: { + id: createDetectionProgramId('program-1'), + code: 'detection code', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + }, + draftDetectionProgram: { + id: createDetectionProgramId('draft-1'), + code: 'draft code', + version: 2, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + }, + }; + mockActiveDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [programWithRelations], + ); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.OK); + }); + + it('returns WIP for TO_REVIEW active program', async () => { + const programWithRelations: LanguageDetectionPrograms = { + id: createActiveDetectionProgramId('active-1'), + detectionProgramVersion: createDetectionProgramId('program-1'), + ruleId: mockRuleId, + language, + detectionProgramDraftVersion: createDetectionProgramId('draft-1'), + detectionProgram: { + id: createDetectionProgramId('program-1'), + code: 'detection code', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.TO_REVIEW, + sourceCodeState: 'AST', + }, + draftDetectionProgram: { + id: createDetectionProgramId('draft-1'), + code: 'draft code', + version: 2, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + }, + }; + mockActiveDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [programWithRelations], + ); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.WIP); + }); + + it('returns WIP for no active program', async () => { + const programWithRelations: LanguageDetectionPrograms = { + id: createActiveDetectionProgramId('active-1'), + detectionProgramVersion: null, + ruleId: mockRuleId, + language, + detectionProgramDraftVersion: createDetectionProgramId('draft-1'), + detectionProgram: null, + draftDetectionProgram: { + id: createDetectionProgramId('draft-1'), + code: 'draft code', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + }, + }; + mockActiveDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [programWithRelations], + ); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.WIP); + }); + }); + + describe('when draft is TO_REVIEW', () => { + it('returns OK for SUCCESS active program', async () => { + const programWithRelations: LanguageDetectionPrograms = { + id: createActiveDetectionProgramId('active-1'), + detectionProgramVersion: createDetectionProgramId('program-1'), + ruleId: mockRuleId, + language, + detectionProgramDraftVersion: createDetectionProgramId('draft-1'), + detectionProgram: { + id: createDetectionProgramId('program-1'), + code: 'detection code', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + }, + draftDetectionProgram: { + id: createDetectionProgramId('draft-1'), + code: 'draft code', + version: 2, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.TO_REVIEW, + sourceCodeState: 'AST', + }, + }; + mockActiveDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [programWithRelations], + ); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.OK); + }); + + it('returns WIP for both draft and active TO_REVIEW', async () => { + const programWithRelations: LanguageDetectionPrograms = { + id: createActiveDetectionProgramId('active-1'), + detectionProgramVersion: createDetectionProgramId('program-1'), + ruleId: mockRuleId, + language, + detectionProgramDraftVersion: createDetectionProgramId('draft-1'), + detectionProgram: { + id: createDetectionProgramId('program-1'), + code: 'detection code', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.TO_REVIEW, + sourceCodeState: 'AST', + }, + draftDetectionProgram: { + id: createDetectionProgramId('draft-1'), + code: 'draft code', + version: 2, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.TO_REVIEW, + sourceCodeState: 'AST', + }, + }; + mockActiveDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [programWithRelations], + ); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.WIP); + }); + + it('returns WIP for no active program', async () => { + const programWithRelations: LanguageDetectionPrograms = { + id: createActiveDetectionProgramId('active-1'), + detectionProgramVersion: null, + ruleId: mockRuleId, + language, + detectionProgramDraftVersion: createDetectionProgramId('draft-1'), + detectionProgram: null, + draftDetectionProgram: { + id: createDetectionProgramId('draft-1'), + code: 'draft code', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language, + status: DetectionStatus.TO_REVIEW, + sourceCodeState: 'AST', + }, + }; + mockActiveDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [programWithRelations], + ); + + const result = await useCase.execute({ + ruleId: mockRuleId, + language, + }); + + expect(result.status).toBe(RuleLanguageDetectionStatus.WIP); + }); + }); + }); + + it('propagates repository errors from ActiveDetectionProgramRepository', async () => { + const error = new Error('Repository error'); + mockActiveDetectionProgramRepository.findByRuleIdWithPrograms.mockRejectedValue( + error, + ); + + await expect( + useCase.execute({ + ruleId: mockRuleId, + language, + }), + ).rejects.toThrow('Repository error'); + }); + + it('propagates repository errors from RuleDetectionAssessmentRepository', async () => { + mockActiveDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [], + ); + const error = new Error('Assessment repository error'); + mockRuleDetectionAssessmentRepository.get.mockRejectedValue(error); + + await expect( + useCase.execute({ + ruleId: mockRuleId, + language, + }), + ).rejects.toThrow('Assessment repository error'); + }); +}); diff --git a/packages/linter/src/application/useCases/computeRuleLanguageDetectionStatus/ComputeRuleLanguageDetectionStatusUseCase.ts b/packages/linter/src/application/useCases/computeRuleLanguageDetectionStatus/ComputeRuleLanguageDetectionStatusUseCase.ts new file mode 100644 index 000000000..e0602e943 --- /dev/null +++ b/packages/linter/src/application/useCases/computeRuleLanguageDetectionStatus/ComputeRuleLanguageDetectionStatusUseCase.ts @@ -0,0 +1,127 @@ +import { PackmindLogger } from '@packmind/logger'; +import { DetectionStatus, RuleLanguageDetectionStatus } from '@packmind/types'; +import { + ComputeRuleLanguageDetectionStatusCommand, + ComputeRuleLanguageDetectionStatusResponse, + IComputeRuleLanguageDetectionStatusUseCase, + RuleDetectionAssessmentStatus, +} from '@packmind/types'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; + +const origin = 'ComputeRuleLanguageDetectionStatusUseCase'; + +export class ComputeRuleLanguageDetectionStatusUseCase implements IComputeRuleLanguageDetectionStatusUseCase { + constructor( + private readonly repositories: ILinterRepositories, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: ComputeRuleLanguageDetectionStatusCommand, + ): Promise<ComputeRuleLanguageDetectionStatusResponse> { + this.logger.info('Computing rule language detection status', { + ruleId: command.ruleId, + language: command.language, + }); + + try { + // Fetch ActiveDetectionProgram with relations (includes both programs) + const activeDetectionPrograms = await this.repositories + .getActiveDetectionProgramRepository() + .findByRuleIdWithPrograms(command.ruleId); + + const activeDetectionProgram = activeDetectionPrograms.find( + (program) => program.language === command.language, + ); + + // If active program status is READY → OK + if ( + activeDetectionProgram?.detectionProgram?.status === + DetectionStatus.READY + ) { + this.logger.info( + 'Active detection program is ready, returning OK status', + { + ruleId: command.ruleId, + language: command.language, + }, + ); + return { status: RuleLanguageDetectionStatus.OK }; + } + + const hasDraft = + activeDetectionProgram !== undefined && + activeDetectionProgram.draftDetectionProgram !== null; + const hasActive = + activeDetectionProgram !== undefined && + activeDetectionProgram.detectionProgram !== null; + + // If no programs exist, check assessment status (first 3 rows of truth table) + if (!hasDraft && !hasActive) { + const assessment = await this.repositories + .getRuleDetectionAssessmentRepository() + .get(command.ruleId, command.language); + + if ( + !assessment || + assessment.status === RuleDetectionAssessmentStatus.NOT_STARTED + ) { + this.logger.info( + 'No assessment or assessment not started, returning NONE status', + { + ruleId: command.ruleId, + language: command.language, + assessmentStatus: assessment?.status, + }, + ); + return { status: RuleLanguageDetectionStatus.NONE }; + } + + if (assessment.status === RuleDetectionAssessmentStatus.FAILED) { + this.logger.info('Assessment failed, returning NONE status', { + ruleId: command.ruleId, + language: command.language, + }); + return { status: RuleLanguageDetectionStatus.NONE }; + } + + if (assessment.status === RuleDetectionAssessmentStatus.IN_PROGRESS) { + return { status: RuleLanguageDetectionStatus.NONE }; + } + + if (assessment.status === RuleDetectionAssessmentStatus.SUCCESS) { + return { status: RuleLanguageDetectionStatus.WIP }; + } + + // Unexpected assessment status + this.logger.info( + 'Unexpected assessment status, returning NONE status', + { + ruleId: command.ruleId, + language: command.language, + assessmentStatus: assessment.status, + }, + ); + return { status: RuleLanguageDetectionStatus.NONE }; + } + + // Has draft or active program but not READT → WIP + this.logger.info('Programs exist but not ready, returning WIP status', { + ruleId: command.ruleId, + language: command.language, + hasDraft, + hasActive, + activeStatus: activeDetectionProgram?.detectionProgram?.status, + draftStatus: activeDetectionProgram?.draftDetectionProgram?.status, + }); + return { status: RuleLanguageDetectionStatus.WIP }; + } catch (error) { + this.logger.error('Failed to compute rule language detection status', { + ruleId: command.ruleId, + language: command.language, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/copyDetectionHeuristics/CopyDetectionHeuristicsUseCase.spec.ts b/packages/linter/src/application/useCases/copyDetectionHeuristics/CopyDetectionHeuristicsUseCase.spec.ts new file mode 100644 index 000000000..82712ac3a --- /dev/null +++ b/packages/linter/src/application/useCases/copyDetectionHeuristics/CopyDetectionHeuristicsUseCase.spec.ts @@ -0,0 +1,827 @@ +import { createOrganizationId, createUserId } from '@packmind/types'; +import { createRuleId, ProgrammingLanguage } from '@packmind/types'; +import { CopyDetectionHeuristicsUseCase } from './CopyDetectionHeuristicsUseCase'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { IRuleDetectionHeuristicsRepository } from '../../../domain/repositories/IRuleDetectionHeuristicsRepository'; +import { detectionHeuristicsFactory } from '../../../../test/detectionHeuristicsFactory'; +import { v4 as uuidv4 } from 'uuid'; + +describe('CopyDetectionHeuristicsUseCase', () => { + let useCase: CopyDetectionHeuristicsUseCase; + let repositories: jest.Mocked<ILinterRepositories>; + let heuristicsRepository: jest.Mocked<IRuleDetectionHeuristicsRepository>; + + const oldRuleId = createRuleId(uuidv4()); + const newRuleId = createRuleId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + + beforeEach(() => { + heuristicsRepository = { + upsertHeuristics: jest.fn(), + getHeuristicsForRule: jest.fn(), + getAllHeuristicsForRule: jest.fn(), + updateHeuristics: jest.fn(), + getHeuristicsById: jest.fn(), + appendHeuristic: jest.fn(), + } as jest.Mocked<IRuleDetectionHeuristicsRepository>; + + repositories = { + getRuleDetectionHeuristicsRepository: jest.fn(() => heuristicsRepository), + } as unknown as jest.Mocked<ILinterRepositories>; + + useCase = new CopyDetectionHeuristicsUseCase(repositories); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when old rule has detection heuristics', () => { + it('returns correct count of copied heuristics', async () => { + const heuristic1 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TypeScript heuristics'], + }); + const heuristic2 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic1, + heuristic2, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + const result = await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + expect(result.copiedHeuristicsCount).toBe(2); + }); + + it('calls upsertHeuristics for each heuristic', async () => { + const heuristic1 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TypeScript heuristics'], + }); + const heuristic2 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic1, + heuristic2, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + expect(heuristicsRepository.upsertHeuristics).toHaveBeenCalledTimes(2); + }); + + it('copies first heuristic with new ruleId', async () => { + const heuristic1 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TypeScript heuristics'], + }); + const heuristic2 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic1, + heuristic2, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const firstCopiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[0][0]; + expect(firstCopiedHeuristic.ruleId).toBe(newRuleId); + }); + + it('copies first heuristic with new ID', async () => { + const heuristic1 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TypeScript heuristics'], + }); + const heuristic2 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic1, + heuristic2, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const firstCopiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[0][0]; + expect(firstCopiedHeuristic.id).not.toBe(heuristic1.id); + }); + + it('preserves TypeScript language for first heuristic', async () => { + const heuristic1 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TypeScript heuristics'], + }); + const heuristic2 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic1, + heuristic2, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const firstCopiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[0][0]; + expect(firstCopiedHeuristic.language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + + it('preserves heuristics content for first heuristic', async () => { + const heuristic1 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TypeScript heuristics'], + }); + const heuristic2 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic1, + heuristic2, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const firstCopiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[0][0]; + expect(firstCopiedHeuristic.heuristics).toEqual([ + 'TypeScript heuristics', + ]); + }); + + it('copies second heuristic with new ruleId', async () => { + const heuristic1 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TypeScript heuristics'], + }); + const heuristic2 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic1, + heuristic2, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const secondCopiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[1][0]; + expect(secondCopiedHeuristic.ruleId).toBe(newRuleId); + }); + + it('copies second heuristic with new ID', async () => { + const heuristic1 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TypeScript heuristics'], + }); + const heuristic2 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic1, + heuristic2, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const secondCopiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[1][0]; + expect(secondCopiedHeuristic.id).not.toBe(heuristic2.id); + }); + + it('preserves Python language for second heuristic', async () => { + const heuristic1 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TypeScript heuristics'], + }); + const heuristic2 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic1, + heuristic2, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const secondCopiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[1][0]; + expect(secondCopiedHeuristic.language).toBe(ProgrammingLanguage.PYTHON); + }); + + it('preserves heuristics content for second heuristic', async () => { + const heuristic1 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TypeScript heuristics'], + }); + const heuristic2 = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic1, + heuristic2, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const secondCopiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[1][0]; + expect(secondCopiedHeuristic.heuristics).toEqual(['Python heuristics']); + }); + + it('preserves TypeScript language', async () => { + const heuristic = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TypeScript specific heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const copiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[0][0]; + expect(copiedHeuristic.language).toBe(ProgrammingLanguage.TYPESCRIPT); + }); + + it('preserves TypeScript heuristics content', async () => { + const heuristic = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TypeScript specific heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const copiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[0][0]; + expect(copiedHeuristic.heuristics).toEqual([ + 'TypeScript specific heuristics', + ]); + }); + + it('preserves Python language', async () => { + const heuristic = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python specific heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const copiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[0][0]; + expect(copiedHeuristic.language).toBe(ProgrammingLanguage.PYTHON); + }); + + it('preserves Python heuristics content', async () => { + const heuristic = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python specific heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const copiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[0][0]; + expect(copiedHeuristic.heuristics).toEqual([ + 'Python specific heuristics', + ]); + }); + + it('preserves Java language', async () => { + const heuristic = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.JAVA, + heuristics: ['Java specific heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const copiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[0][0]; + expect(copiedHeuristic.language).toBe(ProgrammingLanguage.JAVA); + }); + + it('preserves Java heuristics content', async () => { + const heuristic = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.JAVA, + heuristics: ['Java specific heuristics'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const copiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[0][0]; + expect(copiedHeuristic.heuristics).toEqual(['Java specific heuristics']); + }); + + it('preserves Rust language', async () => { + const heuristic = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.RUST, + heuristics: ['Detailed Rust heuristics with specific rules'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const copiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[0][0]; + expect(copiedHeuristic.language).toBe(ProgrammingLanguage.RUST); + }); + + it('preserves Rust heuristics content', async () => { + const heuristic = detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.RUST, + heuristics: ['Detailed Rust heuristics with specific rules'], + }); + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([ + heuristic, + ]); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const copiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[0][0]; + expect(copiedHeuristic.heuristics).toEqual([ + 'Detailed Rust heuristics with specific rules', + ]); + }); + + it('returns correct count for multiple languages', async () => { + const heuristics = [ + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TS heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.JAVA, + heuristics: ['Java heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.GO, + heuristics: ['Go heuristics'], + }), + ]; + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue( + heuristics, + ); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + const result = await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + expect(result.copiedHeuristicsCount).toBe(4); + }); + + it('calls upsertHeuristics for each language', async () => { + const heuristics = [ + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TS heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.JAVA, + heuristics: ['Java heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.GO, + heuristics: ['Go heuristics'], + }), + ]; + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue( + heuristics, + ); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + expect(heuristicsRepository.upsertHeuristics).toHaveBeenCalledTimes(4); + }); + + it('preserves TypeScript language in multiple languages scenario', async () => { + const heuristics = [ + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TS heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.JAVA, + heuristics: ['Java heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.GO, + heuristics: ['Go heuristics'], + }), + ]; + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue( + heuristics, + ); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const firstCopiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[0][0]; + expect(firstCopiedHeuristic.language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + + it('preserves Python language in multiple languages scenario', async () => { + const heuristics = [ + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TS heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.JAVA, + heuristics: ['Java heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.GO, + heuristics: ['Go heuristics'], + }), + ]; + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue( + heuristics, + ); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const secondCopiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[1][0]; + expect(secondCopiedHeuristic.language).toBe(ProgrammingLanguage.PYTHON); + }); + + it('preserves Java language in multiple languages scenario', async () => { + const heuristics = [ + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TS heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.JAVA, + heuristics: ['Java heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.GO, + heuristics: ['Go heuristics'], + }), + ]; + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue( + heuristics, + ); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const thirdCopiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[2][0]; + expect(thirdCopiedHeuristic.language).toBe(ProgrammingLanguage.JAVA); + }); + + it('preserves Go language in multiple languages scenario', async () => { + const heuristics = [ + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TS heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['Python heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.JAVA, + heuristics: ['Java heuristics'], + }), + detectionHeuristicsFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.GO, + heuristics: ['Go heuristics'], + }), + ]; + + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue( + heuristics, + ); + heuristicsRepository.upsertHeuristics.mockResolvedValue(undefined); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const fourthCopiedHeuristic = + heuristicsRepository.upsertHeuristics.mock.calls[3][0]; + expect(fourthCopiedHeuristic.language).toBe(ProgrammingLanguage.GO); + }); + }); + + describe('when old rule has no detection heuristics', () => { + it('returns 0 copied heuristics count', async () => { + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([]); + + const result = await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + expect(result.copiedHeuristicsCount).toBe(0); + }); + + it('does not call upsertHeuristics', async () => { + heuristicsRepository.getAllHeuristicsForRule.mockResolvedValue([]); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + expect(heuristicsRepository.upsertHeuristics).not.toHaveBeenCalled(); + }); + }); + + describe('when repository throws error', () => { + it('logs error and re-throws', async () => { + const error = new Error('Database connection failed'); + heuristicsRepository.getAllHeuristicsForRule.mockRejectedValue(error); + + await expect( + useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }), + ).rejects.toThrow('Database connection failed'); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/copyDetectionHeuristics/CopyDetectionHeuristicsUseCase.ts b/packages/linter/src/application/useCases/copyDetectionHeuristics/CopyDetectionHeuristicsUseCase.ts new file mode 100644 index 000000000..96e84ac11 --- /dev/null +++ b/packages/linter/src/application/useCases/copyDetectionHeuristics/CopyDetectionHeuristicsUseCase.ts @@ -0,0 +1,78 @@ +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { + ICopyDetectionHeuristics, + CopyDetectionHeuristicsCommand, + CopyDetectionHeuristicsResponse, + DetectionHeuristics, + createDetectionHeuristicsId, +} from '@packmind/types'; + +const origin = 'CopyDetectionHeuristicsUseCase'; + +export class CopyDetectionHeuristicsUseCase implements ICopyDetectionHeuristics { + constructor( + private readonly repositories: ILinterRepositories, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: CopyDetectionHeuristicsCommand, + ): Promise<CopyDetectionHeuristicsResponse> { + this.logger.info('Starting to copy detection heuristics to new rule', { + oldRuleId: command.oldRuleId, + newRuleId: command.newRuleId, + organizationId: command.organizationId, + userId: command.userId, + }); + + try { + const oldHeuristics = await this.repositories + .getRuleDetectionHeuristicsRepository() + .getAllHeuristicsForRule(command.oldRuleId); + + if (oldHeuristics.length === 0) { + this.logger.info('No detection heuristics found for old rule', { + oldRuleId: command.oldRuleId, + }); + return { copiedHeuristicsCount: 0 }; + } + + this.logger.info('Found detection heuristics to copy', { + count: oldHeuristics.length, + oldRuleId: command.oldRuleId, + }); + + for (const oldHeuristic of oldHeuristics) { + const newHeuristic: DetectionHeuristics = { + ...oldHeuristic, + id: createDetectionHeuristicsId(uuidv4()), + ruleId: command.newRuleId, + }; + + await this.repositories + .getRuleDetectionHeuristicsRepository() + .upsertHeuristics(newHeuristic); + } + + this.logger.info( + 'Successfully copied all detection heuristics to new rule', + { + oldRuleId: command.oldRuleId, + newRuleId: command.newRuleId, + copiedHeuristicsCount: oldHeuristics.length, + }, + ); + + return { copiedHeuristicsCount: oldHeuristics.length }; + } catch (error) { + this.logger.error('Failed to copy detection heuristics', { + oldRuleId: command.oldRuleId, + newRuleId: command.newRuleId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/copyDetectionProgramsToNewRule/CopyDetectionProgramsToNewRuleUseCase.spec.ts b/packages/linter/src/application/useCases/copyDetectionProgramsToNewRule/CopyDetectionProgramsToNewRuleUseCase.spec.ts new file mode 100644 index 000000000..41ad0b658 --- /dev/null +++ b/packages/linter/src/application/useCases/copyDetectionProgramsToNewRule/CopyDetectionProgramsToNewRuleUseCase.spec.ts @@ -0,0 +1,588 @@ +import { CopyDetectionProgramsToNewRuleUseCase } from './CopyDetectionProgramsToNewRuleUseCase'; +import { IDetectionProgramRepository } from '../../../domain/repositories/IDetectionProgramRepository'; +import { IActiveDetectionProgramRepository } from '../../../domain/repositories/IActiveDetectionProgramRepository'; +import { IDetectionProgramMetadataRepository } from '../../../domain/repositories/IDetectionProgramMetadataRepository'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { stubLogger } from '@packmind/test-utils'; +import { PackmindLogger } from '@packmind/logger'; +import { + createOrganizationId, + createUserId, + DetectionProgramMetadata, +} from '@packmind/types'; +import { + ActiveDetectionProgram, + createRuleId, + DetectionSeverity, + ProgrammingLanguage, + DetectionProgram, + LanguageDetectionPrograms, +} from '@packmind/types'; +import { + detectionProgramFactory, + activeDetectionProgramFactory, + detectionProgramMetadataFactory, +} from '../../../../test'; +import { v4 as uuidv4 } from 'uuid'; + +describe('CopyDetectionProgramsToNewRuleUseCase', () => { + let useCase: CopyDetectionProgramsToNewRuleUseCase; + let detectionProgramRepository: jest.Mocked<IDetectionProgramRepository>; + let activeDetectionProgramRepository: jest.Mocked<IActiveDetectionProgramRepository>; + let detectionProgramMetadataRepository: jest.Mocked<IDetectionProgramMetadataRepository>; + let repositories: jest.Mocked<ILinterRepositories>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + const oldRuleId = createRuleId(uuidv4()); + const newRuleId = createRuleId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + + beforeEach(() => { + detectionProgramRepository = { + add: jest.fn(), + findById: jest.fn(), + findByRuleId: jest.fn(), + findByRuleIdAndVersion: jest.fn(), + getLatestVersionByRuleId: jest.fn(), + getLatestVersionByRuleIdAndLanguage: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + list: jest.fn(), + } as unknown as jest.Mocked<IDetectionProgramRepository>; + + activeDetectionProgramRepository = { + add: jest.fn(), + findById: jest.fn(), + findByRuleId: jest.fn(), + findByRuleIdAndLanguage: jest.fn(), + findByRuleIdWithPrograms: jest.fn(), + updateActiveDetectionProgram: jest.fn(), + deleteByRuleId: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + list: jest.fn(), + } as unknown as jest.Mocked<IActiveDetectionProgramRepository>; + + detectionProgramMetadataRepository = { + add: jest.fn().mockImplementation(async (m) => m), + findByDetectionProgramId: jest.fn(), + findByDetectionProgramIds: jest.fn().mockResolvedValue([]), + addLog: jest.fn(), + updateProgramDescription: jest.fn(), + updateTokensUsed: jest.fn(), + softDeleteByDetectionProgramIds: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + findById: jest.fn(), + list: jest.fn(), + } as unknown as jest.Mocked<IDetectionProgramMetadataRepository>; + + repositories = { + getDetectionProgramRepository: jest + .fn() + .mockReturnValue(detectionProgramRepository), + getActiveDetectionProgramRepository: jest + .fn() + .mockReturnValue(activeDetectionProgramRepository), + getDetectionProgramMetadataRepository: jest + .fn() + .mockReturnValue(detectionProgramMetadataRepository), + } as unknown as jest.Mocked<ILinterRepositories>; + + stubbedLogger = stubLogger(); + + useCase = new CopyDetectionProgramsToNewRuleUseCase( + repositories, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when old rule has detection programs', () => { + describe('when copying all detection programs', () => { + let result: { copiedProgramsCount: number }; + let detectionProgram1: DetectionProgram; + let detectionProgram2: DetectionProgram; + + beforeEach(async () => { + detectionProgram1 = detectionProgramFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + version: 1, + }); + detectionProgram2 = detectionProgramFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + version: 1, + }); + + detectionProgramRepository.findByRuleId.mockResolvedValue([ + detectionProgram1, + detectionProgram2, + ]); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [], + ); + + detectionProgramRepository.add.mockImplementation( + async (program: DetectionProgram) => program, + ); + + result = await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + }); + + it('returns copied programs count of 2', () => { + expect(result.copiedProgramsCount).toBe(2); + }); + + it('calls add on detection program repository twice', () => { + expect(detectionProgramRepository.add).toHaveBeenCalledTimes(2); + }); + + it('assigns new rule ID to first copied program', () => { + const firstCall = detectionProgramRepository.add.mock.calls[0][0]; + expect(firstCall.ruleId).toBe(newRuleId); + }); + + it('preserves version of first copied program', () => { + const firstCall = detectionProgramRepository.add.mock.calls[0][0]; + expect(firstCall.version).toBe(1); + }); + + it('preserves language of first copied program', () => { + const firstCall = detectionProgramRepository.add.mock.calls[0][0]; + expect(firstCall.language).toBe(ProgrammingLanguage.TYPESCRIPT); + }); + + it('generates new ID for first copied program', () => { + const firstCall = detectionProgramRepository.add.mock.calls[0][0]; + expect(firstCall.id).not.toBe(detectionProgram1.id); + }); + + it('assigns new rule ID to second copied program', () => { + const secondCall = detectionProgramRepository.add.mock.calls[1][0]; + expect(secondCall.ruleId).toBe(newRuleId); + }); + + it('preserves version of second copied program', () => { + const secondCall = detectionProgramRepository.add.mock.calls[1][0]; + expect(secondCall.version).toBe(1); + }); + + it('preserves language of second copied program', () => { + const secondCall = detectionProgramRepository.add.mock.calls[1][0]; + expect(secondCall.language).toBe(ProgrammingLanguage.PYTHON); + }); + + it('generates new ID for second copied program', () => { + const secondCall = detectionProgramRepository.add.mock.calls[1][0]; + expect(secondCall.id).not.toBe(detectionProgram2.id); + }); + }); + + describe('when copying active detection programs', () => { + let detectionProgram: DetectionProgram; + let activeProgram: LanguageDetectionPrograms; + + beforeEach(async () => { + detectionProgram = detectionProgramFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + version: 1, + }); + + activeProgram = activeDetectionProgramFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgramVersion: detectionProgram.id, + detectionProgramDraftVersion: null, + }) as LanguageDetectionPrograms; + + detectionProgramRepository.findByRuleId.mockResolvedValue([ + detectionProgram, + ]); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [activeProgram], + ); + + detectionProgramRepository.add.mockImplementation( + async (program: DetectionProgram) => program, + ); + activeDetectionProgramRepository.add.mockImplementation( + async (active) => active, + ); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + }); + + it('calls add on active detection program repository once', () => { + expect(activeDetectionProgramRepository.add).toHaveBeenCalledTimes(1); + }); + + it('assigns new rule ID to copied active program', () => { + const activeCall = + activeDetectionProgramRepository.add.mock.calls[0][0]; + expect(activeCall.ruleId).toBe(newRuleId); + }); + + it('preserves language of copied active program', () => { + const activeCall = + activeDetectionProgramRepository.add.mock.calls[0][0]; + expect(activeCall.language).toBe(ProgrammingLanguage.TYPESCRIPT); + }); + + it('generates new ID for copied active program', () => { + const activeCall = + activeDetectionProgramRepository.add.mock.calls[0][0]; + expect(activeCall.id).not.toBe(activeProgram.id); + }); + + it('updates detection program version reference to new program', () => { + const activeCall = + activeDetectionProgramRepository.add.mock.calls[0][0]; + expect(activeCall.detectionProgramVersion).not.toBe( + detectionProgram.id, + ); + }); + + it('sets a truthy detection program version reference', () => { + const activeCall = + activeDetectionProgramRepository.add.mock.calls[0][0]; + expect(activeCall.detectionProgramVersion).toBeTruthy(); + }); + + it('preserves severity of copied active program', () => { + const activeCall = + activeDetectionProgramRepository.add.mock.calls[0][0]; + expect(activeCall.severity).toBe(DetectionSeverity.ERROR); + }); + }); + + describe('when active programs have both active and draft versions', () => { + let activeCall: ActiveDetectionProgram; + + beforeEach(async () => { + const detectionProgram1 = detectionProgramFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + version: 1, + }); + const detectionProgram2 = detectionProgramFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + version: 2, + }); + + const activeProgram = activeDetectionProgramFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgramVersion: detectionProgram1.id, + detectionProgramDraftVersion: detectionProgram2.id, + }) as LanguageDetectionPrograms; + + detectionProgramRepository.findByRuleId.mockResolvedValue([ + detectionProgram1, + detectionProgram2, + ]); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [activeProgram], + ); + + detectionProgramRepository.add.mockImplementation( + async (program: DetectionProgram) => program, + ); + activeDetectionProgramRepository.add.mockImplementation( + async (active) => active, + ); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + activeCall = activeDetectionProgramRepository.add.mock.calls[0][0]; + }); + + it('sets truthy detection program version', () => { + expect(activeCall.detectionProgramVersion).toBeTruthy(); + }); + + it('sets truthy detection program draft version', () => { + expect(activeCall.detectionProgramDraftVersion).toBeTruthy(); + }); + + it('uses different IDs for active and draft versions', () => { + expect(activeCall.detectionProgramVersion).not.toBe( + activeCall.detectionProgramDraftVersion, + ); + }); + }); + + describe('when handling multiple languages per rule', () => { + let result: { copiedProgramsCount: number }; + + beforeEach(async () => { + const tsProgram = detectionProgramFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + }); + const pyProgram = detectionProgramFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + }); + + const tsActiveProgram = activeDetectionProgramFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgramVersion: tsProgram.id, + }) as LanguageDetectionPrograms; + + const pyActiveProgram = activeDetectionProgramFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + detectionProgramVersion: pyProgram.id, + }) as LanguageDetectionPrograms; + + detectionProgramRepository.findByRuleId.mockResolvedValue([ + tsProgram, + pyProgram, + ]); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [tsActiveProgram, pyActiveProgram], + ); + + detectionProgramRepository.add.mockImplementation( + async (program: DetectionProgram) => program, + ); + activeDetectionProgramRepository.add.mockImplementation( + async (active) => active, + ); + + result = await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + }); + + it('returns copied programs count of 2', () => { + expect(result.copiedProgramsCount).toBe(2); + }); + + it('calls add on detection program repository twice', () => { + expect(detectionProgramRepository.add).toHaveBeenCalledTimes(2); + }); + + it('calls add on active detection program repository twice', () => { + expect(activeDetectionProgramRepository.add).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('when old rule has detection programs with metadata', () => { + let result: { copiedProgramsCount: number; copiedMetadataCount: number }; + let detectionProgram: DetectionProgram; + let metadata: DetectionProgramMetadata; + + beforeEach(async () => { + detectionProgram = detectionProgramFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + version: 1, + }); + + metadata = detectionProgramMetadataFactory({ + detectionProgramId: detectionProgram.id, + programDescription: 'Detects unused imports', + tokens: { input: 100, output: 200 }, + logs: [ + { timestamp: Date.now(), message: 'AI_AGENT_PROGRAM_SUCCESSFUL' }, + ], + }); + + detectionProgramRepository.findByRuleId.mockResolvedValue([ + detectionProgram, + ]); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [], + ); + detectionProgramRepository.add.mockImplementation( + async (program: DetectionProgram) => program, + ); + detectionProgramMetadataRepository.findByDetectionProgramIds.mockResolvedValue( + [metadata], + ); + + result = await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + }); + + it('returns copied metadata count of 1', () => { + expect(result.copiedMetadataCount).toBe(1); + }); + + it('adds metadata with new detection program ID', () => { + expect(detectionProgramMetadataRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + programDescription: 'Detects unused imports', + tokens: { input: 100, output: 200 }, + }), + ); + }); + + it('generates new ID for copied metadata', () => { + const addedMetadata = + detectionProgramMetadataRepository.add.mock.calls[0][0]; + expect(addedMetadata.id).not.toBe(metadata.id); + }); + + it('maps metadata to new detection program ID', () => { + const addedMetadata = + detectionProgramMetadataRepository.add.mock.calls[0][0]; + expect(addedMetadata.detectionProgramId).not.toBe(detectionProgram.id); + }); + + it('copies execution logs to new metadata', () => { + expect(detectionProgramMetadataRepository.addLog).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'AI_AGENT_PROGRAM_SUCCESSFUL', + }), + expect.any(String), + ); + }); + }); + + describe('when old rule has detection programs without metadata', () => { + let result: { copiedProgramsCount: number; copiedMetadataCount: number }; + + beforeEach(async () => { + const detectionProgram = detectionProgramFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + detectionProgramRepository.findByRuleId.mockResolvedValue([ + detectionProgram, + ]); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [], + ); + detectionProgramRepository.add.mockImplementation( + async (program: DetectionProgram) => program, + ); + detectionProgramMetadataRepository.findByDetectionProgramIds.mockResolvedValue( + [], + ); + + result = await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + }); + + it('returns zero copied metadata count', () => { + expect(result.copiedMetadataCount).toBe(0); + }); + + it('does not call add on metadata repository', () => { + expect(detectionProgramMetadataRepository.add).not.toHaveBeenCalled(); + }); + }); + + describe('when old rule has no detection programs', () => { + let result: { copiedProgramsCount: number }; + + beforeEach(async () => { + detectionProgramRepository.findByRuleId.mockResolvedValue([]); + + result = await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + }); + + it('returns zero copied programs count', () => { + expect(result.copiedProgramsCount).toBe(0); + }); + + it('does not call add on detection program repository', () => { + expect(detectionProgramRepository.add).not.toHaveBeenCalled(); + }); + + it('does not call findByRuleIdWithPrograms on active detection program repository', () => { + expect( + activeDetectionProgramRepository.findByRuleIdWithPrograms, + ).not.toHaveBeenCalled(); + }); + + it('does not call add on active detection program repository', () => { + expect(activeDetectionProgramRepository.add).not.toHaveBeenCalled(); + }); + }); + + describe('when old rule has detection programs but no active programs', () => { + let result: { copiedProgramsCount: number }; + + beforeEach(async () => { + const detectionProgram = detectionProgramFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + detectionProgramRepository.findByRuleId.mockResolvedValue([ + detectionProgram, + ]); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [], + ); + + detectionProgramRepository.add.mockImplementation( + async (program: DetectionProgram) => program, + ); + + result = await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + }); + + it('returns copied programs count of 1', () => { + expect(result.copiedProgramsCount).toBe(1); + }); + + it('calls add on detection program repository once', () => { + expect(detectionProgramRepository.add).toHaveBeenCalledTimes(1); + }); + + it('does not call add on active detection program repository', () => { + expect(activeDetectionProgramRepository.add).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/copyDetectionProgramsToNewRule/CopyDetectionProgramsToNewRuleUseCase.ts b/packages/linter/src/application/useCases/copyDetectionProgramsToNewRule/CopyDetectionProgramsToNewRuleUseCase.ts new file mode 100644 index 000000000..b6b0405e1 --- /dev/null +++ b/packages/linter/src/application/useCases/copyDetectionProgramsToNewRule/CopyDetectionProgramsToNewRuleUseCase.ts @@ -0,0 +1,211 @@ +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger, LogLevel } from '@packmind/logger'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { + ICopyDetectionProgramsToNewRule, + CopyDetectionProgramsToNewRuleCommand, + CopyDetectionProgramsToNewRuleResponse, + DetectionProgram, + DetectionProgramId, + createDetectionProgramId, + ActiveDetectionProgram, + createActiveDetectionProgramId, +} from '@packmind/types'; + +const origin = 'CopyDetectionProgramsToNewRuleUseCase'; + +export class CopyDetectionProgramsToNewRuleUseCase implements ICopyDetectionProgramsToNewRule { + constructor( + private readonly repositories: ILinterRepositories, + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.DEBUG, + ), + ) {} + + async execute( + command: CopyDetectionProgramsToNewRuleCommand, + ): Promise<CopyDetectionProgramsToNewRuleResponse> { + this.logger.info('Starting to copy detection programs to new rule', { + oldRuleId: command.oldRuleId, + newRuleId: command.newRuleId, + organizationId: command.organizationId, + userId: command.userId, + }); + + try { + // 1. Get all DetectionPrograms for the old rule + const oldDetectionPrograms = await this.repositories + .getDetectionProgramRepository() + .findByRuleId(command.oldRuleId); + + if (oldDetectionPrograms.length === 0) { + this.logger.info('No detection programs found for old rule', { + oldRuleId: command.oldRuleId, + }); + return { copiedProgramsCount: 0, copiedMetadataCount: 0 }; + } + + this.logger.debug('Found detection programs to copy', { + count: oldDetectionPrograms.length, + oldRuleId: command.oldRuleId, + }); + + // 2. Copy all DetectionPrograms and build mapping + const oldToNewProgramIdMap = new Map< + DetectionProgramId, + DetectionProgramId + >(); + + await Promise.all( + oldDetectionPrograms.map(async (oldProgram) => { + const newProgramId = createDetectionProgramId(uuidv4()); + const newProgram: DetectionProgram = { + ...oldProgram, + id: newProgramId, + ruleId: command.newRuleId, + createdAt: new Date(), + }; + + await this.repositories + .getDetectionProgramRepository() + .add(newProgram); + + oldToNewProgramIdMap.set(oldProgram.id, newProgramId); + + this.logger.debug('Copied detection program', { + oldProgramId: oldProgram.id, + newProgramId, + version: oldProgram.version, + language: oldProgram.language, + }); + }), + ); + + // 3. Copy DetectionProgramMetadata and ExecutionLogs + const oldProgramIds = Array.from(oldToNewProgramIdMap.keys()); + const metadataEntries = await this.repositories + .getDetectionProgramMetadataRepository() + .findByDetectionProgramIds(oldProgramIds); + + let copiedMetadataCount = 0; + if (metadataEntries.length > 0) { + await Promise.all( + metadataEntries.map(async (oldMetadata) => { + const newDetectionProgramId = oldToNewProgramIdMap.get( + oldMetadata.detectionProgramId, + ); + if (!newDetectionProgramId) { + return; + } + + const newMetadataId = uuidv4(); + const newMetadata = { + ...oldMetadata, + id: newMetadataId, + detectionProgramId: newDetectionProgramId, + logs: null, // Logs are stored separately in their own table + }; + + await this.repositories + .getDetectionProgramMetadataRepository() + .add(newMetadata); + + // Copy execution logs if present + if (oldMetadata.logs && oldMetadata.logs.length > 0) { + for (const log of oldMetadata.logs) { + await this.repositories + .getDetectionProgramMetadataRepository() + .addLog(log, newDetectionProgramId); + } + } + + copiedMetadataCount++; + }), + ); + + this.logger.debug('Copied detection program metadata', { + copiedMetadataCount, + oldRuleId: command.oldRuleId, + newRuleId: command.newRuleId, + }); + } + + // 4. Get all ActiveDetectionPrograms for the old rule + const oldActivePrograms = await this.repositories + .getActiveDetectionProgramRepository() + .findByRuleIdWithPrograms(command.oldRuleId); + + if (oldActivePrograms.length === 0) { + this.logger.info( + 'No active detection programs found for old rule, only copied detection programs', + { + oldRuleId: command.oldRuleId, + copiedProgramsCount: oldDetectionPrograms.length, + }, + ); + return { + copiedProgramsCount: oldDetectionPrograms.length, + copiedMetadataCount, + }; + } + + // 5. Copy all ActiveDetectionPrograms with updated references + await Promise.all( + oldActivePrograms.map(async (oldActiveProgram) => { + const newActiveDetectionProgram: ActiveDetectionProgram = { + id: createActiveDetectionProgramId(uuidv4()), + ruleId: command.newRuleId, + language: oldActiveProgram.language, + detectionProgramVersion: oldActiveProgram.detectionProgramVersion + ? oldToNewProgramIdMap.get( + oldActiveProgram.detectionProgramVersion, + ) || null + : null, + detectionProgramDraftVersion: + oldActiveProgram.detectionProgramDraftVersion + ? oldToNewProgramIdMap.get( + oldActiveProgram.detectionProgramDraftVersion, + ) || null + : null, + severity: oldActiveProgram.severity, + }; + + await this.repositories + .getActiveDetectionProgramRepository() + .add(newActiveDetectionProgram); + + this.logger.debug('Copied active detection program', { + oldActiveId: oldActiveProgram.id, + newActiveId: newActiveDetectionProgram.id, + language: oldActiveProgram.language, + hasActiveVersion: + !!newActiveDetectionProgram.detectionProgramVersion, + hasDraftVersion: + !!newActiveDetectionProgram.detectionProgramDraftVersion, + }); + }), + ); + + this.logger.info('Successfully copied detection programs to new rule', { + oldRuleId: command.oldRuleId, + newRuleId: command.newRuleId, + copiedProgramsCount: oldDetectionPrograms.length, + copiedActiveCount: oldActivePrograms.length, + copiedMetadataCount, + }); + + return { + copiedProgramsCount: oldDetectionPrograms.length, + copiedMetadataCount, + }; + } catch (error) { + this.logger.error('Failed to copy detection programs to new rule', { + oldRuleId: command.oldRuleId, + newRuleId: command.newRuleId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/copyLinterArtefacts/CopyLinterArtefactsUseCase.spec.ts b/packages/linter/src/application/useCases/copyLinterArtefacts/CopyLinterArtefactsUseCase.spec.ts new file mode 100644 index 000000000..6ac4b5716 --- /dev/null +++ b/packages/linter/src/application/useCases/copyLinterArtefacts/CopyLinterArtefactsUseCase.spec.ts @@ -0,0 +1,390 @@ +import { + createOrganizationId, + createUserId, + createRuleId, + ILinterPort, + CopyDetectionHeuristicsResponse, + CopyRuleDetectionAssessmentsResponse, + CopyDetectionProgramsToNewRuleResponse, +} from '@packmind/types'; +import { CopyLinterArtefactsUseCase } from './CopyLinterArtefactsUseCase'; +import { v4 as uuidv4 } from 'uuid'; +import { stubLogger } from '@packmind/test-utils'; + +describe('CopyLinterArtefactsUseCase', () => { + let useCase: CopyLinterArtefactsUseCase; + let linterPort: jest.Mocked<ILinterPort>; + + const oldRuleId = createRuleId(uuidv4()); + const newRuleId = createRuleId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + + const command = { + oldRuleId, + newRuleId, + organizationId, + userId, + }; + + beforeEach(() => { + linterPort = { + copyDetectionHeuristics: jest.fn(), + copyRuleDetectionAssessments: jest.fn(), + copyDetectionProgramsToNewRule: jest.fn(), + } as unknown as jest.Mocked<ILinterPort>; + + useCase = new CopyLinterArtefactsUseCase(linterPort, stubLogger()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when copying all linter artefacts', () => { + it('returns aggregated counts from all three operations', async () => { + const heuristicsResponse: CopyDetectionHeuristicsResponse = { + copiedHeuristicsCount: 2, + }; + const assessmentsResponse: CopyRuleDetectionAssessmentsResponse = { + copiedAssessmentsCount: 3, + }; + const programsResponse: CopyDetectionProgramsToNewRuleResponse = { + copiedProgramsCount: 5, + copiedMetadataCount: 2, + }; + + linterPort.copyDetectionHeuristics.mockResolvedValue(heuristicsResponse); + linterPort.copyRuleDetectionAssessments.mockResolvedValue( + assessmentsResponse, + ); + linterPort.copyDetectionProgramsToNewRule.mockResolvedValue( + programsResponse, + ); + + const result = await useCase.execute(command); + + expect(result).toEqual({ + copiedHeuristicsCount: 2, + copiedAssessmentsCount: 3, + copiedProgramsCount: 5, + copiedMetadataCount: 2, + }); + }); + + it('calls copyDetectionHeuristics via linterPort', async () => { + linterPort.copyDetectionHeuristics.mockResolvedValue({ + copiedHeuristicsCount: 0, + }); + linterPort.copyRuleDetectionAssessments.mockResolvedValue({ + copiedAssessmentsCount: 0, + }); + linterPort.copyDetectionProgramsToNewRule.mockResolvedValue({ + copiedProgramsCount: 0, + copiedMetadataCount: 0, + }); + + await useCase.execute(command); + + expect(linterPort.copyDetectionHeuristics).toHaveBeenCalledWith(command); + }); + + it('calls copyRuleDetectionAssessments via linterPort', async () => { + linterPort.copyDetectionHeuristics.mockResolvedValue({ + copiedHeuristicsCount: 0, + }); + linterPort.copyRuleDetectionAssessments.mockResolvedValue({ + copiedAssessmentsCount: 0, + }); + linterPort.copyDetectionProgramsToNewRule.mockResolvedValue({ + copiedProgramsCount: 0, + copiedMetadataCount: 0, + }); + + await useCase.execute(command); + + expect(linterPort.copyRuleDetectionAssessments).toHaveBeenCalledWith( + command, + ); + }); + + it('calls copyDetectionProgramsToNewRule via linterPort', async () => { + linterPort.copyDetectionHeuristics.mockResolvedValue({ + copiedHeuristicsCount: 0, + }); + linterPort.copyRuleDetectionAssessments.mockResolvedValue({ + copiedAssessmentsCount: 0, + }); + linterPort.copyDetectionProgramsToNewRule.mockResolvedValue({ + copiedProgramsCount: 0, + copiedMetadataCount: 0, + }); + + await useCase.execute(command); + + expect(linterPort.copyDetectionProgramsToNewRule).toHaveBeenCalledWith( + command, + ); + }); + + it('calls copyDetectionHeuristics exactly once', async () => { + linterPort.copyDetectionHeuristics.mockResolvedValue({ + copiedHeuristicsCount: 1, + }); + linterPort.copyRuleDetectionAssessments.mockResolvedValue({ + copiedAssessmentsCount: 1, + }); + linterPort.copyDetectionProgramsToNewRule.mockResolvedValue({ + copiedProgramsCount: 1, + copiedMetadataCount: 0, + }); + + await useCase.execute(command); + + expect(linterPort.copyDetectionHeuristics).toHaveBeenCalledTimes(1); + }); + + it('calls copyRuleDetectionAssessments exactly once', async () => { + linterPort.copyDetectionHeuristics.mockResolvedValue({ + copiedHeuristicsCount: 1, + }); + linterPort.copyRuleDetectionAssessments.mockResolvedValue({ + copiedAssessmentsCount: 1, + }); + linterPort.copyDetectionProgramsToNewRule.mockResolvedValue({ + copiedProgramsCount: 1, + copiedMetadataCount: 0, + }); + + await useCase.execute(command); + + expect(linterPort.copyRuleDetectionAssessments).toHaveBeenCalledTimes(1); + }); + + it('calls copyDetectionProgramsToNewRule exactly once', async () => { + linterPort.copyDetectionHeuristics.mockResolvedValue({ + copiedHeuristicsCount: 1, + }); + linterPort.copyRuleDetectionAssessments.mockResolvedValue({ + copiedAssessmentsCount: 1, + }); + linterPort.copyDetectionProgramsToNewRule.mockResolvedValue({ + copiedProgramsCount: 1, + copiedMetadataCount: 0, + }); + + await useCase.execute(command); + + expect(linterPort.copyDetectionProgramsToNewRule).toHaveBeenCalledTimes( + 1, + ); + }); + }); + + describe('when no artefacts exist', () => { + it('returns all counts as zero', async () => { + linterPort.copyDetectionHeuristics.mockResolvedValue({ + copiedHeuristicsCount: 0, + }); + linterPort.copyRuleDetectionAssessments.mockResolvedValue({ + copiedAssessmentsCount: 0, + }); + linterPort.copyDetectionProgramsToNewRule.mockResolvedValue({ + copiedProgramsCount: 0, + copiedMetadataCount: 0, + }); + + const result = await useCase.execute(command); + + expect(result).toEqual({ + copiedHeuristicsCount: 0, + copiedAssessmentsCount: 0, + copiedProgramsCount: 0, + copiedMetadataCount: 0, + }); + }); + }); + + describe('when one operation has artefacts', () => { + describe('when only heuristics exist', () => { + beforeEach(() => { + linterPort.copyDetectionHeuristics.mockResolvedValue({ + copiedHeuristicsCount: 4, + }); + linterPort.copyRuleDetectionAssessments.mockResolvedValue({ + copiedAssessmentsCount: 0, + }); + linterPort.copyDetectionProgramsToNewRule.mockResolvedValue({ + copiedProgramsCount: 0, + }); + }); + + it('returns correct heuristics count', async () => { + const result = await useCase.execute(command); + + expect(result.copiedHeuristicsCount).toBe(4); + }); + + it('returns zero assessments count', async () => { + const result = await useCase.execute(command); + + expect(result.copiedAssessmentsCount).toBe(0); + }); + + it('returns zero programs count', async () => { + const result = await useCase.execute(command); + + expect(result.copiedProgramsCount).toBe(0); + }); + }); + + describe('when only assessments exist', () => { + beforeEach(() => { + linterPort.copyDetectionHeuristics.mockResolvedValue({ + copiedHeuristicsCount: 0, + }); + linterPort.copyRuleDetectionAssessments.mockResolvedValue({ + copiedAssessmentsCount: 6, + }); + linterPort.copyDetectionProgramsToNewRule.mockResolvedValue({ + copiedProgramsCount: 0, + }); + }); + + it('returns zero heuristics count', async () => { + const result = await useCase.execute(command); + + expect(result.copiedHeuristicsCount).toBe(0); + }); + + it('returns correct assessments count', async () => { + const result = await useCase.execute(command); + + expect(result.copiedAssessmentsCount).toBe(6); + }); + + it('returns zero programs count', async () => { + const result = await useCase.execute(command); + + expect(result.copiedProgramsCount).toBe(0); + }); + }); + + describe('when only programs exist', () => { + beforeEach(() => { + linterPort.copyDetectionHeuristics.mockResolvedValue({ + copiedHeuristicsCount: 0, + }); + linterPort.copyRuleDetectionAssessments.mockResolvedValue({ + copiedAssessmentsCount: 0, + }); + linterPort.copyDetectionProgramsToNewRule.mockResolvedValue({ + copiedProgramsCount: 7, + copiedMetadataCount: 3, + }); + }); + + it('returns zero heuristics count', async () => { + const result = await useCase.execute(command); + + expect(result.copiedHeuristicsCount).toBe(0); + }); + + it('returns zero assessments count', async () => { + const result = await useCase.execute(command); + + expect(result.copiedAssessmentsCount).toBe(0); + }); + + it('returns correct programs count', async () => { + const result = await useCase.execute(command); + + expect(result.copiedProgramsCount).toBe(7); + }); + }); + }); + + describe('when one operation fails', () => { + it('throws error from copyDetectionHeuristics', async () => { + const error = new Error('Failed to copy heuristics'); + linterPort.copyDetectionHeuristics.mockRejectedValue(error); + linterPort.copyRuleDetectionAssessments.mockResolvedValue({ + copiedAssessmentsCount: 1, + }); + linterPort.copyDetectionProgramsToNewRule.mockResolvedValue({ + copiedProgramsCount: 1, + copiedMetadataCount: 0, + }); + + await expect(useCase.execute(command)).rejects.toThrow( + 'Failed to copy heuristics', + ); + }); + + it('throws error from copyRuleDetectionAssessments', async () => { + const error = new Error('Failed to copy assessments'); + linterPort.copyDetectionHeuristics.mockResolvedValue({ + copiedHeuristicsCount: 1, + }); + linterPort.copyRuleDetectionAssessments.mockRejectedValue(error); + linterPort.copyDetectionProgramsToNewRule.mockResolvedValue({ + copiedProgramsCount: 1, + copiedMetadataCount: 0, + }); + + await expect(useCase.execute(command)).rejects.toThrow( + 'Failed to copy assessments', + ); + }); + + it('throws error from copyDetectionProgramsToNewRule', async () => { + const error = new Error('Failed to copy programs'); + linterPort.copyDetectionHeuristics.mockResolvedValue({ + copiedHeuristicsCount: 1, + }); + linterPort.copyRuleDetectionAssessments.mockResolvedValue({ + copiedAssessmentsCount: 1, + }); + linterPort.copyDetectionProgramsToNewRule.mockRejectedValue(error); + + await expect(useCase.execute(command)).rejects.toThrow( + 'Failed to copy programs', + ); + }); + }); + + describe('when operations execute in parallel', () => { + it('completes all operations concurrently', async () => { + const executionOrder: string[] = []; + + linterPort.copyDetectionHeuristics.mockImplementation(async () => { + executionOrder.push('heuristics-start'); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionOrder.push('heuristics-end'); + return { copiedHeuristicsCount: 1 }; + }); + + linterPort.copyRuleDetectionAssessments.mockImplementation(async () => { + executionOrder.push('assessments-start'); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionOrder.push('assessments-end'); + return { copiedAssessmentsCount: 1 }; + }); + + linterPort.copyDetectionProgramsToNewRule.mockImplementation(async () => { + executionOrder.push('programs-start'); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionOrder.push('programs-end'); + return { copiedProgramsCount: 1 }; + }); + + await useCase.execute(command); + + expect(executionOrder.slice(0, 3)).toEqual([ + 'heuristics-start', + 'assessments-start', + 'programs-start', + ]); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/copyLinterArtefacts/CopyLinterArtefactsUseCase.ts b/packages/linter/src/application/useCases/copyLinterArtefacts/CopyLinterArtefactsUseCase.ts new file mode 100644 index 000000000..7dcf318e7 --- /dev/null +++ b/packages/linter/src/application/useCases/copyLinterArtefacts/CopyLinterArtefactsUseCase.ts @@ -0,0 +1,58 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + ICopyLinterArtefacts, + CopyLinterArtefactsCommand, + CopyLinterArtefactsResponse, + ILinterPort, +} from '@packmind/types'; + +const origin = 'CopyLinterArtefactsUseCase'; + +export class CopyLinterArtefactsUseCase implements ICopyLinterArtefacts { + constructor( + private readonly linterPort: ILinterPort, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: CopyLinterArtefactsCommand, + ): Promise<CopyLinterArtefactsResponse> { + this.logger.info('Starting to copy all linter artefacts to new rule', { + oldRuleId: command.oldRuleId, + newRuleId: command.newRuleId, + organizationId: command.organizationId, + userId: command.userId, + }); + + try { + const [heuristicsResult, assessmentsResult, programsResult] = + await Promise.all([ + this.linterPort.copyDetectionHeuristics(command), + this.linterPort.copyRuleDetectionAssessments(command), + this.linterPort.copyDetectionProgramsToNewRule(command), + ]); + + const response = { + copiedHeuristicsCount: heuristicsResult.copiedHeuristicsCount, + copiedAssessmentsCount: assessmentsResult.copiedAssessmentsCount, + copiedProgramsCount: programsResult.copiedProgramsCount, + copiedMetadataCount: programsResult.copiedMetadataCount, + }; + + this.logger.info('Successfully copied all linter artefacts', { + oldRuleId: command.oldRuleId, + newRuleId: command.newRuleId, + ...response, + }); + + return response; + } catch (error) { + this.logger.error('Failed to copy linter artefacts', { + oldRuleId: command.oldRuleId, + newRuleId: command.newRuleId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/copyRuleDetectionAssessments/CopyRuleDetectionAssessmentsUseCase.spec.ts b/packages/linter/src/application/useCases/copyRuleDetectionAssessments/CopyRuleDetectionAssessmentsUseCase.spec.ts new file mode 100644 index 000000000..a4919bef2 --- /dev/null +++ b/packages/linter/src/application/useCases/copyRuleDetectionAssessments/CopyRuleDetectionAssessmentsUseCase.spec.ts @@ -0,0 +1,366 @@ +import { createOrganizationId, createUserId } from '@packmind/types'; +import { + createRuleId, + ProgrammingLanguage, + RuleDetectionAssessment, + RuleDetectionAssessmentStatus, + DetectionModeEnum, +} from '@packmind/types'; +import { CopyRuleDetectionAssessmentsUseCase } from './CopyRuleDetectionAssessmentsUseCase'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { IRuleDetectionAssessmentRepository } from '../../../domain/repositories/IRuleDetectionAssessmentRepository'; +import { ruleDetectionAssessmentFactory } from '../../../../test/ruleDetectionAssessmentFactory'; +import { v4 as uuidv4 } from 'uuid'; + +describe('CopyRuleDetectionAssessmentsUseCase', () => { + let useCase: CopyRuleDetectionAssessmentsUseCase; + let repositories: jest.Mocked<ILinterRepositories>; + let ruleDetectionAssessmentRepository: jest.Mocked<IRuleDetectionAssessmentRepository>; + + const oldRuleId = createRuleId(uuidv4()); + const newRuleId = createRuleId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + + beforeEach(() => { + ruleDetectionAssessmentRepository = { + add: jest.fn(), + findById: jest.fn(), + findByRuleId: jest.fn(), + get: jest.fn(), + update: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as jest.Mocked<IRuleDetectionAssessmentRepository>; + + repositories = { + getRuleDetectionAssessmentRepository: jest.fn( + () => ruleDetectionAssessmentRepository, + ), + } as unknown as jest.Mocked<ILinterRepositories>; + + useCase = new CopyRuleDetectionAssessmentsUseCase(repositories); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when old rule has detection assessments', () => { + describe('when copying multiple assessments', () => { + let assessment1: RuleDetectionAssessment; + let assessment2: RuleDetectionAssessment; + + beforeEach(async () => { + assessment1 = ruleDetectionAssessmentFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + status: RuleDetectionAssessmentStatus.SUCCESS, + }); + assessment2 = ruleDetectionAssessmentFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.PYTHON, + status: RuleDetectionAssessmentStatus.NOT_STARTED, + }); + + ruleDetectionAssessmentRepository.findByRuleId.mockResolvedValue([ + assessment1, + assessment2, + ]); + ruleDetectionAssessmentRepository.add.mockImplementation( + async (assessment: RuleDetectionAssessment) => assessment, + ); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + }); + + it('returns correct copied assessments count', async () => { + const result = await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + expect(result.copiedAssessmentsCount).toBe(2); + }); + + it('calls add for each assessment', () => { + expect(ruleDetectionAssessmentRepository.add).toHaveBeenCalledTimes(2); + }); + + it('sets new ruleId on first copied assessment', () => { + const firstCall = + ruleDetectionAssessmentRepository.add.mock.calls[0][0]; + + expect(firstCall.ruleId).toBe(newRuleId); + }); + + it('preserves language on first copied assessment', () => { + const firstCall = + ruleDetectionAssessmentRepository.add.mock.calls[0][0]; + + expect(firstCall.language).toBe(ProgrammingLanguage.TYPESCRIPT); + }); + + it('preserves status on first copied assessment', () => { + const firstCall = + ruleDetectionAssessmentRepository.add.mock.calls[0][0]; + + expect(firstCall.status).toBe(RuleDetectionAssessmentStatus.SUCCESS); + }); + + it('generates new id for first copied assessment', () => { + const firstCall = + ruleDetectionAssessmentRepository.add.mock.calls[0][0]; + + expect(firstCall.id).not.toBe(assessment1.id); + }); + + it('sets new ruleId on second copied assessment', () => { + const secondCall = + ruleDetectionAssessmentRepository.add.mock.calls[1][0]; + + expect(secondCall.ruleId).toBe(newRuleId); + }); + + it('preserves language on second copied assessment', () => { + const secondCall = + ruleDetectionAssessmentRepository.add.mock.calls[1][0]; + + expect(secondCall.language).toBe(ProgrammingLanguage.PYTHON); + }); + + it('preserves status on second copied assessment', () => { + const secondCall = + ruleDetectionAssessmentRepository.add.mock.calls[1][0]; + + expect(secondCall.status).toBe( + RuleDetectionAssessmentStatus.NOT_STARTED, + ); + }); + + it('generates new id for second copied assessment', () => { + const secondCall = + ruleDetectionAssessmentRepository.add.mock.calls[1][0]; + + expect(secondCall.id).not.toBe(assessment2.id); + }); + }); + + describe('when copying assessment with SUCCESS status', () => { + beforeEach(async () => { + const assessment = ruleDetectionAssessmentFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + status: RuleDetectionAssessmentStatus.SUCCESS, + detectionMode: DetectionModeEnum.SINGLE_AST, + details: 'Assessment succeeded', + }); + + ruleDetectionAssessmentRepository.findByRuleId.mockResolvedValue([ + assessment, + ]); + ruleDetectionAssessmentRepository.add.mockImplementation( + async (assessment: RuleDetectionAssessment) => assessment, + ); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + }); + + it('preserves SUCCESS status', () => { + const copiedAssessment = + ruleDetectionAssessmentRepository.add.mock.calls[0][0]; + + expect(copiedAssessment.status).toBe( + RuleDetectionAssessmentStatus.SUCCESS, + ); + }); + + it('preserves details', () => { + const copiedAssessment = + ruleDetectionAssessmentRepository.add.mock.calls[0][0]; + + expect(copiedAssessment.details).toBe('Assessment succeeded'); + }); + }); + + describe('when copying assessment with FAILED status', () => { + beforeEach(async () => { + const assessment = ruleDetectionAssessmentFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.JAVA, + status: RuleDetectionAssessmentStatus.FAILED, + detectionMode: DetectionModeEnum.REGEXP, + details: 'Assessment failed', + }); + + ruleDetectionAssessmentRepository.findByRuleId.mockResolvedValue([ + assessment, + ]); + ruleDetectionAssessmentRepository.add.mockImplementation( + async (assessment: RuleDetectionAssessment) => assessment, + ); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + }); + + it('preserves FAILED status', () => { + const copiedAssessment = + ruleDetectionAssessmentRepository.add.mock.calls[0][0]; + + expect(copiedAssessment.status).toBe( + RuleDetectionAssessmentStatus.FAILED, + ); + }); + + it('preserves details', () => { + const copiedAssessment = + ruleDetectionAssessmentRepository.add.mock.calls[0][0]; + + expect(copiedAssessment.details).toBe('Assessment failed'); + }); + }); + + describe('when copying assessment with NOT_STARTED status', () => { + it('preserves NOT_STARTED status', async () => { + const assessment = ruleDetectionAssessmentFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.GO, + status: RuleDetectionAssessmentStatus.NOT_STARTED, + detectionMode: DetectionModeEnum.SINGLE_AST, + details: '', + }); + + ruleDetectionAssessmentRepository.findByRuleId.mockResolvedValue([ + assessment, + ]); + ruleDetectionAssessmentRepository.add.mockImplementation( + async (assessment: RuleDetectionAssessment) => assessment, + ); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + const copiedAssessment = + ruleDetectionAssessmentRepository.add.mock.calls[0][0]; + + expect(copiedAssessment.status).toBe( + RuleDetectionAssessmentStatus.NOT_STARTED, + ); + }); + }); + + describe('when copying assessment with all properties', () => { + beforeEach(async () => { + const assessment = ruleDetectionAssessmentFactory({ + ruleId: oldRuleId, + language: ProgrammingLanguage.RUST, + status: RuleDetectionAssessmentStatus.SUCCESS, + detectionMode: DetectionModeEnum.FILE_SYSTEM, + details: 'Detailed assessment results', + }); + + ruleDetectionAssessmentRepository.findByRuleId.mockResolvedValue([ + assessment, + ]); + ruleDetectionAssessmentRepository.add.mockImplementation( + async (assessment: RuleDetectionAssessment) => assessment, + ); + + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + }); + + it('preserves language', () => { + const copiedAssessment = + ruleDetectionAssessmentRepository.add.mock.calls[0][0]; + + expect(copiedAssessment.language).toBe(ProgrammingLanguage.RUST); + }); + + it('preserves detectionMode', () => { + const copiedAssessment = + ruleDetectionAssessmentRepository.add.mock.calls[0][0]; + + expect(copiedAssessment.detectionMode).toBe( + DetectionModeEnum.FILE_SYSTEM, + ); + }); + + it('preserves details', () => { + const copiedAssessment = + ruleDetectionAssessmentRepository.add.mock.calls[0][0]; + + expect(copiedAssessment.details).toBe('Detailed assessment results'); + }); + }); + }); + + describe('when old rule has no detection assessments', () => { + beforeEach(() => { + ruleDetectionAssessmentRepository.findByRuleId.mockResolvedValue([]); + }); + + it('returns 0 copied assessments count', async () => { + const result = await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + expect(result.copiedAssessmentsCount).toBe(0); + }); + + it('does not call add', async () => { + await useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }); + + expect(ruleDetectionAssessmentRepository.add).not.toHaveBeenCalled(); + }); + }); + + describe('when repository throws error', () => { + it('re-throws the error', async () => { + const error = new Error('Database connection failed'); + ruleDetectionAssessmentRepository.findByRuleId.mockRejectedValue(error); + + await expect( + useCase.execute({ + oldRuleId, + newRuleId, + organizationId, + userId, + }), + ).rejects.toThrow('Database connection failed'); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/copyRuleDetectionAssessments/CopyRuleDetectionAssessmentsUseCase.ts b/packages/linter/src/application/useCases/copyRuleDetectionAssessments/CopyRuleDetectionAssessmentsUseCase.ts new file mode 100644 index 000000000..16594c1e8 --- /dev/null +++ b/packages/linter/src/application/useCases/copyRuleDetectionAssessments/CopyRuleDetectionAssessmentsUseCase.ts @@ -0,0 +1,83 @@ +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { + ICopyRuleDetectionAssessments, + CopyRuleDetectionAssessmentsCommand, + CopyRuleDetectionAssessmentsResponse, + RuleDetectionAssessment, + createRuleDetectionAssessmentId, +} from '@packmind/types'; + +const origin = 'CopyRuleDetectionAssessmentsUseCase'; + +export class CopyRuleDetectionAssessmentsUseCase implements ICopyRuleDetectionAssessments { + constructor( + private readonly repositories: ILinterRepositories, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: CopyRuleDetectionAssessmentsCommand, + ): Promise<CopyRuleDetectionAssessmentsResponse> { + this.logger.info( + 'Starting to copy rule detection assessments to new rule', + { + oldRuleId: command.oldRuleId, + newRuleId: command.newRuleId, + organizationId: command.organizationId, + userId: command.userId, + }, + ); + + try { + // Get all RuleDetectionAssessments for the old rule + const oldAssessments = await this.repositories + .getRuleDetectionAssessmentRepository() + .findByRuleId(command.oldRuleId); + + if (oldAssessments.length === 0) { + this.logger.info('No rule detection assessments found for old rule', { + oldRuleId: command.oldRuleId, + }); + return { copiedAssessmentsCount: 0 }; + } + + this.logger.info('Found rule detection assessments to copy', { + count: oldAssessments.length, + oldRuleId: command.oldRuleId, + }); + + // Copy all RuleDetectionAssessments + for (const oldAssessment of oldAssessments) { + const newAssessment: RuleDetectionAssessment = { + ...oldAssessment, + id: createRuleDetectionAssessmentId(uuidv4()), + ruleId: command.newRuleId, + }; + + await this.repositories + .getRuleDetectionAssessmentRepository() + .add(newAssessment); + } + + this.logger.info( + 'Successfully copied all rule detection assessments to new rule', + { + oldRuleId: command.oldRuleId, + newRuleId: command.newRuleId, + copiedAssessmentsCount: oldAssessments.length, + }, + ); + + return { copiedAssessmentsCount: oldAssessments.length }; + } catch (error) { + this.logger.error('Failed to copy rule detection assessments', { + oldRuleId: command.oldRuleId, + newRuleId: command.newRuleId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/createDetectionHeuristics/CreateDetectionHeuristicsUseCase.spec.ts b/packages/linter/src/application/useCases/createDetectionHeuristics/CreateDetectionHeuristicsUseCase.spec.ts new file mode 100644 index 000000000..d2662680d --- /dev/null +++ b/packages/linter/src/application/useCases/createDetectionHeuristics/CreateDetectionHeuristicsUseCase.spec.ts @@ -0,0 +1,253 @@ +import { CreateDetectionHeuristicsUseCase } from './CreateDetectionHeuristicsUseCase'; +import { IRuleDetectionHeuristicsRepository } from '../../../domain/repositories/IRuleDetectionHeuristicsRepository'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { + createDetectionHeuristicsId, + createRuleId, + createOrganizationId, + createUserId, + DetectionHeuristics, + ProgrammingLanguage, + CreateDetectionHeuristicsCommand, +} from '@packmind/types'; +import { PackmindLogger } from '@packmind/logger'; +import { stubLogger } from '@packmind/test-utils'; +import { v4 as uuidv4 } from 'uuid'; + +describe('CreateDetectionHeuristicsUseCase', () => { + let useCase: CreateDetectionHeuristicsUseCase; + let heuristicsRepository: jest.Mocked<IRuleDetectionHeuristicsRepository>; + let linterRepositories: jest.Mocked<ILinterRepositories>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + beforeEach(() => { + heuristicsRepository = { + getHeuristicsById: jest.fn(), + updateHeuristics: jest.fn(), + upsertHeuristics: jest.fn(), + getHeuristicsForRule: jest.fn(), + } as unknown as jest.Mocked<IRuleDetectionHeuristicsRepository>; + + linterRepositories = { + getRuleDetectionHeuristicsRepository: jest + .fn() + .mockReturnValue(heuristicsRepository), + getRuleDetectionAssessmentRepository: jest.fn(), + getDetectionProgramRepository: jest.fn(), + getActiveDetectionProgramRepository: jest.fn(), + getDetectionProgramMetadataRepository: jest.fn(), + } as unknown as jest.Mocked<ILinterRepositories>; + + stubbedLogger = stubLogger(); + + useCase = new CreateDetectionHeuristicsUseCase( + linterRepositories, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when heuristics do not exist', () => { + const ruleId = createRuleId(uuidv4()); + const language = ProgrammingLanguage.TYPESCRIPT; + let command: CreateDetectionHeuristicsCommand; + + beforeEach(() => { + heuristicsRepository.getHeuristicsForRule.mockResolvedValue(null); + + command = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + ruleId, + language, + }; + }); + + it('checks if heuristics exist for rule and language', async () => { + await useCase.execute(command); + + expect(heuristicsRepository.getHeuristicsForRule).toHaveBeenCalledWith( + ruleId, + language, + ); + }); + + it('creates new heuristics with correct structure', async () => { + await useCase.execute(command); + + expect(heuristicsRepository.upsertHeuristics).toHaveBeenCalledWith( + expect.objectContaining({ + ruleId, + language, + heuristics: [], + }), + ); + }); + + it('generates unique ID for new heuristics', async () => { + await useCase.execute(command); + + expect(heuristicsRepository.upsertHeuristics).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + }), + ); + }); + + it('returns created heuristics', async () => { + const result = await useCase.execute(command); + + expect(result.detectionHeuristics).toEqual( + expect.objectContaining({ + ruleId, + language, + heuristics: [], + }), + ); + }); + + it('returns heuristics with defined ID', async () => { + const result = await useCase.execute(command); + + expect(result.detectionHeuristics.id).toBeDefined(); + }); + + it('returns heuristics with string ID', async () => { + const result = await useCase.execute(command); + + expect(typeof result.detectionHeuristics.id).toBe('string'); + }); + }); + + describe('when heuristics already exist', () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const language = ProgrammingLanguage.PYTHON; + let existingHeuristics: DetectionHeuristics; + let command: CreateDetectionHeuristicsCommand; + + beforeEach(() => { + existingHeuristics = { + id: heuristicsId, + ruleId, + language, + heuristics: ['Existing heuristics content'], + }; + + heuristicsRepository.getHeuristicsForRule.mockResolvedValue( + existingHeuristics, + ); + + command = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + ruleId, + language, + }; + }); + + it('checks if heuristics exist', async () => { + await useCase.execute(command); + + expect(heuristicsRepository.getHeuristicsForRule).toHaveBeenCalledWith( + ruleId, + language, + ); + }); + + it('does not create new heuristics', async () => { + await useCase.execute(command); + + expect(heuristicsRepository.upsertHeuristics).not.toHaveBeenCalled(); + }); + + it('returns existing heuristics', async () => { + const result = await useCase.execute(command); + + expect(result.detectionHeuristics).toEqual(existingHeuristics); + }); + + it('preserves existing heuristics content', async () => { + const result = await useCase.execute(command); + + expect(result.detectionHeuristics.heuristics).toEqual([ + 'Existing heuristics content', + ]); + }); + }); + + describe('when querying different languages for same rule', () => { + const ruleId = createRuleId(uuidv4()); + + describe('when creating TypeScript heuristics', () => { + let tsCommand: CreateDetectionHeuristicsCommand; + + beforeEach(() => { + heuristicsRepository.getHeuristicsForRule.mockResolvedValue(null); + + tsCommand = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }; + }); + + it('returns heuristics with TypeScript language', async () => { + const result = await useCase.execute(tsCommand); + + expect(result.detectionHeuristics.language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + + it('upserts heuristics with TypeScript language', async () => { + await useCase.execute(tsCommand); + + expect(heuristicsRepository.upsertHeuristics).toHaveBeenCalledWith( + expect.objectContaining({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }), + ); + }); + }); + + describe('when creating JavaScript heuristics', () => { + let jsCommand: CreateDetectionHeuristicsCommand; + + beforeEach(() => { + heuristicsRepository.getHeuristicsForRule.mockResolvedValue(null); + + jsCommand = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }; + }); + + it('returns heuristics with JavaScript language', async () => { + const result = await useCase.execute(jsCommand); + + expect(result.detectionHeuristics.language).toBe( + ProgrammingLanguage.JAVASCRIPT, + ); + }); + + it('upserts heuristics with JavaScript language', async () => { + await useCase.execute(jsCommand); + + expect(heuristicsRepository.upsertHeuristics).toHaveBeenCalledWith( + expect.objectContaining({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }), + ); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/createDetectionHeuristics/CreateDetectionHeuristicsUseCase.ts b/packages/linter/src/application/useCases/createDetectionHeuristics/CreateDetectionHeuristicsUseCase.ts new file mode 100644 index 000000000..9c8b12835 --- /dev/null +++ b/packages/linter/src/application/useCases/createDetectionHeuristics/CreateDetectionHeuristicsUseCase.ts @@ -0,0 +1,72 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + ICreateDetectionHeuristics, + CreateDetectionHeuristicsCommand, + CreateDetectionHeuristicsResponse, + createDetectionHeuristicsId, + DetectionHeuristics, +} from '@packmind/types'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { v4 as uuidv4 } from 'uuid'; + +const origin = 'CreateDetectionHeuristicsUseCase'; + +export class CreateDetectionHeuristicsUseCase implements ICreateDetectionHeuristics { + constructor( + private readonly linterRepositories: ILinterRepositories, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: CreateDetectionHeuristicsCommand, + ): Promise<CreateDetectionHeuristicsResponse> { + this.logger.info('Creating detection heuristics', { + ruleId: command.ruleId, + language: command.language, + }); + + const heuristicsRepo = + this.linterRepositories.getRuleDetectionHeuristicsRepository(); + + // Check if heuristics already exist + const existingHeuristics = await heuristicsRepo.getHeuristicsForRule( + command.ruleId, + command.language, + ); + + if (existingHeuristics) { + this.logger.info( + 'Detection heuristics already exist, returning existing', + { + ruleId: command.ruleId, + language: command.language, + detectionHeuristicsId: existingHeuristics.id, + }, + ); + + return { + detectionHeuristics: existingHeuristics, + }; + } + + // Create new heuristics + const newHeuristics: DetectionHeuristics = { + id: createDetectionHeuristicsId(uuidv4()), + ruleId: command.ruleId, + language: command.language, + heuristics: command.heuristics || [], + }; + + await heuristicsRepo.upsertHeuristics(newHeuristics); + + this.logger.info('Detection heuristics created successfully', { + ruleId: command.ruleId, + language: command.language, + detectionHeuristicsId: newHeuristics.id, + }); + + return { + detectionHeuristics: newHeuristics, + }; + } +} diff --git a/packages/linter/src/application/useCases/createDetectionProgram/CreateDetectionProgramUseCase.spec.ts b/packages/linter/src/application/useCases/createDetectionProgram/CreateDetectionProgramUseCase.spec.ts new file mode 100644 index 000000000..812926f86 --- /dev/null +++ b/packages/linter/src/application/useCases/createDetectionProgram/CreateDetectionProgramUseCase.spec.ts @@ -0,0 +1,1372 @@ +import { CreateDetectionProgramUseCase } from './CreateDetectionProgramUseCase'; +import { IDetectionProgramRepository } from '../../../domain/repositories/IDetectionProgramRepository'; +import { IActiveDetectionProgramRepository } from '../../../domain/repositories/IActiveDetectionProgramRepository'; +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { + createOrganizationId, + createUserId, + ILinterAstPort, +} from '@packmind/types'; +import { + createRuleId, + createStandardId, + createStandardVersionId, + ProgrammingLanguage, + Rule, + Standard, + StandardVersion, + createDetectionProgramId, + DetectionProgram, + DetectionModeEnum, + IStandardsPort, + ActiveDetectionProgram, + createActiveDetectionProgramId, + CreateDetectionProgramCommand, +} from '@packmind/types'; +import { ruleFactory } from '@packmind/standards/test'; +import { DetectionProgramService } from '../../services/DetectionProgramService'; +import { stubLogger } from '@packmind/test-utils'; +import { + activeDetectionProgramFactory, + detectionProgramFactory, +} from '../../../../test'; +import { standardFactory } from '@packmind/standards/test'; + +// Mock clearConsoleLogFromProgramOutput +const mockClearConsoleLog = jest.fn(); +jest.mock( + '../generateProgramUseCase/shared/program/ProgramExecutionUtils', + () => ({ + clearConsoleLogFromProgramOutput: (...args: unknown[]) => + mockClearConsoleLog(...args), + }), +); + +// Add at the top, before other imports +jest.mock('@packmind/node-utils', () => { + const actual = jest.requireActual('@packmind/node-utils'); + return { + ...actual, + SSEEventPublisher: { + publishProgramStatusEvent: jest.fn().mockResolvedValue(undefined), + }, + }; +}); + +describe('CreateDetectionProgramUseCase', () => { + let createDetectionProgramUseCase: CreateDetectionProgramUseCase; + let detectionProgramRepository: jest.Mocked<IDetectionProgramRepository>; + let activeDetectionProgramRepository: jest.Mocked<IActiveDetectionProgramRepository>; + let standardsAdapter: jest.Mocked<IStandardsPort>; + let linterAstPort: jest.Mocked<ILinterAstPort>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + beforeEach(() => { + // Reset mock to return the code as-is by default + mockClearConsoleLog.mockImplementation(async (code: string) => code); + // Mock DetectionProgramRepository + detectionProgramRepository = { + add: jest.fn(), + findById: jest.fn(), + findByRuleId: jest.fn(), + findByRuleIdAndVersion: jest.fn(), + getLatestVersionByRuleId: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as unknown as jest.Mocked<IDetectionProgramRepository>; + + // Mock ActiveDetectionProgramRepository + activeDetectionProgramRepository = { + add: jest.fn(), + findById: jest.fn(), + findByRuleId: jest.fn(), + findByRuleIdAndLanguage: jest.fn(), + findByRuleIdWithPrograms: jest.fn(), + deleteByRuleId: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as unknown as jest.Mocked<IActiveDetectionProgramRepository>; + + standardsAdapter = { + getStandard: jest.fn(), + getRule: jest.fn(), + getStandardVersion: jest.fn(), + getStandardVersionById: jest.fn(), + listStandardVersions: jest.fn(), + getLatestRulesByStandardId: jest.fn(), + getRulesByStandardId: jest.fn(), + listStandardsBySpace: jest.fn(), + getRuleCodeExamples: jest.fn(), + findStandardBySlug: jest.fn(), + getLatestStandardVersion: jest.fn(), + }; + + linterAstPort = { + removeConsoleStatements: jest.fn(), + getAvailableLanguages: jest.fn(), + } as unknown as jest.Mocked<ILinterAstPort>; + + stubbedLogger = stubLogger(); + + const detectionProgramService = { + findActiveByRuleIdAndLanguage: + activeDetectionProgramRepository.findByRuleIdAndLanguage, + addDetectionProgram: detectionProgramRepository.add, + addActiveDetectionProgram: activeDetectionProgramRepository.add, + } as unknown as DetectionProgramService; + + createDetectionProgramUseCase = new CreateDetectionProgramUseCase( + detectionProgramService, + standardsAdapter, + linterAstPort, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute', () => { + describe('when detection program creation succeeds', () => { + let command: CreateDetectionProgramCommand; + let existingRule: Rule; + let existingStandardVersion: StandardVersion; + let existingStandard: Standard; + let createdDetectionProgram: DetectionProgram; + let createdActiveDetectionProgram: ActiveDetectionProgram; + let result: DetectionProgram; + + beforeEach(async () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + + command = { + ruleId, + code: 'if (ast.node.type === "ClassDeclaration") { return { line: ast.node.loc.start.line }; }', + language: ProgrammingLanguage.JAVASCRIPT, + mode: DetectionModeEnum.SINGLE_AST, + userId, + organizationId, + }; + + existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + userId, + scope: null, + }); + + createdDetectionProgram = detectionProgramFactory({ + id: createDetectionProgramId(uuidv4()), + ruleId, + code: command.code, + version: 1, + mode: command.mode, + }); + + createdActiveDetectionProgram = activeDetectionProgramFactory({ + id: createActiveDetectionProgramId(uuidv4()), + detectionProgramVersion: createdDetectionProgram.id, + ruleId, + language: command.language, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.add.mockResolvedValue( + createdDetectionProgram, + ); + activeDetectionProgramRepository.add.mockResolvedValue( + createdActiveDetectionProgram, + ); + + result = await createDetectionProgramUseCase.execute(command); + }); + + it('fetches the rule by id', () => { + expect(standardsAdapter.getRule).toHaveBeenCalledWith(command.ruleId); + }); + + it('fetches the standard version for the rule', () => { + expect(standardsAdapter.getStandardVersion).toHaveBeenCalledWith( + existingRule.standardVersionId, + ); + }); + + it('fetches the standard for the standard version', () => { + expect(standardsAdapter.getStandard).toHaveBeenCalledWith( + existingStandardVersion.standardId, + ); + }); + + it('creates detection program with version 1', () => { + expect(detectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + ruleId: command.ruleId, + code: command.code, + version: 1, + mode: command.mode, + }), + ); + }); + + it('creates active detection program pointing to created detection program', () => { + expect(activeDetectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + detectionProgramVersion: createdDetectionProgram.id, + ruleId: command.ruleId, + language: command.language, + detectionProgramDraftVersion: null, + }), + ); + }); + + it('creates detection program before active detection program', () => { + const detectionProgramCall = + detectionProgramRepository.add.mock.invocationCallOrder[0]; + const activeDetectionProgramCall = + activeDetectionProgramRepository.add.mock.invocationCallOrder[0]; + expect(detectionProgramCall).toBeLessThan(activeDetectionProgramCall); + }); + + it('returns the created detection program', () => { + expect(result).toEqual(createdDetectionProgram); + }); + + it('cleans console.log statements from the code before saving', () => { + expect(mockClearConsoleLog).toHaveBeenCalledWith( + command.code, + linterAstPort, + ); + }); + }); + + describe('when clearConsoleLogFromProgramOutput cleans the code', () => { + it('saves the cleaned code to the database', async () => { + const codeWithConsoleLog = + 'console.log("debug"); if (ast.node.type === "ClassDeclaration") { return { line: ast.node.loc.start.line }; }'; + const cleanedCode = + 'if (ast.node.type === "ClassDeclaration") { return { line: ast.node.loc.start.line }; }'; + + mockClearConsoleLog.mockResolvedValue(cleanedCode); + + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + + const command: CreateDetectionProgramCommand = { + ruleId, + code: codeWithConsoleLog, + language: ProgrammingLanguage.JAVASCRIPT, + mode: DetectionModeEnum.SINGLE_AST, + userId, + organizationId, + }; + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + userId, + scope: null, + }); + + const createdDetectionProgram = detectionProgramFactory({ + id: createDetectionProgramId(uuidv4()), + ruleId, + code: cleanedCode, + version: 1, + mode: command.mode, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.add.mockResolvedValue( + createdDetectionProgram, + ); + activeDetectionProgramRepository.add.mockResolvedValue( + activeDetectionProgramFactory({ + detectionProgramVersion: createdDetectionProgram.id, + ruleId, + language: command.language, + }), + ); + + await createDetectionProgramUseCase.execute(command); + + expect(detectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + code: cleanedCode, + }), + ); + }); + }); + + describe('when rule does not exist', () => { + let command: CreateDetectionProgramCommand; + + beforeEach(() => { + const organizationId = createOrganizationId(uuidv4()); + command = { + ruleId: createRuleId(uuidv4()), + code: 'test code', + language: ProgrammingLanguage.JAVASCRIPT, + mode: DetectionModeEnum.REGEXP, + userId: createUserId(uuidv4()), + organizationId, + }; + + standardsAdapter.getRule.mockResolvedValue(null); + }); + + it('throws an error', async () => { + await expect( + createDetectionProgramUseCase.execute(command), + ).rejects.toThrow('Rule not found'); + }); + + it('does not create detection program', async () => { + try { + await createDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect(detectionProgramRepository.add).not.toHaveBeenCalled(); + }); + + it('does not create active detection program', async () => { + try { + await createDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect(activeDetectionProgramRepository.add).not.toHaveBeenCalled(); + }); + }); + + describe('when rule does not belong to user organization', () => { + describe('when standard version is not found', () => { + let command: CreateDetectionProgramCommand; + + beforeEach(() => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + + command = { + ruleId, + code: 'test code', + language: ProgrammingLanguage.JAVASCRIPT, + mode: DetectionModeEnum.REGEXP, + userId: createUserId(uuidv4()), + organizationId, + }; + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue(null); + }); + + it('throws an error', async () => { + await expect( + createDetectionProgramUseCase.execute(command), + ).rejects.toThrow('Standard version not found for rule'); + }); + + it('does not create detection program', async () => { + try { + await createDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect(detectionProgramRepository.add).not.toHaveBeenCalled(); + }); + + it('does not create active detection program', async () => { + try { + await createDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect(activeDetectionProgramRepository.add).not.toHaveBeenCalled(); + }); + }); + + describe('when standard is not found', () => { + let command: CreateDetectionProgramCommand; + + beforeEach(() => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + + command = { + ruleId, + code: 'test code', + language: ProgrammingLanguage.JAVASCRIPT, + mode: DetectionModeEnum.REGEXP, + userId: createUserId(uuidv4()), + organizationId, + }; + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(null); + }); + + it('throws an error', async () => { + await expect( + createDetectionProgramUseCase.execute(command), + ).rejects.toThrow('Standard not found for rule'); + }); + + it('does not create detection program', async () => { + try { + await createDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect(detectionProgramRepository.add).not.toHaveBeenCalled(); + }); + + it('does not create active detection program', async () => { + try { + await createDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect(activeDetectionProgramRepository.add).not.toHaveBeenCalled(); + }); + }); + + // Note: Organization-level validation was removed as standards are now space-scoped + // and validation should be done at a higher level (e.g., API layer) + }); + + describe('when existing active detection program exists for same rule and language', () => { + describe('when trying to overwrite existing detection program', () => { + let command: CreateDetectionProgramCommand; + let ruleId: ReturnType<typeof createRuleId>; + + beforeEach(() => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + ruleId = createRuleId(uuidv4()); + + command = { + ruleId, + code: 'new detection code', + language: ProgrammingLanguage.JAVASCRIPT, + mode: DetectionModeEnum.SINGLE_AST, + userId, + organizationId, + }; + + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + userId, + scope: null, + }); + + const existingActiveProgram = activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + existingActiveProgram, + ); + }); + + it('throws an error', async () => { + await expect( + createDetectionProgramUseCase.execute(command), + ).rejects.toThrow( + 'Active detection program already exists for this rule and language', + ); + }); + + it('checks for existing active program by rule id and language', async () => { + try { + await createDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect( + activeDetectionProgramRepository.findByRuleIdAndLanguage, + ).toHaveBeenCalledWith(ruleId, ProgrammingLanguage.JAVASCRIPT); + }); + + it('does not create detection program', async () => { + try { + await createDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect(detectionProgramRepository.add).not.toHaveBeenCalled(); + }); + + it('does not create active detection program', async () => { + try { + await createDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect(activeDetectionProgramRepository.add).not.toHaveBeenCalled(); + }); + }); + + describe('when creating detection program for different language on same rule', () => { + let command: CreateDetectionProgramCommand; + let ruleId: ReturnType<typeof createRuleId>; + let createdDetectionProgram: DetectionProgram; + let result: DetectionProgram; + + beforeEach(async () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + ruleId = createRuleId(uuidv4()); + + command = { + ruleId, + code: 'typescript detection code', + language: ProgrammingLanguage.TYPESCRIPT, + mode: DetectionModeEnum.SINGLE_AST, + userId, + organizationId, + }; + + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + userId, + scope: null, + }); + + createdDetectionProgram = detectionProgramFactory({ + ruleId, + code: command.code, + version: 1, + mode: command.mode, + }); + + const createdActiveProgram = activeDetectionProgramFactory({ + detectionProgramVersion: createdDetectionProgram.id, + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.add.mockResolvedValue( + createdDetectionProgram, + ); + activeDetectionProgramRepository.add.mockResolvedValue( + createdActiveProgram, + ); + + result = await createDetectionProgramUseCase.execute(command); + }); + + it('checks for existing active program by rule id and language', () => { + expect( + activeDetectionProgramRepository.findByRuleIdAndLanguage, + ).toHaveBeenCalledWith(ruleId, ProgrammingLanguage.TYPESCRIPT); + }); + + it('creates detection program with correct data', () => { + expect(detectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + ruleId, + code: command.code, + version: 1, + mode: command.mode, + }), + ); + }); + + it('creates active detection program with correct data', () => { + expect(activeDetectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + detectionProgramVersion: createdDetectionProgram.id, + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }), + ); + }); + + it('returns the created detection program', () => { + expect(result).toEqual(createdDetectionProgram); + }); + }); + }); + + describe('with different detection program modes', () => { + it('creates detection program with REGEXP mode', async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + + const command: CreateDetectionProgramCommand = { + ruleId, + code: '/Controller$/.test(className)', + language: ProgrammingLanguage.JAVASCRIPT, + mode: DetectionModeEnum.REGEXP, + userId: createUserId(uuidv4()), + organizationId, + }; + + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + scope: null, + }); + + const createdDetectionProgram = detectionProgramFactory({ + mode: DetectionModeEnum.REGEXP, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.add.mockResolvedValue( + createdDetectionProgram, + ); + activeDetectionProgramRepository.add.mockResolvedValue( + activeDetectionProgramFactory(), + ); + + await createDetectionProgramUseCase.execute(command); + + expect(detectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + mode: DetectionModeEnum.REGEXP, + }), + ); + }); + + it('creates detection program with FILE_SYSTEM mode', async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + + const command: CreateDetectionProgramCommand = { + ruleId, + code: 'const files = fs.readdirSync(directory);', + language: ProgrammingLanguage.JAVASCRIPT, + mode: DetectionModeEnum.FILE_SYSTEM, + userId: createUserId(uuidv4()), + organizationId, + }; + + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + scope: null, + }); + + const createdDetectionProgram = detectionProgramFactory({ + mode: DetectionModeEnum.FILE_SYSTEM, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.add.mockResolvedValue( + createdDetectionProgram, + ); + activeDetectionProgramRepository.add.mockResolvedValue( + activeDetectionProgramFactory(), + ); + + await createDetectionProgramUseCase.execute(command); + + expect(detectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + mode: DetectionModeEnum.FILE_SYSTEM, + }), + ); + }); + }); + + describe('with different programming languages', () => { + it('creates detection program for TypeScript', async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + + const command: CreateDetectionProgramCommand = { + ruleId, + code: 'interface validation code', + language: ProgrammingLanguage.TYPESCRIPT, + mode: DetectionModeEnum.SINGLE_AST, + userId: createUserId(uuidv4()), + organizationId, + }; + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + scope: null, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.add.mockResolvedValue( + detectionProgramFactory(), + ); + activeDetectionProgramRepository.add.mockResolvedValue( + activeDetectionProgramFactory(), + ); + + await createDetectionProgramUseCase.execute(command); + + expect(activeDetectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + language: ProgrammingLanguage.TYPESCRIPT, + }), + ); + }); + + it('creates detection program for Python', async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + + const command: CreateDetectionProgramCommand = { + ruleId, + code: 'def validate_naming(node):', + language: ProgrammingLanguage.PYTHON, + mode: DetectionModeEnum.SINGLE_AST, + userId: createUserId(uuidv4()), + organizationId, + }; + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + scope: null, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.add.mockResolvedValue( + detectionProgramFactory(), + ); + activeDetectionProgramRepository.add.mockResolvedValue( + activeDetectionProgramFactory(), + ); + + await createDetectionProgramUseCase.execute(command); + + expect(activeDetectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + language: ProgrammingLanguage.PYTHON, + }), + ); + }); + }); + + describe('when detection program creation fails', () => { + let command: CreateDetectionProgramCommand; + + beforeEach(() => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + + command = { + ruleId, + code: 'test code', + language: ProgrammingLanguage.JAVASCRIPT, + mode: DetectionModeEnum.REGEXP, + userId: createUserId(uuidv4()), + organizationId, + }; + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + scope: null, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.add.mockRejectedValue( + new Error('Database connection failed'), + ); + }); + + it('throws an error', async () => { + await expect( + createDetectionProgramUseCase.execute(command), + ).rejects.toThrow('Database connection failed'); + }); + + it('does not create active detection program', async () => { + try { + await createDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect(activeDetectionProgramRepository.add).not.toHaveBeenCalled(); + }); + }); + + describe('when active detection program creation fails', () => { + let command: CreateDetectionProgramCommand; + + beforeEach(() => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + + command = { + ruleId, + code: 'test code', + language: ProgrammingLanguage.JAVASCRIPT, + mode: DetectionModeEnum.REGEXP, + userId: createUserId(uuidv4()), + organizationId, + }; + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + scope: null, + }); + + const createdDetectionProgram = detectionProgramFactory(); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.add.mockResolvedValue( + createdDetectionProgram, + ); + activeDetectionProgramRepository.add.mockRejectedValue( + new Error('Active program creation failed'), + ); + }); + + it('throws an error', async () => { + await expect( + createDetectionProgramUseCase.execute(command), + ).rejects.toThrow('Active program creation failed'); + }); + + it('attempts to create detection program before failing', async () => { + try { + await createDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect(detectionProgramRepository.add).toHaveBeenCalledTimes(1); + }); + + it('attempts to create active detection program', async () => { + try { + await createDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect(activeDetectionProgramRepository.add).toHaveBeenCalledTimes(1); + }); + }); + + describe('when mustBeDraftVersion is true', () => { + it('creates active detection program with draft version', async () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + + const command: CreateDetectionProgramCommand = { + ruleId, + code: 'if (ast.node.type === "ClassDeclaration") { return { line: ast.node.loc.start.line }; }', + language: ProgrammingLanguage.JAVASCRIPT, + mode: DetectionModeEnum.SINGLE_AST, + userId, + organizationId, + mustBeDraftVersion: true, + }; + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + userId, + scope: null, + }); + + const createdDetectionProgram = detectionProgramFactory({ + id: createDetectionProgramId(uuidv4()), + ruleId, + code: command.code, + version: 1, + mode: command.mode, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.add.mockResolvedValue( + createdDetectionProgram, + ); + activeDetectionProgramRepository.add.mockResolvedValue( + activeDetectionProgramFactory(), + ); + + await createDetectionProgramUseCase.execute(command); + + expect(activeDetectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + detectionProgramVersion: null, + ruleId: command.ruleId, + language: command.language, + detectionProgramDraftVersion: createdDetectionProgram.id, + }), + ); + }); + }); + + describe('when mustBeDraftVersion is false', () => { + it('creates active detection program with regular version (current behavior)', async () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + + const command: CreateDetectionProgramCommand = { + ruleId, + code: 'if (ast.node.type === "ClassDeclaration") { return { line: ast.node.loc.start.line }; }', + language: ProgrammingLanguage.JAVASCRIPT, + mode: DetectionModeEnum.SINGLE_AST, + userId, + organizationId, + mustBeDraftVersion: false, + }; + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + userId, + scope: null, + }); + + const createdDetectionProgram = detectionProgramFactory({ + id: createDetectionProgramId(uuidv4()), + ruleId, + code: command.code, + version: 1, + mode: command.mode, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.add.mockResolvedValue( + createdDetectionProgram, + ); + activeDetectionProgramRepository.add.mockResolvedValue( + activeDetectionProgramFactory(), + ); + + await createDetectionProgramUseCase.execute(command); + + expect(activeDetectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + detectionProgramVersion: createdDetectionProgram.id, + ruleId: command.ruleId, + language: command.language, + detectionProgramDraftVersion: null, + }), + ); + }); + }); + + describe('command validation', () => { + describe('when code is missing', () => { + let commandWithoutCode: CreateDetectionProgramCommand; + + beforeEach(() => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + + commandWithoutCode = { + ruleId, + code: '', + language: ProgrammingLanguage.JAVASCRIPT, + mode: DetectionModeEnum.REGEXP, + organizationId, + userId: createUserId(uuidv4()), + } as CreateDetectionProgramCommand; + }); + + it('throws an error', async () => { + await expect( + createDetectionProgramUseCase.execute(commandWithoutCode), + ).rejects.toThrow('Code is required'); + }); + + it('does not create detection program', async () => { + try { + await createDetectionProgramUseCase.execute(commandWithoutCode); + } catch { + // Expected to throw + } + + expect(detectionProgramRepository.add).not.toHaveBeenCalled(); + }); + }); + + describe('when language is missing', () => { + let commandWithoutLanguage: CreateDetectionProgramCommand; + + beforeEach(() => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + + commandWithoutLanguage = { + ruleId, + code: 'test code', + language: null, + mode: DetectionModeEnum.REGEXP, + organizationId, + userId: createUserId(uuidv4()), + } as unknown as CreateDetectionProgramCommand; + }); + + it('throws an error', async () => { + await expect( + createDetectionProgramUseCase.execute(commandWithoutLanguage), + ).rejects.toThrow('Language is required'); + }); + + it('does not create detection program', async () => { + try { + await createDetectionProgramUseCase.execute(commandWithoutLanguage); + } catch { + // Expected to throw + } + + expect(detectionProgramRepository.add).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/createDetectionProgram/CreateDetectionProgramUseCase.ts b/packages/linter/src/application/useCases/createDetectionProgram/CreateDetectionProgramUseCase.ts new file mode 100644 index 000000000..c6590db62 --- /dev/null +++ b/packages/linter/src/application/useCases/createDetectionProgram/CreateDetectionProgramUseCase.ts @@ -0,0 +1,151 @@ +import { PackmindLogger, LogLevel } from '@packmind/logger'; +import { SSEEventPublisher } from '@packmind/node-utils'; +import { ILinterAstPort, IStandardsPort } from '@packmind/types'; +import { DetectionStatus } from '@packmind/types'; +import { DetectionProgramService } from '../../services/DetectionProgramService'; +import { v4 as uuidv4 } from 'uuid'; +import { + CreateDetectionProgramCommand, + ICreateDetectionProgram, + createDetectionProgramId, + DetectionProgram, + ActiveDetectionProgram, + createActiveDetectionProgramId, + DetectionSeverity, +} from '@packmind/types'; +import { clearConsoleLogFromProgramOutput } from '../generateProgramUseCase/shared/program/ProgramExecutionUtils'; + +export class CreateDetectionProgramUseCase implements ICreateDetectionProgram { + constructor( + private readonly detectionProgramService: DetectionProgramService, + private readonly standardsAdapter: IStandardsPort, + private readonly linterAstPort: ILinterAstPort | null, + private readonly logger: PackmindLogger = new PackmindLogger( + 'CreateDetectionProgramUseCase', + LogLevel.INFO, + ), + ) {} + + async execute( + command: CreateDetectionProgramCommand, + ): Promise<DetectionProgram> { + try { + // Validate input + if (!command.code || command.code.trim() === '') { + throw new Error('Code is required'); + } + + if (!command.language || command.language.trim() === '') { + throw new Error('Language is required'); + } + + // Validate rule exists and belongs to organization + const rule = await this.standardsAdapter.getRule(command.ruleId); + if (!rule) { + throw new Error('Rule not found'); + } + + // Validate rule belongs to user's organization through StandardVersion -> Standard chain + const standardVersion = await this.standardsAdapter.getStandardVersion( + rule.standardVersionId, + ); + if (!standardVersion) { + throw new Error('Standard version not found for rule'); + } + + const standard = await this.standardsAdapter.getStandard( + standardVersion.standardId, + ); + if (!standard) { + throw new Error('Standard not found for rule'); + } + + // Note: Space-based validation should be done at a higher level + // Standards are now scoped to spaces, not organizations + + const existingActiveProgram = + await this.detectionProgramService.findActiveByRuleIdAndLanguage( + command.ruleId, + command.language, + ); + + if (existingActiveProgram) { + throw new Error( + 'Active detection program already exists for this rule and language', + ); + } + + // Clean console.log statements from the code before saving + const cleanedCode = await clearConsoleLogFromProgramOutput( + command.code, + this.linterAstPort, + ); + + // Create detection program with version 1 (do not increment) + const detectionProgram: DetectionProgram = { + id: createDetectionProgramId(uuidv4()), + ruleId: command.ruleId, + code: cleanedCode, + version: 1, + mode: command.mode, + language: command.language, + status: command.status || DetectionStatus.READY, + sourceCodeState: command.sourceCodeState ?? 'AST', + createdAt: new Date(), + }; + + const createdDetectionProgram = + await this.detectionProgramService.addDetectionProgram( + detectionProgram, + ); + + // Create new active detection program + const activeDetectionProgram: ActiveDetectionProgram = { + id: createActiveDetectionProgramId(uuidv4()), + detectionProgramVersion: command.mustBeDraftVersion + ? null + : createdDetectionProgram.id, + ruleId: command.ruleId, + language: command.language, + detectionProgramDraftVersion: command.mustBeDraftVersion + ? createdDetectionProgram.id + : null, + severity: DetectionSeverity.ERROR, + }; + + await this.detectionProgramService.addActiveDetectionProgram( + activeDetectionProgram, + ); + + // Publish SSE event for new program creation + try { + await SSEEventPublisher.publishProgramStatusEvent( + createdDetectionProgram.id, + createdDetectionProgram.ruleId, + createdDetectionProgram.language, + command.userId, + command.organizationId, + ); + this.logger.info('SSE event published for new program creation', { + detectionProgramId: createdDetectionProgram.id, + status: createdDetectionProgram.status, + userId: command.userId, + }); + } catch (sseError) { + this.logger.error('Failed to publish SSE event for new program', { + detectionProgramId: createdDetectionProgram.id, + error: + sseError instanceof Error ? sseError.message : String(sseError), + }); + // Don't throw here - the main operation was successful + } + + return createdDetectionProgram; + } catch (error) { + this.logger.error('Failed to create detection program', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/createEmptyRuleDetectionAssessment/CreateEmptyRuleDetectionAssessmentUseCase.spec.ts b/packages/linter/src/application/useCases/createEmptyRuleDetectionAssessment/CreateEmptyRuleDetectionAssessmentUseCase.spec.ts new file mode 100644 index 000000000..e88edba3c --- /dev/null +++ b/packages/linter/src/application/useCases/createEmptyRuleDetectionAssessment/CreateEmptyRuleDetectionAssessmentUseCase.spec.ts @@ -0,0 +1,234 @@ +import { + createOrganizationId, + createUserId, + createRuleId, + ProgrammingLanguage, + RuleDetectionAssessment, + RuleDetectionAssessmentStatus, + DetectionModeEnum, +} from '@packmind/types'; +import { CreateEmptyRuleDetectionAssessmentUseCase } from './CreateEmptyRuleDetectionAssessmentUseCase'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { IRuleDetectionAssessmentRepository } from '../../../domain/repositories/IRuleDetectionAssessmentRepository'; +import { ruleDetectionAssessmentFactory } from '../../../../test/ruleDetectionAssessmentFactory'; +import { v4 as uuidv4 } from 'uuid'; + +describe('CreateEmptyRuleDetectionAssessmentUseCase', () => { + let useCase: CreateEmptyRuleDetectionAssessmentUseCase; + let repositories: jest.Mocked<ILinterRepositories>; + let ruleDetectionAssessmentRepository: jest.Mocked<IRuleDetectionAssessmentRepository>; + + const ruleId = createRuleId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + + beforeEach(() => { + ruleDetectionAssessmentRepository = { + add: jest.fn(), + findById: jest.fn(), + findByRuleId: jest.fn(), + get: jest.fn(), + update: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as jest.Mocked<IRuleDetectionAssessmentRepository>; + + repositories = { + getRuleDetectionAssessmentRepository: jest.fn( + () => ruleDetectionAssessmentRepository, + ), + } as unknown as jest.Mocked<ILinterRepositories>; + + useCase = new CreateEmptyRuleDetectionAssessmentUseCase(repositories); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when no assessment exists for rule and language', () => { + beforeEach(() => { + ruleDetectionAssessmentRepository.get.mockResolvedValue(null); + ruleDetectionAssessmentRepository.add.mockImplementation( + async (assessment: RuleDetectionAssessment) => assessment, + ); + }); + + it('creates assessment with NOT_STARTED status', async () => { + const result = await useCase.execute({ + ruleId, + language: ProgrammingLanguage.KOTLIN, + organizationId, + userId, + }); + + expect(result.status).toBe(RuleDetectionAssessmentStatus.NOT_STARTED); + }); + + it('creates assessment with correct ruleId', async () => { + const result = await useCase.execute({ + ruleId, + language: ProgrammingLanguage.KOTLIN, + organizationId, + userId, + }); + + expect(result.ruleId).toBe(ruleId); + }); + + it('creates assessment with correct language', async () => { + const result = await useCase.execute({ + ruleId, + language: ProgrammingLanguage.KOTLIN, + organizationId, + userId, + }); + + expect(result.language).toBe(ProgrammingLanguage.KOTLIN); + }); + + it('creates assessment with SINGLE_AST detection mode', async () => { + const result = await useCase.execute({ + ruleId, + language: ProgrammingLanguage.KOTLIN, + organizationId, + userId, + }); + + expect(result.detectionMode).toBe(DetectionModeEnum.SINGLE_AST); + }); + + it('creates assessment with empty details', async () => { + const result = await useCase.execute({ + ruleId, + language: ProgrammingLanguage.KOTLIN, + organizationId, + userId, + }); + + expect(result.details).toBe(''); + }); + + it('creates assessment with null clarificationQuestion', async () => { + const result = await useCase.execute({ + ruleId, + language: ProgrammingLanguage.KOTLIN, + organizationId, + userId, + }); + + expect(result.clarificationQuestion).toBeNull(); + }); + + it('creates assessment with null clarificationAnswers', async () => { + const result = await useCase.execute({ + ruleId, + language: ProgrammingLanguage.KOTLIN, + organizationId, + userId, + }); + + expect(result.clarificationAnswers).toBeNull(); + }); + + it('stores assessment in repository', async () => { + await useCase.execute({ + ruleId, + language: ProgrammingLanguage.KOTLIN, + organizationId, + userId, + }); + + expect(ruleDetectionAssessmentRepository.add).toHaveBeenCalledTimes(1); + }); + }); + + describe('when assessment already exists for rule and language', () => { + const existingAssessment = ruleDetectionAssessmentFactory({ + ruleId, + language: ProgrammingLanguage.KOTLIN, + status: RuleDetectionAssessmentStatus.SUCCESS, + details: 'Already assessed', + }); + + beforeEach(() => { + ruleDetectionAssessmentRepository.get.mockResolvedValue( + existingAssessment, + ); + }); + + it('returns the existing assessment', async () => { + const result = await useCase.execute({ + ruleId, + language: ProgrammingLanguage.KOTLIN, + organizationId, + userId, + }); + + expect(result).toEqual(existingAssessment); + }); + + it('does not create a new assessment', async () => { + await useCase.execute({ + ruleId, + language: ProgrammingLanguage.KOTLIN, + organizationId, + userId, + }); + + expect(ruleDetectionAssessmentRepository.add).not.toHaveBeenCalled(); + }); + }); + + describe('when custom status is provided', () => { + beforeEach(() => { + ruleDetectionAssessmentRepository.get.mockResolvedValue(null); + ruleDetectionAssessmentRepository.add.mockImplementation( + async (assessment: RuleDetectionAssessment) => assessment, + ); + }); + + describe('when program is provided', () => { + it('creates assessment with SUCCESS', async () => { + const result = await useCase.execute({ + ruleId, + language: ProgrammingLanguage.KOTLIN, + organizationId, + userId, + status: RuleDetectionAssessmentStatus.SUCCESS, + }); + + expect(result.status).toBe(RuleDetectionAssessmentStatus.SUCCESS); + }); + + it('creates assessment with custom details', async () => { + const result = await useCase.execute({ + ruleId, + language: ProgrammingLanguage.KOTLIN, + organizationId, + userId, + status: RuleDetectionAssessmentStatus.SUCCESS, + details: 'Imported from legacy data', + }); + + expect(result.details).toBe('Imported from legacy data'); + }); + }); + }); + + describe('when repository throws error', () => { + it('re-throws the error', async () => { + const error = new Error('Database connection failed'); + ruleDetectionAssessmentRepository.get.mockRejectedValue(error); + + await expect( + useCase.execute({ + ruleId, + language: ProgrammingLanguage.KOTLIN, + organizationId, + userId, + }), + ).rejects.toThrow('Database connection failed'); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/createEmptyRuleDetectionAssessment/CreateEmptyRuleDetectionAssessmentUseCase.ts b/packages/linter/src/application/useCases/createEmptyRuleDetectionAssessment/CreateEmptyRuleDetectionAssessmentUseCase.ts new file mode 100644 index 000000000..a9b55292b --- /dev/null +++ b/packages/linter/src/application/useCases/createEmptyRuleDetectionAssessment/CreateEmptyRuleDetectionAssessmentUseCase.ts @@ -0,0 +1,89 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + CreateEmptyRuleDetectionAssessmentCommand, + CreateEmptyRuleDetectionAssessmentResponse, + ICreateEmptyRuleDetectionAssessment, + RuleDetectionAssessmentStatus, + createRuleDetectionAssessmentId, + DetectionModeEnum, +} from '@packmind/types'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { v4 as uuidv4 } from 'uuid'; + +const origin = 'CreateEmptyRuleDetectionAssessmentUseCase'; + +/** + * Use case for creating a RuleDetectionAssessment. + * By default creates with NOT_STARTED status, but can be configured + * to use SUCCESS status when importing ready-to-use detection programs. + */ +export class CreateEmptyRuleDetectionAssessmentUseCase implements ICreateEmptyRuleDetectionAssessment { + constructor( + private readonly linterRepositories: ILinterRepositories, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: CreateEmptyRuleDetectionAssessmentCommand, + ): Promise<CreateEmptyRuleDetectionAssessmentResponse> { + const status = command.status ?? RuleDetectionAssessmentStatus.NOT_STARTED; + const details = command.details ?? ''; + + this.logger.info('Creating rule detection assessment', { + ruleId: command.ruleId, + language: command.language, + status, + }); + + try { + // Check if assessment already exists for this rule and language + const existingAssessment = await this.linterRepositories + .getRuleDetectionAssessmentRepository() + .get(command.ruleId, command.language); + + if (existingAssessment) { + this.logger.info( + 'Assessment already exists for rule and language, returning existing', + { + assessmentId: existingAssessment.id, + ruleId: command.ruleId, + language: command.language, + }, + ); + return existingAssessment; + } + + const assessmentId = createRuleDetectionAssessmentId(uuidv4()); + const assessment: CreateEmptyRuleDetectionAssessmentResponse = { + id: assessmentId, + ruleId: command.ruleId, + language: command.language, + detectionMode: DetectionModeEnum.SINGLE_AST, + status, + details, + clarificationQuestion: null, + clarificationAnswers: null, + }; + + await this.linterRepositories + .getRuleDetectionAssessmentRepository() + .add(assessment); + + this.logger.info('Assessment created', { + assessmentId: assessment.id, + ruleId: command.ruleId, + language: command.language, + status, + }); + + return assessment; + } catch (error) { + this.logger.error('Failed to create rule detection assessment', { + ruleId: command.ruleId, + language: command.language, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/createNewDetectionProgramVersion/CreateNewDetectionProgramVersionUseCase.spec.ts b/packages/linter/src/application/useCases/createNewDetectionProgramVersion/CreateNewDetectionProgramVersionUseCase.spec.ts new file mode 100644 index 000000000..ec93d6c97 --- /dev/null +++ b/packages/linter/src/application/useCases/createNewDetectionProgramVersion/CreateNewDetectionProgramVersionUseCase.spec.ts @@ -0,0 +1,715 @@ +import { CreateNewDetectionProgramVersionUseCase } from './CreateNewDetectionProgramVersionUseCase'; +import { IDetectionProgramRepository } from '../../../domain/repositories/IDetectionProgramRepository'; +import { IActiveDetectionProgramRepository } from '../../../domain/repositories/IActiveDetectionProgramRepository'; +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { createOrganizationId, createUserId } from '@packmind/types'; +import { + DetectionStatus, + createRuleId, + createStandardVersionId, + createStandardId, + ProgrammingLanguage, + IStandardsPort, + CreateNewDetectionProgramVersionCommand, + createActiveDetectionProgramId, + createDetectionProgramId, + DetectionModeEnum, +} from '@packmind/types'; +import { stubLogger } from '@packmind/test-utils'; +import { ruleFactory } from '@packmind/standards/test'; +import { + activeDetectionProgramFactory, + detectionProgramFactory, +} from '../../../../test'; +import { standardFactory } from '@packmind/standards/test'; + +// Add at the top, before other imports +jest.mock('@packmind/node-utils', () => { + const actual = jest.requireActual('@packmind/node-utils'); + return { + ...actual, + SSEEventPublisher: { + publishProgramStatusEvent: jest.fn().mockResolvedValue(undefined), + }, + }; +}); + +describe('CreateNewDetectionProgramVersionUseCase', () => { + let createNewDetectionProgramVersionUseCase: CreateNewDetectionProgramVersionUseCase; + let detectionProgramRepository: jest.Mocked<IDetectionProgramRepository>; + let activeDetectionProgramRepository: jest.Mocked<IActiveDetectionProgramRepository>; + let standardsAdapter: jest.Mocked<IStandardsPort>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + beforeEach(() => { + // Mock DetectionProgramRepository + detectionProgramRepository = { + add: jest.fn(), + findById: jest.fn(), + findByRuleId: jest.fn(), + findByRuleIdAndVersion: jest.fn(), + getLatestVersionByRuleId: jest.fn(), + getLatestVersionByRuleIdAndLanguage: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as unknown as jest.Mocked<IDetectionProgramRepository>; + + // Mock ActiveDetectionProgramRepository + activeDetectionProgramRepository = { + add: jest.fn(), + findById: jest.fn(), + findByRuleId: jest.fn(), + findByRuleIdAndLanguage: jest.fn(), + findByRuleIdWithPrograms: jest.fn(), + updateActiveDetectionProgram: jest.fn(), + deleteByRuleId: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as unknown as jest.Mocked<IActiveDetectionProgramRepository>; + + standardsAdapter = { + getStandard: jest.fn(), + getRule: jest.fn(), + getStandardVersion: jest.fn(), + getStandardVersionById: jest.fn(), + listStandardVersions: jest.fn(), + getLatestRulesByStandardId: jest.fn(), + getRulesByStandardId: jest.fn(), + listStandardsBySpace: jest.fn(), + getRuleCodeExamples: jest.fn(), + findStandardBySlug: jest.fn(), + getLatestStandardVersion: jest.fn(), + }; + + stubbedLogger = stubLogger(); + + createNewDetectionProgramVersionUseCase = + new CreateNewDetectionProgramVersionUseCase( + detectionProgramRepository, + activeDetectionProgramRepository, + standardsAdapter, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute', () => { + describe('when updateActiveDetectionProgram is true', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + const activeDetectionProgramId = createActiveDetectionProgramId(uuidv4()); + const currentDetectionProgramId = createDetectionProgramId(uuidv4()); + const newDetectionProgramId = createDetectionProgramId(uuidv4()); + + const command: CreateNewDetectionProgramVersionCommand = { + activeDetectionProgramId, + code: 'updated code', + mode: DetectionModeEnum.SINGLE_AST, + status: DetectionStatus.READY, + updateActiveDetectionProgram: true, + userId, + organizationId, + }; + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + userId, + scope: null, + }); + + const activeDetectionProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + detectionProgramVersion: currentDetectionProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + const currentDetectionProgram = detectionProgramFactory({ + id: currentDetectionProgramId, + ruleId, + version: 2, + mode: DetectionModeEnum.REGEXP, + status: DetectionStatus.READY, + }); + + const newDetectionProgram = detectionProgramFactory({ + id: newDetectionProgramId, + ruleId, + code: command.code, + version: 3, + mode: command.mode, + status: command.status, + }); + + const updatedActiveProgram = { + ...activeDetectionProgram, + detectionProgramVersion: newDetectionProgramId, + }; + + let result: Awaited< + ReturnType<typeof createNewDetectionProgramVersionUseCase.execute> + >; + + beforeEach(async () => { + activeDetectionProgramRepository.findById.mockResolvedValue( + activeDetectionProgram, + ); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + detectionProgramRepository.findById.mockResolvedValue( + currentDetectionProgram, + ); + detectionProgramRepository.getLatestVersionByRuleIdAndLanguage.mockResolvedValue( + 2, + ); + detectionProgramRepository.add.mockResolvedValue(newDetectionProgram); + activeDetectionProgramRepository.updateActiveDetectionProgram.mockResolvedValue( + updatedActiveProgram, + ); + + result = await createNewDetectionProgramVersionUseCase.execute(command); + }); + + it('adds new detection program with correct properties', () => { + expect(detectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + ruleId, + code: command.code, + version: 3, + mode: command.mode, + status: command.status, + }), + ); + }); + + it('updates active detection program with new version', () => { + expect( + activeDetectionProgramRepository.updateActiveDetectionProgram, + ).toHaveBeenCalledWith( + expect.objectContaining({ + detectionProgramVersion: newDetectionProgramId, + }), + ); + }); + + it('returns the new detection program', () => { + expect(result).toEqual(newDetectionProgram); + }); + }); + + describe('when updateActiveDetectionProgram is false', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + const activeDetectionProgramId = createActiveDetectionProgramId(uuidv4()); + const currentDetectionProgramId = createDetectionProgramId(uuidv4()); + const newDetectionProgramId = createDetectionProgramId(uuidv4()); + + const command: CreateNewDetectionProgramVersionCommand = { + activeDetectionProgramId, + code: 'updated code', + updateActiveDetectionProgram: false, + organizationId, + userId, + }; + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + userId, + scope: null, + }); + + const activeDetectionProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + detectionProgramVersion: currentDetectionProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + const currentDetectionProgram = detectionProgramFactory({ + id: currentDetectionProgramId, + ruleId, + version: 2, + }); + + const newDetectionProgram = detectionProgramFactory({ + id: newDetectionProgramId, + ruleId, + code: command.code, + version: 3, + }); + + let result: Awaited< + ReturnType<typeof createNewDetectionProgramVersionUseCase.execute> + >; + + beforeEach(async () => { + activeDetectionProgramRepository.findById.mockResolvedValue( + activeDetectionProgram, + ); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + detectionProgramRepository.findById.mockResolvedValue( + currentDetectionProgram, + ); + detectionProgramRepository.getLatestVersionByRuleIdAndLanguage.mockResolvedValue( + 2, + ); + detectionProgramRepository.add.mockResolvedValue(newDetectionProgram); + + result = await createNewDetectionProgramVersionUseCase.execute(command); + }); + + it('adds new detection program with correct properties', () => { + expect(detectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + ruleId, + code: command.code, + version: 3, + }), + ); + }); + + it('does not update active detection program', () => { + expect( + activeDetectionProgramRepository.updateActiveDetectionProgram, + ).not.toHaveBeenCalled(); + }); + + it('returns the new detection program', () => { + expect(result).toEqual(newDetectionProgram); + }); + }); + + describe('when updateActiveDetectionProgram is undefined', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + const activeDetectionProgramId = createActiveDetectionProgramId(uuidv4()); + const currentDetectionProgramId = createDetectionProgramId(uuidv4()); + const newDetectionProgramId = createDetectionProgramId(uuidv4()); + + const command: CreateNewDetectionProgramVersionCommand = { + activeDetectionProgramId, + code: 'updated code', + organizationId, + userId, + }; + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + userId, + scope: null, + }); + + const activeDetectionProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + detectionProgramVersion: currentDetectionProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + const currentDetectionProgram = detectionProgramFactory({ + id: currentDetectionProgramId, + ruleId, + version: 2, + }); + + const newDetectionProgram = detectionProgramFactory({ + id: newDetectionProgramId, + ruleId, + code: command.code, + version: 3, + }); + + let result: Awaited< + ReturnType<typeof createNewDetectionProgramVersionUseCase.execute> + >; + + beforeEach(async () => { + activeDetectionProgramRepository.findById.mockResolvedValue( + activeDetectionProgram, + ); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + detectionProgramRepository.findById.mockResolvedValue( + currentDetectionProgram, + ); + detectionProgramRepository.getLatestVersionByRuleIdAndLanguage.mockResolvedValue( + 2, + ); + detectionProgramRepository.add.mockResolvedValue(newDetectionProgram); + + result = await createNewDetectionProgramVersionUseCase.execute(command); + }); + + it('does not update active detection program', () => { + expect( + activeDetectionProgramRepository.updateActiveDetectionProgram, + ).not.toHaveBeenCalled(); + }); + + it('returns the new detection program', () => { + expect(result).toEqual(newDetectionProgram); + }); + }); + + describe('when active detection program has draft version only', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + const activeDetectionProgramId = createActiveDetectionProgramId(uuidv4()); + const draftDetectionProgramId = createDetectionProgramId(uuidv4()); + const newDetectionProgramId = createDetectionProgramId(uuidv4()); + + const command: CreateNewDetectionProgramVersionCommand = { + activeDetectionProgramId, + code: 'updated code', + updateActiveDetectionProgram: true, + organizationId, + userId, + }; + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + userId, + scope: null, + }); + + const activeDetectionProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + detectionProgramVersion: null, // No regular version + detectionProgramDraftVersion: draftDetectionProgramId, // Has draft version + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + const draftDetectionProgram = detectionProgramFactory({ + id: draftDetectionProgramId, + ruleId, + version: 1, + }); + + const newDetectionProgram = detectionProgramFactory({ + id: newDetectionProgramId, + ruleId, + code: command.code, + version: 2, + }); + + let result: Awaited< + ReturnType<typeof createNewDetectionProgramVersionUseCase.execute> + >; + + beforeEach(async () => { + activeDetectionProgramRepository.findById.mockResolvedValue( + activeDetectionProgram, + ); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + detectionProgramRepository.findById.mockResolvedValue( + draftDetectionProgram, + ); + detectionProgramRepository.getLatestVersionByRuleIdAndLanguage.mockResolvedValue( + 1, + ); + detectionProgramRepository.add.mockResolvedValue(newDetectionProgram); + + result = await createNewDetectionProgramVersionUseCase.execute(command); + }); + + it('fetches the draft detection program', () => { + expect(detectionProgramRepository.findById).toHaveBeenCalledWith( + draftDetectionProgramId, + ); + }); + + it('returns the new detection program', () => { + expect(result).toEqual(newDetectionProgram); + }); + }); + + describe('error cases', () => { + describe('when code is empty', () => { + it('throws error', async () => { + const command: CreateNewDetectionProgramVersionCommand = { + activeDetectionProgramId: createActiveDetectionProgramId(uuidv4()), + code: '', + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + }; + + await expect( + createNewDetectionProgramVersionUseCase.execute(command), + ).rejects.toThrow('Code is required'); + }); + }); + + describe('when active detection program is not found', () => { + it('throws error', async () => { + const command: CreateNewDetectionProgramVersionCommand = { + activeDetectionProgramId: createActiveDetectionProgramId(uuidv4()), + code: 'test code', + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + }; + + activeDetectionProgramRepository.findById.mockResolvedValue(null); + + await expect( + createNewDetectionProgramVersionUseCase.execute(command), + ).rejects.toThrow('Detection program not found'); + }); + }); + + describe('when active detection program has no version or draft version', () => { + it('throws error', async () => { + const activeDetectionProgramId = + createActiveDetectionProgramId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + + const command: CreateNewDetectionProgramVersionCommand = { + activeDetectionProgramId, + code: 'test code', + organizationId, + userId, + }; + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + userId, + scope: null, + }); + + const activeDetectionProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + detectionProgramVersion: null, + detectionProgramDraftVersion: null, + ruleId, + }); + + activeDetectionProgramRepository.findById.mockResolvedValue( + activeDetectionProgram, + ); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + + await expect( + createNewDetectionProgramVersionUseCase.execute(command), + ).rejects.toThrow( + 'Active detection program has no version or draft version', + ); + }); + }); + + describe('when rule is not found', () => { + it('throws error', async () => { + const activeDetectionProgramId = + createActiveDetectionProgramId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + + const command: CreateNewDetectionProgramVersionCommand = { + activeDetectionProgramId, + code: 'test code', + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + }; + + const activeDetectionProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + ruleId, + }); + + activeDetectionProgramRepository.findById.mockResolvedValue( + activeDetectionProgram, + ); + standardsAdapter.getRule.mockResolvedValue(null); + + await expect( + createNewDetectionProgramVersionUseCase.execute(command), + ).rejects.toThrow('Rule not found'); + }); + }); + + describe('when current detection program is not found', () => { + it('throws error', async () => { + const activeDetectionProgramId = + createActiveDetectionProgramId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const standardId = createStandardId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const currentDetectionProgramId = createDetectionProgramId(uuidv4()); + + const command: CreateNewDetectionProgramVersionCommand = { + activeDetectionProgramId, + code: 'test code', + organizationId, + userId, + }; + + const existingRule = ruleFactory({ + id: ruleId, + standardVersionId, + }); + + const existingStandardVersion = { + id: standardVersionId, + standardId, + name: 'Test Standard', + slug: 'test-standard', + description: 'Test Description', + version: 1, + scope: null, + }; + + const existingStandard = standardFactory({ + id: standardId, + name: 'Test Standard', + slug: 'test-standard', + userId, + scope: null, + }); + + const activeDetectionProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + detectionProgramVersion: currentDetectionProgramId, + ruleId, + }); + + activeDetectionProgramRepository.findById.mockResolvedValue( + activeDetectionProgram, + ); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getStandardVersion.mockResolvedValue( + existingStandardVersion, + ); + standardsAdapter.getStandard.mockResolvedValue(existingStandard); + detectionProgramRepository.findById.mockResolvedValue(null); + + await expect( + createNewDetectionProgramVersionUseCase.execute(command), + ).rejects.toThrow('Current detection program not found'); + }); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/createNewDetectionProgramVersion/CreateNewDetectionProgramVersionUseCase.ts b/packages/linter/src/application/useCases/createNewDetectionProgramVersion/CreateNewDetectionProgramVersionUseCase.ts new file mode 100644 index 000000000..afd27978d --- /dev/null +++ b/packages/linter/src/application/useCases/createNewDetectionProgramVersion/CreateNewDetectionProgramVersionUseCase.ts @@ -0,0 +1,126 @@ +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { v4 as uuidv4 } from 'uuid'; +import { + CreateNewDetectionProgramVersionCommand, + ICreateNewDetectionProgramVersion, +} from '@packmind/types'; +import { IDetectionProgramRepository } from '../../../domain/repositories/IDetectionProgramRepository'; +import { IActiveDetectionProgramRepository } from '../../../domain/repositories/IActiveDetectionProgramRepository'; +import { IStandardsPort } from '@packmind/types'; +import { DetectionProgram, createDetectionProgramId } from '@packmind/types'; + +export class CreateNewDetectionProgramVersionUseCase implements ICreateNewDetectionProgramVersion { + constructor( + private readonly detectionProgramRepository: IDetectionProgramRepository, + private readonly activeDetectionProgramRepository: IActiveDetectionProgramRepository, + private readonly standardsAdapter: IStandardsPort, + private readonly logger: PackmindLogger = new PackmindLogger( + 'CreateNewDetectionProgramVersionUseCase', + LogLevel.INFO, + ), + ) {} + + async execute( + command: CreateNewDetectionProgramVersionCommand, + ): Promise<DetectionProgram> { + // Validate input + if (!command.code || command.code.trim() === '') { + throw new Error('Code is required'); + } + + const activeDetectionProgram = + await this.activeDetectionProgramRepository.findById( + command.activeDetectionProgramId, + ); + if (!activeDetectionProgram) { + throw new Error('Detection program not found'); + } + + const rule = await this.standardsAdapter.getRule( + activeDetectionProgram.ruleId, + ); + + if (!rule) { + throw new Error('Rule not found'); + } + + const standardVersion = await this.standardsAdapter.getStandardVersion( + rule.standardVersionId, + ); + if (!standardVersion) { + throw new Error('Standard version not found for rule'); + } + + const standard = await this.standardsAdapter.getStandard( + standardVersion.standardId, + ); + if (!standard) { + throw new Error('Standard not found for rule'); + } + // + // console.log(standard); + // console.log(command.organizationId); + // + // if (standard.organizationId !== command.organizationId) { + // throw new Error('Rule does not belong to user organization'); + // } + + // Handle the case where detectionProgramVersion might be null (draft mode) + let currentDetectionProgram: DetectionProgram | null = null; + if (activeDetectionProgram.detectionProgramVersion) { + currentDetectionProgram = await this.detectionProgramRepository.findById( + activeDetectionProgram.detectionProgramVersion, + ); + if (!currentDetectionProgram) { + throw new Error('Current detection program not found'); + } + } else if (activeDetectionProgram.detectionProgramDraftVersion) { + // If no regular version but has draft version, use the draft as reference + currentDetectionProgram = await this.detectionProgramRepository.findById( + activeDetectionProgram.detectionProgramDraftVersion, + ); + if (!currentDetectionProgram) { + throw new Error('Current draft detection program not found'); + } + } else { + throw new Error( + 'Active detection program has no version or draft version', + ); + } + + const latestVersion = + await this.detectionProgramRepository.getLatestVersionByRuleIdAndLanguage( + activeDetectionProgram.ruleId, + activeDetectionProgram.language, + ); + const newVersion = latestVersion + 1; + + const newDetectionProgram: DetectionProgram = { + id: createDetectionProgramId(uuidv4()), + ruleId: activeDetectionProgram.ruleId, + code: command.code, + version: newVersion, + mode: command.mode || currentDetectionProgram.mode, + language: activeDetectionProgram.language, + status: command.status || currentDetectionProgram.status, + sourceCodeState: command.sourceCodeState || 'AST', + createdAt: new Date(), + }; + + const createdDetectionProgram = + await this.detectionProgramRepository.add(newDetectionProgram); + + // Only update the active detection program if explicitly requested + if (command.updateActiveDetectionProgram) { + const updatedActiveProgram = { + ...activeDetectionProgram, + detectionProgramVersion: createdDetectionProgram.id, + }; + await this.activeDetectionProgramRepository.updateActiveDetectionProgram( + updatedActiveProgram, + ); + } + + return createdDetectionProgram; + } +} diff --git a/packages/linter/src/application/useCases/generateHeuristicFollowingChatbotInput/GenerateHeuristicFollowingChatbotInputUseCase.spec.ts b/packages/linter/src/application/useCases/generateHeuristicFollowingChatbotInput/GenerateHeuristicFollowingChatbotInputUseCase.spec.ts new file mode 100644 index 000000000..ab80011ee --- /dev/null +++ b/packages/linter/src/application/useCases/generateHeuristicFollowingChatbotInput/GenerateHeuristicFollowingChatbotInputUseCase.spec.ts @@ -0,0 +1,640 @@ +import { GenerateHeuristicFollowingChatbotInputUseCase } from './GenerateHeuristicFollowingChatbotInputUseCase'; +import { HeuristicGenerationService } from './shared/HeuristicGenerationService'; +import { IRuleDetectionHeuristicsRepository } from '../../../domain/repositories/IRuleDetectionHeuristicsRepository'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { + createDetectionHeuristicsId, + createRuleId, + createOrganizationId, + createUserId, + createRuleExampleId, + DetectionHeuristics, + ProgrammingLanguage, + UpdateHeuristicsFollowingChatbotInputCommand, + IStandardsPort, + Rule, + RuleExample, + IAccountsPort, + ILlmPort, + AIService, + AiNotConfigured, +} from '@packmind/types'; +import { PackmindLogger } from '@packmind/logger'; +import { stubLogger } from '@packmind/test-utils'; +import { SSEEventPublisher } from '@packmind/node-utils'; +import { v4 as uuidv4 } from 'uuid'; + +// Mock SSEEventPublisher +jest.mock('@packmind/node-utils', () => ({ + ...jest.requireActual('@packmind/node-utils'), + SSEEventPublisher: { + publishDetectionHeuristicsUpdatedEvent: jest.fn(), + }, +})); + +// Mock HeuristicGenerationService +jest.mock('./shared/HeuristicGenerationService'); + +describe('UpdateHeuristicsFollowingChatbotInputUseCase', () => { + let useCase: GenerateHeuristicFollowingChatbotInputUseCase; + let heuristicsRepository: jest.Mocked<IRuleDetectionHeuristicsRepository>; + let linterRepositories: jest.Mocked<ILinterRepositories>; + let standardsAdapter: jest.Mocked<IStandardsPort>; + let accountsPort: jest.Mocked<IAccountsPort>; + let llmPort: jest.Mocked<ILlmPort>; + let mockAiService: jest.Mocked<AIService>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + const userId = createUserId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + + beforeEach(() => { + heuristicsRepository = { + getHeuristicsById: jest.fn(), + updateHeuristics: jest.fn(), + upsertHeuristics: jest.fn(), + getHeuristicsForRule: jest.fn(), + getAllHeuristicsForRule: jest.fn(), + } as unknown as jest.Mocked<IRuleDetectionHeuristicsRepository>; + + linterRepositories = { + getRuleDetectionHeuristicsRepository: jest + .fn() + .mockReturnValue(heuristicsRepository), + getRuleDetectionAssessmentRepository: jest.fn(), + getDetectionProgramRepository: jest.fn(), + getActiveDetectionProgramRepository: jest.fn(), + getDetectionProgramMetadataRepository: jest.fn(), + } as unknown as jest.Mocked<ILinterRepositories>; + + standardsAdapter = { + getRule: jest.fn(), + getRuleCodeExamples: jest.fn(), + } as unknown as jest.Mocked<IStandardsPort>; + + // Mock accounts port to return a user with membership in ANY organization + // This is necessary because different test blocks use different organization IDs + accountsPort = { + getUserById: jest.fn(), + getOrganizationById: jest.fn(), + } as unknown as jest.Mocked<IAccountsPort>; + + // Mock AI service + mockAiService = { + executePrompt: jest.fn(), + isConfigured: jest.fn().mockResolvedValue(true), + } as unknown as jest.Mocked<AIService>; + + // Mock LLM port + llmPort = { + getLlmForOrganization: jest + .fn() + .mockResolvedValue({ aiService: mockAiService }), + } as jest.Mocked<ILlmPort>; + + stubbedLogger = stubLogger(); + + useCase = new GenerateHeuristicFollowingChatbotInputUseCase( + accountsPort, + linterRepositories, + standardsAdapter, + llmPort, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when updating heuristics successfully', () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + let existingHeuristics: DetectionHeuristics; + let command: UpdateHeuristicsFollowingChatbotInputCommand; + let mockRule: Rule; + let mockExamples: RuleExample[]; + + beforeEach(() => { + // Setup user/organization mocks for this test block's IDs + accountsPort.getUserById.mockResolvedValue({ + id: userId, + email: 'test@example.com', + passwordHash: 'hashed', + memberships: [ + { + organizationId: organizationId, + role: 'member', + userId: userId, + }, + ], + active: true, + }); + + accountsPort.getOrganizationById.mockResolvedValue({ + id: organizationId, + name: 'Test Organization', + slug: 'test-org', + }); + + existingHeuristics = { + id: heuristicsId, + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['existing heuristic 1', 'existing heuristic 2'], + }; + + mockRule = { + id: ruleId, + content: 'Test rule content', + } as Rule; + + mockExamples = [ + { + id: createRuleExampleId(uuidv4()), + ruleId, + positive: 'const x = 1;', + negative: 'var x = 1;', + lang: ProgrammingLanguage.TYPESCRIPT, + }, + { + id: createRuleExampleId(uuidv4()), + ruleId, + positive: 'const y = 2;', + negative: 'var y = 2;', + lang: ProgrammingLanguage.JAVASCRIPT, + }, + ]; + + command = { + detectionHeuristicsId: heuristicsId, + question: 'What code pattern should we detect?', + answer: 'Variable declarations using var keyword', + userId, + organizationId, + }; + + heuristicsRepository.getHeuristicsById.mockResolvedValue( + existingHeuristics, + ); + + standardsAdapter.getRule.mockResolvedValue(mockRule); + standardsAdapter.getRuleCodeExamples.mockResolvedValue(mockExamples); + + const MockedService = HeuristicGenerationService as jest.MockedClass< + typeof HeuristicGenerationService + >; + MockedService.mockImplementation( + () => + ({ + generateHeuristic: jest + .fn() + .mockResolvedValue('new generated heuristic'), + }) as unknown as HeuristicGenerationService, + ); + }); + + it('returns generated heuristic', async () => { + const result = await useCase.execute(command); + + expect(result.newHeuristic).toBe('new generated heuristic'); + }); + + it('retrieves existing heuristics by ID', async () => { + await useCase.execute(command); + + expect(heuristicsRepository.getHeuristicsById).toHaveBeenCalledWith( + heuristicsId, + ); + }); + + it('retrieves rule from standards adapter', async () => { + await useCase.execute(command); + + expect(standardsAdapter.getRule).toHaveBeenCalledWith(ruleId); + }); + + it('retrieves rule examples from standards adapter', async () => { + await useCase.execute(command); + + expect(standardsAdapter.getRuleCodeExamples).toHaveBeenCalledWith(ruleId); + }); + + it('filters examples by language', async () => { + const mockGenerateHeuristic = jest + .fn() + .mockResolvedValue('new generated heuristic'); + const MockedService = HeuristicGenerationService as jest.MockedClass< + typeof HeuristicGenerationService + >; + MockedService.mockImplementation( + () => + ({ + generateHeuristic: mockGenerateHeuristic, + }) as unknown as HeuristicGenerationService, + ); + + await useCase.execute(command); + + expect(mockGenerateHeuristic).toHaveBeenCalledWith( + mockRule, + [mockExamples[0]], // Only TypeScript example + existingHeuristics.heuristics, + command.question, + command.answer, + ); + }); + + it('calls AI service with correct parameters', async () => { + const mockGenerateHeuristic = jest + .fn() + .mockResolvedValue('new generated heuristic'); + const MockedService = HeuristicGenerationService as jest.MockedClass< + typeof HeuristicGenerationService + >; + MockedService.mockImplementation( + () => + ({ + generateHeuristic: mockGenerateHeuristic, + }) as unknown as HeuristicGenerationService, + ); + + await useCase.execute(command); + + expect(mockGenerateHeuristic).toHaveBeenCalledWith( + mockRule, + expect.any(Array), + existingHeuristics.heuristics, + command.question, + command.answer, + ); + }); + + it('does not call repository updateHeuristics method', async () => { + await useCase.execute(command); + + expect(heuristicsRepository.updateHeuristics).not.toHaveBeenCalled(); + }); + + it('does not call repository upsertHeuristics method', async () => { + await useCase.execute(command); + + expect(heuristicsRepository.upsertHeuristics).not.toHaveBeenCalled(); + }); + + it('does not publish SSE event', async () => { + await useCase.execute(command); + + expect( + SSEEventPublisher.publishDetectionHeuristicsUpdatedEvent, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when heuristics not found', () => { + it('throws error', async () => { + const testUserId = createUserId(uuidv4()); + const testOrgId = createOrganizationId(uuidv4()); + + // Setup user/organization mocks for this test's IDs + accountsPort.getUserById.mockResolvedValue({ + id: testUserId, + email: 'test@example.com', + passwordHash: 'hashed', + memberships: [ + { + organizationId: testOrgId, + role: 'member', + userId: testUserId, + }, + ], + active: true, + }); + + accountsPort.getOrganizationById.mockResolvedValue({ + id: testOrgId, + name: 'Test Organization', + slug: 'test-org', + }); + + const command: UpdateHeuristicsFollowingChatbotInputCommand = { + detectionHeuristicsId: createDetectionHeuristicsId(uuidv4()), + question: 'test question', + answer: 'test answer', + userId: testUserId, + organizationId: testOrgId, + }; + + heuristicsRepository.getHeuristicsById.mockResolvedValue(null); + + await expect(useCase.execute(command)).rejects.toThrow( + 'Detection heuristics with id', + ); + }); + }); + + describe('when rule not found', () => { + it('throws error', async () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const testUserId = createUserId(uuidv4()); + const testOrgId = createOrganizationId(uuidv4()); + + // Setup user/organization mocks for this test's IDs + accountsPort.getUserById.mockResolvedValue({ + id: testUserId, + email: 'test@example.com', + passwordHash: 'hashed', + memberships: [ + { + organizationId: testOrgId, + role: 'member', + userId: testUserId, + }, + ], + active: true, + }); + + accountsPort.getOrganizationById.mockResolvedValue({ + id: testOrgId, + name: 'Test Organization', + slug: 'test-org', + }); + + const command: UpdateHeuristicsFollowingChatbotInputCommand = { + detectionHeuristicsId: heuristicsId, + question: 'test question', + answer: 'test answer', + userId: testUserId, + organizationId: testOrgId, + }; + + const existingHeuristics: DetectionHeuristics = { + id: heuristicsId, + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['existing heuristic'], + }; + + heuristicsRepository.getHeuristicsById.mockResolvedValue( + existingHeuristics, + ); + standardsAdapter.getRule.mockResolvedValue(null); + + await expect(useCase.execute(command)).rejects.toThrow('Rule with id'); + }); + }); + + describe('when AI returns EMPTY for unrelated answer', () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + let existingHeuristics: DetectionHeuristics; + let command: UpdateHeuristicsFollowingChatbotInputCommand; + let mockRule: Rule; + let mockExamples: RuleExample[]; + + beforeEach(() => { + // Setup user/organization mocks for this test block's IDs + accountsPort.getUserById.mockResolvedValue({ + id: userId, + email: 'test@example.com', + passwordHash: 'hashed', + memberships: [ + { + organizationId: organizationId, + role: 'member', + userId: userId, + }, + ], + active: true, + }); + + accountsPort.getOrganizationById.mockResolvedValue({ + id: organizationId, + name: 'Test Organization', + slug: 'test-org', + }); + + existingHeuristics = { + id: heuristicsId, + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['existing heuristic 1'], + }; + + mockRule = { + id: ruleId, + content: 'Test rule content', + } as Rule; + + mockExamples = [ + { + id: createRuleExampleId(uuidv4()), + ruleId, + positive: 'const x = 1;', + negative: 'var x = 1;', + lang: ProgrammingLanguage.TYPESCRIPT, + }, + ]; + + command = { + detectionHeuristicsId: heuristicsId, + question: 'What should we detect?', + answer: 'toto', + userId, + organizationId, + }; + + heuristicsRepository.getHeuristicsById.mockResolvedValue( + existingHeuristics, + ); + + standardsAdapter.getRule.mockResolvedValue(mockRule); + standardsAdapter.getRuleCodeExamples.mockResolvedValue(mockExamples); + }); + + it('returns empty string', async () => { + const MockedService = HeuristicGenerationService as jest.MockedClass< + typeof HeuristicGenerationService + >; + MockedService.mockImplementation( + () => + ({ + generateHeuristic: jest.fn().mockResolvedValue(''), + }) as unknown as HeuristicGenerationService, + ); + + const result = await useCase.execute(command); + + expect(result.newHeuristic).toBe(''); + }); + + describe('when using case-insensitive EMPTY variations', () => { + it('returns empty string', async () => { + const MockedService = HeuristicGenerationService as jest.MockedClass< + typeof HeuristicGenerationService + >; + MockedService.mockImplementation( + () => + ({ + generateHeuristic: jest.fn().mockResolvedValue(''), + }) as unknown as HeuristicGenerationService, + ); + + const result = await useCase.execute(command); + + expect(result.newHeuristic).toBe(''); + }); + }); + + describe('when EMPTY has whitespace', () => { + it('returns empty string', async () => { + const MockedService = HeuristicGenerationService as jest.MockedClass< + typeof HeuristicGenerationService + >; + MockedService.mockImplementation( + () => + ({ + generateHeuristic: jest.fn().mockResolvedValue(''), + }) as unknown as HeuristicGenerationService, + ); + + const result = await useCase.execute(command); + + expect(result.newHeuristic).toBe(''); + }); + }); + }); + + describe('when AI generation fails', () => { + it('throws error', async () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const userId = createUserId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + + // Setup user/organization mocks for this test's IDs + accountsPort.getUserById.mockResolvedValue({ + id: userId, + email: 'test@example.com', + passwordHash: 'hashed', + memberships: [ + { + organizationId: organizationId, + role: 'member', + userId: userId, + }, + ], + active: true, + }); + + accountsPort.getOrganizationById.mockResolvedValue({ + id: organizationId, + name: 'Test Organization', + slug: 'test-org', + }); + + const command: UpdateHeuristicsFollowingChatbotInputCommand = { + detectionHeuristicsId: heuristicsId, + question: 'test question', + answer: 'test answer', + userId: userId, + organizationId: organizationId, + }; + + const existingHeuristics: DetectionHeuristics = { + id: heuristicsId, + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['existing heuristic'], + }; + + const mockRule: Rule = { + id: ruleId, + content: 'Test rule', + } as Rule; + + heuristicsRepository.getHeuristicsById.mockResolvedValue( + existingHeuristics, + ); + standardsAdapter.getRule.mockResolvedValue(mockRule); + standardsAdapter.getRuleCodeExamples.mockResolvedValue([]); + + const MockedService = HeuristicGenerationService as jest.MockedClass< + typeof HeuristicGenerationService + >; + MockedService.mockImplementation( + () => + ({ + generateHeuristic: jest + .fn() + .mockRejectedValue(new Error('AI generation error')), + }) as unknown as HeuristicGenerationService, + ); + + await expect(useCase.execute(command)).rejects.toThrow( + 'AI generation error', + ); + }); + }); + + describe('when AI service is not configured for organization', () => { + it('throws AiNotConfigured error', async () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const testUserId = createUserId(uuidv4()); + const testOrgId = createOrganizationId(uuidv4()); + + accountsPort.getUserById.mockResolvedValue({ + id: testUserId, + email: 'test@example.com', + passwordHash: 'hashed', + memberships: [ + { + organizationId: testOrgId, + role: 'member', + userId: testUserId, + }, + ], + active: true, + }); + + accountsPort.getOrganizationById.mockResolvedValue({ + id: testOrgId, + name: 'Test Organization', + slug: 'test-org', + }); + + const command: UpdateHeuristicsFollowingChatbotInputCommand = { + detectionHeuristicsId: heuristicsId, + question: 'test question', + answer: 'test answer', + userId: testUserId, + organizationId: testOrgId, + }; + + const existingHeuristics: DetectionHeuristics = { + id: heuristicsId, + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['existing heuristic'], + }; + + const mockRule: Rule = { + id: ruleId, + content: 'Test rule', + } as Rule; + + heuristicsRepository.getHeuristicsById.mockResolvedValue( + existingHeuristics, + ); + standardsAdapter.getRule.mockResolvedValue(mockRule); + standardsAdapter.getRuleCodeExamples.mockResolvedValue([]); + + // Mock LLM port returning undefined aiService + llmPort.getLlmForOrganization.mockResolvedValue({ aiService: undefined }); + + await expect(useCase.execute(command)).rejects.toThrow(AiNotConfigured); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/generateHeuristicFollowingChatbotInput/GenerateHeuristicFollowingChatbotInputUseCase.ts b/packages/linter/src/application/useCases/generateHeuristicFollowingChatbotInput/GenerateHeuristicFollowingChatbotInputUseCase.ts new file mode 100644 index 000000000..4170f030b --- /dev/null +++ b/packages/linter/src/application/useCases/generateHeuristicFollowingChatbotInput/GenerateHeuristicFollowingChatbotInputUseCase.ts @@ -0,0 +1,131 @@ +import { PackmindLogger } from '@packmind/logger'; +import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; +import { + UpdateHeuristicsFollowingChatbotInputCommand, + UpdateHeuristicsFollowingChatbotInputResponse, + IStandardsPort, + IAccountsPort, + ILlmPort, + AiNotConfigured, + createOrganizationId, +} from '@packmind/types'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { HeuristicGenerationService } from './shared/HeuristicGenerationService'; + +const origin = 'GenerateHeuristicFollowingChatbotInputUseCase'; + +export class GenerateHeuristicFollowingChatbotInputUseCase extends AbstractMemberUseCase< + UpdateHeuristicsFollowingChatbotInputCommand, + UpdateHeuristicsFollowingChatbotInputResponse +> { + constructor( + accountsPort: IAccountsPort, + private readonly linterRepositories: ILinterRepositories, + private readonly standardsAdapter: IStandardsPort, + private readonly llmPort: ILlmPort | null, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForMembers( + command: UpdateHeuristicsFollowingChatbotInputCommand & MemberContext, + ): Promise<UpdateHeuristicsFollowingChatbotInputResponse> { + this.logger.info('Updating heuristics from chatbot input', { + detectionHeuristicsId: command.detectionHeuristicsId, + userId: command.userId, + organizationId: command.organizationId, + }); + + try { + const heuristicsRepo = + this.linterRepositories.getRuleDetectionHeuristicsRepository(); + + // Retrieve existing heuristics + const existingHeuristics = await heuristicsRepo.getHeuristicsById( + command.detectionHeuristicsId, + ); + + if (!existingHeuristics) { + const errorMessage = `Detection heuristics with id ${command.detectionHeuristicsId} not found`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + + // Get rule details + const rule = await this.standardsAdapter.getRule( + existingHeuristics.ruleId, + ); + if (!rule) { + const errorMessage = `Rule with id ${existingHeuristics.ruleId} not found`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + + // Get rule examples and filter by language + const allRuleExamples = await this.standardsAdapter.getRuleCodeExamples( + existingHeuristics.ruleId, + ); + + const filteredExamples = allRuleExamples.filter( + (example) => example.lang === existingHeuristics.language, + ); + + this.logger.info('Rule examples filtered for language', { + language: existingHeuristics.language, + totalExamples: allRuleExamples.length, + filteredExamples: filteredExamples.length, + }); + + // Generate new heuristic using AI + if (!this.llmPort) { + throw new AiNotConfigured( + 'LLM port not configured for heuristic generation', + ); + } + const response = await this.llmPort.getLlmForOrganization({ + organizationId: createOrganizationId(command.organizationId), + }); + if (!response.aiService) { + this.logger.warn( + 'AI service not configured for organization - cannot generate heuristic', + { + organizationId: command.organizationId, + }, + ); + throw new AiNotConfigured( + 'AI service not configured for this organization', + ); + } + const aiService = response.aiService; + const heuristicGenerationService = new HeuristicGenerationService( + aiService, + ); + + const newHeuristic = await heuristicGenerationService.generateHeuristic( + rule, + filteredExamples, + existingHeuristics.heuristics, + command.question, + command.answer, + ); + + this.logger.info('Generated new heuristic', { + detectionHeuristicsId: command.detectionHeuristicsId, + heuristic: newHeuristic, + }); + + return { + newHeuristic, + }; + } catch (error) { + this.logger.error('Failed to update heuristics from chatbot input', { + detectionHeuristicsId: command.detectionHeuristicsId, + userId: command.userId, + organizationId: command.organizationId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/generateHeuristicFollowingChatbotInput/prompts/generate_heuristic_from_answer.ts b/packages/linter/src/application/useCases/generateHeuristicFollowingChatbotInput/prompts/generate_heuristic_from_answer.ts new file mode 100644 index 000000000..ed09ed28a --- /dev/null +++ b/packages/linter/src/application/useCases/generateHeuristicFollowingChatbotInput/prompts/generate_heuristic_from_answer.ts @@ -0,0 +1,57 @@ +export const generate_heuristic_from_answer = ` +You are an expert in static code analysis and pattern detection. Your task is to generate a precise, actionable heuristic for detecting violations of a coding rule. + +## Context + +You will be provided with: +- A coding rule description +- Code examples (positive and negative) +- Existing heuristics (if any) that help detect this rule +- A clarification question that was asked to the user +- The user's answer to that question + +## Your Task + +Based on all this information, generate ONE concise heuristic (1 sentence, maximum 2 if absolutely necessary) that describes a specific code pattern to detect. + +**Requirements:** +- Focus on concrete, detectable code patterns (e.g., specific function calls, variable declarations, import statements, class structures) +- Be precise and actionable - avoid vague descriptions +- Use clear, accessible language - avoid technical jargon (like "AST", "tree", "node", etc.) +- Reference specific code elements when possible (e.g., "methods", "classes", "functions" instead of "nodes" or "declarations") +- The heuristic should complement the existing heuristics, not duplicate them + +**Output Format:** +Return ONLY the heuristic text - no JSON, no markdown code blocks, no explanations, no wrapping. + +**Validation Rules:** +If the user's answer is completely unrelated to the coding rule, appears to be spam, or seems like an attempt to trick the system (e.g., single characters, random words, off-topic responses), return exactly the word: EMPTY + +Do not generate a heuristic if the answer has no meaningful connection to the rule being analyzed. + +--- + +## Coding Rule + +$RULE_CONTENT$ + +$GOOD_EXAMPLES$ + +$BAD_EXAMPLES$ + +## Existing Heuristics + +$EXISTING_HEURISTICS$ + +## User Input + +For this question: "$QUESTION$" + +The user answered: "$ANSWER$" + +## Generate Heuristic + +Now, generate a single heuristic (1-2 sentences maximum) that captures the detection pattern based on the user's answer. + +**IMPORTANT: Output ONLY the heuristic sentence itself. Do not include any prefix like "Heuristic:", no explanations, no formatting, no additional commentary - just the detection pattern sentence.** +`; diff --git a/packages/linter/src/application/useCases/generateHeuristicFollowingChatbotInput/shared/HeuristicGenerationService.spec.ts b/packages/linter/src/application/useCases/generateHeuristicFollowingChatbotInput/shared/HeuristicGenerationService.spec.ts new file mode 100644 index 000000000..b6c3a8dfc --- /dev/null +++ b/packages/linter/src/application/useCases/generateHeuristicFollowingChatbotInput/shared/HeuristicGenerationService.spec.ts @@ -0,0 +1,286 @@ +import { HeuristicGenerationService } from './HeuristicGenerationService'; +import { PackmindLogger } from '@packmind/logger'; +import { stubLogger } from '@packmind/test-utils'; +import { AIService } from '@packmind/types'; +import { Rule, RuleExample, ProgrammingLanguage } from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; +import { createRuleId, createRuleExampleId } from '@packmind/types'; + +describe('HeuristicGenerationService', () => { + let service: HeuristicGenerationService; + let mockAiService: jest.Mocked<AIService>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + const mockRule: Rule = { + id: createRuleId(uuidv4()), + content: 'Use const instead of var for variable declarations', + } as Rule; + + const mockExamples: RuleExample[] = [ + { + id: createRuleExampleId(uuidv4()), + ruleId: mockRule.id, + positive: 'const x = 1;', + negative: 'var x = 1;', + lang: ProgrammingLanguage.TYPESCRIPT, + }, + ]; + + beforeEach(() => { + mockAiService = { + executePromptWithHistory: jest.fn(), + isConfigured: jest.fn().mockResolvedValue(true), + executePrompt: jest.fn(), + } as unknown as jest.Mocked<AIService>; + + stubbedLogger = stubLogger(); + + service = new HeuristicGenerationService(mockAiService, stubbedLogger); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when generating valid heuristic', () => { + it('returns generated heuristic text', async () => { + const expectedHeuristic = + 'Detect variable declarations using the var keyword'; + mockAiService.executePromptWithHistory.mockResolvedValue({ + data: expectedHeuristic, + success: true, + attempts: 1, + model: 'test-model', + }); + + const result = await service.generateHeuristic( + mockRule, + mockExamples, + ['existing heuristic'], + 'What pattern should we detect?', + 'Variable declarations using var', + ); + + expect(result).toBe(expectedHeuristic); + }); + + it('trims whitespace from AI response', async () => { + mockAiService.executePromptWithHistory.mockResolvedValue({ + data: ' Detect var keyword \n', + success: true, + attempts: 1, + model: 'test-model', + }); + + const result = await service.generateHeuristic( + mockRule, + mockExamples, + [], + 'What to detect?', + 'var keyword', + ); + + expect(result).toBe('Detect var keyword'); + }); + }); + + describe('when AI returns EMPTY for unrelated answer', () => { + it('returns empty string', async () => { + mockAiService.executePromptWithHistory.mockResolvedValue({ + data: 'EMPTY', + success: true, + attempts: 1, + model: 'test-model', + }); + + const result = await service.generateHeuristic( + mockRule, + mockExamples, + ['existing heuristic'], + 'What should we detect?', + 'toto', + ); + + expect(result).toBe(''); + }); + + it('returns empty string for lowercase empty', async () => { + mockAiService.executePromptWithHistory.mockResolvedValue({ + data: 'empty', + success: true, + attempts: 1, + model: 'test-model', + }); + + const result = await service.generateHeuristic( + mockRule, + mockExamples, + [], + 'What pattern?', + '.', + ); + + expect(result).toBe(''); + }); + + it('returns empty string for mixed case Empty', async () => { + mockAiService.executePromptWithHistory.mockResolvedValue({ + data: 'Empty', + success: true, + attempts: 1, + model: 'test-model', + }); + + const result = await service.generateHeuristic( + mockRule, + mockExamples, + [], + 'Question', + 'How are you today?', + ); + + expect(result).toBe(''); + }); + + describe('when EMPTY has leading whitespace', () => { + it('returns empty string', async () => { + mockAiService.executePromptWithHistory.mockResolvedValue({ + data: ' EMPTY', + success: true, + attempts: 1, + model: 'test-model', + }); + + const result = await service.generateHeuristic( + mockRule, + mockExamples, + [], + 'Question', + 'nonsense', + ); + + expect(result).toBe(''); + }); + }); + + describe('when EMPTY has trailing whitespace', () => { + it('returns empty string', async () => { + mockAiService.executePromptWithHistory.mockResolvedValue({ + data: 'EMPTY \n', + success: true, + attempts: 1, + model: 'test-model', + }); + + const result = await service.generateHeuristic( + mockRule, + mockExamples, + [], + 'Question', + 'random', + ); + + expect(result).toBe(''); + }); + }); + }); + + describe('when AI service fails', () => { + describe('when retrying after network errors', () => { + beforeEach(() => { + mockAiService.executePromptWithHistory + .mockRejectedValueOnce(new Error('Network error')) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ + data: 'Valid heuristic', + success: true, + attempts: 3, + model: 'test-model', + }); + }); + + it('returns valid heuristic after successful retry', async () => { + const result = await service.generateHeuristic( + mockRule, + mockExamples, + [], + 'Question', + 'Answer', + ); + + expect(result).toBe('Valid heuristic'); + }); + + it('calls AI service multiple times until success', async () => { + await service.generateHeuristic( + mockRule, + mockExamples, + [], + 'Question', + 'Answer', + ); + + expect(mockAiService.executePromptWithHistory).toHaveBeenCalledTimes(3); + }); + }); + + describe('when all retries fail', () => { + beforeEach(() => { + mockAiService.executePromptWithHistory.mockRejectedValue( + new Error('Network error'), + ); + }); + + it('throws error after MAX_RETRY attempts', async () => { + await expect( + service.generateHeuristic( + mockRule, + mockExamples, + [], + 'Question', + 'Answer', + ), + ).rejects.toThrow('Failed to generate heuristic after maximum retries'); + }); + + it('calls AI service multiple times before failing', async () => { + try { + await service.generateHeuristic( + mockRule, + mockExamples, + [], + 'Question', + 'Answer', + ); + } catch { + // Expected to throw + } + + // AIRequestEmitter has MAX_RETRY=2 (3 attempts total), HeuristicGenerationService has MAX_RETRY=3 (3 attempts) + // So total calls = 3 * 3 = 9 + expect(mockAiService.executePromptWithHistory).toHaveBeenCalled(); + }); + }); + + describe('when AI returns no data', () => { + it('throws error', async () => { + mockAiService.executePromptWithHistory.mockResolvedValue({ + data: null, + success: false, + attempts: 1, + model: 'test-model', + }); + + await expect( + service.generateHeuristic( + mockRule, + mockExamples, + [], + 'Question', + 'Answer', + ), + ).rejects.toThrow('Failed to generate heuristic after maximum retries'); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/generateHeuristicFollowingChatbotInput/shared/HeuristicGenerationService.ts b/packages/linter/src/application/useCases/generateHeuristicFollowingChatbotInput/shared/HeuristicGenerationService.ts new file mode 100644 index 000000000..1ed8194e2 --- /dev/null +++ b/packages/linter/src/application/useCases/generateHeuristicFollowingChatbotInput/shared/HeuristicGenerationService.ts @@ -0,0 +1,147 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + AI_RESPONSE_FORMAT, + AIService, + LLMModelPerformance, + OpenAIServiceTier, + PromptConversationRole, +} from '@packmind/types'; +import { Rule, RuleExample } from '@packmind/types'; +import AIRequestEmitter from '../../../../domain/entities/AIRequestEmitter'; +import { generate_heuristic_from_answer } from '../prompts/generate_heuristic_from_answer'; +import { + getBadExamplesCode, + getGoodExamplesCode, +} from '../../generateProgramUseCase/shared/utils/PromptUtils'; + +const origin = 'HeuristicGenerationService'; + +export class HeuristicGenerationService extends AIRequestEmitter { + constructor( + protected readonly _aiService: AIService, + protected readonly _logger = new PackmindLogger(origin), + ) { + super('generate-heuristic-from-answer', _aiService, _logger); + } + + public async generateHeuristic( + rule: Rule, + examples: RuleExample[], + existingHeuristics: string[], + question: string, + answer: string, + ): Promise<string> { + const prompt = this.buildPrompt( + rule, + examples, + existingHeuristics, + question, + answer, + ); + + const MAX_RETRY = 3; + let i = 0; + + while (i < MAX_RETRY) { + try { + const response = await this.callAiProvider( + [ + { + role: PromptConversationRole.USER, + message: prompt, + }, + ], + { + responseFormat: AI_RESPONSE_FORMAT.PLAIN_TEXT, + performance: LLMModelPerformance.FAST, + service_tier: OpenAIServiceTier.PRIORITY, // Will have no impact if not OpenAI. This is for optimizing response time for the ChatBot experience. + }, + ); + + if (!response?.data) { + throw new Error('No data provided by AI'); + } + + // Ensure response.data is a string and trim it + const heuristic = + typeof response.data === 'string' + ? response.data.trim() + : String(response.data).trim(); + + // Check if AI detected invalid/unrelated answer + if (heuristic.toUpperCase() === 'EMPTY') { + this._logger.warn( + 'No heuristic generated - user answer unrelated to rule', + { + question, + answer, + ruleId: rule.id, + }, + ); + return ''; + } + + this._logger.info(`Generated heuristic: ${heuristic}`); + + return heuristic; + } catch (error) { + this._logger.error( + `[TaskId=${this._taskId}] Error when generating heuristic for ruleId=${rule.id} - ${error instanceof Error ? error.message : String(error)}`, + ); + i++; + } + } + + if (i >= MAX_RETRY) { + this._logger.error( + `[TaskId=${this._taskId}] Max retries reached for generating heuristic for ruleId=${rule.id}`, + ); + throw new Error('Failed to generate heuristic after maximum retries'); + } + + // Should never reach here due to throw above, but TypeScript needs this + throw new Error('Failed to generate heuristic'); + } + + private buildPrompt( + rule: Rule, + examples: RuleExample[], + existingHeuristics: string[], + question: string, + answer: string, + ): string { + let prompt = generate_heuristic_from_answer; + + // Replace rule content + prompt = prompt.replace('$RULE_CONTENT$', rule.content); + + // Replace examples + const goodExamples = getGoodExamplesCode(examples); + const badExamples = getBadExamplesCode(examples); + prompt = prompt.replace( + '$GOOD_EXAMPLES$', + goodExamples || 'No good examples provided.', + ); + prompt = prompt.replace( + '$BAD_EXAMPLES$', + badExamples || 'No bad examples provided.', + ); + + // Replace existing heuristics + const heuristicsText = + existingHeuristics.length > 0 + ? existingHeuristics.map((h, i) => `${i + 1}. ${h}`).join('\n') + : 'No existing heuristics yet.'; + prompt = prompt.replace('$EXISTING_HEURISTICS$', heuristicsText); + + // Replace question and answer + prompt = prompt.replace('$QUESTION$', question); + prompt = prompt.replace('$ANSWER$', answer); + + return prompt; + } + + getOperationType(): string { + return 'GENERATE_HEURISTIC_FROM_CHATBOT_ANSWER'; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/GenerateProgramUseCase.ts b/packages/linter/src/application/useCases/generateProgramUseCase/GenerateProgramUseCase.ts new file mode 100644 index 000000000..c6e5a36ef --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/GenerateProgramUseCase.ts @@ -0,0 +1,168 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + IStandardsPort, + ILinterAstPort, + ILlmPort, + AiNotConfigured, + createOrganizationId, +} from '@packmind/types'; +import { GenerateProgramOutput } from '@packmind/types'; +import { DetectionProgram } from '@packmind/types'; +import { DetectionProgramRuleInput } from '@packmind/types'; +import { GenerateRuleDetection } from './shared/generation/GenerateRuleDetection'; +import DetectionToolingLogWriter from './shared/log/DetectionToolingLogWriter'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { + GenerateProgramJobCommand, + IGenerateProgramJob, +} from '@packmind/types'; + +const origin = 'GenerateProgramUseCase'; + +export class GenerateProgramUseCase implements IGenerateProgramJob { + constructor( + private readonly linterRepositories: ILinterRepositories, + private readonly standardsAdapter: IStandardsPort, + private readonly linterAstAdapter: ILinterAstPort | null, + private readonly llmPort: ILlmPort | null, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: GenerateProgramJobCommand, + ): Promise<GenerateProgramOutput> { + try { + this.logger.info('Job starting - checking if detection program exists', { + jobId: command.jobId, + language: command.language, + }); + + // Fetch rule examples and filter by the target language + const allRuleExamples = await this.standardsAdapter.getRuleCodeExamples( + command.ruleId, + ); + + const filteredExamples = allRuleExamples.filter( + (example) => example.lang === command.language, + ); + + this.logger.info('Rule examples filtered for language', { + jobId: command.jobId, + language: command.language, + totalExamples: allRuleExamples.length, + filteredExamples: filteredExamples.length, + }); + + // Fetch existing heuristics for this rule and language + const existingHeuristics = await this.linterRepositories + .getRuleDetectionHeuristicsRepository() + .getHeuristicsForRule(command.ruleId, command.language); + + this.logger.info('Existing heuristics check', { + jobId: command.jobId, + ruleId: command.ruleId, + language: command.language, + heuristicsFound: !!existingHeuristics, + heuristicsCount: existingHeuristics?.heuristics.length || 0, + }); + + // Ensure detection program exists before continuing + const detectionProgram = await this.linterRepositories + .getDetectionProgramRepository() + .findById(command.detectionProgramId); + + if (!detectionProgram) { + throw new Error( + `Detection program ${command.detectionProgramId} not found`, + ); + } + + this.logger.info('Using existing detection program for generation', { + jobId: command.jobId, + detectionProgramId: detectionProgram.id, + activeDetectionProgramId: command.activeDetectionProgramId, + }); + + // Build detection program rule input + const detectionProgramRuleInput: DetectionProgramRuleInput = { + rule: command.rule, + ruleExamples: filteredExamples, + language: command.language, + heuristics: existingHeuristics?.heuristics, + }; + + this.logger.info('Starting program generation', { + jobId: command.jobId, + examplesCount: filteredExamples.length, + }); + + // Get AI service for the organization + if (!this.llmPort) { + throw new AiNotConfigured( + 'LLM port not configured for program generation', + ); + } + const response = await this.llmPort.getLlmForOrganization({ + organizationId: createOrganizationId(command.organizationId), + }); + if (!response.aiService) { + this.logger.warn( + 'AI service not configured for organization - cannot generate program', + { + organizationId: command.organizationId, + jobId: command.jobId, + }, + ); + throw new AiNotConfigured( + 'AI service not configured for this organization', + ); + } + const aiService = response.aiService; + + const generateRuleDetection = new GenerateRuleDetection( + command.jobId, + detectionProgram.id, + detectionProgramRuleInput, + aiService, + new DetectionToolingLogWriter( + this.linterRepositories.getDetectionProgramMetadataRepository(), + detectionProgram.id, + ), + this.linterAstAdapter, + ); + const program: Omit<DetectionProgram, 'version'> & { + generatedHeuristics?: string[] | null; + } = await generateRuleDetection.assessDetectionPractice(); + + this.logger.info('Program generation completed, returning output', { + jobId: command.jobId, + }); + + // Return the output with the final code and DetectionProgram ID + // The DetectionProgram will be updated in the completed step of the delayed job + const output: GenerateProgramOutput = { + code: program.code, + language: command.language, + status: program.status, + detectionProgramId: detectionProgram.id, + mode: program.mode, + sourceCodeState: program.sourceCodeState, + activeDetectionProgramId: command.activeDetectionProgramId, + generatedHeuristics: program.generatedHeuristics ?? null, + }; + + this.logger.info('Job completed', { + jobId: command.jobId, + detectionProgramId: output.detectionProgramId, + activeDetectionProgramId: output.activeDetectionProgramId, + }); + return output; + } catch (error) { + this.logger.error('Failed to generate program', { + jobId: command.jobId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/GenerateProgramDelayedJob.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/GenerateProgramDelayedJob.ts new file mode 100644 index 000000000..6d2e36f59 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/GenerateProgramDelayedJob.ts @@ -0,0 +1,433 @@ +import { PackmindLogger } from '@packmind/logger'; +import { SSEEventPublisher } from '@packmind/node-utils'; +import { + IStandardsPort, + ILinterAstPort, + ILinterPort, + ILlmPort, + ProgrammingLanguage, +} from '@packmind/types'; +import { DetectionStatus } from '@packmind/types'; +import { + AbstractAIDelayedJob, + IQueue, + QueueListeners, + WorkerListeners, +} from '@packmind/node-utils'; +import { GenerateProgramInput } from '@packmind/types'; +import { GenerateProgramOutput } from '@packmind/types'; +import { Job } from 'bullmq'; +import { DetectionProgram } from '@packmind/types'; +import { UpdateDetectionProgramCommand } from '@packmind/types'; +import { GenerateProgramUseCase } from '../GenerateProgramUseCase'; +import { GenerateProgramJobCommand } from '@packmind/types'; +import { ILinterRepositories } from '../../../../domain/repositories/ILinterRepositories'; +import { UpdateDetectionProgramUseCase } from '../../updateDetectionProgram/UpdateDetectionProgramUseCase'; + +const origin = 'GenerateProgramDelayedJob'; + +export class GenerateProgramDelayedJob extends AbstractAIDelayedJob< + GenerateProgramInput, + GenerateProgramOutput +> { + readonly origin = 'GenerateProgramJob'; + + constructor( + queueFactory: ( + queueListeners: Partial<QueueListeners>, + ) => Promise<IQueue<GenerateProgramInput, GenerateProgramOutput>>, + private readonly linterRepositories: ILinterRepositories, + private readonly getStandardsAdapter: () => IStandardsPort, + private readonly getLinterAstAdapter: () => ILinterAstPort | null, + private readonly getLinterAdapter: () => ILinterPort, + private readonly getLlmPort: () => ILlmPort, + ) { + super(queueFactory, new PackmindLogger(origin)); + } + + async onFail(jobId: string): Promise<void> { + this.logger.error( + `[${this.origin}] Job ${jobId} failed - status will be updated in failed listener`, + ); + } + + async runJob( + jobId: string, + input: GenerateProgramInput, + _controller: AbortController, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise<GenerateProgramOutput> { + this.logger.info( + `[${this.origin}] Processing job ${jobId} with input: ${input.value}`, + ); + + // Convert GenerateProgramInput to GenerateProgramJobCommand + const command: GenerateProgramJobCommand = { + value: input.value, + rule: input.rule, + ruleId: input.rule.id, + jobId, + organizationId: input.organizationId, + userId: input.userId, + language: input.language, + detectionProgramId: input.detectionProgramId, + activeDetectionProgramId: input.activeDetectionProgramId, + }; + + const linterAstAdapter = this.getLinterAstAdapter(); + + const useCase = new GenerateProgramUseCase( + this.linterRepositories, + this.getStandardsAdapter(), + linterAstAdapter, + this.getLlmPort(), + ); + + return await useCase.execute(command); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getJobName(_input: GenerateProgramInput): string { + return `generate-program-${Date.now()}`; + } + + jobStartedInfo(input: GenerateProgramInput): string { + return `value: ${input.value} - detectionProgramId: ${input.detectionProgramId}`; + } + + getWorkerListener(): Partial< + WorkerListeners<GenerateProgramInput, GenerateProgramOutput> + > { + return { + completed: async ( + job: Job<GenerateProgramInput, GenerateProgramOutput, string>, + result: GenerateProgramOutput, + ) => { + this.logger.info( + `[${this.origin}] Job ${job.id} completed successfully`, + ); + + try { + // Get the input data from the job + const input = job.data; + + const updatedDetectionProgram = await this.updateDetectionProgram( + result, + input, + ); + this.logger.info( + `[${this.origin}] DetectionProgram updated with final code and ${result.status} status for job ${job.id}`, + { + detectionProgramId: result.detectionProgramId, + status: result.status, + }, + ); + + // Persist generated heuristics if any + if ( + result.generatedHeuristics && + result.generatedHeuristics.length > 0 + ) { + await this.persistHeuristics( + job, + updatedDetectionProgram, + result, + input, + ); + } + + // Publish SSE event for program completion + await SSEEventPublisher.publishProgramStatusEvent( + result.detectionProgramId, + updatedDetectionProgram.ruleId, + updatedDetectionProgram.language, + input.userId, + input.organizationId, + ); + this.logger.info( + `[${this.origin}] SSE event published for program completion - job ${job.id}`, + { + detectionProgramId: result.detectionProgramId, + status: result.status, + userId: input.userId, + }, + ); + } catch (error) { + this.logger.error( + `[${this.origin}] Failed to update DetectionProgram for job ${job.id}`, + { + error: error instanceof Error ? error.message : String(error), + }, + ); + // Note: We don't throw here to avoid marking the job as failed + // since the program generation itself was successful + } + }, + failed: async (job, error) => { + this.logger.error( + `[${this.origin}] Job ${job.id} failed with error: ${error.message}`, + ); + + try { + const input = job.data; + + // Try to get program ID from job return value if it exists + let detectionProgramId: string | undefined; + if (job.returnvalue?.detectionProgramId) { + detectionProgramId = job.returnvalue.detectionProgramId; + } + if (!detectionProgramId && job.data?.detectionProgramId) { + detectionProgramId = job.data + .detectionProgramId as unknown as string; + } + + // Update detection program status to ERROR if it was created + if (detectionProgramId) { + let programForEvent: DetectionProgram | null = null; + try { + // Fetch existing program to preserve current code when updating status + const existingProgram = await this.linterRepositories + .getDetectionProgramRepository() + .findById( + detectionProgramId as unknown as DetectionProgram['id'], + ); + + if (!existingProgram) { + throw new Error('Detection program not found for failed job'); + } + + programForEvent = existingProgram; + + const updateDetectionProgramCommand: UpdateDetectionProgramCommand = + { + detectionProgramId: + detectionProgramId as unknown as DetectionProgram['id'], + code: existingProgram.code, + status: DetectionStatus.ERROR, + organizationId: input.organizationId, + userId: input.userId, + }; + + const updateDetectionProgramUseCase = + new UpdateDetectionProgramUseCase( + this.linterRepositories.getDetectionProgramRepository(), + ); + await updateDetectionProgramUseCase.execute( + updateDetectionProgramCommand, + ); + + this.logger.info( + `[${this.origin}] DetectionProgram status updated to ERROR for failed job ${job.id}`, + { + detectionProgramId, + }, + ); + } catch (updateError) { + this.logger.error( + `[${this.origin}] Failed to update DetectionProgram status to ERROR for job ${job.id}`, + { + error: + updateError instanceof Error + ? updateError.message + : String(updateError), + }, + ); + } + if (programForEvent) { + // Publish SSE event for program failure + await SSEEventPublisher.publishProgramStatusEvent( + String(detectionProgramId), + String(programForEvent.ruleId), + String(programForEvent.language), + input.userId, + input.organizationId, + ); + this.logger.info( + `[${this.origin}] SSE event published for program failure - job ${job.id}`, + { + detectionProgramId, + status: DetectionStatus.ERROR, + userId: input.userId, + }, + ); + } else { + this.logger.warn( + `[${this.origin}] Skipping SSE publish for program failure - missing program context`, + { + detectionProgramId, + }, + ); + } + } else { + this.logger.warn( + `[${this.origin}] Could not update DetectionProgram or publish SSE event for failed job ${job.id} - no detection program ID available`, + ); + } + } catch (sseError) { + this.logger.error( + `[${this.origin}] Failed to handle failed job ${job.id}`, + { + error: + sseError instanceof Error ? sseError.message : String(sseError), + }, + ); + } + }, + }; + } + + private async persistHeuristics( + job: Job<GenerateProgramInput, GenerateProgramOutput, string>, + updatedDetectionProgram: DetectionProgram, + result: GenerateProgramOutput, + input: GenerateProgramInput, + ) { + // Type guard: we already checked that generatedHeuristics is a non-empty array + const generatedHeuristics = result.generatedHeuristics!; + + this.logger.info( + `[${this.origin}] Persisting generated heuristics for job ${job.id}`, + { + ruleId: updatedDetectionProgram.ruleId, + language: result.language, + heuristicsCount: generatedHeuristics.length, + }, + ); + + try { + const linterAdapter = this.getLinterAdapter(); + const heuristicsRepo = + this.linterRepositories.getRuleDetectionHeuristicsRepository(); + + // Fetch existing heuristics from database + const existingHeuristics = await heuristicsRepo.getHeuristicsForRule( + updatedDetectionProgram.ruleId, + result.language as ProgrammingLanguage, + ); + + if (!existingHeuristics) { + // No existing heuristics, create new ones + this.logger.info( + `[${this.origin}] No existing heuristics found, creating new for job ${job.id}`, + { + ruleId: updatedDetectionProgram.ruleId, + language: result.language, + }, + ); + + await linterAdapter.createDetectionHeuristics({ + ruleId: updatedDetectionProgram.ruleId, + language: result.language as ProgrammingLanguage, + heuristics: generatedHeuristics, + organizationId: input.organizationId, + userId: input.userId, + }); + + this.logger.info( + `[${this.origin}] Detection heuristics created successfully for job ${job.id}`, + { + ruleId: updatedDetectionProgram.ruleId, + language: result.language, + }, + ); + } else { + // Existing heuristics found, compare and update if different + if ( + this.areHeuristicsDifferent( + existingHeuristics.heuristics, + generatedHeuristics, + ) + ) { + this.logger.info( + `[${this.origin}] Heuristics differ from database, updating for job ${job.id}`, + { + ruleId: updatedDetectionProgram.ruleId, + language: result.language, + existingCount: existingHeuristics.heuristics.length, + generatedCount: generatedHeuristics.length, + }, + ); + + await linterAdapter.updateRuleDetectionHeuristics({ + detectionHeuristicsId: existingHeuristics.id, + heuristics: generatedHeuristics, + organizationId: input.organizationId, + userId: input.userId, + skipAssessmentTrigger: true, + }); + + this.logger.info( + `[${this.origin}] Detection heuristics updated successfully for job ${job.id}`, + { + ruleId: updatedDetectionProgram.ruleId, + language: result.language, + }, + ); + } else { + this.logger.info( + `[${this.origin}] Heuristics unchanged, skipping update for job ${job.id}`, + { + ruleId: updatedDetectionProgram.ruleId, + language: result.language, + }, + ); + } + } + } catch (heuristicsError) { + this.logger.error( + `[${this.origin}] Failed to persist detection heuristics for job ${job.id}`, + { + error: + heuristicsError instanceof Error + ? heuristicsError.message + : String(heuristicsError), + ruleId: updatedDetectionProgram.ruleId, + language: result.language, + }, + ); + // Continue execution even if heuristics persistence fails + } + } + + private areHeuristicsDifferent( + existing: string[], + generated: string[], + ): boolean { + // Compare length first + if (existing.length !== generated.length) { + return true; + } + + // Compare content (order matters) + for (let i = 0; i < existing.length; i++) { + if (existing[i] !== generated[i]) { + return true; + } + } + + return false; + } + + private async updateDetectionProgram( + result: GenerateProgramOutput, + input: GenerateProgramInput, + ) { + // First, update the DetectionProgram with final code and SUCCESS status + const updateDetectionProgramCommand: UpdateDetectionProgramCommand = { + detectionProgramId: result.detectionProgramId, + code: result.code, + status: result.status, + organizationId: input.organizationId, + userId: input.userId, + sourceCodeState: result.sourceCodeState, + }; + + const updateDetectionProgramUseCase = new UpdateDetectionProgramUseCase( + this.linterRepositories.getDetectionProgramRepository(), + ); + const updatedDetectionProgram = await updateDetectionProgramUseCase.execute( + updateDetectionProgramCommand, + ); + + return updatedDetectionProgram; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/assessment/GenerateRuleHeuristics.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/assessment/GenerateRuleHeuristics.ts new file mode 100644 index 000000000..76f244fc9 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/assessment/GenerateRuleHeuristics.ts @@ -0,0 +1,95 @@ +import AIRequestEmitter from '../../../../../domain/entities/AIRequestEmitter'; +import { DetectionProgramRuleInput } from '@packmind/types'; +import { generate_rule_heuristics } from './prompts/generate_rule_heuristics'; +import { PackmindLogger } from '@packmind/logger'; +import { getErrorMessage } from '@packmind/node-utils'; +import { + AIService, + PromptConversationRole, + AI_RESPONSE_FORMAT, +} from '@packmind/types'; +import { getBadExamplesCode, getGoodExamplesCode } from '../utils/PromptUtils'; + +const origin = 'GenerateRuleHeuristics'; + +export class GenerateRuleHeuristics extends AIRequestEmitter { + constructor( + protected readonly _taskId: string, + protected readonly _aiService: AIService, + protected readonly _logger = new PackmindLogger(origin), + ) { + super(_taskId, _aiService, _logger); + } + + public async generateHeuristics( + detectionProgramRuleInput: DetectionProgramRuleInput, + ): Promise<string[]> { + const prompt = this.buildPrompt(detectionProgramRuleInput); + + const MAX_RETRY = 3; + let i = 0; + while (i < MAX_RETRY) { + try { + const response = await this.callAiProvider( + [ + { + role: PromptConversationRole.USER, + message: prompt, + }, + ], + { + responseFormat: AI_RESPONSE_FORMAT.PLAIN_TEXT, + }, + ); + + if (!response?.data) { + throw new Error('No data provided by AI'); + } + + // Split by newlines and filter out empty lines + const heuristicsArray = response.data + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + this._logger.info( + `Detection Heuristics for ruleId=${detectionProgramRuleInput.rule.id}:\n${response.data}`, + ); + + return heuristicsArray; + } catch (error) { + this._logger.error( + `[TaskId=${this._taskId}] Error when generating detection heuristics for ruleId=${detectionProgramRuleInput.rule.id} ${detectionProgramRuleInput.rule.content} - ${getErrorMessage(error)}`, + ); + i++; + } + } + + if (i >= MAX_RETRY) { + this._logger.error( + `[TaskId=${this._taskId}] Max retries reached for generating detection heuristics for ruleId=${detectionProgramRuleInput.rule.id} ${detectionProgramRuleInput.rule.content}`, + ); + } + throw new Error('Max retries reached for generating detection heuristics'); + } + + private buildPrompt(rule: DetectionProgramRuleInput): string { + const ruleText = this.getRuleText(rule); + return generate_rule_heuristics + .replace('$CODING_RULE$', ruleText) + .replace('$RULE_LANGUAGE$', rule.language); + } + + private getRuleText(rule: DetectionProgramRuleInput): string { + return ` +Rule name: ${rule.rule.content} +Rule programming language: ${rule.language} +${getGoodExamplesCode(rule.ruleExamples)} +${getBadExamplesCode(rule.ruleExamples)} +`; + } + + getOperationType(): string { + return 'GENERATE_RULE_HEURISTICS'; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/assessment/prompts/generate_rule_heuristics.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/assessment/prompts/generate_rule_heuristics.ts new file mode 100644 index 000000000..6107f01c8 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/assessment/prompts/generate_rule_heuristics.ts @@ -0,0 +1,47 @@ +export const generate_rule_heuristics = ` +## Context +You are a seasoned static-analysis expert in $RULE_LANGUAGE$ who designs automated detectors for coding-standard violations in the Packmind platform. + +## Mission + +Your task is to generate a "Detection Heuristics" documentation that provides conceptual, logical rules on how violations of the following coding rule will be detected within a single source file. +You must create clear, actionable detection heuristics that explain why the coding rule is violated, based on the rule description and its code examples. + +**Important**: +* Conceptual, logical rules that describe what code patterns to detect within a single language and file context +* Specific enough for a developer to understand exactly what will be detected without technical implementation details +* Use the code examples to understand what constitutes a violation and what represents correct usage (note: positive examples may not always be provided) + +## Instructions: + +* Analyze the coding rule description to understand its intent and scope. +* Study the code examples when provided: positive examples (correct usage) and negative examples (violations) to identify patterns. +* Create **up to 5 detection heuristics** as concise bullet points using **assertive action verbs** (e.g., "Flag...", "Focus on...", "Exclude...", "Identify...", "Consider...", "Ignore..."). +* **Prioritize quality over quantity**: Include only the most essential heuristics (3-5 typically). If a rule is simple, fewer heuristics are better. +* Focus on describing the logical detection criteria without mentioning specific technical implementation details. +* **Avoid mentioning "the detector", "Packmind", or any tool name**—use direct, assertive language instead. +* Each heuristic should be precise, unambiguous, and add unique value—avoid redundancy. +* Keep each heuristic concise (1 sentence maximum). + + +## Output +Only emit the bullet points—no section headers, no JSON, no code fences, no extra commentary. +Do NOT include "## Detection Heuristics" or any other header in your output. +Start directly with the first bullet point. +**Limit output to a maximum of 5 bullet points, but use fewer if the rule is straightforward.** + +Example output format: +* Flag any top-level function call named 'test' or 'it' that appears directly within a describe block as a test case. +* Count all expectation calls within a test case, regardless of nesting or conditional execution. +* Identify expectation calls as function calls where the identifier is 'expect' immediately followed by a matcher call. + + +## Input + +Here is the coding rule with its examples: + +""" +$CODING_RULE$ +""" + +`; diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/errors.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/errors.ts new file mode 100644 index 000000000..896845bb0 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/errors.ts @@ -0,0 +1,37 @@ +import { RuleId, StandardId } from '@packmind/types'; + +export class StandardNotFoundForProgramGenerationError extends Error { + constructor(standardId: StandardId) { + super( + `Standard with id ${String( + standardId, + )} not found for program generation.`, + ); + this.name = 'StandardNotFoundForProgramGenerationError'; + } +} + +export class RuleNotFoundForProgramGenerationError extends Error { + constructor(ruleId: RuleId) { + super(`Rule with id ${String(ruleId)} not found for program generation.`); + this.name = 'RuleNotFoundForProgramGenerationError'; + } +} + +export class RuleNotLinkedToStandardForProgramGenerationError extends Error { + constructor(ruleId: RuleId, standardId: StandardId) { + super( + `Rule ${String(ruleId)} is not linked to standard ${String( + standardId, + )} for program generation.`, + ); + this.name = 'RuleNotLinkedToStandardForProgramGenerationError'; + } +} + +export class UnauthorizedProgramGenerationError extends Error { + constructor() { + super('You are not authorized to generate a program for this standard.'); + this.name = 'UnauthorizedProgramGenerationError'; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/generation/AbstractRuleDetectionMethod.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/generation/AbstractRuleDetectionMethod.ts new file mode 100644 index 000000000..52cdbc026 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/generation/AbstractRuleDetectionMethod.ts @@ -0,0 +1,68 @@ +import DetectionToolingLogWriter from '../log/DetectionToolingLogWriter'; +import { PackmindLogger } from '@packmind/logger'; +import { + AIService, + PromptConversation, + PromptConversationRole, + TokensUsed, +} from '@packmind/types'; +//import { getBadExamplesCode, getGoodExamplesCode } from '../utils/PromptUtils'; +import { DetectionProgramRuleInput } from '@packmind/types'; + +const origin = 'AbstractRuleDetectionMethod'; + +export abstract class AbstractRuleDetectionMethod { + protected _conversations: PromptConversation[] = []; + protected _tokensUsed: TokensUsed[] = []; + protected _context: string; + protected _aborted = false; + + constructor( + protected readonly _detectionProgramRule: DetectionProgramRuleInput, + protected readonly _aiService: AIService, + protected readonly _logsWriter: DetectionToolingLogWriter, + protected readonly _logger: PackmindLogger = new PackmindLogger(origin), + ) { + this._context = ''; + } + + protected clearConversations() { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] Clear conversations`, + ); + this._conversations = []; + } + + protected clearConversationsAndKeepOriginalInstructions() { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] Clear conversations And Keep Original Instructions`, + ); + const contextOfTheConversation = this._conversations[0]; + this._conversations = [contextOfTheConversation]; + } + + protected async resetConversationWithContext() { + this.clearConversations(); + this.addMessageToConversation(this._context, PromptConversationRole.USER); + } + + protected addMessageToConversation( + prompt: string, + role: PromptConversationRole, + ) { + this._conversations.push({ + role, + message: prompt, + }); + } + + protected abstract getMethodType(): string; + + get tokensUsed(): TokensUsed[] { + return this._tokensUsed; + } + + setAborted() { + this._aborted = true; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/generation/GeneratePracticeDetectionServiceJob.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/generation/GeneratePracticeDetectionServiceJob.ts new file mode 100644 index 000000000..7f2cea42b --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/generation/GeneratePracticeDetectionServiceJob.ts @@ -0,0 +1,153 @@ +// import {IRepositories} from "../../domain/repository/IRepositories"; +// import {IPracticeDetectionToolingRepository} from "../../domain/repository/detection/IPracticeDetectionToolingRepository"; +// import AIProvider from "../../domain/AIProvider"; +// import {buildAIProvider} from "../../infra/ai/AIFactory"; +// import {GenerateRuleDetection} from "./GenerateRuleDetection"; +// import DetectionToolingLogWriter from "./log/DetectionToolingLogWriter"; +// import { PackmindLanguage, PracticeDetectionTooling } from '../../domain/PracticeDetectionTooling'; +// import {logger} from "../../utils/Logger"; +// import RepositoriesFactory from "../../infra/RepositoriesFactory"; +// import {Practice} from "../../domain/Practice"; +// import {PackmindUser} from "../../domain/PackmindUser"; +// import IAiAgentInternalEventEmitter from "../../domain/IAiAgentInternalEventEmitter"; +// import InternalEventEmitterFactory from "../../infra/InternalEventEmitterFactory"; +// import {PackmindTimeoutError} from "../../domain/Error"; +// import PracticeDiagnosis from "./diagnosis/PracticeDiagnosis"; +// import {AIConfig} from "../../domain/AIConfig"; +// import { getLanguage, isPackmindLanguage } from './ToolingLanguageUtils'; +// +// export async function generateToolingInBackground(taskId: string, practice: Practice, ai: AIConfig, user: PackmindUser, signal: AbortSignal): Promise<void> { +// //this is awful since our infra layer is called here, but I don't know how to fix it +// const repositories: IRepositories = RepositoriesFactory.buildRepositories(); +// const aiAgentEventEmitter: IAiAgentInternalEventEmitter = InternalEventEmitterFactory.buildEventEmitter(); +// const practiceDetectionRepository: IPracticeDetectionToolingRepository = repositories.getPracticeDetectionToolingRepository(); +// +// if (!practice) throw new Error("Practice is missing"); +// if (!ai) throw new Error("AI is missing"); +// if (!practiceDetectionRepository) throw new Error("practiceDetectRepository is missing"); +// if (!user) throw new Error("User is missing"); +// +// if (signal.aborted) { +// throw new Error("Timeout"); +// } +// +// //We need to wait 2 seconds to ensure the "IN_QUEUE" has been stored +// await new Promise((resolve) => setTimeout(resolve, 2000)); +// +// const tooling = await upsertToolingStatusToPending(taskId, practice, practiceDetectionRepository); +// +// //dirty but no other option +// const aiProvider: AIProvider = buildAIProvider(ai); +// const practiceDetection = new GenerateRuleDetection(taskId, practice, aiProvider, new DetectionToolingLogWriter(practiceDetectionRepository, tooling._id)); +// +// if (signal.aborted) { +// logger.warn(`Aborted signal due to timeout receive for job ${taskId}`); +// practiceDetection.setAborted(); +// } +// +// signal.addEventListener('abort', () => { +// logger.warn(`Operation aborted for job ${taskId}`); +// practiceDetection.setAborted(); +// }); +// +// const MAX_RETRY = 3; +// let retry = 1; +// while (retry < MAX_RETRY) { +// if (signal.aborted) { +// logger.info(`[${practice.id}] Job aborted, exiting`); +// return; +// } +// try { +// logger.info(`[${practice.id}}] Generation of tooling started ${retry}/${MAX_RETRY}`); +// await generationDetection(practiceDetection, user, practice, aiAgentEventEmitter, tooling, practiceDetectionRepository); +// return; +// } catch (error) { +// if (error instanceof PackmindTimeoutError) { +// logger.error(`[${practice.id}] Timeout error during generation of tooling`); +// return; +// } +// logger.error(`[${practice.id}] Error during generation of tooling, retry ${retry + 1}/${MAX_RETRY}`); +// retry++; +// if (retry === MAX_RETRY) { +// return; +// } +// } +// } +// } +// +// async function generationDetection(practiceDetection: GenerateRuleDetection, +// user: PackmindUser, +// practice: Practice, +// aiAgentEventEmitter: IAiAgentInternalEventEmitter, +// tooling: PracticeDetectionTooling, +// practiceDetectionRepository: IPracticeDetectionToolingRepository) { +// const generatedDetection = await practiceDetection.assessDetectionPractice(); +// const tokens = practiceDetection.assessTokensUsage(); +// +// //Extract below in method +// const result: PracticeDetectionTooling = { +// ...generatedDetection, +// tokens, +// }; +// +// //If tooling is cancelled, do not update it, we do nothing, otherwise, we do it like below +// logger.info(`[${practice.id}] Update tooling in database`); +// const lastToolingVersion = await practiceDetectionRepository.getToolingForPractice(tooling.practiceId.toString()); +// if (!lastToolingVersion) { +// logger.info(`[${practice.id}] A more recent tooling has been generated, current one has been removed.`); +// return; +// } +// if (lastToolingVersion._id.toString() !== tooling._id.toString()) { +// logger.info(`[${practice.id}] A more recent tooling has been generated, no update of tooling`); +// return; +// } +// if (lastToolingVersion.status === 'CANCELLED') { +// logger.info(`[${practice.id}] Job has been cancelled, no update of tooling`); +// return; +// } +// logger.info(`[${practice.id}] Delete existing tooling and insert new one`); +// await practiceDetectionRepository.deleteToolingsForPractice(practice.id); +// await practiceDetectionRepository.insertTooling(result); +// } +// +// async function upsertToolingStatusToPending(taskId: string, practice: Practice, practiceDetectionRepository: IPracticeDetectionToolingRepository): Promise<PracticeDetectionTooling> { +// const tooling = await practiceDetectionRepository.getToolingForPractice(practice.id); +// if(tooling && tooling.taskId === taskId) { +// return await updateToolingStatusToPending(practice, practiceDetectionRepository); +// } +// +// return await initTooling(taskId, practice, practiceDetectionRepository); +// } +// +// async function initTooling(taskId: string, practice: Practice, practiceDetectionRepository: IPracticeDetectionToolingRepository): Promise<PracticeDetectionTooling> { +// logger.info(`[${practice.id}] Init tooling for practice`); +// const result: PracticeDetectionTooling = { +// taskId, +// practiceId: practice.id, +// status: 'PENDING', +// language: isPackmindLanguage(practice.language) ? practice.language as PackmindLanguage : getLanguage(practice.language), +// sourceCodeState: 'NONE', +// }; +// await practiceDetectionRepository.deleteToolingsForPractice(practice.id); +// await practiceDetectionRepository.insertTooling(result); +// const tooling = await practiceDetectionRepository.getToolingForPractice(practice.id); +// return tooling; +// } +// +// async function updateToolingStatusToPending(practice: Practice, practiceDetectionRepository: IPracticeDetectionToolingRepository): Promise<PracticeDetectionTooling> { +// logger.info(`[${practice.id}] Update tooling status to PENDING`); +// const toolingToUpdate = await practiceDetectionRepository.getToolingForPractice(practice.id); +// if(!toolingToUpdate) { +// logger.error(`[${practice.id}] No tooling found to update for practice ${practice.id}`); +// return; +// } +// +// const toolingUpdated: PracticeDetectionTooling = { +// ...toolingToUpdate, +// status: 'PENDING', +// } +// +// await practiceDetectionRepository.upsertTooling(toolingUpdated); +// const tooling = await practiceDetectionRepository.getToolingForPractice(practice.id); +// return tooling; +// } diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/generation/GenerateRuleDetection.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/generation/GenerateRuleDetection.ts new file mode 100644 index 000000000..dd78f8934 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/generation/GenerateRuleDetection.ts @@ -0,0 +1,184 @@ +import { DetectionMethodType, DetectionTechniqueGenerated } from './Types'; +import DetectionProgramPackmindRule from '../program/DetectionProgramPackmindRule'; +import DetectionToolingLogWriter from '../log/DetectionToolingLogWriter'; +import { PackmindLogger } from '@packmind/logger'; +import { getErrorMessage } from '@packmind/node-utils'; +import { AIService, TokensUsed } from '@packmind/types'; +import { DetectionStatus, ILinterAstPort } from '@packmind/types'; +import { DetectionProgramRuleInput } from '@packmind/types'; +import { + DetectionProgram, + DetectionProgramId, + DetectionModeEnum, +} from '@packmind/types'; +import { clearConsoleLogFromProgramOutput } from '../program/ProgramExecutionUtils'; + +const origin = 'GenerateRuleDetection'; + +export class GenerateRuleDetection { + private readonly tokensUsed: TokensUsed[] = []; + private _generatedHeuristics: string[] | null = null; + + constructor( + private readonly _taskId: string, + private readonly _detectionProgramId: DetectionProgramId, + private readonly _detectionProgramRuleInput: DetectionProgramRuleInput, + private readonly _aiService: AIService, + private readonly _logsWriter: DetectionToolingLogWriter, + private readonly _linterAstAdapter: ILinterAstPort | null = null, + private readonly _programGenerator = new DetectionProgramPackmindRule( + _detectionProgramRuleInput, + _aiService, + _logsWriter, + _linterAstAdapter, + ), + private readonly _logger = new PackmindLogger(origin), + ) {} + + //These are some stuff related to first work achieved in Q3 24, but were abandonned. + public async assessDetectionPractice(): Promise< + Omit<DetectionProgram, 'version'> & { + generatedHeuristics?: string[] | null; + } + > { + await this._logsWriter.addLogsMessage( + DetectionToolingLogWriter.MESSAGES.AI_AGENT_STRATEGY_ASSESSMENT, + ); + + try { + const detectionTechniqueGenerated = + await this.getSyntacticDetectionTechnique(); + + // Store generated heuristics for later use + this._generatedHeuristics = + detectionTechniqueGenerated.generatedHeuristics ?? null; + + this._logger.info( + `[${this._detectionProgramRuleInput.rule.id}] Detection Technique Generated over`, + ); + const detectionTechnique = DetectionMethodType.PROGRAM; + + if ( + detectionTechniqueGenerated.success && + detectionTechniqueGenerated.programDescription + ) { + await this._logsWriter.addLogsMessage( + DetectionToolingLogWriter.MESSAGES.AI_AGENT_RESULT_SUCCESS, + ); + await this._logsWriter.updateProgramDescription( + detectionTechniqueGenerated.programDescription, + ); + await this._logsWriter.updateTokensUsed(this.assessTokensUsage()); + return await this.buildAndReturnSuccessfulSyntacticDetectionTooling( + detectionTechniqueGenerated, + ); + } else { + await this._logsWriter.addLogsMessage( + DetectionToolingLogWriter.MESSAGES + .AI_AGENT_RESULT_NOT_GOOD_WILL_RESTART, + ); + await this._logsWriter.updateTokensUsed(this.assessTokensUsage()); + return this.buildAndReturnNotSuccessfulSyntacticDetectionTooling( + detectionTechnique, + detectionTechniqueGenerated, + ); + } + } catch (error) { + await this._logsWriter.addLogsMessage( + DetectionToolingLogWriter.MESSAGES.AI_AGENT_CRASH_RESTART, + ); + await this._logsWriter.updateTokensUsed(this.assessTokensUsage()); + this._logger.error( + `Error while generating the detection tooling: ${getErrorMessage(error)}`, + { error }, + ); + //In case of error, we return a failure to avoid pending + return { + id: this._detectionProgramId, + ruleId: this._detectionProgramRuleInput.rule.id, + status: DetectionStatus.FAILURE, + language: this._detectionProgramRuleInput.language, + //logs: this._logsWriter.logs, + sourceCodeState: 'NONE', + code: '', + mode: DetectionModeEnum.SINGLE_AST, + generatedHeuristics: this._generatedHeuristics, + }; + } + } + + private async buildAndReturnSuccessfulSyntacticDetectionTooling( + detectionTechniqueGenerated: DetectionTechniqueGenerated, + ): Promise< + Omit<DetectionProgram, 'version'> & { + generatedHeuristics?: string[] | null; + } + > { + const cleanedProgram = await clearConsoleLogFromProgramOutput( + detectionTechniqueGenerated.program ?? '', + this._linterAstAdapter, + ); + return { + id: this._detectionProgramId, + ruleId: this._detectionProgramRuleInput.rule.id, + status: DetectionStatus.READY, + sourceCodeState: detectionTechniqueGenerated.sourceCodeState, + language: this._detectionProgramRuleInput.language, + mode: DetectionModeEnum.SINGLE_AST, + code: cleanedProgram, + generatedHeuristics: this._generatedHeuristics, + //programDescription: detectionTechniqueGenerated.programDescription, + //logs: this._logsWriter.logs + }; + } + + private buildAndReturnNotSuccessfulSyntacticDetectionTooling( + detectionTechnique: string, + detectionTechniqueGenerated: DetectionTechniqueGenerated, + ): Omit<DetectionProgram, 'version'> & { + generatedHeuristics?: string[] | null; + } { + return { + id: this._detectionProgramId, + ruleId: this._detectionProgramRuleInput.rule.id, + status: DetectionStatus.READY, + sourceCodeState: detectionTechniqueGenerated.sourceCodeState, + mode: DetectionModeEnum.SINGLE_AST, + language: this._detectionProgramRuleInput.language, + code: detectionTechniqueGenerated.program ?? '', + generatedHeuristics: this._generatedHeuristics, + //programDescription: detectionTechniqueGenerated.programDescription, + //logs: this._logsWriter.logs, + }; + } + + private async getSyntacticDetectionTechnique(): Promise<DetectionTechniqueGenerated> { + return this._programGenerator.generateProgram(); + } + + public setAborted(): void { + this._programGenerator.setAborted(); + } + + getAiService(): AIService { + return this._aiService; + } + + public assessTokensUsage(): TokensUsed { + let totalInputTokens = 0; + let totalOutputTokens = 0; + + for (const token of this.tokensUsed) { + totalInputTokens += token.input; + totalOutputTokens += token.output; + } + for (const token of this._programGenerator.tokensUsed) { + totalInputTokens += token.input; + totalOutputTokens += token.output; + } + return { + input: totalInputTokens, + output: totalOutputTokens, + }; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/generation/Types.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/generation/Types.ts new file mode 100644 index 000000000..1e852e4d7 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/generation/Types.ts @@ -0,0 +1,77 @@ +import { SourceCodeState } from '@packmind/types'; +import { RuleId } from '@packmind/types'; +import { TokensUsed } from '@packmind/types'; + +export type AiAnswer = { + answer: string | null; + tokensUsed?: TokensUsed; +}; + +export type AiAnswerWithDetails = { + answer: string; + details: string; + type?: DetectionMethodType; +}; + +export type DetectionTechniqueGenerated = { + program?: string; + programDescription?: string; + success: boolean; + sourceCodeState: SourceCodeState; + generatedHeuristics?: string[] | null; +}; + +export enum DetectionMethodType { + PROGRAM = 'PROGRAM', +} + +export type AnalysisResult = { + ruleId: RuleId; + method: DetectionMethodType; + filePath: string; + positive: boolean; + precision: number; + recall: number; + truePositives: LineViolation[]; + falsePositives: number[]; + falseNegatives: LineViolation[]; +}; + +export type ProgramGenerationStatus = { + program: string; + programDescription: string; + success: boolean; + sourceCodeState: SourceCodeState; + generatedHeuristics?: string[] | null; +}; + +export type ProgramGenerationResult = { + program: string; + results: AnalysisResult[]; + sourceCodeState: SourceCodeState; +}; + +export type FileWithViolations = { + path: string; + violations: RuleViolation[]; +}; + +export type PracticeViolationWithLineNumbersForBatch = { + path: string; + violations: RuleViolationWithLineNumbers[]; +}; + +export type RuleViolationWithLineNumbers = { + rule: RuleId; + lines: number[]; +}; + +export type RuleViolation = { + rule: RuleId; + lines: LineViolation[]; +}; + +export type LineViolation = { + start: number; + end: number; +}; diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/generation/TypesProgramGeneration.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/generation/TypesProgramGeneration.ts new file mode 100644 index 000000000..a22f117cc --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/generation/TypesProgramGeneration.ts @@ -0,0 +1,14 @@ +import { SourceCodeState } from '@packmind/types'; + +export type Program = { + program: string; + programFound: boolean; + sourceCodeState: SourceCodeState; +}; + +export class PackmindTimeoutError extends Error { + constructor(message: string | undefined) { + super(message); + this.name = 'PackmindTimeoutError'; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/log/DetectionToolingLogWriter.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/log/DetectionToolingLogWriter.ts new file mode 100644 index 000000000..e43c20b98 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/log/DetectionToolingLogWriter.ts @@ -0,0 +1,55 @@ +import { + ExecutionLog, + ExecutionLogMetadata, + DETECTION_LOG_MESSAGES, +} from '@packmind/types'; +import { IDetectionProgramMetadataRepository } from '../../../../../domain/repositories/IDetectionProgramMetadataRepository'; +import { DetectionProgramId } from '@packmind/types'; +import { TokensUsed } from '@packmind/types'; + +export default class DetectionToolingLogWriter { + public static readonly MESSAGES = DETECTION_LOG_MESSAGES; + + private _logs: ExecutionLog[] = []; + + constructor( + private readonly _programDetectionMetadataRepository: IDetectionProgramMetadataRepository, + private readonly _detectionProgramId: DetectionProgramId, + ) {} + + public async addLogsMessage( + message: string, + metadata?: ExecutionLogMetadata, + ): Promise<void> { + const timestamp = Date.now(); + const log: ExecutionLog = { + timestamp, + message: message.replace(/:/g, '-'), + metadata, + }; + this._logs.push(log); + await this._programDetectionMetadataRepository.addLog( + log, + this._detectionProgramId, + ); + } + + // Temporary set in program metadata + public async updateProgramDescription(programDescription: string) { + await this._programDetectionMetadataRepository.updateProgramDescription( + programDescription, + this._detectionProgramId, + ); + } + + public async updateTokensUsed(tokens: TokensUsed) { + await this._programDetectionMetadataRepository.updateTokensUsed( + tokens, + this._detectionProgramId, + ); + } + + get logs(): ExecutionLog[] { + return this._logs; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/AbstractRuleDetectionProgram.spec.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/AbstractRuleDetectionProgram.spec.ts new file mode 100644 index 000000000..473f73a34 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/AbstractRuleDetectionProgram.spec.ts @@ -0,0 +1,194 @@ +import AbstractRuleDetectionProgram from './AbstractRuleDetectionProgram'; +import { ProgramGenerationResult } from '../generation/Types'; +import { + DetectionProgramRuleInput, + ProgrammingLanguage, + createRuleId, + Rule, +} from '@packmind/types'; +import { AIService } from '@packmind/llm'; +import DetectionToolingLogWriter from '../log/DetectionToolingLogWriter'; +import { stubLogger } from '@packmind/test-utils'; +import { PackmindLogger } from '@packmind/logger'; +import { v4 as uuidv4 } from 'uuid'; + +// Concrete implementation for testing purposes +class TestableRuleDetectionProgram extends AbstractRuleDetectionProgram { + async testProgramAndIterateWithAgent(): Promise<ProgramGenerationResult> { + return { + program: 'test program', + results: [], + sourceCodeState: 'RAW', + }; + } + + async getFinalAnalysisResults(): Promise<[]> { + return []; + } +} + +describe('AbstractRuleDetectionProgram', () => { + let program: TestableRuleDetectionProgram; + let mockAiService: jest.Mocked<AIService>; + let mockLogsWriter: jest.Mocked<DetectionToolingLogWriter>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + beforeEach(() => { + mockAiService = { + executePromptWithHistory: jest.fn(), + } as unknown as jest.Mocked<AIService>; + + mockLogsWriter = { + addLogsMessage: jest.fn(), + updateDetectionHeuristics: jest.fn(), + } as unknown as jest.Mocked<DetectionToolingLogWriter>; + + stubbedLogger = stubLogger(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('shouldGenerateHeuristics', () => { + describe('when heuristics is undefined', () => { + beforeEach(() => { + const detectionProgramRuleInput: DetectionProgramRuleInput = { + rule: { + id: createRuleId(uuidv4()), + content: 'Test rule', + } as Rule, + ruleExamples: [], + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: undefined, + }; + + program = new TestableRuleDetectionProgram( + detectionProgramRuleInput, + mockAiService, + mockLogsWriter, + null, + stubbedLogger, + ); + }); + + it('returns true', () => { + const result = program.shouldGenerateHeuristics(); + + expect(result).toBe(true); + }); + }); + + describe('when heuristics is null', () => { + beforeEach(() => { + const detectionProgramRuleInput: DetectionProgramRuleInput = { + rule: { + id: createRuleId(uuidv4()), + content: 'Test rule', + } as Rule, + ruleExamples: [], + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: null as unknown as undefined, + }; + + program = new TestableRuleDetectionProgram( + detectionProgramRuleInput, + mockAiService, + mockLogsWriter, + null, + stubbedLogger, + ); + }); + + it('returns true', () => { + const result = program.shouldGenerateHeuristics(); + + expect(result).toBe(true); + }); + }); + + describe('when heuristics is an empty array', () => { + beforeEach(() => { + const detectionProgramRuleInput: DetectionProgramRuleInput = { + rule: { + id: createRuleId(uuidv4()), + content: 'Test rule', + } as Rule, + ruleExamples: [], + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: [], + }; + + program = new TestableRuleDetectionProgram( + detectionProgramRuleInput, + mockAiService, + mockLogsWriter, + null, + stubbedLogger, + ); + }); + + it('returns true', () => { + const result = program.shouldGenerateHeuristics(); + + expect(result).toBe(true); + }); + }); + + describe('when heuristics has content', () => { + beforeEach(() => { + const detectionProgramRuleInput: DetectionProgramRuleInput = { + rule: { + id: createRuleId(uuidv4()), + content: 'Test rule', + } as Rule, + ruleExamples: [], + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['Check for pattern X', 'Verify condition Y'], + }; + + program = new TestableRuleDetectionProgram( + detectionProgramRuleInput, + mockAiService, + mockLogsWriter, + null, + stubbedLogger, + ); + }); + + it('returns false', () => { + const result = program.shouldGenerateHeuristics(); + + expect(result).toBe(false); + }); + }); + + describe('when heuristics has single item', () => { + beforeEach(() => { + const detectionProgramRuleInput: DetectionProgramRuleInput = { + rule: { + id: createRuleId(uuidv4()), + content: 'Test rule', + } as Rule, + ruleExamples: [], + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['Single heuristic'], + }; + + program = new TestableRuleDetectionProgram( + detectionProgramRuleInput, + mockAiService, + mockLogsWriter, + null, + stubbedLogger, + ); + }); + + it('returns false', () => { + const result = program.shouldGenerateHeuristics(); + + expect(result).toBe(false); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/AbstractRuleDetectionProgram.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/AbstractRuleDetectionProgram.ts new file mode 100644 index 000000000..4551df72f --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/AbstractRuleDetectionProgram.ts @@ -0,0 +1,817 @@ +import { AbstractRuleDetectionMethod } from '../generation/AbstractRuleDetectionMethod'; +import { + AiAnswer, + AnalysisResult, + DetectionMethodType, + ProgramGenerationResult, + ProgramGenerationStatus, +} from '../generation/Types'; +import { + deleteProgram, + executeProgramInDebugMode, + extractionsViolationsFromRawOutput, + writeProgram, +} from './ProgramExecutionUtils'; +import AnalysisResultCalculator from '../results/AnalysisResultCalculator'; +import DetectionToolingLogWriter from '../log/DetectionToolingLogWriter'; +import { + buildConversationWithDebuggingInformation, + displayLinesOfViolations, + getPromptInformDebuggingHasStarted, +} from './ProgramDebugger'; +import { checkIfSourceCodeParsableWithAST } from './ProgramThirdPartyLibrary'; +import ASTGenerationStrategy from './strategy/ast/ASTGenerationStrategy'; +import RAWGenerationStrategy from './strategy/raw/RAWGenerationStrategy'; +import AbstractGenerationStrategy from './strategy/AbstractGenerationStrategy'; +import { parseCodeOrJsonFromAIAnswer } from './ProgramOutputUtils'; +import { PackmindLogger } from '@packmind/logger'; +import { getErrorMessage } from '@packmind/node-utils'; +import { + AI_RESPONSE_FORMAT, + AIService, + PromptConversation, + PromptConversationRole, + TokensUsed, + AIPromptOptions, +} from '@packmind/types'; +import { ILinterAstPort } from '@packmind/types'; +import { + PackmindTimeoutError, + Program, +} from '../generation/TypesProgramGeneration'; +import { callIndexJsWithInput } from '../utils/IO'; +import { SourceCodeState } from '@packmind/types'; +import { DetectionProgramRuleInput } from '@packmind/types'; +import Globals from '../utils/Globals'; +import { GenerateRuleHeuristics } from '../assessment/GenerateRuleHeuristics'; + +export type SourceCodeRepresentation = { + content: string; + sourceCodeState: SourceCodeState; +}; + +const origin = 'AbstractRuleDetectionProgram'; + +export default abstract class AbstractRuleDetectionProgram extends AbstractRuleDetectionMethod { + protected _generationStrategy: AbstractGenerationStrategy | null = null; + private readonly ERROR_ICON: string = '🟥'; + + constructor( + protected readonly _detectionProgramRule: DetectionProgramRuleInput, + protected readonly _aiService: AIService, + protected readonly _logsWriter: DetectionToolingLogWriter, + protected readonly _linterAstAdapter: ILinterAstPort | null = null, + protected readonly _logger = new PackmindLogger(origin), + ) { + super(_detectionProgramRule, _aiService, _logsWriter); + } + + public shouldGenerateHeuristics(): boolean { + return ( + !this._detectionProgramRule.heuristics || + this._detectionProgramRule.heuristics.length === 0 + ); + } + + public async generateProgram(): Promise<ProgramGenerationStatus> { + await this._logsWriter.addLogsMessage( + DetectionToolingLogWriter.MESSAGES.AI_AGENT_PROGRAM_GENERATION_STARTED, + ); + + this._logger.info( + `[${this._detectionProgramRule.rule.id}] === Start generateProgram`, + ); + + // Generate detection heuristics before program generation if they don't exist + let generatedHeuristics: string[] | null = null; + if (this.shouldGenerateHeuristics()) { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] No existing heuristics found, generating new ones`, + ); + const generateHeuristics = new GenerateRuleHeuristics( + this._detectionProgramRule.rule.id, + this._aiService, + ); + const heuristics = await generateHeuristics.generateHeuristics( + this._detectionProgramRule, + ); + + if (heuristics) { + this._detectionProgramRule.heuristics = heuristics; + generatedHeuristics = heuristics; + this._logger.info( + `[${this._detectionProgramRule.rule.id}] Detection heuristics generated successfully`, + ); + } else { + this._logger.warn( + `[${this._detectionProgramRule.rule.id}] No heuristics generated, continuing without them`, + ); + } + } else { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] Using existing heuristics (${this._detectionProgramRule.heuristics?.length ?? 0} items)`, + ); + } + + // IF AST Possible + // 1. try with + // 2. if not working, try without + // IF AST Not Possible + // 1. try without + // 2. try without + const isSourceCodeParsableWithAST = await checkIfSourceCodeParsableWithAST( + this._detectionProgramRule, + this._linterAstAdapter, + ); + let programGenerationResult; + if (isSourceCodeParsableWithAST) { + programGenerationResult = await this.runASTGenerationStrategy(); + } else { + programGenerationResult = await this.runRAWGenerationStrategy(); + } + + if (!programGenerationResult) { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] - Error in program generation`, + ); + throw new Error('Error in program generation - Please check logs'); + } + + this._logger.info( + `[${this._detectionProgramRule.rule.id}] ===== programGenerationResult`, + ); + programGenerationResult.results.forEach((result) => + this._logger.info( + `\t[${this._detectionProgramRule.rule.id}] ${JSON.stringify(result)}`, + ), + ); + + await this.logFilesWithFailures(programGenerationResult); + + const success = programGenerationResult.results.every( + (r) => r.recall === 1 && r.precision === 1, + ); + + let programDescription = ''; + if (success && programGenerationResult.program?.length) { + programDescription = await this.generateProgramDescription( + programGenerationResult.program, + ); + } + + return { + program: programGenerationResult.program, + success, + programDescription, + sourceCodeState: programGenerationResult.sourceCodeState, + generatedHeuristics, + }; + } + + private async runRAWGenerationStrategy() { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] RAW Strategy started`, + ); + let programGenerationResult = + await this.tryToGenerateProgramWithRawCodeStrategy(); + const isSuccessful = (programGenerationResult?.results ?? []).every( + (r) => r.recall === 1 && r.precision === 1, + ); + if (!isSuccessful) { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] RAW Strategy started again after first attempt failed`, + ); + (programGenerationResult?.results ?? []).forEach((result) => + this._logger.info( + `\t[${this._detectionProgramRule.rule.id}] ${JSON.stringify(result)}`, + ), + ); + programGenerationResult = + await this.tryToGenerateProgramWithRawCodeStrategy(); + } + return programGenerationResult; + } + + private async runASTGenerationStrategy() { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] AST Strategy started`, + ); + let programGenerationResult = + await this.tryToGenerateProgramWithAstStrategy(); + const isSuccessful = (programGenerationResult?.results ?? []).every( + (r) => r.recall === 1 && r.precision === 1, + ); + if (!isSuccessful) { + if (programGenerationResult) { + await this.logFilesWithFailures(programGenerationResult); + this._logger.info( + `[${this._detectionProgramRule.rule.id}] RAW Strategy started after AST Failed`, + ); + (programGenerationResult?.results ?? []).forEach((result) => + this._logger.info( + `\t[${this._detectionProgramRule.rule.id}] ${JSON.stringify(result)}`, + ), + ); + } + programGenerationResult = + await this.tryToGenerateProgramWithRawCodeStrategy(); + } + return programGenerationResult; + } + + private async logFilesWithFailures( + programGenerationResult: ProgramGenerationResult, + ) { + if (programGenerationResult) { + programGenerationResult.results.forEach(async (r) => { + const success = r.recall === 1 && r.precision === 1; + if (!success) { + const message = `🔴 Program failed on ${r.positive ? `positive` : `negative`} example in ${r.filePath}`; + await this._logsWriter.addLogsMessage(message); + } + }); + } + } + + private async tryToGenerateProgramWithAstStrategy(): Promise<ProgramGenerationResult | null> { + this.combineTokensUsed(); + try { + this._generationStrategy = new ASTGenerationStrategy( + this._detectionProgramRule, + this._aiService, + this._linterAstAdapter, + ); + const program = await this._generationStrategy.getInitialProgram(); + return this.tryToGenerateProgramWithStrategy(program); + } catch (error) { + const message = getErrorMessage(error); + this._logger.error( + `[${this._detectionProgramRule.rule.id}] Error when generating program with AST Strategy : ${message}`, + ); + await this._logsWriter.addLogsMessage( + `${this.ERROR_ICON} Error when generating program - ${message}`, + ); + return null; + } + } + + private async tryToGenerateProgramWithRawCodeStrategy(): Promise<ProgramGenerationResult | null> { + this.combineTokensUsed(); + + try { + this._generationStrategy = new RAWGenerationStrategy( + this._detectionProgramRule, + this._aiService, + ); + const program = await this._generationStrategy.getInitialProgram(); + + return this.tryToGenerateProgramWithStrategy(program); + } catch (error) { + const message = getErrorMessage(error); + this._logger.error( + `⚠[${this._detectionProgramRule.rule.id}] Error when generating program with RAW Strategy : ${message}`, + ); + await this._logsWriter.addLogsMessage( + `${this.ERROR_ICON} Error when generating program - ${message}`, + ); + return null; + } + } + + private async tryToGenerateProgramWithStrategy( + program: string, + ): Promise<ProgramGenerationResult> { + this.clearConversations(); + if (!this._generationStrategy) { + throw new Error('_generationStrategy not initialized'); + } + + this._context = this._generationStrategy.initialPrompt; + this.addMessageToConversation(program, PromptConversationRole.ASSISTANT); + return this.testProgramAndIterateWithAgent(program); + } + + private async generateProgramDescription(program: string) { + const promptForProgramDescription = this.getPromptDescribeFunction(program); + const answer = await this.callAiProvider([ + { + role: PromptConversationRole.USER, + message: promptForProgramDescription, + }, + ]); + + if (answer.tokensUsed) { + this._tokensUsed.push(answer.tokensUsed); + } + + const programDescription = (answer?.data ?? '').trim(); + this._logger.info( + `[${this._detectionProgramRule.rule.id}] Program Description generated`, + ); + return programDescription; + } + + abstract testProgramAndIterateWithAgent( + program: string, + ): Promise<ProgramGenerationResult>; + + protected async computeProgram( + inputSourceCodeFileToAnalyze: string, + analysisCalculator: AnalysisResultCalculator, + program: string, + ): Promise<Program> { + let programFound = false; + let errorCount = 0; + const MAX_ERRORS = 4; + if (!this._generationStrategy) { + throw new Error('_generationStrategy not initialized'); + } + + let programPath = await writeProgram( + program, + this._detectionProgramRule.rule.id, + this._generationStrategy, + ); + + const inputFileContent: SourceCodeRepresentation = + await this._generationStrategy.getFileInputFromContent( + inputSourceCodeFileToAnalyze, + this._detectionProgramRule.language, + ); + + let tryNumber = 1; + const MAX_RETRY = 2; + while (tryNumber <= MAX_RETRY) { + if (this._aborted) { + throw new PackmindTimeoutError( + `[${this._detectionProgramRule.rule.id}] Job aborted, exiting`, + ); + } + + this._logger.info( + `[${this._detectionProgramRule.rule.id}] Compute Program - Try number ${tryNumber}/${MAX_RETRY}`, + ); + try { + const outputArray = await executeProgramInDebugMode( + programPath, + inputFileContent, + ); + deleteProgram(programPath); + + const result: AnalysisResult = + analysisCalculator.computeAnalysisResult(outputArray); + const requirementsFulfilled = + result.precision === 1 && result.recall === 1; + + if (requirementsFulfilled) { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] !!!! Congrats, precision and recall are of 1!`, + ); + programFound = true; + break; + } else { + await this.resetConversationWithContext(); + this._conversations.push({ + role: PromptConversationRole.ASSISTANT, + message: program, + }); + const prompt = + this.preparePromptThatOutputsResultsOfProgramOnFirstIterations( + result, + inputFileContent, + ); + this.addMessageToConversation(prompt, PromptConversationRole.USER); + + const response = await this.callAiProvider(this._conversations); + if (!response.data) { + throw new Error('No data returned by AI'); + } + + program = parseCodeOrJsonFromAIAnswer(response.data); + if (program?.trim().length === 0) { + throw new Error( + `[${this._detectionProgramRule.rule.id}] Generated Program is empty`, + ); + } + programPath = await writeProgram( + program, + this._detectionProgramRule.rule.id, + this._generationStrategy, + ); + this._logger.debug( + `[${this._detectionProgramRule.rule.id}] Write new version of program ${program.length}`, + ); + } + } catch (error) { + this._logger.error( + `[${this._detectionProgramRule.rule.id}] Error when getting or executing the program ${getErrorMessage(error)}`, + ); + this.throwErrorIfOutputContainFatalError( + getErrorMessage(error), + program, + ); + errorCount++; + + await this.resetConversationWithContext(); + this._conversations.push({ + role: PromptConversationRole.ASSISTANT, + message: program, + }); + const prompt = this.getPromptTryAgainBecauseOfProgramFailure( + isError(error) ? (error.stack ?? error.message) : '', + ); + this.addMessageToConversation(prompt, PromptConversationRole.USER); + + const response = await this.callAiProvider(this._conversations); + if (!response.data) { + throw new Error('No response from AI'); + } + + this.addMessageToConversation( + response.data, + PromptConversationRole.ASSISTANT, + ); + + this._logger.debug( + `[${this._detectionProgramRule.rule.id}] Write new version of program after mistake`, + ); + program = parseCodeOrJsonFromAIAnswer(response.data); + if (program.trim().length === 0) { + throw new Error( + `[${this._detectionProgramRule.rule.id}] Generated Program is empty`, + ); + } + programPath = await writeProgram( + program, + this._detectionProgramRule.rule.id, + this._generationStrategy, + ); + } + tryNumber++; + await this._logsWriter.addLogsMessage( + DetectionToolingLogWriter.MESSAGES.AI_AGENT_NEW_PROGRAM_UNDER_TEST, + ); + + if (errorCount >= MAX_ERRORS) { + //We consider that the Agent has taken a wrong path and we need to start over + throw new Error( + `[${this._detectionProgramRule.rule.id}] Too many errors in program execution`, + ); + } + } + + if (!programFound) { + this.clearConversationsAndKeepOriginalInstructions(); + const __ret = await this.startDebugMode( + program, + inputFileContent, + analysisCalculator, + programFound, + ); + program = __ret.program; + programFound = __ret.programFound; + } + return { + program, + programFound, + sourceCodeState: this._generationStrategy.getSourceCodeState(), + }; + } + + private async callAiProvider( + prompt: PromptConversation[], + options: AIPromptOptions = { + responseFormat: AI_RESPONSE_FORMAT.PLAIN_TEXT, + }, + ) { + const MAX_RETRY = 2; + let i = 0; + while (i <= MAX_RETRY) { + try { + const response = await this._aiService.executePromptWithHistory( + prompt, + options, + ); + if (response.tokensUsed) { + this._tokensUsed.push(response.tokensUsed); + } + + return response; + } catch (error) { + const message = `${this.ERROR_ICON} Error when calling AI Provider - ${getErrorMessage(error)}`; + this._logger.error( + `[${this._detectionProgramRule.rule.id}] Error when calling AI Provider ${message}`, + ); + await this._logsWriter.addLogsMessage(message); + + if (hasInnerError(error)) { + //This display errors from Azure Open AI in case of content filtering + this._logger.error( + `[${this._detectionProgramRule.rule.id}] Inner Error : ${JSON.stringify(error.innererror)}`, + ); + } + + i++; + } + } + throw new Error('Multiple errors after requesting AI provider'); + } + + private async startDebugMode( + program: string, + fileContent: SourceCodeRepresentation, + analysisCalculator: AnalysisResultCalculator, + programFound: boolean, + ) { + this._logger.info(`[${this._detectionProgramRule.rule.id}] Ok, let's help`); + const suggestDebugPrompt = getPromptInformDebuggingHasStarted(); + this.addMessageToConversation(program, PromptConversationRole.ASSISTANT); + this.addMessageToConversation( + suggestDebugPrompt, + PromptConversationRole.USER, + ); + const response = await this.callAiProvider(this._conversations); + if (!response.data) { + throw new Error('No data returned by AI'); + } + this._logger.debug(response.data); + + program = parseCodeOrJsonFromAIAnswer(response.data); + //Ok, let's run this. + await this._logsWriter.addLogsMessage( + DetectionToolingLogWriter.MESSAGES.AI_AGENT_PROGRAM_DEBUGGING, + ); + + for (let i = 0; i < Globals.MAX_DEBUG_RETRY; i++) { + if (this._aborted) { + throw new PackmindTimeoutError( + `[${this._detectionProgramRule.rule.id}] Job aborted, exiting`, + ); + } + + await this._logsWriter.addLogsMessage( + DetectionToolingLogWriter.MESSAGES.AI_AGENT_NEW_PROGRAM_UNDER_TEST, + ); + this._logger.info( + `[${this._detectionProgramRule.rule.id}] Debugging [${i}/${Globals.MAX_DEBUG_RETRY}]`, + ); + const { output, result } = await this.runDebuggedProgramAndAnalyzeResults( + { + program, + programFound: true, + }, + fileContent, + analysisCalculator, + ); + if (result.precision === 1 && result.recall === 1) { + programFound = true; + await this._logsWriter.addLogsMessage( + DetectionToolingLogWriter.MESSAGES.AI_AGENT_PROGRAM_SUCCESSFUL, + ); + this._logger.info( + `[${this._detectionProgramRule.rule.id}] !!! Generation successful at try ${i}`, + ); + break; + } + this._logger.info( + `[${this._detectionProgramRule.rule.id}] result.precision ${result.precision} - result.recall ${result.recall}`, + ); + const debugOutput = await this.runDebugProgram( + fileContent, + program, + output, + result, + ); + if (!debugOutput.answer) { + throw new Error('No answer returned from AI'); + } + + program = parseCodeOrJsonFromAIAnswer(debugOutput.answer); + if (program.trim().length === 0) { + throw new Error( + `[${this._detectionProgramRule.rule.id}] Generated Program is empty`, + ); + } + } + return { program, programFound }; + } + + private preparePromptThatOutputsResultsOfProgramOnFirstIterations( + result: AnalysisResult, + fileContent: SourceCodeRepresentation, + ) { + //We enter here when the program is not working as expected, so at least one of these conditions will be true + let prompt = ` +With the following source code file as input of the program execution: +""" +${fileContent.content} +""" +`; + if (result.falsePositives.length) { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] add prompt with false positive`, + ); + prompt = `${prompt} +${this.getPromptTryAgainBecauseOfFalsePositives(result)}`; + } + if (result.falseNegatives.length) { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] add prompt with false negative`, + ); + prompt = `${prompt} +${this.getPromptTryAgainBecauseOfFalseNegatives(result)}`; + } + if (result.truePositives.length) { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] add prompt with true positives`, + ); + prompt = `${prompt} +${this.getPromptToIndicateTruePositives(result)}`; + } + return prompt; + } + + protected async runDebugProgram( + fileContent: SourceCodeRepresentation, + program: string, + output: string, + result: AnalysisResult, + ): Promise<AiAnswer> { + const debuggingConversation = + await buildConversationWithDebuggingInformation( + fileContent, + this._context, + program, + output, + result, + ); + const responseDiag = await this.callAiProvider(debuggingConversation, {}); + this._logger.debug(`[${this._detectionProgramRule.rule.id}] === Diag`); + return { + answer: responseDiag.data, + tokensUsed: responseDiag.tokensUsed, + }; + } + + protected async runDebuggedProgramAndAnalyzeResults( + program: Omit<Program, 'sourceCodeState'>, + fileContent: SourceCodeRepresentation, + analysisResultCalculator: AnalysisResultCalculator, + ): Promise<{ + output: string; + result: AnalysisResult; + }> { + const output: string = await this.getOutputForProgramBasedOnSourceCodeState( + program, + fileContent, + ); + const programOutput = extractionsViolationsFromRawOutput(output); + const result = + analysisResultCalculator.computeAnalysisResult(programOutput); + return { output, result }; + } + + protected async getOutputForProgramBasedOnSourceCodeState( + program: Omit<Program, 'sourceCodeState'>, + fileContent: SourceCodeRepresentation, + ): Promise<string> { + if (!this._generationStrategy) { + throw new Error('_generationStrategy not initialized'); + } + const programPath = await writeProgram( + program.program, + this._detectionProgramRule.rule.id, + this._generationStrategy, + ); + const output = await this.executeProgramAndGetRawOutput( + programPath, + fileContent, + ); + this.throwErrorIfOutputContainFatalError(output, program.program); + deleteProgram(programPath); + return output; + } + + private throwErrorIfOutputContainFatalError(output: string, program: string) { + if (output?.includes('write EPIPE')) { + this._logger.error( + `[${this._detectionProgramRule.rule.id}] throw error as output contains a Fatal Error`, + ); + throw new Error( + `Fatal error, program is too corrupted to be debugged : ${output}); - Here is the program : ${program}`, + ); + } else { + this._logger.info('No fatal error found in output'); + } + } + + private async executeProgramAndGetRawOutput( + programPath: string, + fileContent: SourceCodeRepresentation, + ) { + try { + const output: string = await callIndexJsWithInput( + programPath, + fileContent.content, + ); + this._logger.debug(`Output of debug program : ${output}`); + return output.toString().trim().length ? output.toString().trim() : '[]'; + } catch (error) { + this._logger.error( + `[${this._detectionProgramRule.rule.id}] Error in execution of program: ${getErrorMessage(error)}`, + ); + return `The program has raised a runtime error ${getErrorMessage(error)}`; + } + } + + private getPromptTryAgainBecauseOfFalseNegatives(result: AnalysisResult) { + return ` +* The provided program has missed false negatives on the following lines: +${displayLinesOfViolations(result.falseNegatives)} +Can you please check your program again and update it so that it can match these missed lines? +Please return only the program \n`; + } + + protected getPromptToIndicateTruePositives(result: AnalysisResult) { + return ` +* God job, the provided program has detected the following lines as violations: +${displayLinesOfViolations(result.truePositives)} +This is great, and the updated version should preserve this behavior. \n`; + } + + protected getPromptTryAgainBecauseOfFalsePositives(result: AnalysisResult) { + return ` +* The provided program has generated false positives on the following lines: ${result.falsePositives}, +Can you please check your program again and update it to avoid the false positives? +Please return only the program. \n`; + } + + protected getPromptTryAgainBecauseOfProgramFailure(error: string) { + return ` +The provided program has generated the following runtime errors: + ---BeginCode--- + ${error} + ---EndCode--- + Can you please check your program again to avoid such errors? + Remember the main JavaScript function should be named 'checkSourceCode'. + Please return only the program`; + } + + private getPromptDescribeFunction(candidateProgram: string) { + return ` +Describe how the 'checkSourceCode' function works and what kind of violations it aims to detect. +Be as concise as possible. +Discard the console.log instructions in the codef if you find some. + +Format your response as follows: +- Start the first bullet point with "This program..." +- Provide at most 5 bullet points total +- Each bullet point must be exactly 1 sentence +- Focus on the algorithm and violation detection logic +- Don't mention how to use the program, only how it works +- Don't include 'example usage' in your description + +Return your output only as a bulleted list in plain text. + ---BeginCode--- +${candidateProgram} + ---EndCode---`; + } + + abstract getFinalAnalysisResults(program: Program): Promise<AnalysisResult[]>; + + protected getMethodType(): DetectionMethodType { + return DetectionMethodType.PROGRAM; + } + + private combineTokensUsed() { + if (this._generationStrategy) { + this._tokensUsed = [ + ...this._tokensUsed, + ...this._generationStrategy.tokensUsed, + ]; + } + } + + get tokensUsed(): TokensUsed[] { + if (!this._generationStrategy) { + throw new Error('_generationStrategy not initialized'); + } + return [...this._tokensUsed, ...this._generationStrategy.tokensUsed]; + } +} + +function hasInnerError(tbd: unknown): tbd is { innererror: unknown } { + return ( + tbd !== null && + typeof tbd !== 'object' && + (tbd as { innererror: unknown })?.innererror !== undefined + ); +} + +function isError(tbd: unknown): tbd is { stack?: string; message: string } { + return ( + tbd !== null && + typeof tbd !== 'object' && + (tbd as { message: string }).message !== undefined + ); +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/DetectionProgramPackmindRule.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/DetectionProgramPackmindRule.ts new file mode 100644 index 000000000..3483d56b3 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/DetectionProgramPackmindRule.ts @@ -0,0 +1,271 @@ +import AbstractRuleDetectionProgram from './AbstractRuleDetectionProgram'; +import { AnalysisResult, ProgramGenerationResult } from '../generation/Types'; +import { + deleteProgram, + executeProgramInProductionMode, + writeProgram, +} from './ProgramExecutionUtils'; +import DetectionToolingLogWriter from '../log/DetectionToolingLogWriter'; +import AnalysisResultCalculatorForPackmindRulesAndPositiveExample from '../results/AnalysisResultCalculatorForPackmindRulesAndPositiveExample'; +import AnalysisResultCalculatorForPackmindRulesAndNegativeExample from '../results/AnalysisResultCalculatorForPackmindRulesAndNegativeExample'; +import { + PackmindTimeoutError, + Program, +} from '../generation/TypesProgramGeneration'; +import { indentFileContent, limitFileContent } from '../utils/PromptUtils'; + +export default class DetectionProgramPackmindRule extends AbstractRuleDetectionProgram { + async testProgramAndIterateWithAgent( + program: string, + ): Promise<ProgramGenerationResult> { + await this._logsWriter.addLogsMessage( + DetectionToolingLogWriter.MESSAGES.AI_AGENT_PROGRAM_TEST_AGAINST_EXAMPLES, + ); + + let tryNumber = 0; + const MAX_RETRY = 3; + + while (tryNumber <= MAX_RETRY) { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] testProgramAndIterateWithAgent - try ${tryNumber}/${MAX_RETRY}`, + ); + try { + const result = await this.testAgainstNegativeExamples(program); + program = result.program; + const programFoundOnNegativeExamples = + result.programFoundOnNegativeExamples; + const programAfterCheckAgainstNegativeExamples = program; + + if (programFoundOnNegativeExamples) { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] Now checking program against ${this.getNumberOfPositiveExamples()} good examples`, + ); + if (this.hasPositiveExamples()) { + await this._logsWriter.addLogsMessage( + DetectionToolingLogWriter.MESSAGES + .AI_AGENT_PROGRAM_CHECK_POSITIVE_EXAMPLES, + ); + program = await this.buildProgramAgainstPositiveExamples(program); + } + if (program === programAfterCheckAgainstNegativeExamples) { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] Program has not been updated against positive examples`, + ); + break; + } else { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] Program has been updated against positive examples, we need to check again against negative examples`, + ); + } + } else { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] Some negative examples are not covered by the program, we need to regenerate the program`, + ); + } + } catch (error) { + this._logger.error( + `[${this._detectionProgramRule.rule.id}] Runtime error ${error}`, + ); + if (error instanceof PackmindTimeoutError) { + await this._logsWriter.addLogsMessage( + DetectionToolingLogWriter.MESSAGES.AI_AGENT_TIMEOUT, + ); + throw new Error(error.message); + } + } + tryNumber++; + } + + await this._logsWriter.addLogsMessage( + DetectionToolingLogWriter.MESSAGES.AI_AGENT_NEW_PROGRAM_UNDER_TEST, + ); + if (!this._generationStrategy) { + throw new Error('_generationStrategy not initialized'); + } + const sourceCodeState = this._generationStrategy.getSourceCodeState(); + const analysisResults = await this.getFinalAnalysisResults({ + program, + programFound: true, + sourceCodeState, + }); + + return { + program, + sourceCodeState, + results: analysisResults, + }; + } + + private hasPositiveExamples(): boolean { + return this._detectionProgramRule.ruleExamples.some( + (ex) => ex.positive?.length && ex.positive?.trim().length, + ); + } + + private getNumberOfPositiveExamples(): number { + return this._detectionProgramRule.ruleExamples.filter( + (ex) => ex.positive?.length && ex.positive?.trim().length, + ).length; + } + + private getNumberOfNegativeExamples(): number { + return this._detectionProgramRule.ruleExamples.filter( + (ex) => ex.negative?.length && ex.negative?.trim().length, + ).length; + } + + private async testAgainstNegativeExamples(program: string): Promise<{ + program: string; + programFoundOnNegativeExamples: boolean; + }> { + let programFoundOnNegativeExamples = true; + let cptExample = 1; + this._logger.info( + `[${this._detectionProgramRule.rule.id}] start testing against negative examples`, + ); + + for (const example of this._detectionProgramRule.ruleExamples) { + if (example.negative?.length && example.negative?.trim().length) { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] testing program against negative example ${cptExample}/${this.getNumberOfNegativeExamples()}`, + ); + const analysisCalculator = + new AnalysisResultCalculatorForPackmindRulesAndNegativeExample( + this._detectionProgramRule.rule.id, + example, + this.getMethodType(), + ); + const result = await this.computeProgram( + example.negative, + analysisCalculator, + program, + ); + program = result.program; + programFoundOnNegativeExamples = + programFoundOnNegativeExamples && result.programFound; + cptExample++; + + await this.logTestExecution(`Example #${cptExample}`, example.negative); + } + } + return { program, programFoundOnNegativeExamples }; + } + + private async buildProgramAgainstPositiveExamples( + program: string, + ): Promise<string> { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] start testing against positive examples`, + ); + let cptExample = 1; + + for (const example of this._detectionProgramRule.ruleExamples) { + if (example.positive?.length && example.positive.trim()?.length) { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] testing program against positive example ${cptExample}/${this.getNumberOfPositiveExamples()}`, + ); + const analysisCalculator = + new AnalysisResultCalculatorForPackmindRulesAndPositiveExample( + this._detectionProgramRule.rule.id, + example, + this.getMethodType(), + ); + const result = await this.computeProgram( + example.positive, + analysisCalculator, + program, + ); + program = result.program; + await this.logTestExecution(`Example #${cptExample}`, example.positive); + cptExample++; + } + } + return program; + } + + private async logTestExecution(testName: string, testContent: string) { + if (testName) { + return this._logsWriter.addLogsMessage( + DetectionToolingLogWriter.MESSAGES.AI_AGENT_RUN_TEST, + { testName }, + ); + } + return this._logsWriter.addLogsMessage( + DetectionToolingLogWriter.MESSAGES.AI_AGENT_RUN_TEST_NO_NAME, + { + testContent: indentFileContent(limitFileContent(testContent, 5), ' '), + }, + ); + } + + async getFinalAnalysisResults(program: Program): Promise<AnalysisResult[]> { + this._logger.info( + `[${this._detectionProgramRule.rule.id}] build final results`, + ); + const analysisResults: AnalysisResult[] = []; + if (!this._generationStrategy) { + throw new Error('_generationStrategy not initialized'); + } + + const programPath = await writeProgram( + program.program, + this._detectionProgramRule.rule.id, + this._generationStrategy, + ); + const methodType = this.getMethodType(); + + this._logger.info( + `[${this._detectionProgramRule.rule.id}] final results will be tested against ${this._detectionProgramRule.ruleExamples.length} examples`, + ); + + for (const example of this._detectionProgramRule.ruleExamples) { + if (example.negative?.length) { + const inputForProgram = + await this._generationStrategy.getFileInputFromContent( + example.negative, + this._detectionProgramRule.language, + ); + const lines = await executeProgramInProductionMode( + this._detectionProgramRule.rule.id, + programPath, + inputForProgram, + `Negative Example`, + ); + const result: AnalysisResult = + new AnalysisResultCalculatorForPackmindRulesAndNegativeExample( + this._detectionProgramRule.rule.id, + example, + methodType, + ).computeAnalysisResult(lines); + analysisResults.push(result); + } + + if (example.positive?.length) { + const inputForProgram = + await this._generationStrategy.getFileInputFromContent( + example.positive, + this._detectionProgramRule.language, + ); + const lines = await executeProgramInProductionMode( + this._detectionProgramRule.rule.id, + programPath, + inputForProgram, + `Positive Example`, + ); + const result: AnalysisResult = + new AnalysisResultCalculatorForPackmindRulesAndPositiveExample( + this._detectionProgramRule.rule.id, + example, + methodType, + ).computeAnalysisResult(lines); + analysisResults.push(result); + } + } + + this._logger.info( + `[${this._detectionProgramRule.rule.id}] final ${analysisResults.length} results have been built`, + ); + deleteProgram(programPath); + return analysisResults; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramDebugger.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramDebugger.ts new file mode 100644 index 000000000..f66911e3c --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramDebugger.ts @@ -0,0 +1,132 @@ +import { AnalysisResult, LineViolation } from '../generation/Types'; +import { SourceCodeRepresentation } from './AbstractRuleDetectionProgram'; +import { PackmindLogger } from '@packmind/logger'; +import { PromptConversation, PromptConversationRole } from '@packmind/types'; + +const logger = new PackmindLogger('ProgramDebugger'); + +function getPromptSuggestAddDebugInformation() { + return ` +Your program may not working as expected, so please add some console.log to understand what is happening. +I'll then execute the program and send you the output so that you can analyze it. +Only output the updated JavaScript program with the function 'checkSourceCode', do not mention anything else, for instance some explanations or how to execute this code. +If your code contains multiple functions, include all of them.`; +} + +export function getPromptInformDebuggingHasStarted() { + return ` +Thank you for this program. +It seems that the program is not working as expected. +I suggest you to debug the program and add some console.log to understand what is happening. +Can you please update the program and add all the console.log you need? +I'll then execute the program and send you the output so that you can analyze it. +Only output the updated JavaScript program, do not mention anything else, for instance some explanations or how to execute this code. +If your code contains multiple functions, include all of them. +`; +} + +export async function addPromptWithDebuggingInformation( + fileContent: SourceCodeRepresentation, + output: string, + result: AnalysisResult, +) { + let promptDiag = ` +Thank you for this program. +It seems that the program is not totally working as expected. +I now provide you details and debugging information about the execution of your program. + +Here's the input passed to the checkSourceCode function: +""" +${fileContent.content} +""" + +The program has generated the following output: +""" +${output} +""" + +Here are the observations about the program execution with the input source code:`; + + if (result.falsePositives.length) { + logger.debug('add prompt with false positive'); + promptDiag = `${promptDiag} +* The provided program has generated false positives on the following lines: ${result.falsePositives}. +These are false positives, so they should not be detected by the program.`; + } + if (result.falseNegatives.length) { + logger.debug('add prompt with false negative'); + promptDiag = `${promptDiag} +* The provided program has missed false negatives on the following lines: +${displayLinesOfViolations(result.falseNegatives)}. + These are false negatives, so they should be detected by the program.`; + } + if (result.truePositives.length) { + logger.debug('add prompt with true positives'); + promptDiag = `${promptDiag} +* Good job, the program has detected the following lines as violations: +${displayLinesOfViolations(result.truePositives)}. +These are true positives, so they are correctly detected by the program.`; + } + + if ( + !result.falsePositives.length && + !result.falseNegatives.length && + !result.truePositives.length + ) { + throw new Error('But what the fuck are we doing here ?!'); + } + + promptDiag = `${promptDiag} + +Can you please analyze the output and the results, and update the program accordingly? +You can add more console.log to understand the behavior of the program. +You can remove console.log if you do not need them anymore. +I will execute this program and will send you the output back for debugging. + +Only output the updated JavaScript program, do not mention anything else, for instance some explanations or how to execute this code. +Remember the main JavaScript function should be named 'checkSourceCode'. +If your code contains multiple functions, include all of them. +Do not include examples on how to use this program, I will do it on my own.`; + + return promptDiag; +} + +export function displayLinesOfViolations(violations: LineViolation[]) { + return violations + .map((v) => { + if (v.start < v.end) { + return ` - A line between line ${v.start} and ${v.end}`; + } + return ` - Line ${v.start}`; + }) + .join('\n'); +} + +export async function buildConversationWithDebuggingInformation( + fileContent: SourceCodeRepresentation, + context: string, + program: string, + output: string, + result: AnalysisResult, +): Promise<PromptConversation[]> { + const promptDiag: PromptConversation[] = []; + // Original instruction + promptDiag.push({ + role: PromptConversationRole.USER, + message: `${context} \n ${getPromptSuggestAddDebugInformation()}`, + }); + promptDiag.push({ + role: PromptConversationRole.ASSISTANT, + message: program, + }); + promptDiag.push({ + role: PromptConversationRole.USER, + message: await addPromptWithDebuggingInformation( + fileContent, + output, + result, + ), + }); + + return promptDiag; +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramExecutionUtils.spec.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramExecutionUtils.spec.ts new file mode 100644 index 000000000..cbf6705da --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramExecutionUtils.spec.ts @@ -0,0 +1,161 @@ +import { extractionsViolationsFromRawOutput } from './ProgramExecutionUtils'; + +describe('extractionsViolationsFromRawOutput', () => { + it('returns the array as a string suffixed by "Output:"', () => { + const programOutput = ` + Blabla + Hello + Output: [1, 2, 3, 4, 5] + `; + const expectedOutput = [1, 2, 3, 4, 5]; + const result = extractionsViolationsFromRawOutput(programOutput); + expect(result).toEqual(expectedOutput); + }); + + it('returns the array as a string', () => { + const programOutput = ` + Blabla + Hello + [1, 2, 3, 4, 5] + `; + const expectedOutput = [1, 2, 3, 4, 5]; + const result = extractionsViolationsFromRawOutput(programOutput); + expect(result).toEqual(expectedOutput); + }); + + it('filters out negative line numbers and deduplicates', () => { + const programOutput = ` + Blabla + Hello + Output: [1, 2, 3, 4, 5, -1, 2, 3] + `; + const expectedOutput = [1, 2, 3, 4, 5]; + const result = extractionsViolationsFromRawOutput(programOutput); + expect(result).toEqual(expectedOutput); + }); + + it('handles complex output with duplicates and debugging info', () => { + const programOutput = ` + Debug: Starting analysis + Processing file: test.js + Found violations at lines: [1, 2, 3, 4, 5] + Output: [1, 2, 3, 4, 5] + `; + const expectedOutput = [1, 2, 3, 4, 5]; + const result = extractionsViolationsFromRawOutput(programOutput); + expect(result).toEqual(expectedOutput); + }); + + describe('when the raw output contains only an array', () => { + it('returns [0]', () => { + const programOutput = '[0]'; + const expectedOutput = [0]; + const result = extractionsViolationsFromRawOutput(programOutput); + expect(result).toEqual(expectedOutput); + }); + + it('returns [14]', () => { + const programOutput = '[14]'; + const expectedOutput = [14]; + const result = extractionsViolationsFromRawOutput(programOutput); + expect(result).toEqual(expectedOutput); + }); + + it('returns [14, 18]', () => { + const programOutput = '[14, 18]'; + const expectedOutput = [14, 18]; + const result = extractionsViolationsFromRawOutput(programOutput); + expect(result).toEqual(expectedOutput); + }); + }); + + describe('when the raw output contains debugging information', () => { + it('returns the array of violations', () => { + const programOutput = ` + Debug: Starting analysis + Processing file: test.js + Found violations at lines: [1, 2, 3, 4, 5] + Output: [1, 2, 3, 4, 5] + `; + const expectedOutput = [1, 2, 3, 4, 5]; + const result = extractionsViolationsFromRawOutput(programOutput); + expect(result).toEqual(expectedOutput); + }); + }); + + describe('when the array contains violations with line breaks', () => { + it('returns an array of line numbers', () => { + const programOutput = ` + Blabla + Hello + Output: [1, 2, 3, 4, 5] + `; + const expectedOutput = [1, 2, 3, 4, 5]; + const result = extractionsViolationsFromRawOutput(programOutput); + expect(result).toEqual(expectedOutput); + }); + }); + + describe('when raw output contains duplicates', () => { + it('returns unique and sorted line numbers', () => { + const programOutput = ` + Blabla + Hello + Output: [1, 2, 3, 4, 5, 1, 2, 3] + `; + const expectedOutput = [1, 2, 3, 4, 5]; + const result = extractionsViolationsFromRawOutput(programOutput); + expect(result).toEqual(expectedOutput); + }); + }); + + describe('when output contains ANSI color codes', () => { + it('strips ANSI escape sequences and parses correctly', () => { + const programOutput = ` + \x1b[32mDebug: Starting analysis\x1b[0m + \x1b[33mProcessing file: test.js\x1b[0m + \x1b[31mFound violations at lines: [1, 2, 3, 4, 5]\x1b[0m + Output: [1, 2, 3, 4, 5] + `; + const expectedOutput = [1, 2, 3, 4, 5]; + const result = extractionsViolationsFromRawOutput(programOutput); + expect(result).toEqual(expectedOutput); + }); + + it('handles complex output with ANSI codes and debugging info', () => { + const programOutput = ` + \x1b[32mDebug: Starting analysis\x1b[0m + \x1b[33mProcessing file: test.js\x1b[0m + \x1b[31mFound violations at lines: [1, 2, 3, 4, 5]\x1b[0m + \x1b[32mOutput: [1, 2, 3, 4, 5]\x1b[0m + `; + const expectedOutput = [1, 2, 3, 4, 5]; + const result = extractionsViolationsFromRawOutput(programOutput); + expect(result).toEqual(expectedOutput); + }); + }); +}); + +describe('extractionsViolationsFromRawOutput (additional tests)', () => { + it('returns the array as a string suffixed by "Output:"', () => { + const programOutput = ` + Blabla + Hello + Output: [1, 2, 3, 4, 5] + `; + const expectedOutput = [1, 2, 3, 4, 5]; + const result = extractionsViolationsFromRawOutput(programOutput); + expect(result).toEqual(expectedOutput); + }); + + it('returns the array as a string', () => { + const programOutput = ` + Blabla + Hello + [1, 2, 3, 4, 5] + `; + const expectedOutput = [1, 2, 3, 4, 5]; + const result = extractionsViolationsFromRawOutput(programOutput); + expect(result).toEqual(expectedOutput); + }); +}); diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramExecutionUtils.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramExecutionUtils.ts new file mode 100644 index 000000000..cfe540f43 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramExecutionUtils.ts @@ -0,0 +1,315 @@ +import AbstractGenerationStrategy from './strategy/AbstractGenerationStrategy'; +import { SourceCodeRepresentation } from './AbstractRuleDetectionProgram'; +import { PackmindLogger } from '@packmind/logger'; +import { getErrorMessage } from '@packmind/node-utils'; +import { ILinterAstPort } from '@packmind/types'; +import { + callIndexJsWithInput, + deleteFile, + writeFileContent, +} from '../utils/IO'; +import { RuleId } from '@packmind/types'; +import { ProgrammingLanguage } from '@packmind/types'; +import { stripVTControlCharacters } from 'node:util'; +import Globals from '../utils/Globals'; + +const logger = new PackmindLogger('ProgramExecutionUtils'); + +export function extractStringOrObjectOfString( + input: string | object[], +): string { + // Check if the input is a string + if (typeof input === 'string') { + return input; + } + + // Check if the input is an array of objects + if ( + Array.isArray(input) && + input.every((item) => typeof item === 'object' && item !== null) + ) { + return input + .map((obj) => { + // Convert each object to a plain text representation + return Object.entries(obj) + .map(([key, value]) => `${key}: ${value}`) + .join('\n'); + }) + .join('\n'); + } + + // Default case, return an empty string or handle error case as needed + return ''; +} + +export function extractionsViolationsFromRawOutput(output: string): number[] { + try { + if (!output?.length) { + logger.error(`No output frrom the program - return`); + return []; + } + // Convert the output to a string and trim any extra whitespace. + const outputString = output.toString().trim(); + // Remove ANSI/VT control characters first (before finding bracket position) + const stringWithoutAnsi = stripVTControlCharacters(outputString); + // Find the last occurrence of the opening bracket "[". + const lastBracketIndex = stringWithoutAnsi.lastIndexOf('['); + // Extract the substring starting from the last bracket to the end. + const jsonString = stringWithoutAnsi.substring(lastBracketIndex).trim(); + // Parse the extracted substring as JSON. + const stringWithoutLineBreaks = jsonString + .replace(/\\n/g, '') + .replace(/\\t/g, '') + .replace(/\\r/g, ''); + const numbers: number[] = JSON.parse(stringWithoutLineBreaks); + // Deduplicate and sort line numbers + const validNumbers = numbers.filter((n) => n >= 0); + const uniqueNumbers = [...new Set(validNumbers)].sort((a, b) => a - b); + return uniqueNumbers; + } catch (error) { + logger.error( + `Error while parsing the output as JSON. Original output: "${output}" | ` + + `Extracted JSON string: "${output.toString().trim().substring(output.toString().trim().lastIndexOf('['))}" | ` + + `Character codes: [${Array.from(output) + .map((c) => c.charCodeAt(0)) + .join(', ')}] | ` + + `Error: ${getErrorMessage(error)}`, + ); + return []; + } +} + +/** + * Extracts violations from raw output for multiple practices + * The output is expected to be a JSON map where keys are practice IDs and values are arrays of line numbers + * @param output The raw output from the program execution + * @returns A map where keys are practice IDs and values are arrays of line numbers + */ +export function extractViolationsFromRawOutputForMultipleRules( + output: string, +): Map<string, number[]> { + try { + if (!output?.length) { + logger.error(`No output from the program - return empty map`); + return new Map<string, number[]>(); + } + // Convert the output to a string and trim any extra whitespace + const outputString = output.toString().trim(); + + // Remove ANSI/VT control characters first (before finding curly brace position) + const stringWithoutAnsi = stripVTControlCharacters(outputString); + + // Find the last occurrence of the opening curly brace "{" + const lastCurlyBraceIndex = stringWithoutAnsi.lastIndexOf('{'); + if (lastCurlyBraceIndex === -1) { + logger.error(`No JSON object found in the output: ${output}`); + return new Map<string, number[]>(); + } + + // Extract the substring starting from the last curly brace to the end + const jsonString = stringWithoutAnsi.substring(lastCurlyBraceIndex).trim(); + + // Clean the string from escape characters + const stringWithoutLineBreaks = jsonString + .replace(/\\n/g, '') + .replace(/\\t/g, '') + .replace(/\\r/g, ''); + + // Parse the extracted substring as JSON + const violationsMap = JSON.parse(stringWithoutLineBreaks); + + // Convert the plain object to a Map + const resultMap = new Map<string, number[]>(); + for (const [ruleId, violations] of Object.entries(violationsMap)) { + const validViolations = (violations as number[]).filter((n) => n >= 0); + const uniqueViolations = [...new Set(validViolations)].sort( + (a, b) => a - b, + ); + resultMap.set(ruleId, uniqueViolations); + } + + return resultMap; + } catch (error) { + logger.error( + `Error while parsing the output as JSON map: ${output}, Error: ${error}`, + ); + return new Map<string, number[]>(); + } +} + +export async function executeProgramInProductionMode( + ruleId: RuleId, + programPath: string, + inputCodeRepresentation: SourceCodeRepresentation, + filePath: string, +): Promise<number[]> { + try { + const output: string = await callIndexJsWithInput( + programPath, + inputCodeRepresentation.content, + ); + const outputString = output.toString().trim(); + const outputArrayUniques = extractionsViolationsFromRawOutput(outputString); + return outputArrayUniques; + } catch (error) { + logger.error( + `The program has run the following error in production mode for rule ${ruleId} and file ${filePath}: ${getErrorMessage(error)}`, + ); + return []; + } +} + +export async function executeProgramInDebugMode( + programPath: string, + inputForProgram: SourceCodeRepresentation, +): Promise<number[]> { + const output: string = await callIndexJsWithInput( + programPath, + inputForProgram.content, + ); + const outputString = output.toString().trim(); + const outputArrayUniques = extractionsViolationsFromRawOutput(outputString); + return outputArrayUniques; +} + +export async function writeProgram( + program: string, + ruleId: RuleId, + generationStrategy: AbstractGenerationStrategy, +): Promise<string> { + const suffix: string = await generationStrategy.getSuffixCode(); + const fileContent = `${program}\n${suffix}`; + const programPath = `${Globals.JS_PLAYGROUND_PATH}/${generateRandomAlphanumeric(24)}.js`; + await writeFileContent(fileContent, programPath); + logger.debug(`Program has been written to temporary file ${programPath}`); + return programPath; +} + +export const RUNTIME_DIRECTORIES = { + CPP: 'cpp', + CSS: 'css', + CSHARP: 'csharp', + GENERIC: 'generic', + GO: 'go', + HTML: 'html', + JAVA: 'java', + JAVASCRIPT: 'js', + JSON: 'json', + KOTLIN: 'kotlin', + PHP: 'php', + PYTHON: 'python', + RUBY: 'ruby', + SCSS: 'scss', + SQL: 'sql', + SWIFT: 'swift', + TYPESCRIPT: 'ts', + TYPESCRIPT_JSX: 'tsx', + YAML: 'yaml', +}; + +export function getLanguageDirectory(language: ProgrammingLanguage) { + switch (language) { + case 'JAVASCRIPT': + return RUNTIME_DIRECTORIES.JAVASCRIPT; + case 'TYPESCRIPT': + return RUNTIME_DIRECTORIES.TYPESCRIPT; + case 'TYPESCRIPT_TSX': + return RUNTIME_DIRECTORIES.TYPESCRIPT_JSX; + case 'JAVA': + return RUNTIME_DIRECTORIES.JAVA; + case 'PYTHON': + return RUNTIME_DIRECTORIES.PYTHON; + case 'PHP': + return RUNTIME_DIRECTORIES.PHP; + case 'CSS': + return RUNTIME_DIRECTORIES.CSS; + case 'SCSS': + return RUNTIME_DIRECTORIES.SCSS; + case 'CSHARP': + return RUNTIME_DIRECTORIES.CSHARP; + case 'KOTLIN': + return RUNTIME_DIRECTORIES.KOTLIN; + case 'GO': + return RUNTIME_DIRECTORIES.GO; + case 'HTML': + return RUNTIME_DIRECTORIES.HTML; + case 'YAML': + return RUNTIME_DIRECTORIES.YAML; + case 'RUBY': + return RUNTIME_DIRECTORIES.RUBY; + case 'JSON': + return RUNTIME_DIRECTORIES.JSON; + case 'SWIFT': + return RUNTIME_DIRECTORIES.SWIFT; + case 'CPP': + return RUNTIME_DIRECTORIES.CPP; + default: + logger.info(`Use generic directory for language ${language}`); + return RUNTIME_DIRECTORIES.GENERIC; + } +} + +export function getMarkdownLanguage(language: ProgrammingLanguage) { + if (language === 'GENERIC') { + logger.error(`Markdown language not found for ${language}`); + return ''; + } + return language.toLowerCase(); +} + +export function generateRandomAlphanumeric(size = 10) { + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < size; i++) { + const randomIndex = Math.floor(Math.random() * chars.length); + result += chars[randomIndex]; + } + return result; +} + +// Not async since we don't need to wait +export function deleteProgram(programPath: string) { + // Delete the program file + deleteFile(programPath); +} + +export async function executeCombinedProgramInProductionMode( + programPath: string, + input: string, +): Promise<Map<string, number[]>> { + try { + const output: string = await callIndexJsWithInput(programPath, input); + const outputString = output.toString().trim(); + const violationsMap = + extractViolationsFromRawOutputForMultipleRules(outputString); + return violationsMap; + } catch (error) { + logger.error(`Error executing combined program: ${error}`); + return new Map<string, number[]>(); + } +} + +export async function clearConsoleLogFromProgramOutput( + programFunction: string, + linterAstAdapter: ILinterAstPort | null, +): Promise<string> { + // If no adapter is provided, return the original code unchanged + if (!linterAstAdapter) { + logger.error(`linterAstAdapter is null`); + return programFunction; + } + + try { + logger.info(`Clean up program to remove console.log`); + // Use the AST adapter's console removal service + return await linterAstAdapter.removeConsoleStatements( + programFunction, + ProgrammingLanguage.JAVASCRIPT, + ); + } catch (error) { + // If parsing fails, return the original code unchanged + logger.error(`Failed to remove console statements: ${error}`); + return programFunction; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramOutputUtils.spec.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramOutputUtils.spec.ts new file mode 100644 index 000000000..b96f81432 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramOutputUtils.spec.ts @@ -0,0 +1,318 @@ +import Yaml from 'yaml'; +import { + parseCodeOrJsonFromAIAnswer, + parseCodeOrYamlFromAIAnswer, +} from './ProgramOutputUtils'; +import { extractStringOrObjectOfString } from './ProgramExecutionUtils'; + +describe('parseCodeOrJsonFromAIAnswer', () => { + it('returns the code from a string with js tags', () => { + const code = `Hello this is a code + \`\`\`javascript + console.log("hello"); + const a = 1; + \`\`\` bye`; + const expectedCode = `console.log("hello"); + const a = 1;`; + const result = parseCodeOrJsonFromAIAnswer(code); + expect(result).toEqual(expectedCode); + }); + + it('returns the code from a string with code tags', () => { + const code = `Hello this is a code + \`\`\` + console.log("hello"); + const a = 1; + \`\`\``; + const expectedCode = `console.log("hello"); + const a = 1;`; + const result = parseCodeOrJsonFromAIAnswer(code); + expect(result).toEqual(expectedCode); + }); + + it('returns the code from a string with no tags', () => { + const code = `console.log("hello"); + const a = 1;`; + const expectedCode = `console.log("hello"); + const a = 1;`; + const result = parseCodeOrJsonFromAIAnswer(code); + expect(result).toEqual(expectedCode); + }); +}); + +describe('parseCodeOrJsonFromAIAnswer', () => { + it('parses code from OpenAI answer that contains json code marks', () => { + const output = + '```json\n["^[ \\\\t]*FROM[ \\\\t]+ubuntu(:[^ \\\\t]*)?[ \\\\t]*$", "^[ \\\\t]*FROM[ \\\\t]+ubuntu[ \\\\t]+.*?$"]\n```'; + const expected = `["^[ \\\\t]*FROM[ \\\\t]+ubuntu(:[^ \\\\t]*)?[ \\\\t]*$", "^[ \\\\t]*FROM[ \\\\t]+ubuntu[ \\\\t]+.*?$"]`; + const result = parseCodeOrJsonFromAIAnswer(output); + expect(result).toEqual(expected); + }); + + it('parses code from OpenAI answer that contains JavaScript code marks', () => { + const output = '```javascript\nconst i = 0;```'; + const expected = `const i = 0;`; + const result = parseCodeOrJsonFromAIAnswer(output); + expect(result).toEqual(expected); + }); + + it('parses code from OpenAI answer that contains JS code marks', () => { + const output = '```js\nconst i = 0;```'; + const expected = `const i = 0;`; + const result = parseCodeOrJsonFromAIAnswer(output); + expect(result).toEqual(expected); + }); + + it('parses code from OpenAI answer that contains generic code marks', () => { + const output = '```\nconst i = 0;```'; + const expected = `const i = 0;`; + const result = parseCodeOrJsonFromAIAnswer(output); + expect(result).toEqual(expected); + }); + + it('extracts the right program from debugging steps', () => { + const output = + "Certainly! Let's add more in the function. I'll add `console.log` statements inside the `traverse` function:\n\n```javascript\nconst Parser = require('tree-sitter')``` so I can analyze and debug accordingly."; + const expected = "const Parser = require('tree-sitter')"; + const result = parseCodeOrJsonFromAIAnswer(output); + expect(result).toEqual(expected); + }); + + it('parses the first code extract from Json or Plain text', () => { + const input = `Here is an implementation of the \`checkSourceCode\` function based on the requirements: +\`\`\`javascript +function checkSourceCode(ast) {} +\`\`\` +Usage example: +\`\`\`javascript +const ast +\`\`\` +`; + const code = parseCodeOrJsonFromAIAnswer(input); + expect(code).toEqual('function checkSourceCode(ast) {}'); + }); + + it('parses the first code extract from plain code', () => { + const input = ` +function checkSourceCode(ast) { + console.log(\`Violations found at lines: \${violations}\`); +}`; + const code = parseCodeOrJsonFromAIAnswer(input); + expect(code).toEqual(input); + }); + + describe('when parsing JSON with answer and details properties', () => { + const output = + '```json\n{\n "answer": "semantic",\n "details": "Detecting exclamation marks within the text content of <Alert> components requires understanding both static and dynamic content. While static text can be analyzed syntactically, many applications use variables, functions, or expressions to generate alert messages. For example, the text might come from a variable like `message`, a function call, or template literals with interpolated values. Syntax analysis would not capture exclamation marks embedded in these dynamic sources. Therefore, semantic analysis is necessary to evaluate the actual text content that will be displayed at runtime, ensuring that exclamation marks are detected regardless of how the message is constructed."\n}\n```'; + let result: string; + let jsonObject: Record<string, unknown>; + + beforeEach(() => { + result = parseCodeOrJsonFromAIAnswer(output); + jsonObject = JSON.parse(result); + }); + + it('parses without throwing errors', () => { + expect(() => JSON.parse(result)).not.toThrow(); + }); + + it('returns an object', () => { + expect(jsonObject).toEqual(expect.any(Object)); + }); + + it('contains answer property', () => { + expect(jsonObject).toHaveProperty('answer'); + }); + + it('contains details property', () => { + expect(jsonObject).toHaveProperty('details'); + }); + }); + + describe('when parsing JSON with quoted strings in answer', () => { + const output = '```json\n{\n "answer": "`message` or \'message\'"\n}\n```'; + let result: string; + let jsonObject: Record<string, unknown>; + + beforeEach(() => { + result = parseCodeOrJsonFromAIAnswer(output); + jsonObject = JSON.parse(result); + }); + + it('parses without throwing errors', () => { + expect(() => JSON.parse(result)).not.toThrow(); + }); + + it('returns an object', () => { + expect(jsonObject).toEqual(expect.any(Object)); + }); + + it('contains answer property', () => { + expect(jsonObject).toHaveProperty('answer'); + }); + }); +}); + +describe('parseCodeOrYamlFromAIAnswer', () => { + it('parses code from OpenAI answer that contains JS code marks', () => { + const output = `\`\`\`yaml + guidelines: > + - Detection Context: \`Look\` for instances where i18n keys are being used within TypeScript or JavaScript files + \`\`\``; + const validYaml = `guidelines: > + - Detection Context: \`Look\` for instances where i18n keys are being used within TypeScript or JavaScript files`; + const expected = Yaml.parse(validYaml); + const result = Yaml.parse(parseCodeOrYamlFromAIAnswer(output)); + expect(result).toEqual(expected); + }); + + describe('when parsing whole guidelines from OpenAI answer', () => { + const output = `\`\`\`yaml +guidelines: | + - Detection Context: The practice should be detected in any part of the code where internationalization (i18n) keys are used, particularly within functions and components that interact with translation libraries. + - Violation Detection Method: Look for string concatenations or template literals used to create i18n keys dynamically within translation function calls. + - Violation Detection Method: Search for the usage of variables in place of static strings that should define i18n keys, especially when these variables are constructed or modified at runtime. + - Violation Detection Method: Pay attention to translation function calls (like \`t()\`, \`i18n.t()\`, etc.) that do not use hard-coded strings as their parameters for i18n keys. + +positive_example: | + return ( + <div className="title-container-right" data-testid={dataTestId}> + <PMTypography text={t('DETECTION_CONFIGURATION_TOOLTIP')} /> + </div> + ) + +negative_examples: + - negative_example: | + const messageKey = sectionName + '_TOOLTIP'; + return t(messageKey); + reason_for_violation: "This example dynamically constructs the i18n key using a variable 'sectionName' during runtime. Hardcoding the key is preferable to avoid missing translations and ensure maintainability." + + - negative_example: | + return t(\`ERROR_\${errorCode}\`); + reason_for_violation: "The i18n key is dynamically created using a template literal with 'errorCode', which may lead to unexpected behavior if the code is incorrect. Static i18n key strings should be used." + +location: "A violation should be raised on any use of constructed strings or variables for i18n keys in translation function calls." +\`\`\``; + let result: Record<string, unknown>; + + beforeEach(() => { + result = Yaml.parse(parseCodeOrYamlFromAIAnswer(output)); + }); + + it('parses guidelines as string', () => { + expect(typeof result.guidelines).toEqual('string'); + }); + + it('parses positive_example as string', () => { + expect(typeof result.positive_example).toEqual('string'); + }); + + it('parses negative_examples as array', () => { + expect(Array.isArray(result.negative_examples)).toBe(true); + }); + + it('contains two negative examples', () => { + expect(result.negative_examples).toHaveLength(2); + }); + }); + + it('produces a string from guidelines that have incorrect indentation and thus 2 objects', () => { + const output = ` +guidelines: +- Detection Context: JavaScript variable declarations in global scope or within functions. + Violation Detection Method: Search for "var" keywords and replace with either "let" or "const", based on the intended mutability of the variables (mutable should use 'let', immutable/fixed values should use 'const'). +- Content Focus: Detecting inappropriate usage of variable declarations indicating potential bugs related to scope and hoisting. + Audience Consideration: Emphasize understanding block scopes introduced by ES6+ features for better code maintainability, especially relevant with unintentional global variable declaration using 'var'. +`; + + const expected = ` +Detection Context: JavaScript variable declarations in global scope or within functions. +Violation Detection Method: Search for "var" keywords and replace with either "let" or "const", based on the intended mutability of the variables (mutable should use 'let', immutable/fixed values should use 'const'). +Content Focus: Detecting inappropriate usage of variable declarations indicating potential bugs related to scope and hoisting. +Audience Consideration: Emphasize understanding block scopes introduced by ES6+ features for better code maintainability, especially relevant with unintentional global variable declaration using 'var'. +`; + const result = Yaml.parse(parseCodeOrYamlFromAIAnswer(output)); + const guidelinesInString = extractStringOrObjectOfString(result.guidelines); + expect(guidelinesInString.trim()).toEqual(expected.trim()); + }); + + it('produces a string from guidelines that have incorrect indentation and thus 2 objects', () => { + const output = ` +guidelines: + - Detection Context: JavaScript variable declarations in global scope or within functions. + Violation Detection Method: Search for "var" keywords and replace with either "let" or "const", based on the intended mutability of the variables (mutable should use 'let', immutable/fixed values should use 'const'). + - Content Focus: Detecting inappropriate usage of variable declarations indicating potential bugs related to scope and hoisting. + Audience Consideration: Emphasize understanding block scopes introduced by ES6+ features for better code maintainability, especially relevant with unintentional global variable declaration using 'var'. +`; + + const expected = ` +Detection Context: JavaScript variable declarations in global scope or within functions. +Violation Detection Method: Search for "var" keywords and replace with either "let" or "const", based on the intended mutability of the variables (mutable should use 'let', immutable/fixed values should use 'const'). +Content Focus: Detecting inappropriate usage of variable declarations indicating potential bugs related to scope and hoisting. +Audience Consideration: Emphasize understanding block scopes introduced by ES6+ features for better code maintainability, especially relevant with unintentional global variable declaration using 'var'. +`; + const result = Yaml.parse(parseCodeOrYamlFromAIAnswer(output)); + const guidelinesInString = extractStringOrObjectOfString(result.guidelines); + expect(guidelinesInString.trim()).toEqual(expected.trim()); + }); + + describe('when code examples contain backticks', () => { + const output = ` +Based on the coding practice description, here are the guidelines for detecting violations: +\`\`\`yaml +guidelines: + - Detection Context: A coroutine is created using the \`withContext\` function. + - Violation Detection Method: Search for the \`withContext\` function calls that use + \`Dispatchers.Default\` directly as the parameter. A violation should be + raised if found. +positive_example: | + \`\`\`kotlin + class NewsRepository( + private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default + ) { + suspend fun loadNews() = withContext(defaultDispatcher) { /* ... */ } + } + \`\`\` +negative_examples: + - negative_example: | + \`\`\`kotlin + class NewsRepository { + suspend fun loadNews() = withContext(Dispatchers.Default) { /* ... */ } + } + \`\`\` + reason_for_violation: The \`Dispatchers.Default\` is hardcoded directly into the + \`withContext\` function, which makes it difficult to replace for testing + purposes. +location: A violation should be raised on any \`withContext\` function call that + uses \`Dispatchers.Default\` directly as the parameter. +\`\`\` +`; + let guidelinesInString: string; + let positiveExample: unknown; + let negativeExamples: unknown[]; + + beforeEach(() => { + const parsedYaml = parseCodeOrYamlFromAIAnswer(output); + const result = Yaml.parse(parsedYaml); + guidelinesInString = extractStringOrObjectOfString(result.guidelines); + positiveExample = result.positive_example; + negativeExamples = result.negative_examples; + }); + + it('parses guidelines as string', () => { + expect(typeof guidelinesInString).toEqual('string'); + }); + + it('parses positive_example as string', () => { + expect(typeof positiveExample).toEqual('string'); + }); + + it('parses negative_examples as array', () => { + expect(Array.isArray(negativeExamples)).toBe(true); + }); + + it('contains one negative example', () => { + expect(negativeExamples).toHaveLength(1); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramOutputUtils.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramOutputUtils.ts new file mode 100644 index 000000000..ae65c7ae3 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramOutputUtils.ts @@ -0,0 +1,83 @@ +import Yaml from 'yaml'; +import { PackmindLogger } from '@packmind/logger'; + +const logger = new PackmindLogger('ProgramOutputUtils'); + +export function parseOpenAIAnswerAsJsonObject(answer: string) { + try { + return JSON.parse(parseCodeOrJsonFromAIAnswer(answer)); + } catch { + logger.error(`Error while parsing the answer as JSON: ${answer}`); + throw new Error('JSON parsing error'); + } +} + +export function parseOpenAIAnswerAsYamlObject(answer: string) { + try { + return Yaml.parse(answer); + } catch { + logger.warn( + `Answer is not a raw YAML object, try to extract object from text`, + ); + try { + return Yaml.parse(parseCodeOrYamlFromAIAnswer(answer)); + } catch { + logger.error(`Error while parsing the answer as YAML: ${answer}`); + throw new Error('YAML parsing error'); + } + } +} + +export function parseCodeOrJsonFromAIAnswer(inputString: string): string { + if (inputString.trim().startsWith(`function checkSourceCode`)) { + return inputString; + } + + // Updated regex to capture code blocks with optional language identifiers and inline code + // Yes sometimes LLM returns code in Python + const codeBlockRegex = /```(?:javascript|js|json|markdown)?\n([\s\S]*?)```/; + const inlineCodeRegex = /`([\s\S]*?)`/g; + + // Match the first code block + const codeBlockMatch = codeBlockRegex.exec(inputString); + if (codeBlockMatch && codeBlockMatch[1]) { + return codeBlockMatch[1].trim(); + } + + // If no code block is found, fallback to the last inline code + let lastInlineCode = ''; + let match; + while ((match = inlineCodeRegex.exec(inputString)) !== null) { + lastInlineCode = match[1].trim(); + } + + // Return the last inline code if available, otherwise return the input + return lastInlineCode || inputString; +} + +export function parseCodeOrYamlFromAIAnswer(inputString: string): string { + const firstBacktickIndex = inputString.indexOf('```'); + const lastBacktickIndex = inputString.lastIndexOf('```'); + + if ( + firstBacktickIndex !== -1 && + lastBacktickIndex !== -1 && + firstBacktickIndex < lastBacktickIndex + ) { + // Extract content between the first and last triple backticks + let extractedCode = inputString + .substring(firstBacktickIndex + 3, lastBacktickIndex) + .trim(); + + // Remove the first line if it contains only a language identifier + const lines = extractedCode.split('\n'); + if (/^\s*\w+\s*$/.test(lines[0])) { + lines.shift(); + extractedCode = lines.join('\n').trim(); + } + + return extractedCode; + } else { + return inputString; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramThirdPartyLibrary.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramThirdPartyLibrary.ts new file mode 100644 index 000000000..df57aed9b --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/ProgramThirdPartyLibrary.ts @@ -0,0 +1,140 @@ +import { PackmindLogger } from '@packmind/logger'; +import { getErrorMessage } from '@packmind/node-utils'; +import { ILinterAstPort } from '@packmind/types'; +import { ProgrammingLanguage } from '@packmind/types'; +import { DetectionProgramRuleInput } from '@packmind/types'; + +const logger = new PackmindLogger('ProgramThirdPartyLibrary'); + +export async function buildPromptForExternalLibraries( + rule: DetectionProgramRuleInput, + linterAstAdapter?: ILinterAstPort | null, +): Promise<string> { + try { + const areInputExamplesValid = await checkIfExamplesCanBeParsed( + rule, + linterAstAdapter, + ); + if (areInputExamplesValid) { + logger.info( + `[${rule.rule.id}] All examples have been assessed, third party libraries are allowed for external parsings`, + ); + return buildPromptForExternalLibrariesIfCodeIsValid(rule.language); + } else { + logger.info( + `[${rule.rule.id}] All examples can not be parsed, third party libraries are not allowed for external parsings`, + ); + return buildPromptCannotUseExternalLibraries(); + } + } catch (error) { + logger.error( + `[${rule.rule.id}] Error while building prompt for external libraries: ${getErrorMessage(error)}`, + ); + logger.info( + `[${rule.rule.id}] All examples can not be parsed, third party libraries are not allowed for external parsings`, + ); + return buildPromptCannotUseExternalLibraries(); + } +} + +function buildPromptCannotUseExternalLibraries(): string { + return `You cannot use any third-party libraries to parse the code. +Based on the string input, you're free to manipulate it as much as you want, but can not rely on any external libraries.`; +} + +async function checkIfExamplesCanBeParsed( + rule: DetectionProgramRuleInput, + linterAstAdapter?: ILinterAstPort | null, +): Promise<boolean> { + try { + for (const pairOfCodeExamples of rule.ruleExamples) { + if (pairOfCodeExamples.positive?.length) { + await parseCodeWithExternalLib( + pairOfCodeExamples.positive, + rule.language, + linterAstAdapter, + ); + } + if (pairOfCodeExamples.negative?.length) { + await parseCodeWithExternalLib( + pairOfCodeExamples.negative, + rule.language, + linterAstAdapter, + ); + } + } + return true; + } catch (error) { + logger.error( + `[${rule.rule.id}] Error while parsing the examples for rule: ${getErrorMessage(error)}`, + ); + return false; + } +} + +//Check first if the examples can be parsed using tree-sitter +export function buildPromptForExternalLibrariesIfCodeIsValid( + language: ProgrammingLanguage, +): string { + const beginPrompt = `Keep in mind the following npm libraries can be used:`; + + const endPrompt = `Don't assume the existence of some others third-party libraries, as they won't be available. +Using AST visitors is not mandatory, you can use alternative methods to parse the code, such as regular expressions or string manipulation.`; + + let languageSpecificLibs = ''; + switch (language) { + case 'YAML': + languageSpecificLibs = `- 'yaml' to parse YAML files. + Reminder on how to use it: + const YAML = require('yaml') + YAML.parse("sourcecode"); + //or, if the code contains multiple documents: + YAML.parseAllDocuments("sourcecode");`; + } + if (languageSpecificLibs.length) { + return `${beginPrompt} +${languageSpecificLibs} +You should NOT use any other third-party libraries. +${endPrompt}`; + } else { + return `You should NOT use any third-party libraries`; + } +} + +export async function checkIfSourceCodeParsableWithAST( + rule: DetectionProgramRuleInput, + linterAstAdapter?: ILinterAstPort | null, +): Promise<boolean> { + if (!linterAstAdapter) { + return false; + } + + return ( + linterAstAdapter.isLanguageSupported(rule.language) && + checkIfExamplesCanBeParsed(rule, linterAstAdapter) + ); +} + +async function parseCodeWithExternalLib( + code: string, + language: ProgrammingLanguage, + linterAstAdapter?: ILinterAstPort | null, +): Promise<void> { + if (language === 'GENERIC') { + throw new Error('Generic examples cannot be parsed'); + } + + // Try using linter-ast adapter first if available and language is supported + if (linterAstAdapter?.isLanguageSupported(language)) { + try { + await linterAstAdapter.parseSourceCode(code, language); + return; // Successfully parsed with linter-ast + } catch (error) { + throw new Error(`Error while parsing the examples ${error}`); + } + } + + throw new Error( + `Source code in language=${language} is not supported yet in AST MODE`, + ); +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/AbstractGenerationStrategy.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/AbstractGenerationStrategy.ts new file mode 100644 index 000000000..49a6f95bb --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/AbstractGenerationStrategy.ts @@ -0,0 +1,30 @@ +import { SourceCodeRepresentation } from '../AbstractRuleDetectionProgram'; +import { TokensUsed } from '@packmind/types'; +import { ProgrammingLanguage } from '@packmind/types'; +import { SourceCodeState } from '@packmind/types'; + +export default abstract class AbstractGenerationStrategy { + protected _tokensUsed: TokensUsed[] = []; + protected _initialPrompt = ''; + + abstract getInitialProgram(): Promise<string>; + + get initialPrompt(): string { + return this._initialPrompt; + } + + get tokensUsed(): TokensUsed[] { + return this._tokensUsed; + } + + abstract getFileInputFromContent( + fileContent: string, + language: ProgrammingLanguage, + ): Promise<SourceCodeRepresentation>; + + abstract getSuffixCode(): Promise<string>; + + abstract getSuffixCodeForMultiplePrograms(): Promise<string>; + + abstract getSourceCodeState(): SourceCodeState; +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/ASTGenerationStrategy.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/ASTGenerationStrategy.ts new file mode 100644 index 000000000..36899a835 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/ASTGenerationStrategy.ts @@ -0,0 +1,320 @@ +import { buildPromptForExternalLibraries } from '../../ProgramThirdPartyLibrary'; +import { convert_rule_guidelines_to_ast } from './convert_rule_guidelines_to_ast'; +import AbstractGenerationStrategy from '../AbstractGenerationStrategy'; +import YAML from 'yaml'; +import { generate_program_for_ast_json } from './generate_program_for_ast_json'; +import { + parseCodeOrJsonFromAIAnswer, + parseCodeOrYamlFromAIAnswer, +} from '../../ProgramOutputUtils'; +import { SourceCodeRepresentation } from '../../AbstractRuleDetectionProgram'; +import { + getFullAstFromASourceCode, + getPartialAstFromASourceCode, +} from './ASTUtils'; +import { PackmindLogger } from '@packmind/logger'; +import { getErrorMessage } from '@packmind/node-utils'; +import { + AI_RESPONSE_FORMAT, + AIService, + PromptConversationRole, +} from '@packmind/types'; +import { ILinterAstPort } from '@packmind/types'; +import { RuleExample } from '@packmind/types'; +import { ProgrammingLanguage } from '@packmind/types'; +import { DetectionProgramRuleInput } from '@packmind/types'; +import { getFileContent } from '../../../utils/IO'; +import Globals from '../../../utils/Globals'; +import { SourceCodeState } from '@packmind/types'; + +const origin = 'ASTGenerationStrategy'; + +export default class ASTGenerationStrategy extends AbstractGenerationStrategy { + constructor( + private readonly _detectionProgramRuleInput: DetectionProgramRuleInput, + private readonly _aiService: AIService, + private readonly _linterAstAdapter: ILinterAstPort | null = null, + private readonly _logger = new PackmindLogger(origin), + ) { + super(); + } + + async getInitialProgram(): Promise<string> { + this._logger.info( + `[${this._detectionProgramRuleInput.rule.id}] ===== Generation Detection Guidelines for AST mode`, + ); + + const prompt = + await this.convertRuleAndGuidelinesToPromptDedicatedToASTMode(); + + const MAX_RETRY = 5; + let i = 0; + + while (i < MAX_RETRY) { + const response = await this._aiService.executePromptWithHistory( + [ + { + role: PromptConversationRole.USER, + message: prompt, + }, + ], + { + responseFormat: AI_RESPONSE_FORMAT.PLAIN_TEXT, + }, + ); + if (response.tokensUsed) { + this._tokensUsed.push(response.tokensUsed); + } + try { + if (!response.data) { + throw new Error('No response provided by AI'); + } + const guidelinesInASTMode = this.parseGuidelinesForAst(response.data); + + this._logger.info( + `[${this._detectionProgramRuleInput.rule.id}] ===== Guidelines in AST Mode have been generated`, + ); + return this.generateInitialProgram(guidelinesInASTMode); + } catch (error) { + this._logger.error(getErrorMessage(error)); + } + i++; + } + + throw new Error('Unable to generate program - MAX_RETRY reached'); + } + + private async generateInitialProgram( + guidelinesInASTMode: string, + ): Promise<string> { + const programGenerationPrompt = + await this.createPromptToGenerateProgram(guidelinesInASTMode); + + this._initialPrompt = + await this.createPromptToGenerateProgramAndRemoveExamples( + guidelinesInASTMode, + ); + + const program = await this._aiService.executePrompt( + programGenerationPrompt, + { + responseFormat: AI_RESPONSE_FORMAT.PLAIN_TEXT, + }, + ); + if (program.tokensUsed) { + this._tokensUsed.push(program.tokensUsed); + } + + if (!program.data) { + throw new Error('No data provided by AI'); + } + return parseCodeOrJsonFromAIAnswer(program.data); + } + + parseGuidelinesForAst(response: string) { + const output = parseCodeOrYamlFromAIAnswer(response); + const yamlObject = YAML.parse(output); + let i = 1; + let prompt = ''; + for (const guideline of yamlObject.guidelines) { + prompt += `## Guideline for scenario ${i}\n`; + prompt += `${guideline.guideline}\n`; + i++; + } + return prompt.trim(); + } + + // Our input is a practice that contains instructions related to source code. + // Here we want to work on a AST representation in JSON. + // So the first prompt that will generate a program should not have any source as input. + private async convertRuleAndGuidelinesToPromptDedicatedToASTMode(): Promise<string> { + return this.updatePromptWithRuleInformationAndExamplesInJsonASTMode( + convert_rule_guidelines_to_ast, + ); + } + + private async createPromptToGenerateProgram(guidelinesInASTMode: string) { + const prompt = generate_program_for_ast_json; + try { + const promptExternalLibraries = await buildPromptForExternalLibraries( + this._detectionProgramRuleInput, + this._linterAstAdapter, + ); + + return prompt + .replace('$RULE_CONTENT$', this._detectionProgramRuleInput.rule.content) + .replace('$RULE_AST_GUIDELINES$', guidelinesInASTMode) + .replace('$RULE_LANGUAGE$', this._detectionProgramRuleInput.language) + .replace('$PROGRAM_EXTERNAL_LIBRARIES$', promptExternalLibraries) + .replace( + '$RULE_BAD_EXAMPLES$', + await this.getBadExamplesCodeInJSONMode( + this._detectionProgramRuleInput.ruleExamples, + this._detectionProgramRuleInput.language, + ), + ) + .replace( + '$RULE_GOOD_EXAMPLES$', + await this.getGoodExamplesCodeInJSONMode( + this._detectionProgramRuleInput.ruleExamples, + this._detectionProgramRuleInput.language, + ), + ); + } catch (error) { + this._logger.error('Failed to create prompt for program generation', { + ruleId: this._detectionProgramRuleInput.rule.id, + language: this._detectionProgramRuleInput.language, + error: getErrorMessage(error), + }); + return prompt; + } + } + + private async updatePromptWithRuleInformationAndExamplesInJsonASTMode( + prompt: string, + ) { + try { + const promptExternalLibraries = await buildPromptForExternalLibraries( + this._detectionProgramRuleInput, + this._linterAstAdapter, + ); + const formattedHeuristics = + this._detectionProgramRuleInput.heuristics && + this._detectionProgramRuleInput.heuristics.length > 0 + ? this._detectionProgramRuleInput.heuristics + .map((h) => `* ${h}`) + .join('\n') + : ''; + + return prompt + .replace('$RULE_CONTENT$', this._detectionProgramRuleInput.rule.content) + .replace('$RULE_HEURISTICS$', formattedHeuristics) + .replace('$RULE_LANGUAGE$', this._detectionProgramRuleInput.language) + .replace('$PROGRAM_EXTERNAL_LIBRARIES$', promptExternalLibraries) + .replace( + '$RULE_BAD_EXAMPLES$', + await this.getBadExamplesCodeInJSONMode( + this._detectionProgramRuleInput.ruleExamples, + this._detectionProgramRuleInput.language, + ), + ) + .replace( + '$RULE_GOOD_EXAMPLES$', + await this.getGoodExamplesCodeInJSONMode( + this._detectionProgramRuleInput.ruleExamples, + this._detectionProgramRuleInput.language, + ), + ); + } catch (error) { + this._logger.error( + 'Failed to update prompt with rule information in AST mode', + { + ruleId: this._detectionProgramRuleInput.rule.id, + language: this._detectionProgramRuleInput.language, + error: getErrorMessage(error), + }, + ); + return prompt; + } + } + + private async createPromptToGenerateProgramAndRemoveExamples( + guidelinesInASTMode: string, + ): Promise<string> { + const prompt = generate_program_for_ast_json; + try { + const promptExternalLibraries = await buildPromptForExternalLibraries( + this._detectionProgramRuleInput, + this._linterAstAdapter, + ); + + return prompt + .replace('$RULE_CONTENT$', this._detectionProgramRuleInput.rule.content) + .replace('$RULE_AST_GUIDELINES$', guidelinesInASTMode) + .replace('$RULE_LANGUAGE$', this._detectionProgramRuleInput.language) + .replace('$PROGRAM_EXTERNAL_LIBRARIES$', promptExternalLibraries) + .replace('$RULE_BAD_EXAMPLES$', '') // We don't include source code examples here as they'll be introduced later in AST JSON mode if needed + .replace('$RULE_GOOD_EXAMPLES$', ''); // Same + } catch (error) { + this._logger.error('Failed to create prompt for program generation', { + ruleId: this._detectionProgramRuleInput.rule.id, + language: this._detectionProgramRuleInput.language, + error: getErrorMessage(error), + }); + return prompt; + } + } + + async getFileInputFromContent( + fileContent: string, + language: ProgrammingLanguage, + ): Promise<SourceCodeRepresentation> { + const code = await getFullAstFromASourceCode( + fileContent, + language, + this._linterAstAdapter, + ); + return { + content: code, + sourceCodeState: 'AST', + }; + } + + private async getGoodExamplesCodeInJSONMode( + ruleExamples: RuleExample[], + language: ProgrammingLanguage, + ) { + const goodExamples = ruleExamples + .map((example) => example.negative) + .filter((example) => example?.length); + + if (!goodExamples.length) { + return ''; + } + + let goodExamplesCode = '## Good code examples for the practice:'; + for (const example of goodExamples) { + const prompt = `\`\`\` +${await getPartialAstFromASourceCode(example, 0, example.split('\n').length, language, this._linterAstAdapter)} +\`\`\` `; + goodExamplesCode = `${goodExamplesCode} \n ${prompt}`; + } + return goodExamplesCode; + } + + private async getBadExamplesCodeInJSONMode( + ruleExamples: RuleExample[], + language: ProgrammingLanguage, + ) { + const badExamples = ruleExamples + .map((example) => example.negative) + .filter((example) => example?.length); + + if (!badExamples.length) { + return ''; + } + + let badExamplesCode = '## Bad code examples for the practice:'; + + for (const example of badExamples) { + const prompt = `\`\`\` +${await getPartialAstFromASourceCode(example, 0, example.split('\n').length, language, this._linterAstAdapter)} +\`\`\` +`; + badExamplesCode = `${badExamplesCode} \n ${prompt}`; + } + return badExamplesCode; + } + + async getSuffixCode(): Promise<string> { + return getFileContent(`${Globals.JS_PLAYGROUND_PATH}/suffix_ast.js`); + } + + async getSuffixCodeForMultiplePrograms(): Promise<string> { + return getFileContent(`${Globals.JS_PLAYGROUND_PATH}/suffix_ast_multi.js`); + } + + getSourceCodeState(): SourceCodeState { + return 'AST'; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/ASTUtils.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/ASTUtils.ts new file mode 100644 index 000000000..18ac2acdb --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/ASTUtils.ts @@ -0,0 +1,152 @@ +import { PackmindLogger } from '@packmind/logger'; +import { getErrorMessage } from '@packmind/node-utils'; +import { ILinterAstPort } from '@packmind/types'; +import { ProgrammingLanguage } from '@packmind/types'; + +const logger = new PackmindLogger('ASTUtils'); + +/** + * Generate full AST from source code with NO filtering or shortening. + * This should be used for program execution and validation, NOT for AI prompts. + * Uses linter-ast adapter for supported languages (TypeScript, JavaScript, Java, Python, Go, etc.), otherwise falls back to js-playground. + */ +export async function getFullAstFromASourceCode( + sourceCode: string, + language: ProgrammingLanguage, + linterAstAdapter?: ILinterAstPort | null, +): Promise<string> { + if (language === 'GENERIC') { + throw new Error('Cannot parse generic language'); + } + + // Try linter-ast for supported languages + if (linterAstAdapter?.isLanguageSupported(language)) { + logger.info(`Parse source code in ${language} using linter-ast module`); + try { + const astNode = await linterAstAdapter.parseSourceCode( + sourceCode, + language, + ); + return JSON.stringify(astNode, null, 2); + } catch (error) { + throw new Error( + `Unable to parse source code in language=${language} using linter-ast module, ${getErrorMessage(error)}`, + ); + } + } + + throw new Error(`Unsupported programming language=${language} for AST mode`); +} + +/** + * Generate partial AST from source code with filtering on line areas, pruned nodes, and text reduction. + * This should be used when passing examples to AI prompts to reduce token usage and focus on relevant areas. + */ +export async function getPartialAstFromASourceCode( + sourceCode: string, + startLine: number, + endLine: number, + language: ProgrammingLanguage, + linterAstAdapter?: ILinterAstPort | null, +): Promise<string> { + const ast = await getFullAstFromASourceCode( + sourceCode, + language, + linterAstAdapter, + ); + const astJson = JSON.parse(ast); + const astFiltered = filterNodesWithLine(astJson, startLine, endLine); + if (!astFiltered) return ''; + const astPrunedWithUselessNodes = pruneAstWithUseLessNodes(astFiltered); + const astFilteredWithLinesTooLong = shortenLongTextIfLongerThanPredefinedSize( + astPrunedWithUselessNodes, + ); + return JSON.stringify(astFilteredWithLinesTooLong); +} + +function filterNodesWithLine( + ast: AstNode, + startLine: number, + endLine: number, +): AstNode | null { + // Check if the current node matches the criteria or if its children do + const newNode = { ...ast }; + const offset = 2; + if (Array.isArray(ast.children)) { + // Recursively filter children + newNode.children = ast.children + .map((child) => filterNodesWithLine(child, startLine, endLine)) // Apply filtering to each child + .filter((child) => child !== null); // Remove null children + } + + // If this node or any of its children has the line number, return the node + if ( + (newNode.line >= startLine - offset && newNode.line <= endLine + offset) || + (newNode.children && newNode.children.length > 0) + ) { + return newNode; + } + + // If this node and its children don't match, return null + return null; +} + +/** + * Recursively removes nodes from the AST when: + * 1. node.type === node.text + * 2. node.children.length === 0 + */ +type AstNode = { + type: string; + text: string; + children: AstNode[]; + line: number; +}; +export function pruneAstWithUseLessNodes(ast: AstNode): AstNode { + const newAst: AstNode = { + ...ast, + children: [], + }; + + for (const child of ast.children) { + // Recursively prune children + const prunedChild = pruneAstWithUseLessNodes(child); + + // Keep the child only if it does NOT match the prune criteria + const shouldPrune = + prunedChild.type === prunedChild.text && + prunedChild.children.length === 0; + + if (!shouldPrune) { + newAst.children.push(prunedChild); + } + } + + return newAst; +} + +export function shortenLongTextIfLongerThanPredefinedSize( + ast: AstNode, + textLimit = 300, +): AstNode { + //Based on the parser, the text can ben very long (like the whole class for 'class_declaration' type). So we need to shorten it otherwise the prompt might be very long + const newAst: AstNode = { + ...ast, + text: + ast.text && ast.text.length > textLimit + ? `${ast.text.substring(0, textLimit)} [...Truncated]` + : ast.text, + children: [], + }; + + if (ast.children && ast.children.length > 0) { + for (const child of ast.children) { + // Recursively shorten text for children + newAst.children.push( + shortenLongTextIfLongerThanPredefinedSize(child, textLimit), + ); + } + } + + return newAst; +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/convert_rule_guidelines_to_ast.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/convert_rule_guidelines_to_ast.ts new file mode 100644 index 000000000..3d4b60dc3 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/convert_rule_guidelines_to_ast.ts @@ -0,0 +1,70 @@ +export const convert_rule_guidelines_to_ast = ` +## Context: +You're a senior software developer with strong skills in source code analysis and abstract syntax tree analysis. +Your mission is to generate detection guidelines for a coding rule, so that an AI Agent will later use them to generate a program that detects violations of this coding rule. +Your output will be critical to help AI Agent in succeeding in its mission. + +## Instructions: +* You'll be given as input a coding rule with its name, a description, some detection guidelines specific to the $RULE_LANGUAGE$ programming language, and a list of code examples. +* The list of code examples contain both 'good' and 'bad' examples. Good examples refer to code that are compliant with the coding rule. Bad examples refer to code that are not compliant with the coding rule. +* All source code are written using their representation in JSON mode, with the following structure: + - a "type" property that defines the type of the node + - a "text" property that define the content of the node + - a "line" property that defines the line number where the node is located + - a "children" property that contains an array of child nodes, containing these 4 properties, with no limit in terms of depth. +* You need to generate detection guidelines for a program that will take as input JSON objects that are Abstract Syntax Tree representation of the original source. +* Keep in mind that the original coding rule description and guidelines have been written by human and target actual source code. You need to keep the conversion to AST and JSON mode in mind. + +## Guidelines requirements: +- Audience Consideration: Write as if addressing a beginner developer; include details that might be obvious to experienced developers. +- Maximum Number of steps: + - Do not exceed 10 steps to detect the violation. +- Conciseness and Clarity: + - Be concise and go straight to the point. + - Use short sentences without unnecessary words. +- Structured Format: + - Use bullet points to create a detailed action plan for detecting violations. + - Organize the guidelines in a clear and logical order. +- Focus on Syntax Elements: + - Indicate what JSON objects and properties must be checked. + - Focus only and what is strictly necessary to detect the violation. For instance, if the violation is about a method call, whatever the context, you can skip the surrounding class or method. + - Try to find the simplest way to detect the violation. + - Keep in mind that the depth level maybe different from one example to another. + - Do not include in your guidelines the root element, often called "program" or "compilation_unit". + - Do not include in your guidelines the "type: 'ERROR'" which is caused by temporary parsing issues, but do not reflect the code representation. Just ignore this. +- Detection Only: + - Do not include recommendations on how to fix the violation; focus solely on how to detect it. +- Multiple Detection Methods: + - If there are multiple ways to detect violations, create separate guidelines for each scenario. + + +## Output Format: + +The output must be in YAML format with the following structure: + +\`\`\` +guidelines: + - guideline: | + <Your guidelines to detect violations in the first identified scenario> + - guideline: | + <If you have identified multiple scenarios, add as many guidelines as you want.></If> +\`\`\` + +Output ONLY the YAML object, nothing else. + +## Input + +Here is the coding rule description: +""" +$RULE_CONTENT$ +""" + +Here are the detection heuristics: +""" +$RULE_HEURISTICS$ +""" + +$RULE_BAD_EXAMPLES$ + +$RULE_GOOD_EXAMPLES$ +`; diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/generate_program_for_ast_json.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/generate_program_for_ast_json.ts new file mode 100644 index 000000000..95e3a2861 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/generate_program_for_ast_json.ts @@ -0,0 +1,48 @@ +export const generate_program_for_ast_json = ` +## Instructions: +1. You're a senior software developer with strong skills in source code analysis and abstract syntax tree analysis. +2. Your mission is to generate a program that detects violations of a given coding rule. +3. You need to generate a Javascript function called "checkSourceCode" that takes as input a $RULE_LANGUAGE$ source code, and detect violations of the coding rule described below. +4. The source code is an AST representation in JSON format, generated with tree-sitter, containing the structure of the code. Each object has: +- a "type" property that defines the type of the node +- a "text" property that define the content of the node +- a "children" property that contains an array of child nodes +- a "line" property that defines the line number where the node is located + +## Methodology: +* Focus on the "Guidelines Detection" property of the coding rule to understand the steps to follow. +* The guidelines may contain multiple scenarios to detect violations. Your program should consider all these possible scenarios. +* Add comments in your code to improve the quality of your logic +* Check nullity of the json objects you manipulate, to reduce runtime errors. +* Regarding the violations to report: + - You need to be very accurate with the line numbers. The first line should be 0, not 1. + - The reported line should be the start of the pattern location matched. +* $PROGRAM_EXTERNAL_LIBRARIES$ +* CRITICAL OUTPUT FORMAT: The output of the function MUST be a simple array of line numbers ONLY. Return EXACTLY this format: [1, 3, 10]. DO NOT return objects with properties like {line: 2, message: "..."} or any other format. ONLY return an array of numbers representing line numbers where violations are found. +* Do not use asynchronous/ code or Promise.all. Use only synchronous code. +* The output should be a JavaScript function with the following form: +\`\`\` +function checkSourceCode(ast) { + // Your code here +} +\`\`\` +* Please output only the function code, NOTHING else. +* Do not use any other programming languages than JavaScript. +* Keep in mind the 'ast' parameter is the JSON representation of the AST of the source code, so you should manipulate this as JSON object with the structure described above. + +## Input: + +Here is the coding rule description: +""" +$RULE_CONTENT$ +""" + +### Detection Guidelines for the rule +""" +$RULE_AST_GUIDELINES$ +""" + +$RULE_BAD_EXAMPLES$ + +$RULE_GOOD_EXAMPLES$ +`; diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/prompts/convert_rule_guidelines_heuristics_to_ast.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/prompts/convert_rule_guidelines_heuristics_to_ast.ts new file mode 100644 index 000000000..272a9774f --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/prompts/convert_rule_guidelines_heuristics_to_ast.ts @@ -0,0 +1,72 @@ +export const convert_rule_guidelines_heuristics_to_ast = ` +## Context: +You're a senior software developer with strong skills in source code analysis and abstract syntax tree analysis. +Your mission is to generate detection guidelines for a coding rule, so that an AI Agent will later use them to generate a program that detects violations of this coding rule. +Your output will be critical to help AI Agent in succeeding in its mission. + +## Instructions: +* You'll be given as input a coding rule with its name, a description, some detection guidelines specific to the $RULE_LANGUAGE$ programming language, and a list of code examples. +* The list of code examples contain both 'good' and 'bad' examples. Good examples refer to code that are compliant with the coding rule. Bad examples refer to code that are not compliant with the coding rule. +* All source code are written using their representation in JSON mode, with the following structure: + - a "type" property that defines the type of the node + - a "text" property that define the content of the node + - a "line" property that defines the line number where the node is located + - a "children" property that contains an array of child nodes, containing these 4 properties, with no limit in terms of depth. +* You need to generate detection guidelines for a program that will take as input JSON objects that are Abstract Syntax Tree representation of the original source. +* Keep in mind that the original coding rule description and heuristics have been written by human and target actual source code. You need to keep the conversion to AST and JSON mode in mind. + +## Guidelines requirements: +- Audience Consideration: Write as if addressing a beginner developer; include details that might be obvious to experienced developers. +- Maximum Number of steps: + - Do not exceed 10 steps to detect the violation. +- Conciseness and Clarity: + - Be concise and go straight to the point. + - Use short sentences without unnecessary words. +- Structured Format: + - Use bullet points to create a detailed action plan for detecting violations. + - Organize the guidelines in a clear and logical order. +- Focus on Syntax Elements: + - Indicate what JSON objects and properties must be checked. + - Focus only and what is strictly necessary to detect the violation. For instance, if the violation is about a method call, whatever the context, you can skip the surrounding class or method. + - Try to find the simplest way to detect the violation. + - Keep in mind that the depth level maybe different from one example to another. + - Do not include in your guidelines the root element, often called "program" or "compilation_unit". + - Do not include in your guidelines the "type: 'ERROR'" which is caused by temporary parsing issues, but do not reflect the code representation. Just ignore this. +- Detection Only: + - Do not include recommendations on how to fix the violation; focus solely on how to detect it. +- Multiple Detection Methods: + - If there are multiple ways to detect violations, create separate guidelines for each scenario. + + +## Output Format: + +The output must be in YAML format with the following structure: + +\`\`\` +guidelines: + - guideline: | + <Your guidelines to detect violations in the first identified scenario> + - guideline: | + <If you have identified multiple scenarios, add as many guidelines as you want.></If> +\`\`\` + +Output ONLY the YAML object, nothing else. + +## Input + +Here is the coding rule description: +""" +$RULE_CONTENT$ +""" + +This coding rule concerns source code written in $RULE_LANGUAGE$. + +Here are the detection heuristics for the coding rule: +""" +$RULE_HEURISTICS$ +""" + +$RULE_BAD_EXAMPLES$ + +$RULE_GOOD_EXAMPLES$ +`; diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/prompts/generate_program_for_ast_json_with_heuristics.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/prompts/generate_program_for_ast_json_with_heuristics.ts new file mode 100644 index 000000000..5011d44b3 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/ast/prompts/generate_program_for_ast_json_with_heuristics.ts @@ -0,0 +1,48 @@ +export const generate_program_for_ast_json_with_heuristics = ` +## Instructions: +1. You're a senior software developer with strong skills in source code analysis and abstract syntax tree analysis. +2. Your mission is to generate a program that detects violations of a given coding rule. +3. You need to generate a Javascript function called "checkSourceCode" that takes as input a $RULE_LANGUAGE$ source code, and detect violations of the coding rule described below. +4. The source code is an AST representation in JSON format, generated with tree-sitter, containing the structure of the code. Each object has: +- a "type" property that defines the type of the node +- a "text" property that define the content of the node +- a "children" property that contains an array of child nodes +- a "line" property that defines the line number where the node is located + +## Methodology: +* Focus on the "Guidelines Detection" property of the rule to understand the steps to follow. +* The guidelines may contain multiple scenarios to detect violations. Your program should consider all these possible scenarios. +* Add comments in your code to improve the quality of your logic +* Check nullity of the json objects you manipulate, to reduce runtime errors. +* Regarding the violations to report: + - You need to be very accurate with the line numbers. The first line should be 0, not 1. + - The reported line should be the start of the pattern location matched. +* $PROGRAM_EXTERNAL_LIBRARIES$ +* CRITICAL OUTPUT FORMAT: The output of the function MUST be a simple array of line numbers ONLY. Return EXACTLY this format: [1, 3, 10]. DO NOT return objects with properties like {line: 2, message: "..."} or any other format. ONLY return an array of numbers representing line numbers where violations are found. +* Do not use asynchronous/ code or Promise.all. Use only synchronous code. +* The output should be a JavaScript function with the following form: +\`\`\` +function checkSourceCode(ast) { + // Your code here +} +\`\`\` +* Please output only the function code, NOTHING else. +* Do not use any other programming languages than JavaScript. +* Keep in mind the 'ast' parameter is the JSON representation of the AST of the source code, so you should manipulate this as JSON object with the structure described above. + +## Input: + +Here is the coding rule description: +""" +$RULE_CONTENT$ +""" + +# Detection Heuristics +""" +$RULE_HEURISTICS$ +""" + +$RULE_BAD_EXAMPLES$ + +$RULE_GOOD_EXAMPLES$ +`; diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/raw/RAWGenerationStrategy.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/raw/RAWGenerationStrategy.ts new file mode 100644 index 000000000..8f2402040 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/raw/RAWGenerationStrategy.ts @@ -0,0 +1,107 @@ +import AbstractGenerationStrategy from '../AbstractGenerationStrategy'; +import { generate_program_code } from './generate_program_code'; +import { parseCodeOrJsonFromAIAnswer } from '../../ProgramOutputUtils'; +import { SourceCodeRepresentation } from '../../AbstractRuleDetectionProgram'; +import { DetectionProgramRuleInput } from '@packmind/types'; +import { PackmindLogger } from '@packmind/logger'; +import { getErrorMessage } from '@packmind/node-utils'; +import { AI_RESPONSE_FORMAT, AIService } from '@packmind/types'; +import { + getBadExamplesCode, + getGoodExamplesCode, +} from '../../../utils/PromptUtils'; +import { getFileContent } from '../../../utils/IO'; +import Globals from '../../../utils/Globals'; +import { SourceCodeState } from '@packmind/types'; + +const origin = 'RAWGenerationStrategy'; +export default class RAWGenerationStrategy extends AbstractGenerationStrategy { + constructor( + private readonly _detectionProgramRuleInput: DetectionProgramRuleInput, + private readonly _aiProvider: AIService, + private readonly _logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(); + } + + async getInitialProgram(): Promise<string> { + this._logger.info( + `[${this._detectionProgramRuleInput.rule.id}] ===== Create Initial Program For RAW Mode`, + ); + + const promptToGenerateProgram = this.buildPromptWithCode(); + + this._initialPrompt = promptToGenerateProgram; + + const MAX_RETRY = 5; + let i = 0; + + while (i < MAX_RETRY) { + const response = await this._aiProvider.executePrompt( + promptToGenerateProgram, + { + responseFormat: AI_RESPONSE_FORMAT.PLAIN_TEXT, + }, + ); + if (response.tokensUsed) { + this._tokensUsed.push(response.tokensUsed); + } + + try { + if (!response.data) { + throw new Error('No data returned by AI'); + } + return parseCodeOrJsonFromAIAnswer(response.data); + } catch (error) { + this._logger.error(getErrorMessage(error)); + } + i++; + } + throw new Error( + 'Failed to create Initial Program for RAW Mode - MAX_TRY reached', + ); + } + + private buildPromptWithCode(): string { + const formattedHeuristics = + this._detectionProgramRuleInput.heuristics && + this._detectionProgramRuleInput.heuristics.length > 0 + ? this._detectionProgramRuleInput.heuristics + .map((h) => `* ${h}`) + .join('\n') + : ''; + return generate_program_code + .replace('$RULE_CONTENT', this._detectionProgramRuleInput.rule.content) + .replace('$RULE_HEURISTICS$', formattedHeuristics) + .replace('$RULE_LANGUAGE$', this._detectionProgramRuleInput.language) + .replace( + '$RULE_BAD_EXAMPLES$', + getBadExamplesCode(this._detectionProgramRuleInput.ruleExamples), + ) + .replace( + '$RULE_GOOD_EXAMPLES$', + getGoodExamplesCode(this._detectionProgramRuleInput.ruleExamples), + ); + } + + async getFileInputFromContent( + fileContent: string, + ): Promise<SourceCodeRepresentation> { + return { + content: fileContent, + sourceCodeState: 'RAW', + }; + } + + async getSuffixCode(): Promise<string> { + return getFileContent(`${Globals.JS_PLAYGROUND_PATH}/suffix_code.js`); + } + + async getSuffixCodeForMultiplePrograms(): Promise<string> { + return getFileContent(`${Globals.JS_PLAYGROUND_PATH}/suffix_code_multi.js`); + } + + getSourceCodeState(): SourceCodeState { + return 'RAW'; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/raw/generate_program_code.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/raw/generate_program_code.ts new file mode 100644 index 000000000..49aa1b81e --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/raw/generate_program_code.ts @@ -0,0 +1,34 @@ +export const generate_program_code = ` +You need to generate a Javascript function called "checkSourceCode" that takes as input a $RULE_LANGUAGE$ source code as a string, +and detect violations of the coding rule described below. + +Here is the coding rule description: +""" +$RULE_CONTENT$ +""" + +# Detection Heuristics +""" +$RULE_HEURISTICS$ +""" + +$RULE_BAD_EXAMPLES$ + +$RULE_GOOD_EXAMPLES$ + +Instructions: +* Use the "Detection Heuristics" that are documentation that provides specific, technical details on how violations of the following coding rule will be detected using static analysis within a single source file. +* Add comments in your code to improve the quality of your logic +* Regarding the violations to report: + - You need to be very accurate with the line numbers. The first line should be 0, not 1. + - The reported line should be the start of the pattern location matched. +* $PROGRAM_EXTERNAL_LIBRARIES$ +* CRITICAL OUTPUT FORMAT: The output of the function MUST be a simple array of line numbers ONLY. Return EXACTLY this format: [1, 3, 10]. DO NOT return objects with properties like {line: 2, message: "..."} or any other format. ONLY return an array of numbers representing line numbers where violations are found. +* Your answer should only contain the function code, nothing else, even though there are requirements (such as library installation). Example of expected answer: +\`\`\` +function checkSourceCode(code) { + // Your code here +} +\`\`\` +* Please output only the function code, NOTHING else. +* Do not use any other programming languages than JavaScript.`; diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/raw/generate_program_code_heuristics.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/raw/generate_program_code_heuristics.ts new file mode 100644 index 000000000..a42eb5fe5 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/program/strategy/raw/generate_program_code_heuristics.ts @@ -0,0 +1,35 @@ +export const generate_program_code_heuristics = ` +You need to generate a Javascript function called "checkSourceCode" that takes as input a $RULE_LANGUAGE$ source code as a string, +and detect violations of the coding rule described below. + +Here is the coding rule description: + +""" +$RULE_CONTENT$ +""" + +# Detection Heuristics +""" +$RULE_HEURISTICS$ +""" + +$RULE_BAD_EXAMPLES$ + +$RULE_GOOD_EXAMPLES$ + +Instructions: +* Use the "Detection Heuristics" that are documentation that provides specific, technical details on how violations of the following coding rule will be detected using static analysis within a single source file. +* Add comments in your code to improve the quality of your logic +* Regarding the violations to report: + - You need to be very accurate with the line numbers. The first line should be 0, not 1. + - The reported line should be the start of the pattern location matched. +* $PROGRAM_EXTERNAL_LIBRARIES$ +* CRITICAL OUTPUT FORMAT: The output of the function MUST be a simple array of line numbers ONLY. Return EXACTLY this format: [1, 3, 10]. DO NOT return objects with properties like {line: 2, message: "..."} or any other format. ONLY return an array of numbers representing line numbers where violations are found. +* Your answer should only contain the function code, nothing else, even though there are requirements (such as library installation). Example of expected answer: +\`\`\` +function checkSourceCode(code) { + // Your code here +} +\`\`\` +* Please output only the function code, NOTHING else. +* Do not use any other programming languages than JavaScript.`; diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculator.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculator.ts new file mode 100644 index 000000000..13a880d26 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculator.ts @@ -0,0 +1,5 @@ +import { AnalysisResult } from '../generation/Types'; + +export default interface AnalysisResultCalculator { + computeAnalysisResult(results: number[]): AnalysisResult; +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculatorForPackmindRules.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculatorForPackmindRules.ts new file mode 100644 index 000000000..0da37940d --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculatorForPackmindRules.ts @@ -0,0 +1,36 @@ +import { + AnalysisResult, + LineViolation, + RuleViolation, +} from '../generation/Types'; +import AnalysisResultCalculator from './AnalysisResultCalculator'; + +/** + * In this class, we compute result for source files where we have just negative and positive examples for 1 file + */ +export default abstract class AnalysisResultCalculatorForPackmindRules implements AnalysisResultCalculator { + public abstract computeAnalysisResult(results: number[]): AnalysisResult; + + public abstract isPositive(): boolean; + + protected isViolationFoundInSourceCode( + results: number[], + badExampleForRule: RuleViolation, + ): boolean { + // This checks that at least one violation is detected within the range of the bad example + const truePositives = badExampleForRule.lines.filter((v) => + this.isViolationDetected(results, v), + ); + return truePositives.length > 0; + } + + protected isViolationDetected( + results: number[], + lineViolation: LineViolation, + ): boolean { + const isViolationDetected = results.some( + (r) => r >= lineViolation.start && r <= lineViolation.end, + ); + return isViolationDetected; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculatorForPackmindRulesAndNegativeExample.spec.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculatorForPackmindRulesAndNegativeExample.spec.ts new file mode 100644 index 000000000..5d3c39cb0 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculatorForPackmindRulesAndNegativeExample.spec.ts @@ -0,0 +1,127 @@ +import { + createRuleExampleId, + createRuleId, + ProgrammingLanguage, + RuleExample, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; +import { DetectionMethodType } from '../generation/Types'; +import AnalysisResultCalculatorForPackmindRulesAndNegativeExample from './AnalysisResultCalculatorForPackmindRulesAndNegativeExample'; + +describe('AnalysisResultCalculatorForPackmindRulesAndNegativeExample', () => { + describe('computeAnalysisResult', () => { + describe('when no violations are detected', () => { + it('returns false negatives', () => { + const ruleId = createRuleId(uuidv4()); + const ruleExample: RuleExample = { + id: createRuleExampleId(uuidv4()), + ruleId, + lang: ProgrammingLanguage.JAVASCRIPT, + positive: 'const x = 1;\nconst y = 2;', + negative: 'function bad(a, b, c) {\n return a + b + c;\n}', + }; + + const analysis = + new AnalysisResultCalculatorForPackmindRulesAndNegativeExample( + ruleId, + ruleExample, + DetectionMethodType.PROGRAM, + ); + const result = analysis.computeAnalysisResult([]); + + expect(result).toEqual({ + ruleId, + method: DetectionMethodType.PROGRAM, + filePath: `Negative example for RuleExample ${ruleExample.id}`, + positive: false, + precision: 0, + recall: 0, + truePositives: [], + falsePositives: [], + falseNegatives: [ + { + start: 0, + end: 2, + }, + ], + }); + }); + }); + + describe('when violation is detected in negative example', () => { + it('returns true positives', () => { + const ruleId = createRuleId(uuidv4()); + const ruleExample: RuleExample = { + id: createRuleExampleId(uuidv4()), + ruleId, + lang: ProgrammingLanguage.JAVASCRIPT, + positive: 'const x = 1;\nconst y = 2;', + negative: 'function bad(a, b, c) {\n return a + b + c;\n}', + }; + + const analysis = + new AnalysisResultCalculatorForPackmindRulesAndNegativeExample( + ruleId, + ruleExample, + DetectionMethodType.PROGRAM, + ); + const result = analysis.computeAnalysisResult([1]); + + expect(result).toEqual({ + ruleId, + method: DetectionMethodType.PROGRAM, + filePath: `Negative example for RuleExample ${ruleExample.id}`, + positive: false, + precision: 1, + recall: 1, + truePositives: [ + { + start: 0, + end: 2, + }, + ], + falsePositives: [], + falseNegatives: [], + }); + }); + }); + + describe('when violation is detected outside the negative example range', () => { + it('returns false negatives', () => { + const ruleId = createRuleId(uuidv4()); + const ruleExample: RuleExample = { + id: createRuleExampleId(uuidv4()), + ruleId, + lang: ProgrammingLanguage.JAVASCRIPT, + positive: 'const x = 1;', + negative: 'function bad(a, b, c) {}', + }; + + const analysis = + new AnalysisResultCalculatorForPackmindRulesAndNegativeExample( + ruleId, + ruleExample, + DetectionMethodType.PROGRAM, + ); + const result = analysis.computeAnalysisResult([15]); + + expect(result).toEqual({ + ruleId, + method: DetectionMethodType.PROGRAM, + filePath: `Negative example for RuleExample ${ruleExample.id}`, + positive: false, + precision: 0, + recall: 0, + truePositives: [], + falsePositives: [], + falseNegatives: [ + { + start: 0, + end: 0, + }, + ], + }); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculatorForPackmindRulesAndNegativeExample.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculatorForPackmindRulesAndNegativeExample.ts new file mode 100644 index 000000000..f69487be0 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculatorForPackmindRulesAndNegativeExample.ts @@ -0,0 +1,75 @@ +import AnalysisResultCalculatorForPackmindRules from './AnalysisResultCalculatorForPackmindRules'; +import { + AnalysisResult, + DetectionMethodType, + RuleViolation, +} from '../generation/Types'; +import { RuleExample, RuleId } from '@packmind/types'; + +export default class AnalysisResultCalculatorForPackmindRulesAndNegativeExample extends AnalysisResultCalculatorForPackmindRules { + constructor( + private readonly ruleId: RuleId, + private readonly example: RuleExample, + private readonly detectionType: DetectionMethodType, + ) { + super(); + } + + computeAnalysisResult(results: number[]): AnalysisResult { + const violation: RuleViolation = { + rule: this.ruleId, + lines: [ + { + start: 0, // We don't have location area in our new Packmind system + end: this.example.negative.split('\n').length - 1, + }, + ], + }; + if ( + results.length && + this.isViolationFoundInSourceCode(results, violation) + ) { + //At least a result has been found + const truePositives = violation.lines.filter((v) => + this.isViolationDetected(results, v), + ); + return { + ruleId: this.ruleId, + method: this.detectionType, + filePath: this.getNegativeExampleFilePath(), + positive: this.isPositive(), + precision: 1, + recall: 1, + truePositives, + falsePositives: [], + falseNegatives: [], + }; + } else { + //No result were found, so the violation has not been detected + return { + ruleId: this.ruleId, + method: this.detectionType, + filePath: this.getNegativeExampleFilePath(), + positive: this.isPositive(), + precision: 0, + recall: 0, + truePositives: [], + falsePositives: [], + falseNegatives: [ + { + start: 0, // We don't have location area in our new Packmind system + end: this.example.negative.split('\n').length - 1, + }, + ], + }; + } + } + + private getNegativeExampleFilePath() { + return `Negative example for RuleExample ${this.example.id}`; + } + + isPositive(): boolean { + return false; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculatorForPackmindRulesAndPositiveExample.spec.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculatorForPackmindRulesAndPositiveExample.spec.ts new file mode 100644 index 000000000..b91f3089f --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculatorForPackmindRulesAndPositiveExample.spec.ts @@ -0,0 +1,112 @@ +import { + createRuleExampleId, + createRuleId, + ProgrammingLanguage, + RuleExample, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; +import { DetectionMethodType } from '../generation/Types'; +import AnalysisResultCalculatorForPackmindRulesAndPositiveExample from './AnalysisResultCalculatorForPackmindRulesAndPositiveExample'; + +describe('AnalysisResultCalculatorForPackmindRulesAndPositiveExample', () => { + describe('computeAnalysisResult', () => { + describe('when no violation is found on a positive example', () => { + it('returns perfect precision and recall', () => { + const ruleId = createRuleId(uuidv4()); + const ruleExample: RuleExample = { + id: createRuleExampleId(uuidv4()), + ruleId, + lang: ProgrammingLanguage.JAVASCRIPT, + positive: 'function good(a, b) {\n return a + b;\n}', + negative: 'function bad(a, b, c) {}', + }; + + const analysis = + new AnalysisResultCalculatorForPackmindRulesAndPositiveExample( + ruleId, + ruleExample, + DetectionMethodType.PROGRAM, + ); + const result = analysis.computeAnalysisResult([]); + + expect(result).toEqual({ + ruleId, + method: DetectionMethodType.PROGRAM, + filePath: `Positive example for RuleExample ${ruleExample.id}`, + positive: true, + precision: 1, + recall: 1, + truePositives: [], + falsePositives: [], + falseNegatives: [], + }); + }); + }); + + describe('when violation is found on a positive example', () => { + it('returns zero precision and recall', () => { + const ruleId = createRuleId(uuidv4()); + const ruleExample: RuleExample = { + id: createRuleExampleId(uuidv4()), + ruleId, + lang: ProgrammingLanguage.JAVASCRIPT, + positive: 'function good(a, b) {}', + negative: 'function bad(a, b, c) {}', + }; + + const analysis = + new AnalysisResultCalculatorForPackmindRulesAndPositiveExample( + ruleId, + ruleExample, + DetectionMethodType.PROGRAM, + ); + const result = analysis.computeAnalysisResult([0]); + + expect(result).toEqual({ + ruleId, + method: DetectionMethodType.PROGRAM, + filePath: `Positive example for RuleExample ${ruleExample.id}`, + positive: true, + precision: 0, + recall: 0, + truePositives: [], + falsePositives: [0], + falseNegatives: [], + }); + }); + }); + + describe('when no violation is found in positive example but violations exist elsewhere', () => { + it('returns perfect precision and recall', () => { + const ruleId = createRuleId(uuidv4()); + const ruleExample: RuleExample = { + id: createRuleExampleId(uuidv4()), + ruleId, + lang: ProgrammingLanguage.JAVASCRIPT, + positive: 'function good(a, b) {}', + negative: 'function bad(a, b, c) {}', + }; + + const analysis = + new AnalysisResultCalculatorForPackmindRulesAndPositiveExample( + ruleId, + ruleExample, + DetectionMethodType.PROGRAM, + ); + const result = analysis.computeAnalysisResult([13, 14, 15]); + + expect(result).toEqual({ + ruleId, + method: DetectionMethodType.PROGRAM, + filePath: `Positive example for RuleExample ${ruleExample.id}`, + positive: true, + precision: 1, + recall: 1, + truePositives: [], + falsePositives: [], + falseNegatives: [], + }); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculatorForPackmindRulesAndPositiveExample.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculatorForPackmindRulesAndPositiveExample.ts new file mode 100644 index 000000000..31cc25254 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/results/AnalysisResultCalculatorForPackmindRulesAndPositiveExample.ts @@ -0,0 +1,79 @@ +import AnalysisResultCalculatorForPackmindRules from './AnalysisResultCalculatorForPackmindRules'; +import { + AnalysisResult, + DetectionMethodType, + RuleViolation, +} from '../generation/Types'; +import { RuleExample, RuleId } from '@packmind/types'; + +export default class AnalysisResultCalculatorForPackmindRulesAndPositiveExample extends AnalysisResultCalculatorForPackmindRules { + constructor( + private readonly ruleId: RuleId, + private readonly example: RuleExample, + private readonly detectionType: DetectionMethodType, + ) { + super(); + } + + computeAnalysisResult(results: number[]): AnalysisResult { + if (!results.length) { + return { + ruleId: this.ruleId, + method: this.detectionType, + filePath: this.getPositiveExampleFilePath(), + positive: this.isPositive(), + precision: 1, + recall: 1, + truePositives: [], + falsePositives: [], + falseNegatives: [], + }; + } + const violation: RuleViolation = { + rule: this.ruleId, + lines: [ + { + start: 0, // We don't have location area in our new Packmind system + end: this.example.positive.split('\n').length - 1, + }, + ], + }; + if (this.isViolationFoundInSourceCode(results, violation)) { + const falsePositives = violation.lines + .filter((v) => this.isViolationDetected(results, v)) + .map((l) => l.start); + return { + ruleId: this.ruleId, + method: this.detectionType, + filePath: this.getPositiveExampleFilePath(), + positive: this.isPositive(), + precision: 0, + recall: 0, + truePositives: [], + falsePositives, + falseNegatives: [], + }; + } else { + //This mean this is not found, and we can't assume nothing on other lines + return { + ruleId: this.ruleId, + method: this.detectionType, + filePath: this.getPositiveExampleFilePath(), + positive: this.isPositive(), + precision: 1, + recall: 1, + truePositives: [], + falsePositives: [], + falseNegatives: [], + }; + } + } + + private getPositiveExampleFilePath() { + return `Positive example for RuleExample ${this.example.id}`; + } + + isPositive(): boolean { + return true; + } +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/utils/Globals.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/utils/Globals.ts new file mode 100644 index 000000000..e155051cc --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/utils/Globals.ts @@ -0,0 +1,8 @@ +export default class Globals { + public static readonly JS_PLAYGROUND_PATH: string = + process.env.JS_PLAYGROUND_PATH || 'js-playground'; + + public static readonly MAX_DEBUG_RETRY: number = process.env.MAX_DEBUG_RETRY + ? parseInt(process.env.MAX_DEBUG_RETRY) + : 6; +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/utils/IO.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/utils/IO.ts new file mode 100644 index 000000000..9d1452106 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/utils/IO.ts @@ -0,0 +1,122 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { spawn } from 'child_process'; +import { PackmindLogger } from '@packmind/logger'; +import { getErrorMessage } from '@packmind/node-utils'; + +const logger = new PackmindLogger('linter-IO'); + +export async function getFileContent(filePath: string): Promise<string> { + const content = await fs.readFile(filePath); + return content.toString(); +} + +export async function writeFileContent( + content: string, + filePath: string, +): Promise<void> { + await fs.writeFile(filePath, content); +} + +export async function deleteFile(filePath: string): Promise<void> { + try { + await fs.unlink(filePath); + } catch (err) { + console.error('Error deleting the file:', err); + } +} + +export async function deleteFiles(paths: string[]): Promise<void> { + //No need to wait for termination + paths.forEach((filePath) => deleteFile(filePath)); +} + +export async function callIndexJsWithInput( + programPath: string, + input: string, +): Promise<string> { + return new Promise((resolve, reject) => { + const directory = path.dirname(programPath); + const baseName = path.basename(programPath); + + logger.debug(`Run program ${baseName} in ${directory}`); + const childProcess = spawn('node', [baseName], { cwd: directory }); + + let output = ''; + let errorOutput = ''; + + let isTimeout = false; + + // Set a timeout + const timeout = setTimeout(() => { + isTimeout = true; + childProcess.kill(); // Kill the process if it exceeds the timeout + reject(new Error(`Operation timed out after 10,000 ms`)); + }, 10000); + + childProcess.on('error', (err) => { + clearTimeout(timeout); // Clear timeout if an error occurs + reject(new Error(`Failed to start child process: ${err.message}`)); + }); + + // Ensure the input is sent only if the child process is ready + childProcess.on('spawn', () => { + try { + // Ensure input is a string or convert it to a string + const inputStr = + typeof input === 'string' ? input : JSON.stringify(input); + + // Write the input and end the stream + childProcess.stdin.write(inputStr, (err) => { + if (err) { + logger.error(`Error writing to stdin: ${err.message}`); + clearTimeout(timeout); // Clear timeout + return reject( + new Error(`Failed to write to stdin: ${err.message}`), + ); + } + // Explicitly close the stdin stream + childProcess.stdin.end(); + }); + } catch (error) { + clearTimeout(timeout); // Clear timeout + reject( + new Error( + `Error handling input for child process: ${getErrorMessage(error)}`, + ), + ); + } + }); + + // Handle errors on stdin separately + childProcess.stdin.on('error', (err) => { + logger.error(`Error in stdin stream: ${err.message}`); + clearTimeout(timeout); // Clear timeout + reject(new Error(`Error writing to stdin: ${err.message}`)); + }); + + // Capture the output from the child process + childProcess.stdout.on('data', (data) => { + output += data.toString(); + }); + + // Capture any error messages from the child process + childProcess.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + + // Capture the exit code when the child process exits + childProcess.on('close', (code) => { + if (!isTimeout) { + clearTimeout(timeout); // Clear timeout if the process finishes in time + if (code === 0) { + resolve(output); + } else { + reject( + new Error(`Child process exited with code ${code}: ${errorOutput}`), + ); + } + } + }); + }); +} diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/utils/PromptUtils.spec.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/utils/PromptUtils.spec.ts new file mode 100644 index 000000000..719ff0ba6 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/utils/PromptUtils.spec.ts @@ -0,0 +1,16 @@ +import { wrapText } from './PromptUtils'; + +describe('wrapText', () => { + it('wraps text at specified length with line breaks', () => { + const lines = wrapText( + 'Hello this is a long text that should be wrapped', + 10, + ); + + expect(lines).toBe(`Hello this +is a long +text that +should be +wrapped`); + }); +}); diff --git a/packages/linter/src/application/useCases/generateProgramUseCase/shared/utils/PromptUtils.ts b/packages/linter/src/application/useCases/generateProgramUseCase/shared/utils/PromptUtils.ts new file mode 100644 index 000000000..413668775 --- /dev/null +++ b/packages/linter/src/application/useCases/generateProgramUseCase/shared/utils/PromptUtils.ts @@ -0,0 +1,93 @@ +import { RuleExample } from '@packmind/types'; + +export function getBadExamplesCode(ruleExamples: RuleExample[]) { + const badExamples = ruleExamples + .map((r) => r.negative) + .filter((ex) => ex?.length); + + if (!badExamples.length) { + return ''; + } + + let badExamplesCode = '## Bad examples for the rule'; + for (const example of badExamples) { + const prompt = ` +\`\`\` +${example} +\`\`\` `; + badExamplesCode = `${badExamplesCode} \n ${prompt}`; + } + return badExamplesCode; +} + +export function getGoodExamplesCode(ruleExamples: RuleExample[]) { + const goodExamples = ruleExamples + .map((r) => r.positive) + .filter((ex) => ex?.length); + + if (!goodExamples.length) { + return ''; + } + + let goodExamplesCode = '## Good examples for the rule'; + for (const example of goodExamples) { + const prompt = ` +\`\`\` +${example} +\`\`\` `; + goodExamplesCode = `${goodExamplesCode} \n ${prompt}`; + } + return goodExamplesCode; +} + +export function wrapText(text: string, maxLength = 140): string { + const words = text.split(' '); + let currentLine = ''; + const lines: string[] = []; + + for (const word of words) { + if ((currentLine + word).length > maxLength) { + lines.push(currentLine.trim()); + currentLine = ''; + } + currentLine += word + ' '; + } + + if (currentLine.trim().length > 0) { + lines.push(currentLine.trim()); + } + + return lines.join('\n'); +} + +export function indentFileContent(fileContent: string, indent: string): string { + return fileContent + .split('\n') + .map((line) => `${indent}${line}`) + .join('\n'); +} + +export function limitFileContent( + fileContent: string, + maxLines: number, +): string { + const lines = fileContent.split('\n'); + if (lines.length <= maxLines) return fileContent; + + let foundNonEmptyLine = false; + const updatedLines = lines + .slice(0, maxLines) + .reverse() + .reduce((acc, line) => { + if (foundNonEmptyLine) { + acc.push(line); + } else if (line.trim().length > 0) { + foundNonEmptyLine = true; + acc.push(line); + } + return acc; + }, [] as string[]) + .reverse(); + + return [...updatedLines, '...'].join('\n'); +} diff --git a/packages/linter/src/application/useCases/getActiveDetectionProgram/GetActiveDetectionProgramUseCase.spec.ts b/packages/linter/src/application/useCases/getActiveDetectionProgram/GetActiveDetectionProgramUseCase.spec.ts new file mode 100644 index 000000000..2c7a42114 --- /dev/null +++ b/packages/linter/src/application/useCases/getActiveDetectionProgram/GetActiveDetectionProgramUseCase.spec.ts @@ -0,0 +1,1229 @@ +import { GetActiveDetectionProgramUseCase } from './GetActiveDetectionProgramUseCase'; +import { IActiveDetectionProgramRepository } from '../../../domain/repositories/IActiveDetectionProgramRepository'; +import { DetectionProgramService } from '../../services/DetectionProgramService'; +import { ruleFactory } from '@packmind/standards/test'; +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { createOrganizationId, createUserId } from '@packmind/types'; +import { + createRuleId, + IStandardsPort, + ProgrammingLanguage, + Rule, + RuleExample, + createRuleExampleId, + ActiveDetectionProgram, + createActiveDetectionProgramId, +} from '@packmind/types'; +import { stubLogger } from '@packmind/test-utils'; +import { + createDetectionProgramId, + DetectionProgram, + DetectionModeEnum, +} from '@packmind/types'; +import { + activeDetectionProgramFactory, + detectionProgramFactory, +} from '../../../../test'; +import { + GetActiveDetectionProgramCommand, + GetActiveDetectionProgramResponse, +} from '@packmind/types'; + +const buildActiveProgramWithRelations = ( + activeProgram: ActiveDetectionProgram, + detectionProgram: DetectionProgram, + draftDetectionProgram: DetectionProgram | null = null, +) => ({ + ...activeProgram, + detectionProgram, + draftDetectionProgram, +}); + +describe('GetActiveDetectionProgramUseCase', () => { + let getActiveDetectionProgramUseCase: GetActiveDetectionProgramUseCase; + let activeDetectionProgramRepository: jest.Mocked<IActiveDetectionProgramRepository>; + let detectionProgramService: jest.Mocked<DetectionProgramService>; + let standardsAdapter: jest.Mocked<IStandardsPort>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + beforeEach(() => { + // Mock ActiveDetectionProgramRepository + activeDetectionProgramRepository = { + add: jest.fn(), + save: jest.fn(), + findById: jest.fn(), + findByRuleId: jest.fn(), + findByRuleIdAndLanguage: jest.fn(), + findByRuleIdWithPrograms: jest.fn(), + deleteByRuleId: jest.fn(), + delete: jest.fn(), + findAll: jest.fn(), + } as unknown as jest.Mocked<IActiveDetectionProgramRepository>; + + standardsAdapter = { + getRule: jest.fn(), + getStandard: jest.fn(), + getStandardVersion: jest.fn(), + getStandardVersionById: jest.fn(), + listStandardVersions: jest.fn(), + getLatestRulesByStandardId: jest.fn(), + getRulesByStandardId: jest.fn(), + listStandardsBySpace: jest.fn(), + getRuleCodeExamples: jest.fn(), + findStandardBySlug: jest.fn(), + getLatestStandardVersion: jest.fn(), + }; + standardsAdapter.getRuleCodeExamples.mockResolvedValue([]); + + stubbedLogger = stubLogger(); + + // Build a mocked DetectionProgramService to satisfy constructor + detectionProgramService = { + findActiveByRuleIdAndLanguage: + activeDetectionProgramRepository.findByRuleIdAndLanguage, + findActiveByRuleIdWithPrograms: + activeDetectionProgramRepository.findByRuleIdWithPrograms, + } as unknown as jest.Mocked<DetectionProgramService>; + + getActiveDetectionProgramUseCase = new GetActiveDetectionProgramUseCase( + detectionProgramService, + standardsAdapter, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute', () => { + describe('when active detection program exists', () => { + let command: GetActiveDetectionProgramCommand; + let existingRule: Rule; + let activeProgram: ActiveDetectionProgram; + let detectionProgram: DetectionProgram; + let draftDetectionProgram: DetectionProgram; + let result: GetActiveDetectionProgramResponse; + + beforeEach(async () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const detectionProgramId = createDetectionProgramId(uuidv4()); + + command = { + ruleId, + organizationId, + userId, + }; + + existingRule = ruleFactory({ + id: ruleId, + }); + + detectionProgram = detectionProgramFactory({ + id: detectionProgramId, + ruleId, + code: 'if (ast.node.type === "ClassDeclaration") { return { line: ast.node.loc.start.line }; }', + version: 3, + mode: DetectionModeEnum.SINGLE_AST, + }); + + draftDetectionProgram = detectionProgramFactory({ + id: createDetectionProgramId(uuidv4()), + ruleId, + code: 'draft program', + version: 4, + mode: DetectionModeEnum.SINGLE_AST, + }); + + activeProgram = activeDetectionProgramFactory({ + id: createActiveDetectionProgramId(uuidv4()), + detectionProgramVersion: detectionProgramId, + detectionProgramDraftVersion: draftDetectionProgram.id, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + const activeProgramWithDetectionProgram = + buildActiveProgramWithRelations( + activeProgram, + detectionProgram, + draftDetectionProgram, + ); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [activeProgramWithDetectionProgram], + ); + + result = await getActiveDetectionProgramUseCase.execute(command); + }); + + it('validates rule exists and belongs to organization', () => { + expect(standardsAdapter.getRule).toHaveBeenCalledWith(command.ruleId); + }); + + it('retrieves active detection program with associated detection program data', () => { + expect( + activeDetectionProgramRepository.findByRuleIdWithPrograms, + ).toHaveBeenCalledWith(command.ruleId); + }); + + it('returns active detection program with detection program data', () => { + expect(result).toEqual({ + programs: [ + { + id: activeProgram.id, + detectionProgramVersion: activeProgram.detectionProgramVersion, + ruleId: activeProgram.ruleId, + language: activeProgram.language, + severity: activeProgram.severity, + detectionProgram: { + id: detectionProgram.id, + ruleId: detectionProgram.ruleId, + code: detectionProgram.code, + version: detectionProgram.version, + mode: detectionProgram.mode, + language: detectionProgram.language, + status: detectionProgram.status, + sourceCodeState: detectionProgram.sourceCodeState, + createdAt: detectionProgram.createdAt, + }, + detectionProgramDraftVersion: + activeProgram.detectionProgramDraftVersion, + draftDetectionProgram: { + id: draftDetectionProgram.id, + ruleId: draftDetectionProgram.ruleId, + code: draftDetectionProgram.code, + version: draftDetectionProgram.version, + mode: draftDetectionProgram.mode, + language: draftDetectionProgram.language, + status: draftDetectionProgram.status, + sourceCodeState: draftDetectionProgram.sourceCodeState, + createdAt: draftDetectionProgram.createdAt, + }, + }, + ], + }); + }); + }); + + describe('when no active detection program exists', () => { + it('returns null', async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + + const command: GetActiveDetectionProgramCommand = { + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingRule = ruleFactory({ + id: ruleId, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [], + ); + + const result = await getActiveDetectionProgramUseCase.execute(command); + + expect(result).toEqual({ programs: null }); + }); + }); + + describe('when rule has examples but no active detection programs', () => { + let result: GetActiveDetectionProgramResponse; + + beforeEach(async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + + const command: GetActiveDetectionProgramCommand = { + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingRule = ruleFactory({ + id: ruleId, + }); + + const ruleExamples: RuleExample[] = [ + { + id: createRuleExampleId('example-ts-1'), + lang: ProgrammingLanguage.TYPESCRIPT, + positive: 'const value = 1;', + negative: 'const badValue = eval(input);', + ruleId, + }, + { + id: createRuleExampleId('example-java-1'), + lang: ProgrammingLanguage.JAVA, + positive: 'public class Sample {}', + negative: 'System.out.println(userInput);', + ruleId, + }, + ]; + + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getRuleCodeExamples.mockResolvedValue(ruleExamples); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [], + ); + + result = await getActiveDetectionProgramUseCase.execute(command); + }); + + it('returns two placeholder entries', () => { + const programs = result.programs ?? []; + + expect(programs).toHaveLength(2); + }); + + it('includes TypeScript language', () => { + const programs = result.programs ?? []; + const languages = programs.map((program) => program.language); + + expect(languages).toContain(ProgrammingLanguage.TYPESCRIPT); + }); + + it('includes Java language', () => { + const programs = result.programs ?? []; + const languages = programs.map((program) => program.language); + + expect(languages).toContain(ProgrammingLanguage.JAVA); + }); + + it('sets detectionProgram to null for TypeScript placeholder', () => { + const programs = result.programs ?? []; + const typescriptProgram = programs.find( + (program) => program.language === ProgrammingLanguage.TYPESCRIPT, + ); + + expect(typescriptProgram?.detectionProgram).toBeNull(); + }); + + it('sets draftDetectionProgram to null for TypeScript placeholder', () => { + const programs = result.programs ?? []; + const typescriptProgram = programs.find( + (program) => program.language === ProgrammingLanguage.TYPESCRIPT, + ); + + expect(typescriptProgram?.draftDetectionProgram).toBeNull(); + }); + + it('marks TypeScript placeholder as example only', () => { + const programs = result.programs ?? []; + const typescriptProgram = programs.find( + (program) => program.language === ProgrammingLanguage.TYPESCRIPT, + ); + + expect(typescriptProgram?.isExampleOnly).toBe(true); + }); + }); + + describe('when rule does not exist', () => { + let command: GetActiveDetectionProgramCommand; + + beforeEach(() => { + command = { + ruleId: createRuleId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + }; + + standardsAdapter.getRule.mockResolvedValue(null); + }); + + it('throws an error', async () => { + await expect( + getActiveDetectionProgramUseCase.execute(command), + ).rejects.toThrow('Rule not found'); + }); + + it('does not query the repository', async () => { + try { + await getActiveDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect( + activeDetectionProgramRepository.findByRuleIdWithPrograms, + ).not.toHaveBeenCalled(); + }); + }); + + describe('with multiple active programs for different languages', () => { + it('returns the first active detection program found', async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const detectionProgramId1 = createDetectionProgramId(uuidv4()); + const detectionProgramId2 = createDetectionProgramId(uuidv4()); + + const command: GetActiveDetectionProgramCommand = { + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingRule = ruleFactory({ + id: ruleId, + }); + + const detectionProgram1 = detectionProgramFactory({ + id: detectionProgramId1, + ruleId, + mode: DetectionModeEnum.REGEXP, + }); + + const detectionProgram2 = detectionProgramFactory({ + id: detectionProgramId2, + ruleId, + mode: DetectionModeEnum.SINGLE_AST, + }); + + const activeProgram1 = activeDetectionProgramFactory({ + detectionProgramVersion: detectionProgramId1, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + const activeProgram2 = activeDetectionProgramFactory({ + detectionProgramVersion: detectionProgramId2, + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + const activeProgramsWithDetectionPrograms = [ + { + ...activeProgram1, + detectionProgram: detectionProgram1, + draftDetectionProgram: null, + }, + { + ...activeProgram2, + detectionProgram: detectionProgram2, + draftDetectionProgram: null, + }, + ]; + + standardsAdapter.getRule.mockResolvedValue(existingRule); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + activeProgramsWithDetectionPrograms, + ); + + const result = await getActiveDetectionProgramUseCase.execute(command); + + expect(result).toEqual({ + programs: [ + { + id: activeProgram1.id, + detectionProgramVersion: activeProgram1.detectionProgramVersion, + ruleId: activeProgram1.ruleId, + language: activeProgram1.language, + severity: activeProgram1.severity, + detectionProgram: detectionProgram1, + draftDetectionProgram: null, + detectionProgramDraftVersion: null, + }, + { + id: activeProgram2.id, + detectionProgramVersion: activeProgram2.detectionProgramVersion, + ruleId: activeProgram2.ruleId, + language: activeProgram2.language, + severity: activeProgram2.severity, + detectionProgram: detectionProgram2, + draftDetectionProgram: null, + detectionProgramDraftVersion: null, + }, + ], + }); + }); + }); + + describe('with different detection program modes', () => { + describe('when mode is REGEXP', () => { + let result: GetActiveDetectionProgramResponse; + + beforeEach(async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const detectionProgramId = createDetectionProgramId(uuidv4()); + + const command: GetActiveDetectionProgramCommand = { + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingRule = ruleFactory({ + id: ruleId, + }); + + const detectionProgram = detectionProgramFactory({ + id: detectionProgramId, + ruleId, + code: '/Controller$/.test(className)', + mode: DetectionModeEnum.REGEXP, + }); + + const activeProgram = activeDetectionProgramFactory({ + detectionProgramVersion: detectionProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeProgram, + detectionProgram, + draftDetectionProgram: null, + }, + ], + ); + + result = await getActiveDetectionProgramUseCase.execute(command); + }); + + it('returns detection program with REGEXP mode', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].detectionProgram.mode).toBe( + DetectionModeEnum.REGEXP, + ); + }); + + it('returns detection program with correct code', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].detectionProgram.code).toBe( + '/Controller$/.test(className)', + ); + }); + }); + + describe('when mode is FILE_SYSTEM', () => { + let result: GetActiveDetectionProgramResponse; + + beforeEach(async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const detectionProgramId = createDetectionProgramId(uuidv4()); + + const command: GetActiveDetectionProgramCommand = { + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingRule = ruleFactory({ + id: ruleId, + }); + + const detectionProgram = detectionProgramFactory({ + id: detectionProgramId, + ruleId, + code: 'const files = fs.readdirSync(directory);', + mode: DetectionModeEnum.FILE_SYSTEM, + }); + + const activeProgram = activeDetectionProgramFactory({ + detectionProgramVersion: detectionProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeProgram, + detectionProgram, + draftDetectionProgram: null, + }, + ], + ); + + result = await getActiveDetectionProgramUseCase.execute(command); + }); + + it('returns detection program with FILE_SYSTEM mode', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].detectionProgram.mode).toBe( + DetectionModeEnum.FILE_SYSTEM, + ); + }); + + it('returns detection program with correct code', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].detectionProgram.code).toBe( + 'const files = fs.readdirSync(directory);', + ); + }); + }); + }); + + describe('with different programming languages', () => { + describe('when language is TypeScript', () => { + it('returns active program with TypeScript language', async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const detectionProgramId = createDetectionProgramId(uuidv4()); + + const command: GetActiveDetectionProgramCommand = { + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingRule = ruleFactory({ + id: ruleId, + }); + + const detectionProgram = detectionProgramFactory({ + id: detectionProgramId, + ruleId, + }); + + const activeProgram = activeDetectionProgramFactory({ + detectionProgramVersion: detectionProgramId, + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeProgram, + detectionProgram, + draftDetectionProgram: null, + }, + ], + ); + + const result = + await getActiveDetectionProgramUseCase.execute(command); + + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + expect(resultArray[0].language).toBe(ProgrammingLanguage.TYPESCRIPT); + }); + }); + + describe('when language is Python', () => { + it('returns active program with Python language', async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const detectionProgramId = createDetectionProgramId(uuidv4()); + + const command: GetActiveDetectionProgramCommand = { + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingRule = ruleFactory({ + id: ruleId, + }); + + const detectionProgram = detectionProgramFactory({ + id: detectionProgramId, + ruleId, + }); + + const activeProgram = activeDetectionProgramFactory({ + detectionProgramVersion: detectionProgramId, + ruleId, + language: ProgrammingLanguage.PYTHON, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeProgram, + detectionProgram, + draftDetectionProgram: null, + }, + ], + ); + + const result = + await getActiveDetectionProgramUseCase.execute(command); + + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + expect(resultArray[0].language).toBe(ProgrammingLanguage.PYTHON); + }); + }); + }); + + describe('with version information', () => { + let result: GetActiveDetectionProgramResponse; + let detectionProgramId: ReturnType<typeof createDetectionProgramId>; + + beforeEach(async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + detectionProgramId = createDetectionProgramId(uuidv4()); + + const command: GetActiveDetectionProgramCommand = { + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingRule = ruleFactory({ + id: ruleId, + }); + + const detectionProgram = detectionProgramFactory({ + id: detectionProgramId, + ruleId, + version: 5, + }); + + const activeProgram = activeDetectionProgramFactory({ + detectionProgramVersion: detectionProgramId, + ruleId, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [{ ...activeProgram, detectionProgram, draftDetectionProgram: null }], + ); + + result = await getActiveDetectionProgramUseCase.execute(command); + }); + + it('returns detection program with correct version', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].detectionProgram.version).toBe(5); + }); + + it('returns detection program with correct id', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].detectionProgram.id).toBe(detectionProgramId); + }); + }); + + describe('when repository query fails', () => { + it('throws an error', async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + + const command: GetActiveDetectionProgramCommand = { + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingRule = ruleFactory({ + id: ruleId, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockRejectedValue( + new Error('Database connection failed'), + ); + + await expect( + getActiveDetectionProgramUseCase.execute(command), + ).rejects.toThrow('Database connection failed'); + }); + }); + + describe('response structure validation', () => { + let result: GetActiveDetectionProgramResponse; + let ruleId: ReturnType<typeof createRuleId>; + let detectionProgramId: ReturnType<typeof createDetectionProgramId>; + let activeProgramId: ReturnType<typeof createActiveDetectionProgramId>; + + beforeEach(async () => { + const organizationId = createOrganizationId(uuidv4()); + ruleId = createRuleId(uuidv4()); + detectionProgramId = createDetectionProgramId(uuidv4()); + activeProgramId = createActiveDetectionProgramId(uuidv4()); + + const command: GetActiveDetectionProgramCommand = { + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingRule = ruleFactory({ + id: ruleId, + }); + + const detectionProgram = detectionProgramFactory({ + id: detectionProgramId, + ruleId, + code: 'test code', + version: 1, + mode: DetectionModeEnum.REGEXP, + }); + + const activeProgram = activeDetectionProgramFactory({ + id: activeProgramId, + detectionProgramVersion: detectionProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [{ ...activeProgram, detectionProgram, draftDetectionProgram: null }], + ); + + result = await getActiveDetectionProgramUseCase.execute(command); + }); + + it('returns one program in the array', () => { + expect(result.programs).toHaveLength(1); + }); + + it('returns active program with correct id', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0]).toHaveProperty('id', activeProgramId); + }); + + it('returns active program with correct detectionProgramVersion', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0]).toHaveProperty( + 'detectionProgramVersion', + detectionProgramId, + ); + }); + + it('returns active program with correct ruleId', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0]).toHaveProperty('ruleId', ruleId); + }); + + it('returns active program with correct language', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0]).toHaveProperty( + 'language', + ProgrammingLanguage.JAVASCRIPT, + ); + }); + + it('returns detection program with correct id', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].detectionProgram).toHaveProperty( + 'id', + detectionProgramId, + ); + }); + + it('returns detection program with correct ruleId', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].detectionProgram).toHaveProperty( + 'ruleId', + ruleId, + ); + }); + + it('returns detection program with correct code', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].detectionProgram).toHaveProperty( + 'code', + 'test code', + ); + }); + + it('returns detection program with correct version', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].detectionProgram).toHaveProperty('version', 1); + }); + + it('returns detection program with correct mode', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].detectionProgram).toHaveProperty( + 'mode', + DetectionModeEnum.REGEXP, + ); + }); + }); + + describe('multi-language support scenarios', () => { + describe('when language parameter is provided', () => { + let result: GetActiveDetectionProgramResponse; + let ruleId: ReturnType<typeof createRuleId>; + + beforeEach(async () => { + const organizationId = createOrganizationId(uuidv4()); + ruleId = createRuleId(uuidv4()); + const detectionProgramId = createDetectionProgramId(uuidv4()); + + const command: GetActiveDetectionProgramCommand = { + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingRule = ruleFactory({ + id: ruleId, + }); + + const detectionProgram = detectionProgramFactory({ + id: detectionProgramId, + ruleId, + }); + + const typescriptActiveProgram = activeDetectionProgramFactory({ + detectionProgramVersion: detectionProgramId, + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + typescriptActiveProgram, + ); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...typescriptActiveProgram, + detectionProgram, + draftDetectionProgram: null, + }, + ], + ); + + result = await getActiveDetectionProgramUseCase.execute(command); + }); + + it('queries repository with language filter', () => { + expect( + activeDetectionProgramRepository.findByRuleIdAndLanguage, + ).toHaveBeenCalledWith(ruleId, ProgrammingLanguage.TYPESCRIPT); + }); + + it('returns one program', () => { + expect(result.programs).toHaveLength(1); + }); + + it('returns program with correct language', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].language).toBe(ProgrammingLanguage.TYPESCRIPT); + }); + }); + + describe('when no language parameter is provided', () => { + let result: GetActiveDetectionProgramResponse; + let ruleId: ReturnType<typeof createRuleId>; + + beforeEach(async () => { + const organizationId = createOrganizationId(uuidv4()); + ruleId = createRuleId(uuidv4()); + const detectionProgramId1 = createDetectionProgramId(uuidv4()); + const detectionProgramId2 = createDetectionProgramId(uuidv4()); + + const command: GetActiveDetectionProgramCommand = { + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingRule = ruleFactory({ + id: ruleId, + }); + + const detectionProgram1 = detectionProgramFactory({ + id: detectionProgramId1, + ruleId, + }); + + const detectionProgram2 = detectionProgramFactory({ + id: detectionProgramId2, + ruleId, + }); + + const javascriptActiveProgram = activeDetectionProgramFactory({ + detectionProgramVersion: detectionProgramId1, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + const typescriptActiveProgram = activeDetectionProgramFactory({ + detectionProgramVersion: detectionProgramId2, + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + const activeProgramsWithDetectionPrograms = [ + { + ...javascriptActiveProgram, + detectionProgram: detectionProgram1, + draftDetectionProgram: null, + }, + { + ...typescriptActiveProgram, + detectionProgram: detectionProgram2, + draftDetectionProgram: null, + }, + ]; + + standardsAdapter.getRule.mockResolvedValue(existingRule); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + activeProgramsWithDetectionPrograms, + ); + + result = await getActiveDetectionProgramUseCase.execute(command); + }); + + it('queries repository with rule id only', () => { + expect( + activeDetectionProgramRepository.findByRuleIdWithPrograms, + ).toHaveBeenCalledWith(ruleId); + }); + + it('does not query by language', () => { + expect( + activeDetectionProgramRepository.findByRuleIdAndLanguage, + ).not.toHaveBeenCalled(); + }); + + it('returns two programs', () => { + expect(result.programs).toHaveLength(2); + }); + + it('returns JavaScript program first', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].language).toBe(ProgrammingLanguage.JAVASCRIPT); + }); + + it('returns TypeScript program second', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[1].language).toBe(ProgrammingLanguage.TYPESCRIPT); + }); + }); + + describe('when specific language has no active program', () => { + let result: GetActiveDetectionProgramResponse; + let ruleId: ReturnType<typeof createRuleId>; + + beforeEach(async () => { + const organizationId = createOrganizationId(uuidv4()); + ruleId = createRuleId(uuidv4()); + + const command: GetActiveDetectionProgramCommand = { + ruleId, + language: ProgrammingLanguage.PYTHON, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingRule = ruleFactory({ + id: ruleId, + }); + + standardsAdapter.getRule.mockResolvedValue(existingRule); + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + + result = await getActiveDetectionProgramUseCase.execute(command); + }); + + it('queries repository with Python language', () => { + expect( + activeDetectionProgramRepository.findByRuleIdAndLanguage, + ).toHaveBeenCalledWith(ruleId, ProgrammingLanguage.PYTHON); + }); + + it('returns null programs', () => { + expect(result).toEqual({ programs: null }); + }); + }); + + describe('when multiple languages have different detection program versions', () => { + let result: GetActiveDetectionProgramResponse; + + beforeEach(async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const detectionProgramId1 = createDetectionProgramId(uuidv4()); + const detectionProgramId2 = createDetectionProgramId(uuidv4()); + + const command: GetActiveDetectionProgramCommand = { + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingRule = ruleFactory({ + id: ruleId, + }); + + const detectionProgram1 = detectionProgramFactory({ + id: detectionProgramId1, + ruleId, + version: 1, + mode: DetectionModeEnum.REGEXP, + }); + + const detectionProgram2 = detectionProgramFactory({ + id: detectionProgramId2, + ruleId, + version: 3, + mode: DetectionModeEnum.SINGLE_AST, + }); + + const javascriptActiveProgram = activeDetectionProgramFactory({ + detectionProgramVersion: detectionProgramId1, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + const pythonActiveProgram = activeDetectionProgramFactory({ + detectionProgramVersion: detectionProgramId2, + ruleId, + language: ProgrammingLanguage.PYTHON, + }); + + const activeProgramsWithDetectionPrograms = [ + { + ...javascriptActiveProgram, + detectionProgram: detectionProgram1, + draftDetectionProgram: null, + }, + { + ...pythonActiveProgram, + detectionProgram: detectionProgram2, + draftDetectionProgram: null, + }, + ]; + + standardsAdapter.getRule.mockResolvedValue(existingRule); + activeDetectionProgramRepository.findByRuleIdWithPrograms.mockResolvedValue( + activeProgramsWithDetectionPrograms, + ); + + result = await getActiveDetectionProgramUseCase.execute(command); + }); + + it('returns two programs', () => { + expect(result.programs).toHaveLength(2); + }); + + it('returns JavaScript program with correct language', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].language).toBe(ProgrammingLanguage.JAVASCRIPT); + }); + + it('returns JavaScript program with version 1', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].detectionProgram.version).toBe(1); + }); + + it('returns JavaScript program with REGEXP mode', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[0].detectionProgram.mode).toBe( + DetectionModeEnum.REGEXP, + ); + }); + + it('returns Python program with correct language', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[1].language).toBe(ProgrammingLanguage.PYTHON); + }); + + it('returns Python program with version 3', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[1].detectionProgram.version).toBe(3); + }); + + it('returns Python program with SINGLE_AST mode', () => { + const resultArray = result.programs as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + + expect(resultArray[1].detectionProgram.mode).toBe( + DetectionModeEnum.SINGLE_AST, + ); + }); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/getActiveDetectionProgram/GetActiveDetectionProgramUseCase.ts b/packages/linter/src/application/useCases/getActiveDetectionProgram/GetActiveDetectionProgramUseCase.ts new file mode 100644 index 000000000..49f9531d7 --- /dev/null +++ b/packages/linter/src/application/useCases/getActiveDetectionProgram/GetActiveDetectionProgramUseCase.ts @@ -0,0 +1,142 @@ +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { ProgrammingLanguage } from '@packmind/types'; +import { RuleExample, RuleId } from '@packmind/types'; +import { + GetActiveDetectionProgramCommand, + GetActiveDetectionProgramResponse, + IGetActiveDetectionProgram, + LanguageDetectionPrograms, + createActiveDetectionProgramId, + DetectionSeverity, +} from '@packmind/types'; +import { DetectionProgramService } from '../../services/DetectionProgramService'; +import { IStandardsPort } from '@packmind/types'; + +export class GetActiveDetectionProgramUseCase implements IGetActiveDetectionProgram { + constructor( + private readonly detectionProgramService: DetectionProgramService, + private readonly standardsAdapter: IStandardsPort, + private readonly logger: PackmindLogger = new PackmindLogger( + 'GetActiveDetectionProgramUseCase', + LogLevel.INFO, + ), + ) {} + + async execute( + command: GetActiveDetectionProgramCommand, + ): Promise<GetActiveDetectionProgramResponse> { + try { + // Validate rule exists + const rule = await this.standardsAdapter.getRule(command.ruleId); + if (!rule) { + throw new Error('Rule not found'); + } + + if (command.language) { + const activeProgram = + await this.detectionProgramService.findActiveByRuleIdAndLanguage( + command.ruleId, + command.language, + ); + + if (!activeProgram) { + return { programs: null }; + } + + const activeProgramsWithPrograms = + await this.detectionProgramService.findActiveByRuleIdWithPrograms( + command.ruleId, + ); + + const normalizedPrograms = activeProgramsWithPrograms.map(mapProgram); + + const matchingProgram = normalizedPrograms.find( + (program) => program.language === command.language, + ); + + return { programs: matchingProgram ? [matchingProgram] : null }; + } + + const [activeProgramsWithPrograms, ruleExamples] = await Promise.all([ + this.detectionProgramService.findActiveByRuleIdWithPrograms( + command.ruleId, + ), + this.standardsAdapter.getRuleCodeExamples(command.ruleId), + ]); + + const normalizedPrograms = activeProgramsWithPrograms.map(mapProgram); + const programsByLanguage = new Map< + ProgrammingLanguage, + LanguageDetectionPrograms + >(); + + for (const program of normalizedPrograms) { + programsByLanguage.set(program.language, program); + } + + const languagesWithExamples = this.extractLanguagesWithExamples( + ruleExamples ?? [], + ); + + const placeholderPrograms = languagesWithExamples + .filter((language) => !programsByLanguage.has(language)) + .map((language) => + this.buildPlaceholderProgram(command.ruleId, language), + ); + + const allPrograms = [...normalizedPrograms, ...placeholderPrograms]; + + if (allPrograms.length === 0) { + return { programs: null }; + } + + // Return all programs as array + return { programs: allPrograms }; + } catch (error) { + this.logger.error('Failed to get active detection program', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + private extractLanguagesWithExamples( + examples: RuleExample[], + ): ProgrammingLanguage[] { + const languages = new Set<ProgrammingLanguage>(); + + for (const example of examples) { + const hasContent = + (example.negative && example.negative.trim().length > 0) || + (example.positive && example.positive.trim().length > 0); + + if (hasContent) { + languages.add(example.lang); + } + } + + return Array.from(languages); + } + + private buildPlaceholderProgram( + ruleId: RuleId, + language: ProgrammingLanguage, + ): LanguageDetectionPrograms { + return { + id: createActiveDetectionProgramId(`${String(ruleId)}-${language}`), + detectionProgramVersion: null, + detectionProgramDraftVersion: null, + ruleId, + language, + severity: DetectionSeverity.ERROR, + detectionProgram: null, + draftDetectionProgram: null, + isExampleOnly: true, + }; + } +} +const mapProgram = (program: LanguageDetectionPrograms) => ({ + ...program, + detectionProgram: program.detectionProgram ?? null, + draftDetectionProgram: program.draftDetectionProgram ?? null, +}); diff --git a/packages/linter/src/application/useCases/getActiveDetectionProgramForRule/GetActiveDetectionProgramForRuleUseCase.spec.ts b/packages/linter/src/application/useCases/getActiveDetectionProgramForRule/GetActiveDetectionProgramForRuleUseCase.spec.ts new file mode 100644 index 000000000..28f1a26f4 --- /dev/null +++ b/packages/linter/src/application/useCases/getActiveDetectionProgramForRule/GetActiveDetectionProgramForRuleUseCase.spec.ts @@ -0,0 +1,835 @@ +import { GetActiveDetectionProgramForRuleUseCase } from './GetActiveDetectionProgramForRuleUseCase'; +import { DetectionProgramService } from '../../services/DetectionProgramService'; +import { ruleFactory } from '@packmind/standards/test'; +import { standardFactory } from '@packmind/standards/test'; +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { createOrganizationId, createUserId } from '@packmind/types'; +import { + createRuleId, + IStandardsPort, + ProgrammingLanguage, + createActiveDetectionProgramId, + createDetectionProgramId, + DetectionModeEnum, + DetectionSeverity, + GetActiveDetectionProgramForRuleCommand, +} from '@packmind/types'; +import { stubLogger } from '@packmind/test-utils'; +import { + activeDetectionProgramFactory, + detectionProgramFactory, +} from '../../../../test'; + +describe('GetActiveDetectionProgramForRuleUseCase', () => { + let useCase: GetActiveDetectionProgramForRuleUseCase; + let detectionProgramService: jest.Mocked<DetectionProgramService>; + let standardsAdapter: jest.Mocked<IStandardsPort>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + beforeEach(() => { + detectionProgramService = { + findActiveByRuleIdWithPrograms: jest.fn(), + } as unknown as jest.Mocked<DetectionProgramService>; + + standardsAdapter = { + getRule: jest.fn(), + getStandard: jest.fn(), + getStandardVersion: jest.fn(), + getStandardVersionById: jest.fn(), + listStandardVersions: jest.fn(), + getLatestRulesByStandardId: jest.fn(), + getRulesByStandardId: jest.fn(), + listStandardsBySpace: jest.fn(), + getRuleCodeExamples: jest.fn(), + findStandardBySlug: jest.fn(), + getLatestStandardVersion: jest.fn(), + }; + + stubbedLogger = stubLogger(); + + useCase = new GetActiveDetectionProgramForRuleUseCase( + detectionProgramService, + standardsAdapter, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute', () => { + describe('when active detection programs exist', () => { + let organizationId: ReturnType<typeof createOrganizationId>; + let userId: ReturnType<typeof createUserId>; + let ruleId: ReturnType<typeof createRuleId>; + let standardSlug: string; + let command: GetActiveDetectionProgramForRuleCommand; + let existingStandard: ReturnType<typeof standardFactory>; + let existingRule: ReturnType<typeof ruleFactory>; + let activeProgram1: ReturnType<typeof detectionProgramFactory>; + let activeProgram2: ReturnType<typeof detectionProgramFactory>; + + beforeEach(() => { + organizationId = createOrganizationId(uuidv4()); + userId = createUserId(uuidv4()); + ruleId = createRuleId(uuidv4()); + standardSlug = 'my-standard'; + + command = { + standardSlug, + ruleId, + organizationId, + userId, + }; + + existingStandard = standardFactory({ + slug: standardSlug, + }); + + existingRule = ruleFactory({ + id: ruleId, + }); + + activeProgram1 = detectionProgramFactory({ + id: createDetectionProgramId(uuidv4()), + ruleId, + code: 'active code for javascript', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + activeProgram2 = detectionProgramFactory({ + id: createDetectionProgramId(uuidv4()), + ruleId, + code: 'active code for typescript', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + const activeDetectionProgram1 = activeDetectionProgramFactory({ + id: createActiveDetectionProgramId(uuidv4()), + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + detectionProgramVersion: activeProgram1.id, + }); + + const activeDetectionProgram2 = activeDetectionProgramFactory({ + id: createActiveDetectionProgramId(uuidv4()), + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgramVersion: activeProgram2.id, + }); + + standardsAdapter.findStandardBySlug.mockResolvedValue(existingStandard); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + existingRule, + ]); + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeDetectionProgram1, + detectionProgram: activeProgram1, + draftDetectionProgram: null, + }, + { + ...activeDetectionProgram2, + detectionProgram: activeProgram2, + draftDetectionProgram: null, + }, + ], + ); + }); + + it('calls findStandardBySlug with standard slug and organization id', async () => { + await useCase.execute(command); + + expect(standardsAdapter.findStandardBySlug).toHaveBeenCalledWith( + standardSlug, + organizationId, + ); + }); + + it('calls getRule with rule id', async () => { + await useCase.execute(command); + + expect(standardsAdapter.getRule).toHaveBeenCalledWith(ruleId); + }); + + it('calls getLatestRulesByStandardId with standard id', async () => { + await useCase.execute(command); + + expect( + standardsAdapter.getLatestRulesByStandardId, + ).toHaveBeenCalledWith(existingStandard.id); + }); + + it('calls findActiveByRuleIdWithPrograms with rule id', async () => { + await useCase.execute(command); + + expect( + detectionProgramService.findActiveByRuleIdWithPrograms, + ).toHaveBeenCalledWith(ruleId); + }); + + it('returns two active programs', async () => { + const result = await useCase.execute(command); + + expect(result.programs).toHaveLength(2); + }); + + it('returns first active program matching javascript with severity', async () => { + const result = await useCase.execute(command); + + expect(result.programs[0]).toEqual({ + ...activeProgram1, + severity: DetectionSeverity.ERROR, + }); + }); + + it('returns second active program matching typescript with severity', async () => { + const result = await useCase.execute(command); + + expect(result.programs[1]).toEqual({ + ...activeProgram2, + severity: DetectionSeverity.ERROR, + }); + }); + + it('returns scope from existing standard', async () => { + const result = await useCase.execute(command); + + expect(result.scope).toBe(existingStandard.scope); + }); + }); + + describe('when detectionProgram language is missing', () => { + let ruleId: ReturnType<typeof createRuleId>; + let command: GetActiveDetectionProgramForRuleCommand; + + beforeEach(() => { + const organizationId = createOrganizationId(uuidv4()); + ruleId = createRuleId(uuidv4()); + const standardSlug = 'my-standard'; + + command = { + standardSlug, + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingStandard = standardFactory({ slug: standardSlug }); + const existingRule = ruleFactory({ id: ruleId }); + + standardsAdapter.findStandardBySlug.mockResolvedValue(existingStandard); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + existingRule, + ]); + }); + + it('falls back to activeDetectionProgram language', async () => { + const detectionProgram = detectionProgramFactory({ + ruleId, + language: null as unknown as ProgrammingLanguage, + }); + + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }), + detectionProgram, + draftDetectionProgram: null, + }, + ], + ); + + const result = await useCase.execute(command); + + expect(result.programs[0].language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + + describe('when detectionProgram language is defined', () => { + it('uses detectionProgram language over activeDetectionProgram language', async () => { + const detectionProgram = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }), + detectionProgram, + draftDetectionProgram: null, + }, + ], + ); + + const result = await useCase.execute(command); + + expect(result.programs[0].language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + }); + }); + + describe('when multiple active programs exist for different languages', () => { + let ruleId: ReturnType<typeof createRuleId>; + let command: GetActiveDetectionProgramForRuleCommand; + let jsActive: ReturnType<typeof detectionProgramFactory>; + let tsActive: ReturnType<typeof detectionProgramFactory>; + let pyActive: ReturnType<typeof detectionProgramFactory>; + + beforeEach(() => { + const organizationId = createOrganizationId(uuidv4()); + ruleId = createRuleId(uuidv4()); + const standardSlug = 'test-standard'; + + command = { + standardSlug, + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingStandard = standardFactory({ slug: standardSlug }); + const existingRule = ruleFactory({ id: ruleId }); + + jsActive = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + tsActive = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }); + pyActive = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.PYTHON, + }); + + standardsAdapter.findStandardBySlug.mockResolvedValue(existingStandard); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + existingRule, + ]); + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }), + detectionProgram: jsActive, + draftDetectionProgram: null, + }, + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }), + detectionProgram: tsActive, + draftDetectionProgram: null, + }, + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.PYTHON, + }), + detectionProgram: pyActive, + draftDetectionProgram: null, + }, + ], + ); + }); + + it('returns three active programs', async () => { + const result = await useCase.execute(command); + + expect(result.programs).toHaveLength(3); + }); + + it('returns first program as JavaScript', async () => { + const result = await useCase.execute(command); + + expect(result.programs[0].language).toBe( + ProgrammingLanguage.JAVASCRIPT, + ); + }); + + it('returns second program as TypeScript', async () => { + const result = await useCase.execute(command); + + expect(result.programs[1].language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + + it('returns third program as Python', async () => { + const result = await useCase.execute(command); + + expect(result.programs[2].language).toBe(ProgrammingLanguage.PYTHON); + }); + }); + + describe('when no active programs exist', () => { + it('returns empty array', async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardSlug = 'my-standard'; + + const command: GetActiveDetectionProgramForRuleCommand = { + standardSlug, + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingStandard = standardFactory({ slug: standardSlug }); + const existingRule = ruleFactory({ id: ruleId }); + + standardsAdapter.findStandardBySlug.mockResolvedValue(existingStandard); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + existingRule, + ]); + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [], + ); + + const result = await useCase.execute(command); + + expect(result.programs).toEqual([]); + }); + + describe('when only draft programs exist', () => { + it('returns empty array', async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardSlug = 'my-standard'; + + const command: GetActiveDetectionProgramForRuleCommand = { + standardSlug, + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingStandard = standardFactory({ slug: standardSlug }); + const existingRule = ruleFactory({ id: ruleId }); + + const draftProgram = detectionProgramFactory({ + ruleId, + code: 'draft code', + }); + + const activeProgram = activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + detectionProgramVersion: null, + detectionProgramDraftVersion: draftProgram.id, + }); + + standardsAdapter.findStandardBySlug.mockResolvedValue( + existingStandard, + ); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + existingRule, + ]); + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeProgram, + detectionProgram: null, + draftDetectionProgram: draftProgram, + }, + ], + ); + + const result = await useCase.execute(command); + + expect(result.programs).toEqual([]); + }); + }); + }); + + describe('when standard not found', () => { + let organizationId: ReturnType<typeof createOrganizationId>; + let ruleId: ReturnType<typeof createRuleId>; + let standardSlug: string; + let thrownError: Error | null; + + beforeEach(async () => { + organizationId = createOrganizationId(uuidv4()); + ruleId = createRuleId(uuidv4()); + standardSlug = 'nonexistent-standard'; + + const command: GetActiveDetectionProgramForRuleCommand = { + standardSlug, + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + standardsAdapter.findStandardBySlug.mockResolvedValue(null); + + thrownError = null; + try { + await useCase.execute(command); + } catch (error) { + thrownError = error as Error; + } + }); + + it('throws error with standard slug in message', () => { + expect(thrownError?.message).toBe( + `Standard with slug '${standardSlug}' not found`, + ); + }); + + it('calls findStandardBySlug with standard slug and organization id', () => { + expect(standardsAdapter.findStandardBySlug).toHaveBeenCalledWith( + standardSlug, + organizationId, + ); + }); + + it('does not call getRule', () => { + expect(standardsAdapter.getRule).not.toHaveBeenCalled(); + }); + + it('does not call findActiveByRuleIdWithPrograms', () => { + expect( + detectionProgramService.findActiveByRuleIdWithPrograms, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when rule not found', () => { + let organizationId: ReturnType<typeof createOrganizationId>; + let ruleId: ReturnType<typeof createRuleId>; + let standardSlug: string; + let thrownError: Error | null; + + beforeEach(async () => { + organizationId = createOrganizationId(uuidv4()); + ruleId = createRuleId(uuidv4()); + standardSlug = 'my-standard'; + + const command: GetActiveDetectionProgramForRuleCommand = { + standardSlug, + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingStandard = standardFactory({ slug: standardSlug }); + + standardsAdapter.findStandardBySlug.mockResolvedValue(existingStandard); + standardsAdapter.getRule.mockResolvedValue(null); + + thrownError = null; + try { + await useCase.execute(command); + } catch (error) { + thrownError = error as Error; + } + }); + + it('throws error with rule id in message', () => { + expect(thrownError?.message).toBe(`Rule with id '${ruleId}' not found`); + }); + + it('calls findStandardBySlug with standard slug and organization id', () => { + expect(standardsAdapter.findStandardBySlug).toHaveBeenCalledWith( + standardSlug, + organizationId, + ); + }); + + it('calls getRule with rule id', () => { + expect(standardsAdapter.getRule).toHaveBeenCalledWith(ruleId); + }); + + it('does not call findActiveByRuleIdWithPrograms', () => { + expect( + detectionProgramService.findActiveByRuleIdWithPrograms, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when rule does not belong to standard', () => { + let organizationId: ReturnType<typeof createOrganizationId>; + let ruleId: ReturnType<typeof createRuleId>; + let standardSlug: string; + let existingStandard: ReturnType<typeof standardFactory>; + let thrownError: Error | null; + + beforeEach(async () => { + organizationId = createOrganizationId(uuidv4()); + ruleId = createRuleId(uuidv4()); + const otherRuleId = createRuleId(uuidv4()); + standardSlug = 'my-standard'; + + const command: GetActiveDetectionProgramForRuleCommand = { + standardSlug, + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + existingStandard = standardFactory({ slug: standardSlug }); + const existingRule = ruleFactory({ id: ruleId }); + const otherRule = ruleFactory({ id: otherRuleId }); + + standardsAdapter.findStandardBySlug.mockResolvedValue(existingStandard); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + otherRule, + ]); + + thrownError = null; + try { + await useCase.execute(command); + } catch (error) { + thrownError = error as Error; + } + }); + + it('throws error with rule id and standard slug in message', () => { + expect(thrownError?.message).toBe( + `Rule '${ruleId}' does not belong to standard '${standardSlug}'`, + ); + }); + + it('calls findStandardBySlug with standard slug and organization id', () => { + expect(standardsAdapter.findStandardBySlug).toHaveBeenCalledWith( + standardSlug, + organizationId, + ); + }); + + it('calls getRule with rule id', () => { + expect(standardsAdapter.getRule).toHaveBeenCalledWith(ruleId); + }); + + it('calls getLatestRulesByStandardId with standard id', () => { + expect( + standardsAdapter.getLatestRulesByStandardId, + ).toHaveBeenCalledWith(existingStandard.id); + }); + + it('does not call findActiveByRuleIdWithPrograms', () => { + expect( + detectionProgramService.findActiveByRuleIdWithPrograms, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when language filter is specified', () => { + let ruleId: ReturnType<typeof createRuleId>; + let command: GetActiveDetectionProgramForRuleCommand; + let tsActive: ReturnType<typeof detectionProgramFactory>; + + beforeEach(() => { + const organizationId = createOrganizationId(uuidv4()); + ruleId = createRuleId(uuidv4()); + const standardSlug = 'my-standard'; + + command = { + standardSlug, + ruleId, + organizationId, + userId: createUserId(uuidv4()), + language: 'TypeScript', + }; + + const existingStandard = standardFactory({ slug: standardSlug }); + const existingRule = ruleFactory({ id: ruleId }); + + const jsActive = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + code: 'javascript active code', + }); + + tsActive = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + code: 'typescript active code', + }); + + const pyActive = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.PYTHON, + code: 'python active code', + }); + + standardsAdapter.findStandardBySlug.mockResolvedValue(existingStandard); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + existingRule, + ]); + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }), + detectionProgram: jsActive, + draftDetectionProgram: null, + }, + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }), + detectionProgram: tsActive, + draftDetectionProgram: null, + }, + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.PYTHON, + }), + detectionProgram: pyActive, + draftDetectionProgram: null, + }, + ], + ); + }); + + it('returns only one program', async () => { + const result = await useCase.execute(command); + + expect(result.programs).toHaveLength(1); + }); + + it('returns the typescript active program with severity', async () => { + const result = await useCase.execute(command); + + expect(result.programs[0]).toEqual({ + ...tsActive, + severity: DetectionSeverity.ERROR, + }); + }); + + it('returns program with TypeScript language', async () => { + const result = await useCase.execute(command); + + expect(result.programs[0].language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + + it('returns program with typescript active code', async () => { + const result = await useCase.execute(command); + + expect(result.programs[0].code).toBe('typescript active code'); + }); + }); + + describe('mixed scenarios', () => { + describe('when some have active programs and others have only drafts', () => { + let ruleId: ReturnType<typeof createRuleId>; + let command: GetActiveDetectionProgramForRuleCommand; + let activeProgram: ReturnType<typeof detectionProgramFactory>; + + beforeEach(() => { + const organizationId = createOrganizationId(uuidv4()); + ruleId = createRuleId(uuidv4()); + const standardSlug = 'my-standard'; + + command = { + standardSlug, + ruleId, + organizationId, + userId: createUserId(uuidv4()), + }; + + const existingStandard = standardFactory({ slug: standardSlug }); + const existingRule = ruleFactory({ id: ruleId }); + + activeProgram = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + const draftProgram = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + code: 'typescript draft', + }); + + standardsAdapter.findStandardBySlug.mockResolvedValue( + existingStandard, + ); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + existingRule, + ]); + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }), + detectionProgram: activeProgram, + draftDetectionProgram: null, + }, + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }), + detectionProgram: null, + draftDetectionProgram: draftProgram, + }, + ], + ); + }); + + it('returns only one program', async () => { + const result = await useCase.execute(command); + + expect(result.programs).toHaveLength(1); + }); + + it('returns the active program with severity', async () => { + const result = await useCase.execute(command); + + expect(result.programs[0]).toEqual({ + ...activeProgram, + severity: DetectionSeverity.ERROR, + }); + }); + + it('returns the JavaScript active program', async () => { + const result = await useCase.execute(command); + + expect(result.programs[0].language).toBe( + ProgrammingLanguage.JAVASCRIPT, + ); + }); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/getActiveDetectionProgramForRule/GetActiveDetectionProgramForRuleUseCase.ts b/packages/linter/src/application/useCases/getActiveDetectionProgramForRule/GetActiveDetectionProgramForRuleUseCase.ts new file mode 100644 index 000000000..18c8bedb1 --- /dev/null +++ b/packages/linter/src/application/useCases/getActiveDetectionProgramForRule/GetActiveDetectionProgramForRuleUseCase.ts @@ -0,0 +1,122 @@ +import { PackmindLogger } from '@packmind/logger'; +import { OrganizationId } from '@packmind/types'; +import { IStandardsPort } from '@packmind/types'; +import { stringToProgrammingLanguage } from '@packmind/types'; +import { + GetActiveDetectionProgramForRuleCommand, + GetActiveDetectionProgramForRuleResponse, + IGetActiveDetectionProgramForRule, + DetectionProgramWithSeverity, +} from '@packmind/types'; +import { DetectionProgramService } from '../../services/DetectionProgramService'; + +const origin = 'GetActiveDetectionProgramForRuleUseCase'; + +export class GetActiveDetectionProgramForRuleUseCase implements IGetActiveDetectionProgramForRule { + constructor( + private readonly detectionProgramService: DetectionProgramService, + private readonly standardsAdapter: IStandardsPort, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: GetActiveDetectionProgramForRuleCommand, + ): Promise<GetActiveDetectionProgramForRuleResponse> { + this.logger.info('Getting active detection programs for rule', { + standardSlug: command.standardSlug, + ruleId: command.ruleId, + organizationId: command.organizationId, + }); + + try { + // Validate standard exists by slug + const standard = await this.standardsAdapter.findStandardBySlug( + command.standardSlug, + command.organizationId as OrganizationId, + ); + + if (!standard) { + this.logger.error('Standard not found by slug', { + standardSlug: command.standardSlug, + organizationId: command.organizationId, + }); + throw new Error( + `Standard with slug '${command.standardSlug}' not found`, + ); + } + + // Validate rule exists + const rule = await this.standardsAdapter.getRule(command.ruleId); + if (!rule) { + this.logger.error('Rule not found', { + ruleId: command.ruleId, + }); + throw new Error(`Rule with id '${command.ruleId}' not found`); + } + + // Security check: Verify rule belongs to the latest version of the standard + const latestRules = + await this.standardsAdapter.getLatestRulesByStandardId(standard.id); + const ruleExistsInStandard = latestRules.some( + (r) => r.id === command.ruleId, + ); + + if (!ruleExistsInStandard) { + this.logger.error('Rule does not belong to the standard', { + ruleId: command.ruleId, + standardSlug: command.standardSlug, + standardId: standard.id, + }); + throw new Error( + `Rule '${command.ruleId}' does not belong to standard '${command.standardSlug}'`, + ); + } + + // Get all active detection programs with their current versions + const activeProgramsWithPrograms = + await this.detectionProgramService.findActiveByRuleIdWithPrograms( + command.ruleId, + ); + + // Extract only active detection programs (current versions) with severity + let programsWithSeverity: DetectionProgramWithSeverity[] = + activeProgramsWithPrograms + .filter((p) => p.detectionProgram !== null) + .map((p) => ({ + ...p.detectionProgram!, + language: p.detectionProgram!.language ?? p.language, + severity: p.severity, + })); + + // Filter by language if specified + if (command.language) { + const targetLanguage = stringToProgrammingLanguage(command.language); + programsWithSeverity = programsWithSeverity.filter( + (p) => p.language === targetLanguage, + ); + } + + this.logger.info('Active detection programs retrieved successfully', { + standardSlug: command.standardSlug, + ruleId: command.ruleId, + ruleContent: rule.content, + language: command.language, + activeCount: programsWithSeverity.length, + scope: standard.scope, + }); + + return { + programs: programsWithSeverity, + ruleContent: rule.content, + scope: standard.scope, + }; + } catch (error) { + this.logger.error('Failed to get active detection programs for rule', { + standardSlug: command.standardSlug, + ruleId: command.ruleId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/getAllDetectionProgramsByRule/GetAllDetectionProgramsByRuleUseCase.spec.ts b/packages/linter/src/application/useCases/getAllDetectionProgramsByRule/GetAllDetectionProgramsByRuleUseCase.spec.ts new file mode 100644 index 000000000..250f1ddb0 --- /dev/null +++ b/packages/linter/src/application/useCases/getAllDetectionProgramsByRule/GetAllDetectionProgramsByRuleUseCase.spec.ts @@ -0,0 +1,208 @@ +import { + createOrganizationId, + createUserId, + createRuleId, + DetectionProgram, + DetectionModeEnum, + createDetectionProgramId, + DetectionStatus, + ProgrammingLanguage, +} from '@packmind/types'; +import { GetAllDetectionProgramsByRuleUseCase } from './GetAllDetectionProgramsByRuleUseCase'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { IDetectionProgramRepository } from '../../../domain/repositories/IDetectionProgramRepository'; +import { stubLogger } from '@packmind/test-utils'; + +describe('GetAllDetectionProgramsByRuleUseCase', () => { + let useCase: GetAllDetectionProgramsByRuleUseCase; + let mockLinterRepositories: jest.Mocked<ILinterRepositories>; + let mockDetectionProgramRepository: jest.Mocked<IDetectionProgramRepository>; + + const mockRuleId = createRuleId('rule-123'); + const mockOrganizationId = createOrganizationId('org-123'); + const mockUserId = createUserId('user-123'); + + beforeEach(() => { + mockDetectionProgramRepository = { + findByRuleId: jest.fn(), + findByRuleIdAndVersion: jest.fn(), + getLatestVersionByRuleIdAndLanguage: jest.fn(), + save: jest.fn(), + add: jest.fn(), + findById: jest.fn(), + list: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as unknown as jest.Mocked<IDetectionProgramRepository>; + + mockLinterRepositories = { + getDetectionProgramRepository: jest.fn( + () => mockDetectionProgramRepository, + ), + getActiveDetectionProgramRepository: jest.fn(), + getDetectionProgramMetadataRepository: jest.fn(), + getRuleDetectionHeuristicsRepository: jest.fn(), + } as unknown as jest.Mocked<ILinterRepositories>; + + useCase = new GetAllDetectionProgramsByRuleUseCase( + mockLinterRepositories, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when programs exist for a rule', () => { + const mockPrograms: DetectionProgram[] = [ + { + id: createDetectionProgramId('prog-1'), + code: 'const x = 1;', + version: 2, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + createdAt: new Date(), + }, + { + id: createDetectionProgramId('prog-2'), + code: 'const y = 2;', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + createdAt: new Date(), + }, + ]; + + beforeEach(() => { + mockDetectionProgramRepository.findByRuleId.mockResolvedValue( + mockPrograms, + ); + }); + + it('returns all detection programs for the rule', async () => { + const result = await useCase.execute({ + ruleId: mockRuleId, + organizationId: mockOrganizationId, + userId: mockUserId, + }); + + expect(result.programs).toEqual(mockPrograms); + }); + + it('calls repository with the correct rule id', async () => { + await useCase.execute({ + ruleId: mockRuleId, + organizationId: mockOrganizationId, + userId: mockUserId, + }); + + expect(mockDetectionProgramRepository.findByRuleId).toHaveBeenCalledWith( + mockRuleId, + ); + }); + }); + + describe('when programs with multiple languages exist', () => { + const mockPrograms: DetectionProgram[] = [ + { + id: createDetectionProgramId('prog-1'), + code: 'const x = 1;', + version: 2, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + createdAt: new Date(), + }, + { + id: createDetectionProgramId('prog-2'), + code: 'const y = 2;', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + createdAt: new Date(), + }, + { + id: createDetectionProgramId('prog-3'), + code: 'def z = 3', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: mockRuleId, + language: ProgrammingLanguage.PYTHON, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + createdAt: new Date(), + }, + ]; + + beforeEach(() => { + mockDetectionProgramRepository.findByRuleId.mockResolvedValue( + mockPrograms, + ); + }); + + it('returns all programs including multiple languages', async () => { + const result = await useCase.execute({ + ruleId: mockRuleId, + organizationId: mockOrganizationId, + userId: mockUserId, + }); + + expect(result.programs).toEqual(mockPrograms); + }); + }); + + describe('when no programs exist', () => { + beforeEach(() => { + mockDetectionProgramRepository.findByRuleId.mockResolvedValue([]); + }); + + it('returns empty array', async () => { + const result = await useCase.execute({ + ruleId: mockRuleId, + organizationId: mockOrganizationId, + userId: mockUserId, + }); + + expect(result.programs).toEqual([]); + }); + + it('calls repository with the correct rule id', async () => { + await useCase.execute({ + ruleId: mockRuleId, + organizationId: mockOrganizationId, + userId: mockUserId, + }); + + expect(mockDetectionProgramRepository.findByRuleId).toHaveBeenCalledWith( + mockRuleId, + ); + }); + }); + + it('propagates repository errors', async () => { + const error = new Error('Repository error'); + mockDetectionProgramRepository.findByRuleId.mockRejectedValue(error); + + await expect( + useCase.execute({ + ruleId: mockRuleId, + organizationId: mockOrganizationId, + userId: mockUserId, + }), + ).rejects.toThrow('Repository error'); + }); +}); diff --git a/packages/linter/src/application/useCases/getAllDetectionProgramsByRule/GetAllDetectionProgramsByRuleUseCase.ts b/packages/linter/src/application/useCases/getAllDetectionProgramsByRule/GetAllDetectionProgramsByRuleUseCase.ts new file mode 100644 index 000000000..ea1291aaf --- /dev/null +++ b/packages/linter/src/application/useCases/getAllDetectionProgramsByRule/GetAllDetectionProgramsByRuleUseCase.ts @@ -0,0 +1,45 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + GetAllDetectionProgramsByRuleCommand, + GetAllDetectionProgramsByRuleResponse, + IGetAllDetectionProgramsByRule, +} from '@packmind/types'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; + +const origin = 'GetAllDetectionProgramsByRuleUseCase'; + +export class GetAllDetectionProgramsByRuleUseCase implements IGetAllDetectionProgramsByRule { + constructor( + private readonly linterRepositories: ILinterRepositories, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: GetAllDetectionProgramsByRuleCommand, + ): Promise<GetAllDetectionProgramsByRuleResponse> { + this.logger.info('Getting all detection programs by rule', { + ruleId: command.ruleId, + organizationId: command.organizationId, + userId: command.userId, + }); + + try { + const programs = await this.linterRepositories + .getDetectionProgramRepository() + .findByRuleId(command.ruleId); + + this.logger.info('Successfully retrieved all detection programs', { + ruleId: command.ruleId, + count: programs.length, + }); + + return { programs }; + } catch (error) { + this.logger.error('Failed to get all detection programs by rule', { + ruleId: command.ruleId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/getDetectionHeuristics/GetDetectionHeuristicsUseCase.spec.ts b/packages/linter/src/application/useCases/getDetectionHeuristics/GetDetectionHeuristicsUseCase.spec.ts new file mode 100644 index 000000000..cfb0ea816 --- /dev/null +++ b/packages/linter/src/application/useCases/getDetectionHeuristics/GetDetectionHeuristicsUseCase.spec.ts @@ -0,0 +1,233 @@ +import { GetDetectionHeuristicsUseCase } from './GetDetectionHeuristicsUseCase'; +import { IRuleDetectionHeuristicsRepository } from '../../../domain/repositories/IRuleDetectionHeuristicsRepository'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { + createDetectionHeuristicsId, + createRuleId, + createOrganizationId, + createUserId, + DetectionHeuristics, + ProgrammingLanguage, + GetDetectionHeuristicsCommand, +} from '@packmind/types'; +import { PackmindLogger } from '@packmind/logger'; +import { stubLogger } from '@packmind/test-utils'; +import { v4 as uuidv4 } from 'uuid'; + +describe('GetDetectionHeuristicsUseCase', () => { + let useCase: GetDetectionHeuristicsUseCase; + let heuristicsRepository: jest.Mocked<IRuleDetectionHeuristicsRepository>; + let linterRepositories: jest.Mocked<ILinterRepositories>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + beforeEach(() => { + heuristicsRepository = { + getHeuristicsById: jest.fn(), + updateHeuristics: jest.fn(), + upsertHeuristics: jest.fn(), + getHeuristicsForRule: jest.fn(), + } as unknown as jest.Mocked<IRuleDetectionHeuristicsRepository>; + + linterRepositories = { + getRuleDetectionHeuristicsRepository: jest + .fn() + .mockReturnValue(heuristicsRepository), + getRuleDetectionAssessmentRepository: jest.fn(), + getDetectionProgramRepository: jest.fn(), + getActiveDetectionProgramRepository: jest.fn(), + getDetectionProgramMetadataRepository: jest.fn(), + } as unknown as jest.Mocked<ILinterRepositories>; + + stubbedLogger = stubLogger(); + + useCase = new GetDetectionHeuristicsUseCase( + linterRepositories, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when detection heuristics exist', () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const language = ProgrammingLanguage.TYPESCRIPT; + let detectionHeuristics: DetectionHeuristics; + let command: GetDetectionHeuristicsCommand; + + beforeEach(() => { + detectionHeuristics = { + id: heuristicsId, + ruleId, + language, + heuristics: ['Test heuristics content'], + }; + + heuristicsRepository.getHeuristicsForRule.mockResolvedValue( + detectionHeuristics, + ); + + command = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + ruleId, + language, + }; + }); + + it('calls repository with correct rule and language', async () => { + await useCase.execute(command); + + expect(heuristicsRepository.getHeuristicsForRule).toHaveBeenCalledWith( + ruleId, + language, + ); + }); + + it('returns detection heuristics matching the repository result', async () => { + const result = await useCase.execute(command); + + expect(result.detectionHeuristics).toEqual(detectionHeuristics); + }); + + it('returns non-null detection heuristics', async () => { + const result = await useCase.execute(command); + + expect(result.detectionHeuristics).not.toBeNull(); + }); + }); + + describe('when detection heuristics do not exist', () => { + const ruleId = createRuleId(uuidv4()); + const language = ProgrammingLanguage.PYTHON; + let command: GetDetectionHeuristicsCommand; + + beforeEach(() => { + heuristicsRepository.getHeuristicsForRule.mockResolvedValue(null); + + command = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + ruleId, + language, + }; + }); + + it('calls repository with correct rule and language', async () => { + await useCase.execute(command); + + expect(heuristicsRepository.getHeuristicsForRule).toHaveBeenCalledWith( + ruleId, + language, + ); + }); + + it('returns null for non-existent heuristics', async () => { + const result = await useCase.execute(command); + + expect(result.detectionHeuristics).toBeNull(); + }); + }); + + describe('when querying different languages for same rule', () => { + const ruleId = createRuleId(uuidv4()); + const tsHeuristicsId = createDetectionHeuristicsId(uuidv4()); + const jsHeuristicsId = createDetectionHeuristicsId(uuidv4()); + let tsHeuristics: DetectionHeuristics; + let jsHeuristics: DetectionHeuristics; + + beforeEach(() => { + tsHeuristics = { + id: tsHeuristicsId, + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['TypeScript heuristics'], + }; + + jsHeuristics = { + id: jsHeuristicsId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + heuristics: ['JavaScript heuristics'], + }; + }); + + describe('when querying for TypeScript', () => { + it('returns TypeScript heuristics', async () => { + heuristicsRepository.getHeuristicsForRule.mockResolvedValueOnce( + tsHeuristics, + ); + + const tsCommand: GetDetectionHeuristicsCommand = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }; + + const tsResult = await useCase.execute(tsCommand); + + expect(tsResult.detectionHeuristics).toEqual(tsHeuristics); + }); + + it('returns TypeScript language', async () => { + heuristicsRepository.getHeuristicsForRule.mockResolvedValueOnce( + tsHeuristics, + ); + + const tsCommand: GetDetectionHeuristicsCommand = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }; + + const tsResult = await useCase.execute(tsCommand); + + expect(tsResult.detectionHeuristics?.language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + }); + + describe('when querying for JavaScript', () => { + it('returns JavaScript heuristics', async () => { + heuristicsRepository.getHeuristicsForRule.mockResolvedValueOnce( + jsHeuristics, + ); + + const jsCommand: GetDetectionHeuristicsCommand = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }; + + const jsResult = await useCase.execute(jsCommand); + + expect(jsResult.detectionHeuristics).toEqual(jsHeuristics); + }); + + it('returns JavaScript language', async () => { + heuristicsRepository.getHeuristicsForRule.mockResolvedValueOnce( + jsHeuristics, + ); + + const jsCommand: GetDetectionHeuristicsCommand = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }; + + const jsResult = await useCase.execute(jsCommand); + + expect(jsResult.detectionHeuristics?.language).toBe( + ProgrammingLanguage.JAVASCRIPT, + ); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/getDetectionHeuristics/GetDetectionHeuristicsUseCase.ts b/packages/linter/src/application/useCases/getDetectionHeuristics/GetDetectionHeuristicsUseCase.ts new file mode 100644 index 000000000..754d96c11 --- /dev/null +++ b/packages/linter/src/application/useCases/getDetectionHeuristics/GetDetectionHeuristicsUseCase.ts @@ -0,0 +1,50 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + IGetDetectionHeuristics, + GetDetectionHeuristicsCommand, + GetDetectionHeuristicsResponse, +} from '@packmind/types'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; + +const origin = 'GetDetectionHeuristicsUseCase'; + +export class GetDetectionHeuristicsUseCase implements IGetDetectionHeuristics { + constructor( + private readonly linterRepositories: ILinterRepositories, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: GetDetectionHeuristicsCommand, + ): Promise<GetDetectionHeuristicsResponse> { + this.logger.info('Getting detection heuristics', { + ruleId: command.ruleId, + language: command.language, + }); + + const heuristicsRepo = + this.linterRepositories.getRuleDetectionHeuristicsRepository(); + + const detectionHeuristics = await heuristicsRepo.getHeuristicsForRule( + command.ruleId, + command.language, + ); + + if (!detectionHeuristics) { + this.logger.info('Detection heuristics not found', { + ruleId: command.ruleId, + language: command.language, + }); + } else { + this.logger.info('Detection heuristics found', { + ruleId: command.ruleId, + language: command.language, + detectionHeuristicsId: detectionHeuristics.id, + }); + } + + return { + detectionHeuristics, + }; + } +} diff --git a/packages/linter/src/application/useCases/getDetectionProgramMetadata/GetDetectionProgramMetadataUseCase.ts b/packages/linter/src/application/useCases/getDetectionProgramMetadata/GetDetectionProgramMetadataUseCase.ts new file mode 100644 index 000000000..81eebbfe5 --- /dev/null +++ b/packages/linter/src/application/useCases/getDetectionProgramMetadata/GetDetectionProgramMetadataUseCase.ts @@ -0,0 +1,52 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + GetDetectionProgramMetadataCommand, + GetDetectionProgramMetadataResponse, + IGetDetectionProgramMetadata, +} from '@packmind/types'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; + +const origin = 'GetDetectionProgramMetadataUseCase'; + +export class GetDetectionProgramMetadataUseCase implements IGetDetectionProgramMetadata { + constructor( + private readonly linterRepositories: ILinterRepositories, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: GetDetectionProgramMetadataCommand, + ): Promise<GetDetectionProgramMetadataResponse> { + this.logger.info('Getting detection program metadata', { + detectionProgramId: command.detectionProgramId, + organizationId: command.organizationId, + userId: command.userId, + }); + + try { + const metadata = await this.linterRepositories + .getDetectionProgramMetadataRepository() + .findByDetectionProgramId(command.detectionProgramId); + + if (!metadata) { + this.logger.info('No metadata found for detection program', { + detectionProgramId: command.detectionProgramId, + }); + return { metadata: null }; + } + + this.logger.info('Successfully retrieved detection program metadata', { + detectionProgramId: command.detectionProgramId, + metadataId: metadata.id, + }); + + return { metadata }; + } catch (error) { + this.logger.error('Failed to get detection program metadata', { + detectionProgramId: command.detectionProgramId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/getDetectionProgramsForPackages/GetDetectionProgramsForPackagesUseCase.spec.ts b/packages/linter/src/application/useCases/getDetectionProgramsForPackages/GetDetectionProgramsForPackagesUseCase.spec.ts new file mode 100644 index 000000000..3b9e79f42 --- /dev/null +++ b/packages/linter/src/application/useCases/getDetectionProgramsForPackages/GetDetectionProgramsForPackagesUseCase.spec.ts @@ -0,0 +1,418 @@ +import { GetDetectionProgramsForPackagesUseCase } from './GetDetectionProgramsForPackagesUseCase'; +import { DetectionProgramService } from '../../services/DetectionProgramService'; +import { ruleFactory, standardFactory } from '@packmind/standards/test'; +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { + createOrganizationId, + createRuleId, + createSpaceId, + createUserId, + IDeploymentPort, + ISpacesPort, + IStandardsPort, + GetDetectionProgramsForPackagesCommand, + ProgrammingLanguage, + createActiveDetectionProgramId, + createDetectionProgramId, + DetectionModeEnum, +} from '@packmind/types'; +import { stubLogger } from '@packmind/test-utils'; +import { + activeDetectionProgramFactory, + detectionProgramFactory, +} from '../../../../test'; + +describe('GetDetectionProgramsForPackagesUseCase', () => { + let useCase: GetDetectionProgramsForPackagesUseCase; + let detectionProgramService: jest.Mocked<DetectionProgramService>; + let deploymentsAdapter: jest.Mocked<IDeploymentPort>; + let standardsAdapter: jest.Mocked<IStandardsPort>; + let spacesAdapter: jest.Mocked<ISpacesPort>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + beforeEach(() => { + detectionProgramService = { + findActiveByRuleIdWithPrograms: jest.fn(), + } as unknown as jest.Mocked<DetectionProgramService>; + + deploymentsAdapter = { + getPackageSummary: jest.fn(), + } as unknown as jest.Mocked<IDeploymentPort>; + + standardsAdapter = { + getRule: jest.fn(), + getStandard: jest.fn(), + getStandardVersion: jest.fn(), + getStandardVersionById: jest.fn(), + listStandardVersions: jest.fn(), + getLatestRulesByStandardId: jest.fn(), + getRulesByStandardId: jest.fn(), + listStandardsBySpace: jest.fn(), + listAllStandardsByOrganization: jest.fn(), + getRuleCodeExamples: jest.fn(), + findStandardBySlug: jest.fn(), + getLatestStandardVersion: jest.fn(), + } as unknown as jest.Mocked<IStandardsPort>; + + spacesAdapter = { + listSpacesByOrganization: jest.fn(), + } as unknown as jest.Mocked<ISpacesPort>; + + stubbedLogger = stubLogger(); + + useCase = new GetDetectionProgramsForPackagesUseCase( + detectionProgramService, + deploymentsAdapter, + standardsAdapter, + spacesAdapter, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('package slug resolution', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const globalSpaceId = createSpaceId(uuidv4()); + const customSpaceId = createSpaceId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + + const setupMocksForSlugTest = ( + spaces: Array<{ + id: ReturnType<typeof createSpaceId>; + slug: string; + name: string; + }>, + ) => { + const standard = standardFactory({ + name: 'Test Standard', + slug: 'test-standard', + scope: null, + spaceId: globalSpaceId, + }); + + const rule = ruleFactory({ id: ruleId }); + + const detectionProgram = detectionProgramFactory({ + id: createDetectionProgramId(uuidv4()), + ruleId, + code: 'test code', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + const activeDetectionProgram = activeDetectionProgramFactory({ + id: createActiveDetectionProgramId(uuidv4()), + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgramVersion: detectionProgram.id, + }); + + spacesAdapter.listSpacesByOrganization.mockResolvedValue(spaces as never); + + standardsAdapter.listAllStandardsByOrganization.mockResolvedValue([ + standard, + ]); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([rule]); + + deploymentsAdapter.getPackageSummary.mockResolvedValue({ + name: 'test-package', + slug: 'test-package', + standards: [{ name: 'Test Standard' }], + } as never); + + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue([ + { + ...activeDetectionProgram, + detectionProgram, + draftDetectionProgram: null, + }, + ]); + }; + + describe('with scoped slug @global/typescript', () => { + it('resolves to bare slug with the matching space ID', async () => { + setupMocksForSlugTest([ + { id: globalSpaceId, slug: 'global', name: 'Global' }, + ]); + + const command: GetDetectionProgramsForPackagesCommand = { + packagesSlugs: ['@global/test-package'], + organizationId, + userId, + }; + + await useCase.execute(command); + + expect(deploymentsAdapter.getPackageSummary).toHaveBeenCalledWith( + expect.objectContaining({ + slug: 'test-package', + spaceId: globalSpaceId, + }), + ); + }); + }); + + describe('with unscoped slug', () => { + it('defaults to the global space', async () => { + setupMocksForSlugTest([ + { id: globalSpaceId, slug: 'global', name: 'Global' }, + ]); + + const command: GetDetectionProgramsForPackagesCommand = { + packagesSlugs: ['test-package'], + organizationId, + userId, + }; + + await useCase.execute(command); + + expect(deploymentsAdapter.getPackageSummary).toHaveBeenCalledWith( + expect.objectContaining({ + slug: 'test-package', + spaceId: globalSpaceId, + }), + ); + }); + }); + + describe('with scoped slug for a custom space', () => { + it('resolves to the correct custom space ID', async () => { + setupMocksForSlugTest([ + { id: globalSpaceId, slug: 'global', name: 'Global' }, + { id: customSpaceId, slug: 'frontend', name: 'Frontend' }, + ]); + + const command: GetDetectionProgramsForPackagesCommand = { + packagesSlugs: ['@frontend/test-package'], + organizationId, + userId, + }; + + await useCase.execute(command); + + expect(deploymentsAdapter.getPackageSummary).toHaveBeenCalledWith( + expect.objectContaining({ + slug: 'test-package', + spaceId: customSpaceId, + }), + ); + }); + }); + + describe('with unknown space slug', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + setupMocksForSlugTest([ + { id: globalSpaceId, slug: 'global', name: 'Global' }, + ]); + + const command: GetDetectionProgramsForPackagesCommand = { + packagesSlugs: ['@unknown/test-package'], + organizationId, + userId, + }; + + result = await useCase.execute(command); + }); + + it('does not fetch the package summary', () => { + expect(deploymentsAdapter.getPackageSummary).not.toHaveBeenCalled(); + }); + + it('returns empty targets', () => { + expect(result.targets).toEqual([]); + }); + }); + }); + + describe('scope parsing', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const spaceId = createSpaceId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + + const setupMocksForScopeTest = (scope: string | null) => { + const standard = standardFactory({ + name: 'Test Standard', + slug: 'test-standard', + scope, + spaceId, + }); + + const rule = ruleFactory({ id: ruleId }); + + const detectionProgram = detectionProgramFactory({ + id: createDetectionProgramId(uuidv4()), + ruleId, + code: 'test code', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + const activeDetectionProgram = activeDetectionProgramFactory({ + id: createActiveDetectionProgramId(uuidv4()), + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgramVersion: detectionProgram.id, + }); + + spacesAdapter.listSpacesByOrganization.mockResolvedValue([ + { id: spaceId, name: 'Test Space', slug: 'global' } as never, + ]); + + standardsAdapter.listAllStandardsByOrganization.mockResolvedValue([ + standard, + ]); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([rule]); + + deploymentsAdapter.getPackageSummary.mockResolvedValue({ + name: 'test-package', + slug: 'test-package', + standards: [{ name: 'Test Standard' }], + } as never); + + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue([ + { + ...activeDetectionProgram, + detectionProgram, + draftDetectionProgram: null, + }, + ]); + + return { standard }; + }; + + describe('with single scope value', () => { + it('returns scope as single-element array', async () => { + setupMocksForScopeTest('**/*.spec.ts'); + + const command: GetDetectionProgramsForPackagesCommand = { + packagesSlugs: ['test-package'], + organizationId, + userId, + }; + + const result = await useCase.execute(command); + + expect(result.targets[0].standards[0].scope).toEqual(['**/*.spec.ts']); + }); + }); + + describe('with comma-separated scope values', () => { + it('splits and trims values into array', async () => { + setupMocksForScopeTest('**/*.spec.ts, **/*.test.ts'); + + const command: GetDetectionProgramsForPackagesCommand = { + packagesSlugs: ['test-package'], + organizationId, + userId, + }; + + const result = await useCase.execute(command); + + expect(result.targets[0].standards[0].scope).toEqual([ + '**/*.spec.ts', + '**/*.test.ts', + ]); + }); + }); + + describe('with multiple comma-separated values', () => { + it('handles three or more values correctly', async () => { + setupMocksForScopeTest('**/*.spec.ts, **/*.test.ts, **/*.e2e.ts'); + + const command: GetDetectionProgramsForPackagesCommand = { + packagesSlugs: ['test-package'], + organizationId, + userId, + }; + + const result = await useCase.execute(command); + + expect(result.targets[0].standards[0].scope).toEqual([ + '**/*.spec.ts', + '**/*.test.ts', + '**/*.e2e.ts', + ]); + }); + }); + + describe('with values containing extra whitespace', () => { + it('trims whitespace from each value', async () => { + setupMocksForScopeTest(' **/*.spec.ts , **/*.test.ts '); + + const command: GetDetectionProgramsForPackagesCommand = { + packagesSlugs: ['test-package'], + organizationId, + userId, + }; + + const result = await useCase.execute(command); + + expect(result.targets[0].standards[0].scope).toEqual([ + '**/*.spec.ts', + '**/*.test.ts', + ]); + }); + }); + + describe('with empty string values after split', () => { + it('filters out empty values', async () => { + setupMocksForScopeTest('**/*.spec.ts, , **/*.test.ts'); + + const command: GetDetectionProgramsForPackagesCommand = { + packagesSlugs: ['test-package'], + organizationId, + userId, + }; + + const result = await useCase.execute(command); + + expect(result.targets[0].standards[0].scope).toEqual([ + '**/*.spec.ts', + '**/*.test.ts', + ]); + }); + }); + + describe('with null scope', () => { + it('returns empty array', async () => { + setupMocksForScopeTest(null); + + const command: GetDetectionProgramsForPackagesCommand = { + packagesSlugs: ['test-package'], + organizationId, + userId, + }; + + const result = await useCase.execute(command); + + expect(result.targets[0].standards[0].scope).toEqual([]); + }); + }); + + describe('with empty string scope', () => { + it('returns empty array', async () => { + setupMocksForScopeTest(''); + + const command: GetDetectionProgramsForPackagesCommand = { + packagesSlugs: ['test-package'], + organizationId, + userId, + }; + + const result = await useCase.execute(command); + + expect(result.targets[0].standards[0].scope).toEqual([]); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/getDetectionProgramsForPackages/GetDetectionProgramsForPackagesUseCase.ts b/packages/linter/src/application/useCases/getDetectionProgramsForPackages/GetDetectionProgramsForPackagesUseCase.ts new file mode 100644 index 000000000..20508a776 --- /dev/null +++ b/packages/linter/src/application/useCases/getDetectionProgramsForPackages/GetDetectionProgramsForPackagesUseCase.ts @@ -0,0 +1,183 @@ +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { + createOrganizationId, + IDeploymentPort, + ISpacesPort, + IStandardsPort, + GetDetectionProgramsForPackagesCommand, + GetDetectionProgramsForPackagesResponse, + IGetDetectionProgramsForPackagesUseCase, +} from '@packmind/types'; +import { parsePackageSlug } from '@packmind/deployments'; +import { DetectionProgramService } from '../../services/DetectionProgramService'; + +const origin = 'GetDetectionProgramsForPackagesUseCase'; + +export class GetDetectionProgramsForPackagesUseCase implements IGetDetectionProgramsForPackagesUseCase { + constructor( + private readonly detectionProgramService: DetectionProgramService, + private readonly deploymentsAdapter: IDeploymentPort | undefined, + private readonly standardsAdapter: IStandardsPort | undefined, + private readonly spacesAdapter: ISpacesPort | undefined, + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.DEBUG, + ), + ) { + this.logger.info('GetDetectionProgramsForPackagesUseCase initialized'); + } + + async execute( + command: GetDetectionProgramsForPackagesCommand, + ): Promise<GetDetectionProgramsForPackagesResponse> { + this.logger.info('Getting detection programs for packages', { + packagesSlugs: command.packagesSlugs, + organizationId: command.organizationId, + }); + + if ( + !this.deploymentsAdapter || + !this.standardsAdapter || + !this.spacesAdapter + ) { + this.logger.warn( + 'Required adapters not available, returning empty results', + ); + return { targets: [] }; + } + + const targets: GetDetectionProgramsForPackagesResponse['targets'] = []; + + // Get all spaces for the organization + const spaces = await this.spacesAdapter.listSpacesByOrganization( + createOrganizationId(command.organizationId), + ); + + // Get all standards across all spaces (no membership check) + const allStandards = + await this.standardsAdapter.listAllStandardsByOrganization( + createOrganizationId(command.organizationId), + ); + + for (const slug of command.packagesSlugs) { + try { + const { spaceSlug, packageSlug } = parsePackageSlug(slug); + const resolvedSpaceSlug = spaceSlug ?? 'global'; + const space = spaces.find((s) => s.slug === resolvedSpaceSlug); + + if (!space) { + this.logger.warn(`Space not found for package: ${slug}`); + continue; + } + + const packageSummary = await this.deploymentsAdapter.getPackageSummary({ + organizationId: createOrganizationId(command.organizationId), + userId: command.userId, + slug: packageSlug, + spaceId: space.id, + }); + + if (!packageSummary) { + this.logger.warn(`Package not found: ${slug}`); + continue; + } + + const standardsWithPrograms: GetDetectionProgramsForPackagesResponse['targets'][0]['standards'] = + []; + + // Get standard names from package + const packageStandardNames = new Set( + packageSummary.standards.map((s) => s.name), + ); + + // Find matching standards by name + const matchingStandards = allStandards.filter((s) => + packageStandardNames.has(s.name), + ); + + for (const standard of matchingStandards) { + try { + const rules = + await this.standardsAdapter.getLatestRulesByStandardId( + standard.id, + ); + + const rulesWithPrograms: GetDetectionProgramsForPackagesResponse['targets'][0]['standards'][0]['rules'] = + []; + + for (const rule of rules) { + const activeWithPrograms = + await this.detectionProgramService.findActiveByRuleIdWithPrograms( + rule.id, + ); + + if (activeWithPrograms && activeWithPrograms.length > 0) { + const mappedPrograms = activeWithPrograms + .filter( + (adp) => + adp.detectionProgram?.code && adp.detectionProgram?.mode, + ) + .map((adp) => ({ + language: adp.language, + severity: adp.severity, + detectionProgram: { + mode: adp.detectionProgram!.mode, + code: adp.detectionProgram!.code, + sourceCodeState: adp.detectionProgram!.sourceCodeState, + }, + })); + + if (mappedPrograms.length > 0) { + rulesWithPrograms.push({ + content: rule.content, + activeDetectionPrograms: mappedPrograms, + }); + } + } + } + + if (rulesWithPrograms.length > 0) { + standardsWithPrograms.push({ + name: standard.name, + slug: standard.slug, + scope: standard.scope + ? standard.scope + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : [], + rules: rulesWithPrograms, + }); + } + } catch (error) { + this.logger.warn( + `Failed to get detection programs for standard: ${standard.name}`, + { + error: error instanceof Error ? error.message : String(error), + }, + ); + } + } + + if (standardsWithPrograms.length > 0) { + targets.push({ + name: packageSummary.name, + path: '/', + standards: standardsWithPrograms, + }); + } + } catch (error) { + this.logger.warn(`Failed to get package: ${slug}`, { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + this.logger.info('Detection programs for packages retrieved successfully', { + packagesSlugs: command.packagesSlugs, + targetsCount: targets.length, + }); + + return { targets }; + } +} diff --git a/packages/linter/src/application/useCases/getDraftDetectionProgramForRule/GetDraftDetectionProgramForRuleUseCase.spec.ts b/packages/linter/src/application/useCases/getDraftDetectionProgramForRule/GetDraftDetectionProgramForRuleUseCase.spec.ts new file mode 100644 index 000000000..dfef9b4b8 --- /dev/null +++ b/packages/linter/src/application/useCases/getDraftDetectionProgramForRule/GetDraftDetectionProgramForRuleUseCase.spec.ts @@ -0,0 +1,780 @@ +import { GetDraftDetectionProgramForRuleUseCase } from './GetDraftDetectionProgramForRuleUseCase'; +import { DetectionProgramService } from '../../services/DetectionProgramService'; +import { ruleFactory } from '@packmind/standards/test'; +import { standardFactory } from '@packmind/standards/test'; +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { createOrganizationId, createUserId } from '@packmind/types'; +import { + createRuleId, + IStandardsPort, + ProgrammingLanguage, + createActiveDetectionProgramId, + createDetectionProgramId, + DetectionModeEnum, + DetectionSeverity, + GetDraftDetectionProgramForRuleCommand, + GetDraftDetectionProgramForRuleResponse, +} from '@packmind/types'; +import { stubLogger } from '@packmind/test-utils'; +import { + activeDetectionProgramFactory, + detectionProgramFactory, +} from '../../../../test'; + +describe('GetDraftDetectionProgramForRuleUseCase', () => { + let useCase: GetDraftDetectionProgramForRuleUseCase; + let detectionProgramService: jest.Mocked<DetectionProgramService>; + let standardsAdapter: jest.Mocked<IStandardsPort>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + beforeEach(() => { + detectionProgramService = { + findActiveByRuleIdWithPrograms: jest.fn(), + } as unknown as jest.Mocked<DetectionProgramService>; + + standardsAdapter = { + getRule: jest.fn(), + getStandard: jest.fn(), + getStandardVersion: jest.fn(), + getStandardVersionById: jest.fn(), + listStandardVersions: jest.fn(), + getLatestRulesByStandardId: jest.fn(), + getRulesByStandardId: jest.fn(), + listStandardsBySpace: jest.fn(), + getRuleCodeExamples: jest.fn(), + findStandardBySlug: jest.fn(), + getLatestStandardVersion: jest.fn(), + }; + + stubbedLogger = stubLogger(); + + useCase = new GetDraftDetectionProgramForRuleUseCase( + detectionProgramService, + standardsAdapter, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute', () => { + describe('when draft detection programs exist', () => { + describe('when valid standard slug and rule id provided', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardSlug = 'my-standard'; + + const command: GetDraftDetectionProgramForRuleCommand = { + standardSlug, + ruleId, + userId, + organizationId, + }; + + const existingStandard = standardFactory({ + slug: standardSlug, + }); + + const existingRule = ruleFactory({ + id: ruleId, + }); + + const draftProgram1 = detectionProgramFactory({ + id: createDetectionProgramId(uuidv4()), + ruleId, + code: 'draft code for javascript', + version: 2, + mode: DetectionModeEnum.SINGLE_AST, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + const draftProgram2 = detectionProgramFactory({ + id: createDetectionProgramId(uuidv4()), + ruleId, + code: 'draft code for typescript', + version: 2, + mode: DetectionModeEnum.SINGLE_AST, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + const activeProgram1 = activeDetectionProgramFactory({ + id: createActiveDetectionProgramId(uuidv4()), + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + detectionProgramDraftVersion: draftProgram1.id, + }); + + const activeProgram2 = activeDetectionProgramFactory({ + id: createActiveDetectionProgramId(uuidv4()), + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgramDraftVersion: draftProgram2.id, + }); + + let result: GetDraftDetectionProgramForRuleResponse; + + beforeEach(async () => { + standardsAdapter.findStandardBySlug.mockResolvedValue( + existingStandard, + ); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + existingRule, + ]); + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeProgram1, + detectionProgram: null, + draftDetectionProgram: draftProgram1, + }, + { + ...activeProgram2, + detectionProgram: null, + draftDetectionProgram: draftProgram2, + }, + ], + ); + + result = await useCase.execute(command); + }); + + it('calls findStandardBySlug with correct parameters', () => { + expect(standardsAdapter.findStandardBySlug).toHaveBeenCalledWith( + standardSlug, + organizationId, + ); + }); + + it('calls getRule with correct ruleId', () => { + expect(standardsAdapter.getRule).toHaveBeenCalledWith(ruleId); + }); + + it('calls getLatestRulesByStandardId with correct standardId', () => { + expect( + standardsAdapter.getLatestRulesByStandardId, + ).toHaveBeenCalledWith(existingStandard.id); + }); + + it('calls findActiveByRuleIdWithPrograms with correct ruleId', () => { + expect( + detectionProgramService.findActiveByRuleIdWithPrograms, + ).toHaveBeenCalledWith(ruleId); + }); + + it('returns two draft programs', () => { + expect(result.programs).toHaveLength(2); + }); + + it('returns first draft program with severity', () => { + expect(result.programs[0]).toEqual({ + ...draftProgram1, + severity: DetectionSeverity.ERROR, + }); + }); + + it('returns second draft program with severity', () => { + expect(result.programs[1]).toEqual({ + ...draftProgram2, + severity: DetectionSeverity.ERROR, + }); + }); + + it('returns correct scope from standard', () => { + expect(result.scope).toBe(existingStandard.scope); + }); + }); + + describe('when multiple drafts exist for different languages', () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardSlug = 'test-standard'; + + const command: GetDraftDetectionProgramForRuleCommand = { + standardSlug, + ruleId, + userId: createUserId(uuidv4()), + organizationId, + }; + + const existingStandard = standardFactory({ slug: standardSlug }); + const existingRule = ruleFactory({ id: ruleId }); + + const jsDraft = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + const tsDraft = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }); + const pyDraft = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.PYTHON, + }); + + let result: GetDraftDetectionProgramForRuleResponse; + + beforeEach(async () => { + standardsAdapter.findStandardBySlug.mockResolvedValue( + existingStandard, + ); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + existingRule, + ]); + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }), + detectionProgram: null, + draftDetectionProgram: jsDraft, + }, + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }), + detectionProgram: null, + draftDetectionProgram: tsDraft, + }, + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.PYTHON, + }), + detectionProgram: null, + draftDetectionProgram: pyDraft, + }, + ], + ); + + result = await useCase.execute(command); + }); + + it('returns three draft programs', () => { + expect(result.programs).toHaveLength(3); + }); + + it('returns JavaScript draft first', () => { + expect(result.programs[0].language).toBe( + ProgrammingLanguage.JAVASCRIPT, + ); + }); + + it('returns TypeScript draft second', () => { + expect(result.programs[1].language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + + it('returns Python draft third', () => { + expect(result.programs[2].language).toBe(ProgrammingLanguage.PYTHON); + }); + }); + }); + + describe('when draftDetectionProgram language is missing', () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardSlug = 'my-standard'; + + const command: GetDraftDetectionProgramForRuleCommand = { + standardSlug, + ruleId, + userId: createUserId(uuidv4()), + organizationId, + }; + + const existingStandard = standardFactory({ slug: standardSlug }); + const existingRule = ruleFactory({ id: ruleId }); + + beforeEach(() => { + standardsAdapter.findStandardBySlug.mockResolvedValue(existingStandard); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + existingRule, + ]); + }); + + it('falls back to activeDetectionProgram language', async () => { + const draftProgram = detectionProgramFactory({ + ruleId, + language: null as unknown as ProgrammingLanguage, + }); + + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }), + detectionProgram: null, + draftDetectionProgram: draftProgram, + }, + ], + ); + + const result = await useCase.execute(command); + + expect(result.programs[0].language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + + describe('when draftDetectionProgram language is defined', () => { + it('uses draftDetectionProgram language over activeDetectionProgram language', async () => { + const draftProgram = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }), + detectionProgram: null, + draftDetectionProgram: draftProgram, + }, + ], + ); + + const result = await useCase.execute(command); + + expect(result.programs[0].language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + }); + }); + + describe('when no draft programs exist', () => { + it('returns empty array', async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardSlug = 'my-standard'; + + const command: GetDraftDetectionProgramForRuleCommand = { + standardSlug, + ruleId, + userId: createUserId(uuidv4()), + organizationId, + }; + + const existingStandard = standardFactory({ slug: standardSlug }); + const existingRule = ruleFactory({ id: ruleId }); + + const publishedProgram = detectionProgramFactory({ + ruleId, + code: 'published code', + }); + + const activeProgram = activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + detectionProgramVersion: publishedProgram.id, + detectionProgramDraftVersion: null, + }); + + standardsAdapter.findStandardBySlug.mockResolvedValue(existingStandard); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + existingRule, + ]); + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeProgram, + detectionProgram: publishedProgram, + draftDetectionProgram: null, + }, + ], + ); + + const result = await useCase.execute(command); + + expect(result.programs).toEqual([]); + }); + + describe('when no active programs exist at all', () => { + it('returns empty array', async () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardSlug = 'my-standard'; + + const command: GetDraftDetectionProgramForRuleCommand = { + standardSlug, + ruleId, + userId: createUserId(uuidv4()), + organizationId, + }; + + const existingStandard = standardFactory({ slug: standardSlug }); + const existingRule = ruleFactory({ id: ruleId }); + + standardsAdapter.findStandardBySlug.mockResolvedValue( + existingStandard, + ); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + existingRule, + ]); + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [], + ); + + const result = await useCase.execute(command); + + expect(result.programs).toEqual([]); + }); + }); + }); + + describe('when standard not found', () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardSlug = 'nonexistent-standard'; + + const command: GetDraftDetectionProgramForRuleCommand = { + standardSlug, + ruleId, + userId: createUserId(uuidv4()), + organizationId, + }; + + let thrownError: Error; + + beforeEach(async () => { + standardsAdapter.findStandardBySlug.mockResolvedValue(null); + + try { + await useCase.execute(command); + } catch (error) { + thrownError = error as Error; + } + }); + + it('throws error with standard not found message', () => { + expect(thrownError.message).toBe( + `Standard with slug '${standardSlug}' not found`, + ); + }); + + it('calls findStandardBySlug with correct parameters', () => { + expect(standardsAdapter.findStandardBySlug).toHaveBeenCalledWith( + standardSlug, + organizationId, + ); + }); + + it('does not call getRule', () => { + expect(standardsAdapter.getRule).not.toHaveBeenCalled(); + }); + + it('does not call findActiveByRuleIdWithPrograms', () => { + expect( + detectionProgramService.findActiveByRuleIdWithPrograms, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when rule not found', () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardSlug = 'my-standard'; + + const command: GetDraftDetectionProgramForRuleCommand = { + standardSlug, + ruleId, + userId: createUserId(uuidv4()), + organizationId, + }; + + const existingStandard = standardFactory({ slug: standardSlug }); + + let thrownError: Error; + + beforeEach(async () => { + standardsAdapter.findStandardBySlug.mockResolvedValue(existingStandard); + standardsAdapter.getRule.mockResolvedValue(null); + + try { + await useCase.execute(command); + } catch (error) { + thrownError = error as Error; + } + }); + + it('throws error with rule not found message', () => { + expect(thrownError.message).toBe(`Rule with id '${ruleId}' not found`); + }); + + it('calls findStandardBySlug with correct parameters', () => { + expect(standardsAdapter.findStandardBySlug).toHaveBeenCalledWith( + standardSlug, + organizationId, + ); + }); + + it('calls getRule with correct ruleId', () => { + expect(standardsAdapter.getRule).toHaveBeenCalledWith(ruleId); + }); + + it('does not call findActiveByRuleIdWithPrograms', () => { + expect( + detectionProgramService.findActiveByRuleIdWithPrograms, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when rule does not belong to standard', () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const otherRuleId = createRuleId(uuidv4()); + const standardSlug = 'my-standard'; + + const command: GetDraftDetectionProgramForRuleCommand = { + standardSlug, + ruleId, + userId: createUserId(uuidv4()), + organizationId, + }; + + const existingStandard = standardFactory({ slug: standardSlug }); + const existingRule = ruleFactory({ id: ruleId }); + const otherRule = ruleFactory({ id: otherRuleId }); + + let thrownError: Error; + + beforeEach(async () => { + standardsAdapter.findStandardBySlug.mockResolvedValue(existingStandard); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + otherRule, + ]); + + try { + await useCase.execute(command); + } catch (error) { + thrownError = error as Error; + } + }); + + it('throws error with rule does not belong message', () => { + expect(thrownError.message).toBe( + `Rule '${ruleId}' does not belong to standard '${standardSlug}'`, + ); + }); + + it('calls findStandardBySlug with correct parameters', () => { + expect(standardsAdapter.findStandardBySlug).toHaveBeenCalledWith( + standardSlug, + organizationId, + ); + }); + + it('calls getRule with correct ruleId', () => { + expect(standardsAdapter.getRule).toHaveBeenCalledWith(ruleId); + }); + + it('calls getLatestRulesByStandardId with correct standardId', () => { + expect( + standardsAdapter.getLatestRulesByStandardId, + ).toHaveBeenCalledWith(existingStandard.id); + }); + + it('does not call findActiveByRuleIdWithPrograms', () => { + expect( + detectionProgramService.findActiveByRuleIdWithPrograms, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when language filter is specified', () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardSlug = 'my-standard'; + + const command: GetDraftDetectionProgramForRuleCommand = { + standardSlug, + ruleId, + userId: createUserId(uuidv4()), + organizationId, + language: 'TypeScript', + }; + + const existingStandard = standardFactory({ slug: standardSlug }); + const existingRule = ruleFactory({ id: ruleId }); + + const jsDraft = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + code: 'javascript draft code', + }); + + const tsDraft = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + code: 'typescript draft code', + }); + + const pyDraft = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.PYTHON, + code: 'python draft code', + }); + + let result: GetDraftDetectionProgramForRuleResponse; + + beforeEach(async () => { + standardsAdapter.findStandardBySlug.mockResolvedValue(existingStandard); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + existingRule, + ]); + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }), + detectionProgram: null, + draftDetectionProgram: jsDraft, + }, + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }), + detectionProgram: null, + draftDetectionProgram: tsDraft, + }, + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.PYTHON, + }), + detectionProgram: null, + draftDetectionProgram: pyDraft, + }, + ], + ); + + result = await useCase.execute(command); + }); + + it('returns only one draft program', () => { + expect(result.programs).toHaveLength(1); + }); + + it('returns the TypeScript draft program with severity', () => { + expect(result.programs[0]).toEqual({ + ...tsDraft, + severity: DetectionSeverity.ERROR, + }); + }); + + it('returns program with TypeScript language', () => { + expect(result.programs[0].language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + + it('returns program with correct code', () => { + expect(result.programs[0].code).toBe('typescript draft code'); + }); + }); + + describe('mixed scenarios', () => { + describe('when some have drafts and others do not', () => { + const organizationId = createOrganizationId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const standardSlug = 'my-standard'; + + const command: GetDraftDetectionProgramForRuleCommand = { + standardSlug, + ruleId, + userId: createUserId(uuidv4()), + organizationId, + }; + + const existingStandard = standardFactory({ slug: standardSlug }); + const existingRule = ruleFactory({ id: ruleId }); + + const publishedProgram = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + const draftProgram = detectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + code: 'typescript draft', + }); + + let result: GetDraftDetectionProgramForRuleResponse; + + beforeEach(async () => { + standardsAdapter.findStandardBySlug.mockResolvedValue( + existingStandard, + ); + standardsAdapter.getRule.mockResolvedValue(existingRule); + standardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + existingRule, + ]); + detectionProgramService.findActiveByRuleIdWithPrograms.mockResolvedValue( + [ + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }), + detectionProgram: publishedProgram, + draftDetectionProgram: null, + }, + { + ...activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + }), + detectionProgram: null, + draftDetectionProgram: draftProgram, + }, + ], + ); + + result = await useCase.execute(command); + }); + + it('returns only one draft program', () => { + expect(result.programs).toHaveLength(1); + }); + + it('returns the draft program with severity', () => { + expect(result.programs[0]).toEqual({ + ...draftProgram, + severity: DetectionSeverity.ERROR, + }); + }); + + it('returns program with TypeScript language', () => { + expect(result.programs[0].language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/getDraftDetectionProgramForRule/GetDraftDetectionProgramForRuleUseCase.ts b/packages/linter/src/application/useCases/getDraftDetectionProgramForRule/GetDraftDetectionProgramForRuleUseCase.ts new file mode 100644 index 000000000..e7c27e740 --- /dev/null +++ b/packages/linter/src/application/useCases/getDraftDetectionProgramForRule/GetDraftDetectionProgramForRuleUseCase.ts @@ -0,0 +1,126 @@ +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { + OrganizationId, + IStandardsPort, + stringToProgrammingLanguage, + DetectionSeverity, + GetDraftDetectionProgramForRuleCommand, + GetDraftDetectionProgramForRuleResponse, + IGetDraftDetectionProgramForRule, + DetectionProgramWithSeverity, +} from '@packmind/types'; +import { DetectionProgramService } from '../../services/DetectionProgramService'; + +const origin = 'GetDraftDetectionProgramForRuleUseCase'; + +export class GetDraftDetectionProgramForRuleUseCase implements IGetDraftDetectionProgramForRule { + constructor( + private readonly detectionProgramService: DetectionProgramService, + private readonly standardsAdapter: IStandardsPort, + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.INFO, + ), + ) {} + + async execute( + command: GetDraftDetectionProgramForRuleCommand, + ): Promise<GetDraftDetectionProgramForRuleResponse> { + this.logger.info('Getting draft detection programs for rule', { + standardSlug: command.standardSlug, + ruleId: command.ruleId, + organizationId: command.organizationId, + }); + + try { + // Validate standard exists by slug + const standard = await this.standardsAdapter.findStandardBySlug( + command.standardSlug, + command.organizationId as OrganizationId, + ); + + if (!standard) { + this.logger.error('Standard not found by slug', { + standardSlug: command.standardSlug, + organizationId: command.organizationId, + }); + throw new Error( + `Standard with slug '${command.standardSlug}' not found`, + ); + } + + // Validate rule exists + const rule = await this.standardsAdapter.getRule(command.ruleId); + if (!rule) { + this.logger.error('Rule not found', { + ruleId: command.ruleId, + }); + throw new Error(`Rule with id '${command.ruleId}' not found`); + } + + // Security check: Verify rule belongs to the latest version of the standard + const latestRules = + await this.standardsAdapter.getLatestRulesByStandardId(standard.id); + const ruleExistsInStandard = latestRules.some( + (r) => r.id === command.ruleId, + ); + + if (!ruleExistsInStandard) { + this.logger.error('Rule does not belong to the standard', { + ruleId: command.ruleId, + standardSlug: command.standardSlug, + standardId: standard.id, + }); + throw new Error( + `Rule '${command.ruleId}' does not belong to standard '${command.standardSlug}'`, + ); + } + + // Get all active detection programs with their drafts + const activeProgramsWithPrograms = + await this.detectionProgramService.findActiveByRuleIdWithPrograms( + command.ruleId, + ); + + // Filter and extract only draft detection programs with severity + let draftProgramsWithSeverity: DetectionProgramWithSeverity[] = + activeProgramsWithPrograms + .filter((p) => p.draftDetectionProgram !== null) + .map((p) => ({ + ...p.draftDetectionProgram!, + language: p.draftDetectionProgram!.language ?? p.language, + severity: p.severity ?? DetectionSeverity.ERROR, + })); + + // Filter by language if specified + if (command.language) { + const targetLanguage = stringToProgrammingLanguage(command.language); + draftProgramsWithSeverity = draftProgramsWithSeverity.filter( + (p) => p.language === targetLanguage, + ); + } + + this.logger.info('Draft detection programs retrieved successfully', { + standardSlug: command.standardSlug, + ruleId: command.ruleId, + ruleContent: rule.content, + language: command.language, + draftCount: draftProgramsWithSeverity.length, + scope: standard.scope, + }); + + return { + programs: draftProgramsWithSeverity, + ruleContent: rule.content, + scope: standard.scope, + }; + } catch (error) { + this.logger.error('Failed to get draft detection programs for rule', { + standardSlug: command.standardSlug, + ruleId: command.ruleId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/getRuleDetectionAssessment/GetRuleDetectionAssessmentUseCase.spec.ts b/packages/linter/src/application/useCases/getRuleDetectionAssessment/GetRuleDetectionAssessmentUseCase.spec.ts new file mode 100644 index 000000000..86f160e73 --- /dev/null +++ b/packages/linter/src/application/useCases/getRuleDetectionAssessment/GetRuleDetectionAssessmentUseCase.spec.ts @@ -0,0 +1,178 @@ +import { + createOrganizationId, + createRuleDetectionAssessmentId, + createRuleId, + createUserId, + DetectionModeEnum, + IAccountsPort, + Organization, + ProgrammingLanguage, + RuleDetectionAssessment, + RuleDetectionAssessmentStatus, + User, +} from '@packmind/types'; +import { stubLogger } from '@packmind/test-utils'; +import { GetRuleDetectionAssessmentUseCase } from './GetRuleDetectionAssessmentUseCase'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { IRuleDetectionAssessmentRepository } from '../../../domain/repositories/IRuleDetectionAssessmentRepository'; + +describe('GetRuleDetectionAssessmentUseCase', () => { + let useCase: GetRuleDetectionAssessmentUseCase; + let mockLinterRepositories: jest.Mocked<ILinterRepositories>; + let mockRuleDetectionAssessmentRepository: jest.Mocked<IRuleDetectionAssessmentRepository>; + let mockAccountsPort: jest.Mocked<IAccountsPort>; + + const mockRuleId = createRuleId('rule-123'); + const mockOrganizationId = createOrganizationId('org-123'); + const mockUserId = createUserId('user-123'); + + beforeEach(() => { + mockRuleDetectionAssessmentRepository = { + get: jest.fn(), + update: jest.fn(), + add: jest.fn(), + findById: jest.fn(), + list: jest.fn(), + delete: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as unknown as jest.Mocked<IRuleDetectionAssessmentRepository>; + + mockLinterRepositories = { + getRuleDetectionAssessmentRepository: jest.fn( + () => mockRuleDetectionAssessmentRepository, + ), + getDetectionProgramRepository: jest.fn(), + getActiveDetectionProgramRepository: jest.fn(), + getDetectionProgramMetadataRepository: jest.fn(), + getRuleDetectionHeuristicsRepository: jest.fn(), + } as unknown as jest.Mocked<ILinterRepositories>; + + mockAccountsPort = { + getUserById: jest.fn(), + getOrganizationById: jest.fn(), + } as unknown as jest.Mocked<IAccountsPort>; + + // Setup default mocks for user and organization + const user: User = { + id: mockUserId, + email: 'test@example.com', + passwordHash: 'hashed', + memberships: [ + { + organizationId: mockOrganizationId, + role: 'member', + userId: mockUserId, + }, + ], + active: true, + }; + const organization: Organization = { + id: mockOrganizationId, + name: 'Test Org', + slug: 'test-org', + }; + + mockAccountsPort.getUserById.mockResolvedValue(user); + mockAccountsPort.getOrganizationById.mockResolvedValue(organization); + + useCase = new GetRuleDetectionAssessmentUseCase( + mockLinterRepositories, + mockAccountsPort, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when assessment exists for rule and language', () => { + const mockAssessment: RuleDetectionAssessment = { + id: createRuleDetectionAssessmentId('assessment-1'), + ruleId: mockRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + detectionMode: DetectionModeEnum.SINGLE_AST, + status: RuleDetectionAssessmentStatus.SUCCESS, + details: 'Assessment completed successfully', + clarificationQuestion: '', + clarificationAnswers: [], + }; + + beforeEach(() => { + mockRuleDetectionAssessmentRepository.get.mockResolvedValue( + mockAssessment, + ); + }); + + it('returns the assessment', async () => { + const result = await useCase.execute({ + ruleId: mockRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + organizationId: mockOrganizationId, + userId: mockUserId, + }); + + expect(result.assessment).toEqual(mockAssessment); + }); + + it('queries the repository with rule and language', async () => { + await useCase.execute({ + ruleId: mockRuleId, + language: ProgrammingLanguage.TYPESCRIPT, + organizationId: mockOrganizationId, + userId: mockUserId, + }); + + expect(mockRuleDetectionAssessmentRepository.get).toHaveBeenCalledWith( + mockRuleId, + ProgrammingLanguage.TYPESCRIPT, + ); + }); + }); + + describe('when assessment does not exist', () => { + beforeEach(() => { + mockRuleDetectionAssessmentRepository.get.mockResolvedValue(null); + }); + + it('returns null', async () => { + const result = await useCase.execute({ + ruleId: mockRuleId, + language: ProgrammingLanguage.PYTHON, + organizationId: mockOrganizationId, + userId: mockUserId, + }); + + expect(result.assessment).toBeNull(); + }); + + it('queries the repository with rule and language', async () => { + await useCase.execute({ + ruleId: mockRuleId, + language: ProgrammingLanguage.PYTHON, + organizationId: mockOrganizationId, + userId: mockUserId, + }); + + expect(mockRuleDetectionAssessmentRepository.get).toHaveBeenCalledWith( + mockRuleId, + ProgrammingLanguage.PYTHON, + ); + }); + }); + + it('propagates repository errors', async () => { + const error = new Error('Repository error'); + mockRuleDetectionAssessmentRepository.get.mockRejectedValue(error); + + await expect( + useCase.execute({ + ruleId: mockRuleId, + language: ProgrammingLanguage.JAVASCRIPT, + organizationId: mockOrganizationId, + userId: mockUserId, + }), + ).rejects.toThrow('Repository error'); + }); +}); diff --git a/packages/linter/src/application/useCases/getRuleDetectionAssessment/GetRuleDetectionAssessmentUseCase.ts b/packages/linter/src/application/useCases/getRuleDetectionAssessment/GetRuleDetectionAssessmentUseCase.ts new file mode 100644 index 000000000..22a3e92a9 --- /dev/null +++ b/packages/linter/src/application/useCases/getRuleDetectionAssessment/GetRuleDetectionAssessmentUseCase.ts @@ -0,0 +1,67 @@ +import { PackmindLogger } from '@packmind/logger'; +import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; +import { + GetRuleDetectionAssessmentCommand, + GetRuleDetectionAssessmentResponse, + IAccountsPort, + IGetRuleDetectionAssessment, +} from '@packmind/types'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; + +const origin = 'GetRuleDetectionAssessmentUseCase'; + +export class GetRuleDetectionAssessmentUseCase + extends AbstractMemberUseCase< + GetRuleDetectionAssessmentCommand, + GetRuleDetectionAssessmentResponse + > + implements IGetRuleDetectionAssessment +{ + constructor( + private readonly linterRepositories: ILinterRepositories, + accountsPort: IAccountsPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForMembers( + command: GetRuleDetectionAssessmentCommand & MemberContext, + ): Promise<GetRuleDetectionAssessmentResponse> { + this.logger.info('Getting rule detection assessment', { + ruleId: command.ruleId, + language: command.language, + organizationId: command.organizationId, + userId: command.userId, + }); + + try { + const assessment = await this.linterRepositories + .getRuleDetectionAssessmentRepository() + .get(command.ruleId, command.language); + + if (assessment) { + this.logger.info('Successfully retrieved rule detection assessment', { + ruleId: command.ruleId, + language: command.language, + assessmentId: assessment.id, + status: assessment.status, + }); + } else { + this.logger.info('No rule detection assessment found', { + ruleId: command.ruleId, + language: command.language, + }); + } + + return { assessment }; + } catch (error) { + this.logger.error('Failed to get rule detection assessment', { + ruleId: command.ruleId, + language: command.language, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/getStandardRulesDetectionStatus/GetStandardRulesDetectionStatusUseCase.spec.ts b/packages/linter/src/application/useCases/getStandardRulesDetectionStatus/GetStandardRulesDetectionStatusUseCase.spec.ts new file mode 100644 index 000000000..4f9130be7 --- /dev/null +++ b/packages/linter/src/application/useCases/getStandardRulesDetectionStatus/GetStandardRulesDetectionStatusUseCase.spec.ts @@ -0,0 +1,406 @@ +import { + ActiveDetectionProgram, + createActiveDetectionProgramId, + createOrganizationId, + createRuleExampleId, + createRuleId, + createStandardId, + createStandardVersionId, + createUserId, + DetectionSeverity, + IAccountsPort, + IComputeRuleLanguageDetectionStatusUseCase, + IStandardsPort, + Organization, + ProgrammingLanguage, + Rule, + RuleExample, + RuleLanguageDetectionStatus, + User, +} from '@packmind/types'; +import { IActiveDetectionProgramRepository } from '../../../domain/repositories/IActiveDetectionProgramRepository'; +import { stubLogger } from '@packmind/test-utils'; +import { GetStandardRulesDetectionStatusUseCase } from './GetStandardRulesDetectionStatusUseCase'; + +describe('GetStandardRulesDetectionStatusUseCase', () => { + let useCase: GetStandardRulesDetectionStatusUseCase; + let mockStandardsAdapter: jest.Mocked<IStandardsPort>; + let mockAccountsPort: jest.Mocked<IAccountsPort>; + let mockComputeRuleLanguageDetectionStatusUseCase: jest.Mocked<IComputeRuleLanguageDetectionStatusUseCase>; + let mockActiveDetectionProgramRepository: jest.Mocked<IActiveDetectionProgramRepository>; + + const mockStandardId = createStandardId('standard-123'); + const mockOrganizationId = createOrganizationId('org-123'); + const mockUserId = createUserId('user-123'); + + beforeEach(() => { + mockComputeRuleLanguageDetectionStatusUseCase = { + execute: jest.fn(), + } as jest.Mocked<IComputeRuleLanguageDetectionStatusUseCase>; + + mockStandardsAdapter = { + getStandardVersion: jest.fn(), + getStandardVersionById: jest.fn(), + listStandardVersions: jest.fn(), + getRuleCodeExamples: jest.fn(), + getStandard: jest.fn(), + getRule: jest.fn(), + getLatestRulesByStandardId: jest.fn(), + getRulesByStandardId: jest.fn(), + listStandardsBySpace: jest.fn(), + findStandardBySlug: jest.fn(), + getLatestStandardVersion: jest.fn(), + } as jest.Mocked<IStandardsPort>; + + // Default mock for getLatestRulesByStandardId + mockStandardsAdapter.getLatestRulesByStandardId.mockResolvedValue([]); + + mockAccountsPort = { + getUserById: jest.fn(), + getOrganizationById: jest.fn(), + } as unknown as jest.Mocked<IAccountsPort>; + + const user: User = { + id: mockUserId, + email: 'test@example.com', + passwordHash: 'hashed', + memberships: [ + { + organizationId: mockOrganizationId, + role: 'member', + userId: mockUserId, + }, + ], + active: true, + }; + const organization: Organization = { + id: mockOrganizationId, + name: 'Test Org', + slug: 'test-org', + }; + + mockAccountsPort.getUserById.mockResolvedValue(user); + mockAccountsPort.getOrganizationById.mockResolvedValue(organization); + + mockComputeRuleLanguageDetectionStatusUseCase.execute.mockResolvedValue({ + status: RuleLanguageDetectionStatus.NONE, + }); + + mockActiveDetectionProgramRepository = { + findByRuleId: jest.fn().mockResolvedValue([]), + findByRuleIdAndLanguage: jest.fn(), + findByRuleIdWithPrograms: jest.fn(), + updateActiveDetectionProgram: jest.fn(), + updateSeverity: jest.fn(), + deleteByRuleId: jest.fn(), + findById: jest.fn(), + add: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as unknown as jest.Mocked<IActiveDetectionProgramRepository>; + + useCase = new GetStandardRulesDetectionStatusUseCase( + mockAccountsPort, + mockStandardsAdapter, + mockComputeRuleLanguageDetectionStatusUseCase, + mockActiveDetectionProgramRepository, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when standard has multiple rules with examples', () => { + const rule1: Rule = { + id: createRuleId('rule-1'), + content: 'Use const instead of var', + standardVersionId: createStandardVersionId('version-id'), + }; + + const rule2: Rule = { + id: createRuleId('rule-2'), + content: 'Use async/await instead of callbacks', + standardVersionId: createStandardVersionId('version-id'), + }; + + const rule1Examples: RuleExample[] = [ + { + id: createRuleExampleId('example-1'), + ruleId: rule1.id, + lang: ProgrammingLanguage.JAVASCRIPT, + positive: 'const x = 1;', + negative: 'var x = 1;', + }, + { + id: createRuleExampleId('example-2'), + ruleId: rule1.id, + lang: ProgrammingLanguage.TYPESCRIPT, + positive: 'const x: number = 1;', + negative: 'var x: number = 1;', + }, + ]; + + const rule2Examples: RuleExample[] = [ + { + id: createRuleExampleId('example-3'), + ruleId: rule2.id, + lang: ProgrammingLanguage.JAVASCRIPT, + positive: 'await fetchData();', + negative: 'fetchData(callback);', + }, + ]; + + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + mockStandardsAdapter.getLatestRulesByStandardId.mockResolvedValue([ + rule1, + rule2, + ]); + mockStandardsAdapter.getRuleCodeExamples + .mockResolvedValueOnce(rule1Examples) + .mockResolvedValueOnce(rule2Examples); + + result = await useCase.execute({ + standardId: mockStandardId, + organizationId: mockOrganizationId, + userId: mockUserId, + }); + }); + + it('returns detection status for all rules', async () => { + expect(result.rules).toHaveLength(2); + }); + + it('returns first rule with correct languages and status', async () => { + expect(result.rules[0]).toEqual({ + ruleId: rule1.id, + languages: [ + { + language: ProgrammingLanguage.JAVASCRIPT, + status: RuleLanguageDetectionStatus.NONE, + }, + { + language: ProgrammingLanguage.TYPESCRIPT, + status: RuleLanguageDetectionStatus.NONE, + }, + ], + }); + }); + + it('returns second rule with correct languages and status', async () => { + expect(result.rules[1]).toEqual({ + ruleId: rule2.id, + languages: [ + { + language: ProgrammingLanguage.JAVASCRIPT, + status: RuleLanguageDetectionStatus.NONE, + }, + ], + }); + }); + }); + + describe('when rule has duplicate language examples', () => { + const rule: Rule = { + id: createRuleId('rule-1'), + content: 'Test rule', + standardVersionId: createStandardVersionId('version-id'), + }; + + const examples: RuleExample[] = [ + { + id: createRuleExampleId('example-1'), + ruleId: rule.id, + lang: ProgrammingLanguage.JAVASCRIPT, + positive: 'code1', + negative: 'code2', + }, + { + id: createRuleExampleId('example-2'), + ruleId: rule.id, + lang: ProgrammingLanguage.JAVASCRIPT, + positive: 'code3', + negative: 'code4', + }, + { + id: createRuleExampleId('example-3'), + ruleId: rule.id, + lang: ProgrammingLanguage.TYPESCRIPT, + positive: 'code5', + negative: 'code6', + }, + ]; + + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + mockStandardsAdapter.getLatestRulesByStandardId.mockResolvedValue([rule]); + mockStandardsAdapter.getRuleCodeExamples.mockResolvedValue(examples); + + result = await useCase.execute({ + standardId: mockStandardId, + organizationId: mockOrganizationId, + userId: mockUserId, + }); + }); + + it('extracts unique languages count', async () => { + expect(result.rules[0].languages).toHaveLength(2); + }); + + it('includes JavaScript as first language', async () => { + expect(result.rules[0].languages[0].language).toBe( + ProgrammingLanguage.JAVASCRIPT, + ); + }); + + it('includes TypeScript as second language', async () => { + expect(result.rules[0].languages[1].language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + }); + + describe('when standard has no rules', () => { + it('returns empty rules array', async () => { + mockStandardsAdapter.getLatestRulesByStandardId.mockResolvedValue([]); + + const result = await useCase.execute({ + standardId: mockStandardId, + organizationId: mockOrganizationId, + userId: mockUserId, + }); + + expect(result.rules).toEqual([]); + }); + }); + + describe('when rule has no examples', () => { + const rule: Rule = { + id: createRuleId('rule-1'), + content: 'Test rule', + standardVersionId: createStandardVersionId('version-id'), + }; + + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + mockStandardsAdapter.getLatestRulesByStandardId.mockResolvedValue([rule]); + mockStandardsAdapter.getRuleCodeExamples.mockResolvedValue([]); + + result = await useCase.execute({ + standardId: mockStandardId, + organizationId: mockOrganizationId, + userId: mockUserId, + }); + }); + + it('returns one rule', async () => { + expect(result.rules).toHaveLength(1); + }); + + it('returns rule with empty languages array', async () => { + expect(result.rules[0]).toEqual({ + ruleId: rule.id, + languages: [], + }); + }); + }); + + it('propagates errors from standards adapter', async () => { + const error = new Error('Standards adapter error'); + mockStandardsAdapter.getLatestRulesByStandardId.mockRejectedValue(error); + + await expect( + useCase.execute({ + standardId: mockStandardId, + organizationId: mockOrganizationId, + userId: mockUserId, + }), + ).rejects.toThrow('Standards adapter error'); + }); + + it('propagates errors from rule examples retrieval', async () => { + const rule: Rule = { + id: createRuleId('rule-1'), + content: 'Test rule', + standardVersionId: createStandardVersionId('version-id'), + }; + + mockStandardsAdapter.getLatestRulesByStandardId.mockResolvedValue([rule]); + + const error = new Error('Failed to get rule examples'); + mockStandardsAdapter.getRuleCodeExamples.mockRejectedValue(error); + + await expect( + useCase.execute({ + standardId: mockStandardId, + organizationId: mockOrganizationId, + userId: mockUserId, + }), + ).rejects.toThrow('Failed to get rule examples'); + }); + + describe('when rule has active detection programs with severity', () => { + const rule: Rule = { + id: createRuleId('rule-1'), + content: 'Test rule', + standardVersionId: createStandardVersionId('version-id'), + }; + + const examples: RuleExample[] = [ + { + id: createRuleExampleId('example-1'), + ruleId: rule.id, + lang: ProgrammingLanguage.TYPESCRIPT, + positive: 'const x = 1;', + negative: 'var x = 1;', + }, + ]; + + const activeDetectionProgramId = createActiveDetectionProgramId('adp-1'); + + const activeDetectionProgram: ActiveDetectionProgram = { + id: activeDetectionProgramId, + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgramVersion: null, + detectionProgramDraftVersion: null, + severity: DetectionSeverity.WARNING, + }; + + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + mockStandardsAdapter.getLatestRulesByStandardId.mockResolvedValue([rule]); + mockStandardsAdapter.getRuleCodeExamples.mockResolvedValue(examples); + mockComputeRuleLanguageDetectionStatusUseCase.execute.mockResolvedValue({ + status: RuleLanguageDetectionStatus.OK, + }); + mockActiveDetectionProgramRepository.findByRuleId.mockResolvedValue([ + activeDetectionProgram, + ]); + + result = await useCase.execute({ + standardId: mockStandardId, + organizationId: mockOrganizationId, + userId: mockUserId, + }); + }); + + it('includes severity in the language status', () => { + expect(result.rules[0].languages[0].severity).toBe( + DetectionSeverity.WARNING, + ); + }); + + it('includes activeDetectionProgramId in the language status', () => { + expect(result.rules[0].languages[0].activeDetectionProgramId).toBe( + activeDetectionProgramId, + ); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/getStandardRulesDetectionStatus/GetStandardRulesDetectionStatusUseCase.ts b/packages/linter/src/application/useCases/getStandardRulesDetectionStatus/GetStandardRulesDetectionStatusUseCase.ts new file mode 100644 index 000000000..059c62235 --- /dev/null +++ b/packages/linter/src/application/useCases/getStandardRulesDetectionStatus/GetStandardRulesDetectionStatusUseCase.ts @@ -0,0 +1,130 @@ +import { PackmindLogger } from '@packmind/logger'; +import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; +import { + GetStandardRulesDetectionStatusCommand, + GetStandardRulesDetectionStatusResponse, + IAccountsPort, + IComputeRuleLanguageDetectionStatusUseCase, + IStandardsPort, + ProgrammingLanguage, + RuleDetectionStatusSummary, + RuleLanguageStatus, +} from '@packmind/types'; +import { IActiveDetectionProgramRepository } from '../../../domain/repositories/IActiveDetectionProgramRepository'; + +const origin = 'GetStandardRulesDetectionStatusUseCase'; + +export class GetStandardRulesDetectionStatusUseCase extends AbstractMemberUseCase< + GetStandardRulesDetectionStatusCommand, + GetStandardRulesDetectionStatusResponse +> { + constructor( + accountsPort: IAccountsPort, + private readonly standardsAdapter: IStandardsPort, + private readonly computeRuleLanguageDetectionStatusUseCase: IComputeRuleLanguageDetectionStatusUseCase, + private readonly activeDetectionProgramRepository: IActiveDetectionProgramRepository, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForMembers( + command: GetStandardRulesDetectionStatusCommand & MemberContext, + ): Promise<GetStandardRulesDetectionStatusResponse> { + this.logger.info('Getting standard rules detection status', { + standardId: command.standardId, + organizationId: command.organizationId, + userId: command.userId, + }); + + try { + // Get all rules for the latest version of this standard + const rules = await this.standardsAdapter.getLatestRulesByStandardId( + command.standardId, + ); + + this.logger.info('Retrieved rules for standard', { + standardId: command.standardId, + rulesCount: rules.length, + }); + + // For each rule, get the detection status for all languages + const rulesStatusPromises = rules.map(async (rule) => { + // Get rule examples to determine available languages + const ruleExamples = await this.standardsAdapter.getRuleCodeExamples( + rule.id, + ); + + // Extract unique languages from examples + const languagesSet = new Set<ProgrammingLanguage>(); + for (const example of ruleExamples) { + languagesSet.add(example.lang); + } + + const languages = Array.from(languagesSet); + + // Compute detection status for each language in parallel + const languageStatusPromises = languages.map(async (language) => { + const statusResponse = + await this.computeRuleLanguageDetectionStatusUseCase.execute({ + ruleId: rule.id, + language, + }); + + const languageStatus: RuleLanguageStatus = { + language, + status: statusResponse.status, + }; + + return languageStatus; + }); + + const languageStatuses = await Promise.all(languageStatusPromises); + + const activeDetectionPrograms = + await this.activeDetectionProgramRepository.findByRuleId(rule.id); + + const adpByLanguage = new Map( + activeDetectionPrograms.map((adp) => [adp.language, adp]), + ); + + const enrichedLanguageStatuses: RuleLanguageStatus[] = + languageStatuses.map((ls) => { + const adp = adpByLanguage.get(ls.language); + if (adp) { + return { + ...ls, + severity: adp.severity, + activeDetectionProgramId: adp.id, + }; + } + return ls; + }); + + const ruleSummary: RuleDetectionStatusSummary = { + ruleId: rule.id, + languages: enrichedLanguageStatuses, + }; + + return ruleSummary; + }); + + const rulesSummaries = await Promise.all(rulesStatusPromises); + + this.logger.info('Successfully computed detection status for all rules', { + standardId: command.standardId, + rulesCount: rulesSummaries.length, + }); + + return { rules: rulesSummaries }; + } catch (error) { + this.logger.error('Failed to get standard rules detection status', { + standardId: command.standardId, + organizationId: command.organizationId, + userId: command.userId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/listDetectionProgram/ListDetectionProgramUseCase.spec.ts b/packages/linter/src/application/useCases/listDetectionProgram/ListDetectionProgramUseCase.spec.ts new file mode 100644 index 000000000..25f2a94fd --- /dev/null +++ b/packages/linter/src/application/useCases/listDetectionProgram/ListDetectionProgramUseCase.spec.ts @@ -0,0 +1,529 @@ +import { stubLogger } from '@packmind/test-utils'; +import { createOrganizationId, createUserId, IGitPort } from '@packmind/types'; +import { + createGitRepoId, + createGitProviderId, + ISpacesPort, + ListDetectionProgramCommand, +} from '@packmind/types'; +import { ListDetectionProgramUseCase } from './ListDetectionProgramUseCase'; +import { DetectionProgramService } from '../../services/DetectionProgramService'; + +describe('ListDetectionProgramUseCase', () => { + let useCase: ListDetectionProgramUseCase; + let mockDetectionProgramService: jest.Mocked<DetectionProgramService>; + let mockGitPort: jest.Mocked<IGitPort>; + let mockDeploymentsAdapter: { + findActiveStandardVersionsByRepository: jest.Mock; + findActiveStandardVersionsByTarget: jest.Mock; + getTargetsByGitRepo: jest.Mock; + }; + let mockStandardsAdapter: { + getStandardVersion: jest.Mock; + getLatestRulesByStandardId: jest.Mock; + listStandardsBySpace: jest.Mock; + }; + let mockSpacesAdapter: jest.Mocked<ISpacesPort>; + + beforeEach(() => { + mockDetectionProgramService = { + findActiveByRuleIdWithPrograms: jest.fn(), + } as unknown as jest.Mocked<DetectionProgramService>; + + mockDeploymentsAdapter = { + findActiveStandardVersionsByRepository: jest.fn(), + findActiveStandardVersionsByTarget: jest.fn(), + getTargetsByGitRepo: jest.fn(), + }; + + mockStandardsAdapter = { + getStandardVersion: jest.fn(), + getLatestRulesByStandardId: jest.fn(), + listStandardsBySpace: jest.fn(), + }; + + mockSpacesAdapter = { + listSpacesByOrganization: jest.fn(), + createSpace: jest.fn(), + getSpaceBySlug: jest.fn(), + getSpaceById: jest.fn(), + } as jest.Mocked<ISpacesPort>; + + mockGitPort = { + findGitRepoByOwnerRepoAndBranchInOrganization: jest.fn(), + getOrganizationRepositories: jest.fn(), + findGitRepoByOwnerAndRepo: jest.fn(), + } as unknown as jest.Mocked<IGitPort>; + + useCase = new ListDetectionProgramUseCase( + mockDetectionProgramService, + mockDeploymentsAdapter as never, + mockStandardsAdapter as never, + mockSpacesAdapter, + mockGitPort, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute', () => { + const organizationId = createOrganizationId('org-123'); + const userId = createUserId('user-123'); + + describe('when branches array is empty', () => { + it('throws an error', async () => { + const command: ListDetectionProgramCommand = { + organizationId, + userId, + gitRemoteUrl: 'github.com/owner/repo', + branches: [], + }; + + await expect(useCase.execute(command)).rejects.toThrow( + 'The current git repository does not have any branch', + ); + }); + }); + + describe('when gitRemoteUrl is empty', () => { + it('throws an error', async () => { + const command: ListDetectionProgramCommand = { + organizationId, + userId, + gitRemoteUrl: '', + branches: ['main'], + }; + + await expect(useCase.execute(command)).rejects.toThrow( + 'gitRemoteUrl is required and cannot be empty', + ); + }); + }); + + describe('when matching repo is found with first branch', () => { + describe('when no targets found', () => { + const gitRepoId = createGitRepoId('git-repo-1'); + const mockGitRepo = { + id: gitRepoId, + owner: 'owner', + repo: 'repo', + branch: 'main', + providerId: createGitProviderId('provider-1'), + }; + let command: ListDetectionProgramCommand; + + beforeEach(() => { + mockGitPort.findGitRepoByOwnerRepoAndBranchInOrganization.mockResolvedValue( + { gitRepo: mockGitRepo }, + ); + mockDeploymentsAdapter.getTargetsByGitRepo.mockResolvedValue([]); + + command = { + organizationId, + userId, + gitRemoteUrl: 'github.com/owner/repo', + branches: ['main', 'develop'], + }; + }); + + it('throws an error', async () => { + await expect(useCase.execute(command)).rejects.toThrow( + 'No targets are found on the git repo owner/repo', + ); + }); + + it('calls findGitRepoByOwnerRepoAndBranchInOrganization with correct parameters', async () => { + try { + await useCase.execute(command); + } catch { + // Expected error + } + + expect( + mockGitPort.findGitRepoByOwnerRepoAndBranchInOrganization, + ).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + branch: 'main', + organizationId, + userId, + }); + }); + + it('calls findGitRepoByOwnerRepoAndBranchInOrganization only once', async () => { + try { + await useCase.execute(command); + } catch { + // Expected error + } + + expect( + mockGitPort.findGitRepoByOwnerRepoAndBranchInOrganization, + ).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when matching repo is found with second branch', () => { + const gitRepoId = createGitRepoId('git-repo-2'); + const mockGitRepo = { + id: gitRepoId, + owner: 'owner', + repo: 'repo', + branch: 'develop', + providerId: createGitProviderId('provider-2'), + }; + let command: ListDetectionProgramCommand; + + beforeEach(() => { + mockGitPort.findGitRepoByOwnerRepoAndBranchInOrganization + .mockResolvedValueOnce({ gitRepo: null }) + .mockResolvedValueOnce({ gitRepo: mockGitRepo }); + mockDeploymentsAdapter.getTargetsByGitRepo.mockResolvedValue([]); + + command = { + organizationId, + userId, + gitRemoteUrl: 'github.com/owner/repo', + branches: ['main', 'develop'], + }; + }); + + describe('when no targets found', () => { + it('throws an error', async () => { + await expect(useCase.execute(command)).rejects.toThrow( + 'No targets are found on the git repo owner/repo', + ); + }); + + it('calls findGitRepoByOwnerRepoAndBranchInOrganization twice', async () => { + try { + await useCase.execute(command); + } catch { + // Expected error + } + + expect( + mockGitPort.findGitRepoByOwnerRepoAndBranchInOrganization, + ).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('when no branch matches but organization-level repo exists', () => { + const gitRepoId = createGitRepoId('git-repo-3'); + const mockGitRepo = { + id: gitRepoId, + owner: 'owner', + repo: 'repo', + branch: 'feature', + providerId: createGitProviderId('provider-3'), + }; + let command: ListDetectionProgramCommand; + + beforeEach(() => { + mockGitPort.findGitRepoByOwnerRepoAndBranchInOrganization.mockResolvedValue( + { gitRepo: null }, + ); + mockGitPort.getOrganizationRepositories.mockResolvedValue([ + mockGitRepo, + ]); + mockDeploymentsAdapter.getTargetsByGitRepo.mockResolvedValue([]); + + command = { + organizationId, + userId, + gitRemoteUrl: 'github.com/owner/repo', + branches: ['main', 'develop'], + }; + }); + + describe('when no targets found', () => { + it('throws an error', async () => { + await expect(useCase.execute(command)).rejects.toThrow( + 'No targets are found on the git repo owner/repo', + ); + }); + + it('calls getOrganizationRepositories with organization ID', async () => { + try { + await useCase.execute(command); + } catch { + // Expected error + } + + expect(mockGitPort.getOrganizationRepositories).toHaveBeenCalledWith( + organizationId, + ); + }); + }); + }); + + describe('when organization has multiple repos with same owner/repo', () => { + const gitRepoId1 = createGitRepoId('git-repo-4'); + const gitRepoId2 = createGitRepoId('git-repo-5'); + const olderRepo = { + id: gitRepoId1, + owner: 'owner', + repo: 'repo', + branch: 'branch1', + providerId: createGitProviderId('provider-4'), + createdAt: new Date('2020-01-01'), + }; + const newerRepo = { + id: gitRepoId2, + owner: 'owner', + repo: 'repo', + branch: 'branch2', + providerId: createGitProviderId('provider-5'), + createdAt: new Date('2021-01-01'), + }; + let command: ListDetectionProgramCommand; + + beforeEach(() => { + mockGitPort.findGitRepoByOwnerRepoAndBranchInOrganization.mockResolvedValue( + { gitRepo: null }, + ); + mockGitPort.getOrganizationRepositories.mockResolvedValue([ + newerRepo, + olderRepo, + ]); + mockDeploymentsAdapter.getTargetsByGitRepo.mockResolvedValue([]); + + command = { + organizationId, + userId, + gitRemoteUrl: 'github.com/owner/repo', + branches: ['main'], + }; + }); + + describe('when no targets found', () => { + it('throws an error', async () => { + await expect(useCase.execute(command)).rejects.toThrow( + 'No targets are found on the git repo owner/repo', + ); + }); + + it('calls getTargetsByGitRepo with the older repo ID', async () => { + try { + await useCase.execute(command); + } catch { + // Expected error + } + + expect( + mockDeploymentsAdapter.getTargetsByGitRepo, + ).toHaveBeenCalledWith({ + organizationId, + userId, + gitRepoId: gitRepoId1, + }); + }); + }); + }); + + describe('when no repo found at all', () => { + it('throws an error', async () => { + mockGitPort.findGitRepoByOwnerRepoAndBranchInOrganization.mockResolvedValue( + { gitRepo: null }, + ); + mockGitPort.getOrganizationRepositories.mockResolvedValue([]); + + const command: ListDetectionProgramCommand = { + organizationId, + userId, + gitRemoteUrl: 'github.com/owner/repo', + branches: ['main'], + }; + + await expect(useCase.execute(command)).rejects.toThrow( + 'Git repository (url: github.com/owner/repo) is not connected to your organization', + ); + }); + }); + }); + + describe('parseGitRemoteUrl', () => { + describe('when given a GitHub URL with simple path', () => { + it('extracts owner and repo correctly', () => { + const result = useCase.parseGitRemoteUrl( + 'github.com/PackmindHub/packmind-monorepo', + ); + + expect(result).toEqual({ + owner: 'PackmindHub', + repo: 'packmind-monorepo', + }); + }); + }); + + describe('when given a GitLab URL with nested groups (2 levels)', () => { + it('extracts owner with subgroup and repo correctly', () => { + const result = useCase.parseGitRemoteUrl( + 'gitlab.com/promyze/sandbox/comments-clustering', + ); + + expect(result).toEqual({ + owner: 'promyze/sandbox', + repo: 'comments-clustering', + }); + }); + }); + + describe('when given a GitLab URL with deeply nested groups (3 levels)', () => { + it('extracts owner with multiple subgroups and repo correctly', () => { + const result = useCase.parseGitRemoteUrl( + 'gitlab.com/company/team/project/my-repo', + ); + + expect(result).toEqual({ + owner: 'company/team/project', + repo: 'my-repo', + }); + }); + }); + + describe('when given a GitLab URL with very deeply nested groups (4 levels)', () => { + it('extracts owner with all subgroups and repo correctly', () => { + const result = useCase.parseGitRemoteUrl( + 'gitlab.com/org/division/team/subteam/repository', + ); + + expect(result).toEqual({ + owner: 'org/division/team/subteam', + repo: 'repository', + }); + }); + }); + + describe('when given a self-hosted GitLab URL with nested groups', () => { + it('extracts owner with subgroups and repo correctly', () => { + const result = useCase.parseGitRemoteUrl( + 'gitlab.example.com/group/subgroup/my-project', + ); + + expect(result).toEqual({ + owner: 'group/subgroup', + repo: 'my-project', + }); + }); + }); + + describe('when given a self-hosted GitLab URL with multiple domain segments and nested groups', () => { + it('extracts owner with nested projects and repo correctly', () => { + const result = useCase.parseGitRemoteUrl( + 'acme.corp.gitlab.internal/group/project1/subproject2', + ); + + expect(result).toEqual({ + owner: 'group/project1', + repo: 'subproject2', + }); + }); + }); + + describe('when given a URL with only domain and repo (no owner)', () => { + it('throws an error', () => { + expect(() => { + useCase.parseGitRemoteUrl('github.com/repo-name'); + }).toThrow('Invalid Git remote URL format: github.com/repo-name'); + }); + }); + + describe('when given a URL with only domain', () => { + it('throws an error', () => { + expect(() => { + useCase.parseGitRemoteUrl('github.com'); + }).toThrow('Invalid Git remote URL format: github.com'); + }); + }); + + describe('when given an empty string', () => { + it('throws an error', () => { + expect(() => { + useCase.parseGitRemoteUrl(''); + }).toThrow('Invalid Git remote URL format: '); + }); + }); + + describe('when given a URL with special characters in repo name', () => { + it('extracts owner and repo correctly', () => { + const result = useCase.parseGitRemoteUrl( + 'github.com/owner/my-repo.name', + ); + + expect(result).toEqual({ + owner: 'owner', + repo: 'my-repo.name', + }); + }); + }); + + describe('when given a URL with numbers in paths', () => { + it('extracts owner and repo correctly', () => { + const result = useCase.parseGitRemoteUrl( + 'gitlab.com/group123/subgroup456/repo789', + ); + + expect(result).toEqual({ + owner: 'group123/subgroup456', + repo: 'repo789', + }); + }); + }); + + describe('when given a Bitbucket-style URL', () => { + it('extracts owner and repo correctly', () => { + const result = useCase.parseGitRemoteUrl( + 'bitbucket.org/workspace/repository', + ); + + expect(result).toEqual({ + owner: 'workspace', + repo: 'repository', + }); + }); + }); + + describe('when given a GitHub URL with single trailing slash', () => { + it('extracts owner and repo correctly', () => { + const result = useCase.parseGitRemoteUrl( + 'github.com/PackmindHub/packmind-monorepo/', + ); + + expect(result).toEqual({ + owner: 'PackmindHub', + repo: 'packmind-monorepo', + }); + }); + }); + + describe('when given a GitHub URL with multiple trailing slashes', () => { + it('extracts owner and repo correctly', () => { + const result = useCase.parseGitRemoteUrl('github.com/owner/repo///'); + + expect(result).toEqual({ + owner: 'owner', + repo: 'repo', + }); + }); + }); + + describe('when given a GitLab nested URL with trailing slash', () => { + it('extracts owner with subgroups and repo correctly', () => { + const result = useCase.parseGitRemoteUrl( + 'gitlab.com/group/subgroup/my-project/', + ); + + expect(result).toEqual({ + owner: 'group/subgroup', + repo: 'my-project', + }); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/listDetectionProgram/ListDetectionProgramUseCase.ts b/packages/linter/src/application/useCases/listDetectionProgram/ListDetectionProgramUseCase.ts new file mode 100644 index 000000000..25e1a4713 --- /dev/null +++ b/packages/linter/src/application/useCases/listDetectionProgram/ListDetectionProgramUseCase.ts @@ -0,0 +1,473 @@ +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { + createOrganizationId, + createUserId, + OrganizationId, + UserId, + IGitPort, + Rule, + StandardVersion, + GitRepo, + GitRepoId, +} from '@packmind/types'; + +import { + IListDetectionProgramUseCase, + ListDetectionProgramCommand, + ListDetectionProgramResponse, + IStandardsPort, + ISpacesPort, + IDeploymentPort, +} from '@packmind/types'; +import { ActiveDetectionProgram } from '@packmind/types'; +import { + DetectionProgram, + DetectionModeEnum, + SourceCodeState, +} from '@packmind/types'; +import { DetectionProgramService } from '../../services/DetectionProgramService'; + +const origin = 'ListDetectionProgramUseCase'; + +export class ListDetectionProgramUseCase implements IListDetectionProgramUseCase { + constructor( + private readonly detectionProgramService: DetectionProgramService, + private readonly deploymentsQueryAdapter: IDeploymentPort | undefined, + private readonly standardsAdapter: IStandardsPort | undefined, + private readonly spacesAdapter: ISpacesPort | undefined, + private readonly gitPort: IGitPort, + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.DEBUG, + ), + ) { + this.logger.info('ListDetectionProgramUseCase initialized'); + } + + async execute( + command: ListDetectionProgramCommand, + ): Promise<ListDetectionProgramResponse> { + this.logger.info('Starting listDetectionProgram process', { + gitRemoteUrl: command.gitRemoteUrl, + organizationId: command.organizationId, + userId: command.userId, + }); + + try { + // Input validation + this.validateInput(command); + + // Parse Git remote URL to extract owner and repo + const { owner, repo } = this.parseGitRemoteUrl(command.gitRemoteUrl); + this.logger.debug('Parsed Git remote URL', { owner, repo }); + + // Try to find Git repository by iterating through branches + let gitRepo: GitRepo | null = null; + for (const branch of command.branches) { + gitRepo = ( + await this.gitPort.findGitRepoByOwnerRepoAndBranchInOrganization({ + owner, + repo, + branch, + organizationId: createOrganizationId(command.organizationId), + userId: command.userId, + }) + )?.gitRepo; + + if (gitRepo) { + this.logger.info('Git repository found with branch match', { + owner, + repo, + branch, + }); + break; + } + } + + // Fallback: search at organization level if no branch matched + if (!gitRepo) { + this.logger.info( + 'No repository found with specific branches, trying organization-level fallback', + { + owner, + repo, + branches: command.branches, + }, + ); + + const orgRepos = await this.gitPort.getOrganizationRepositories( + createOrganizationId(command.organizationId), + ); + + const matchingRepos = orgRepos.filter( + (r) => r.owner === owner && r.repo === repo, + ); + + if (matchingRepos.length > 0) { + // Sort by createdAt if available + const sortedRepos = matchingRepos.sort((a, b) => { + const aCreatedAt = (a as { createdAt?: Date }).createdAt; + const bCreatedAt = (b as { createdAt?: Date }).createdAt; + if (aCreatedAt && bCreatedAt) { + return aCreatedAt.getTime() - bCreatedAt.getTime(); + } + return 0; + }); + + gitRepo = sortedRepos[0]; + this.logger.info('Git repository found using organization fallback', { + owner, + repo, + gitRepoId: gitRepo.id, + }); + } + } + + if (!gitRepo) { + this.logger.info('Git repository not found', { + owner, + repo, + gitRemoteUrl: command.gitRemoteUrl, + }); + throw new Error( + `Git repository (url: ${command.gitRemoteUrl}) is not connected to your organization`, + ); + } + + // Get targets for the git repository + const targets = await this.findTargets( + gitRepo.id, + createOrganizationId(command.organizationId), + createUserId(command.userId), + ); + + if (targets.length === 0) { + this.logger.info('No targets found for repository', { + gitRepoId: gitRepo.id, + branch: gitRepo.branch, + }); + throw new Error( + `No targets are found on the git repo ${gitRepo.owner}/${gitRepo.repo}`, + ); + } + + // For each target, get deployed standards + const targetsWithStandards: ListDetectionProgramResponse['targets'] = []; + + for (const target of targets) { + const deployedStandards = await this.findDeployedStandardsForTarget( + target.id, + createOrganizationId(command.organizationId), + createUserId(command.userId), + ); + + if (deployedStandards.length > 0) { + // Filter standards with detection programs + const standardsWithDetectionPrograms = + await this.filterStandardsWithDetectionPrograms( + deployedStandards, + command.organizationId, + createUserId(command.userId), + ); + + if (standardsWithDetectionPrograms.length > 0) { + targetsWithStandards.push({ + name: target.name, + path: target.path, + standards: standardsWithDetectionPrograms, + }); + } + } + } + + this.logger.info( + 'Successfully retrieved targets with detection programs', + { + targetsCount: targetsWithStandards.length, + }, + ); + + return { targets: targetsWithStandards }; + } catch (error) { + this.logger.error('Failed to list detection programs', { + gitRemoteUrl: command.gitRemoteUrl, + organizationId: command.organizationId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + private validateInput(command: ListDetectionProgramCommand): void { + if (!command.gitRemoteUrl || command.gitRemoteUrl.trim() === '') { + throw new Error('gitRemoteUrl is required and cannot be empty'); + } + if (!command.branches || command.branches.length === 0) { + throw new Error('The current git repository does not have any branch'); + } + } + + public parseGitRemoteUrl(gitRemoteUrl: string): { + owner: string; + repo: string; + } { + // gitRemoteUrl format: domain/path/to/repo + // For GitLab nested groups: gitlab.com/group/subgroup/repo + // owner should be: group/subgroup, repo: repo + + // Remove trailing slashes to prevent empty strings in split result + let cleanUrl = gitRemoteUrl; + while (cleanUrl.endsWith('/')) { + cleanUrl = cleanUrl.slice(0, -1); + } + const gitData = cleanUrl.split('/'); + + if (gitData.length < 3) { + throw new Error(`Invalid Git remote URL format: ${gitRemoteUrl}`); + } + + // Extract domain (first part) and the rest is the project path + const projectPath = gitData.slice(1); + + // Last part is the repo name, everything before is the owner/group path + const repo = projectPath[projectPath.length - 1]; + const owner = projectPath.slice(0, -1).join('/'); + + return { owner, repo }; + } + + private async findGitRepository( + owner: string, + repo: string, + ): Promise<GitRepo | null> { + return this.gitPort.findGitRepoByOwnerAndRepo(owner, repo); + } + + private async findTargets( + gitRepoId: GitRepoId, + organizationId: OrganizationId, + userId: UserId, + ): Promise<import('@packmind/types').Target[]> { + if (!this.deploymentsQueryAdapter) { + this.logger.warn( + 'DeploymentsQueryAdapter not available, returning empty results', + ); + return []; + } + + try { + const targets = await this.deploymentsQueryAdapter.getTargetsByGitRepo({ + organizationId, + userId, + gitRepoId, + }); + + return targets || []; + } catch (error) { + this.logger.error('Failed to get targets for repository', { + gitRepoId, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + } + + private async findDeployedStandardsForTarget( + targetId: import('@packmind/types').TargetId, + organizationId: OrganizationId, + userId: UserId, + ): Promise<StandardVersion[]> { + if (!this.deploymentsQueryAdapter) { + this.logger.warn( + 'DeploymentsQueryAdapter not available, returning empty results', + ); + return []; + } + + if (!this.standardsAdapter) { + this.logger.warn( + 'StandardsAdapter not available, returning empty results', + ); + return []; + } + + try { + const standardVersionInfos = + await this.deploymentsQueryAdapter.findActiveStandardVersionsByTarget({ + organizationId, + userId, + targetId, + }); + + if (!standardVersionInfos || !Array.isArray(standardVersionInfos)) { + return []; + } + + // Convert StandardVersionInfo to StandardVersion entities + const standardVersions: StandardVersion[] = []; + + for (const info of standardVersionInfos) { + try { + const standardVersion = + await this.standardsAdapter.getStandardVersion(info.id); + if (standardVersion) { + standardVersions.push(standardVersion); + } + } catch (error) { + this.logger.warn('Failed to get standard version details', { + standardId: info.standardId, + version: info.version, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return standardVersions; + } catch (error) { + this.logger.error('Failed to get deployed standards for target', { + targetId, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + } + + private async filterStandardsWithDetectionPrograms( + deployedStandards: StandardVersion[], + organizationId: string, + userId: UserId, + ): Promise<ListDetectionProgramResponse['targets'][0]['standards']> { + if (!this.standardsAdapter || !this.spacesAdapter) { + this.logger.warn( + 'StandardsAdapter or SpacesAdapter not available, returning empty results', + ); + return []; + } + + if (deployedStandards.length === 0) { + return []; + } + + const standardIds = [ + ...new Set( + deployedStandards.map( + (standard: StandardVersion) => standard.standardId, + ), + ), + ]; + + // Get all spaces for the organization + const spaces = await this.spacesAdapter.listSpacesByOrganization( + createOrganizationId(organizationId), + ); + + // Get all standards across all spaces + const standardsPort = this.standardsAdapter; + const standardsPerSpace = await Promise.all( + spaces.map((space) => + standardsPort.listStandardsBySpace( + space.id, + createOrganizationId(organizationId), + userId, + ), + ), + ); + + // Flatten standards from all spaces + const allStandards = standardsPerSpace.flat(); + + // Handle case where service returns undefined or null + if (!allStandards || !Array.isArray(allStandards)) { + return []; + } + + // Filter to only include deployed standards + const deployedStandardsWithDetails = allStandards.filter((standard) => + standardIds.includes(standard.id), + ); + + // For each deployed standard, get its rules and filter for detection programs + const filteredStandards: ListDetectionProgramResponse['targets'][0]['standards'] = + []; + + for (const standard of deployedStandardsWithDetails) { + try { + const rules = (await this.standardsAdapter.getLatestRulesByStandardId( + standard.id, + )) as Rule[]; + + const rulesWithDetectionPrograms: { + content: string; + activeDetectionPrograms: { + language: string; + detectionProgram: { + mode: DetectionModeEnum; + code: string; + sourceCodeState: SourceCodeState; + }; + }[]; + }[] = []; + + for (const rule of rules) { + try { + const activeWithPrograms = + (await this.detectionProgramService.findActiveByRuleIdWithPrograms( + rule.id, + )) as (ActiveDetectionProgram & { + detectionProgram: DetectionProgram; + })[]; + const mapped = (activeWithPrograms || []) + .map((adp) => ({ + language: adp.language, + detectionProgram: { + mode: adp.detectionProgram?.mode, + code: adp.detectionProgram?.code, + sourceCodeState: adp.detectionProgram?.sourceCodeState, + }, + })) + .filter((x) => + Boolean( + x.detectionProgram && + x.detectionProgram.code && + x.detectionProgram.mode, + ), + ); + + if (mapped.length > 0) { + rulesWithDetectionPrograms.push({ + content: rule.content, + activeDetectionPrograms: mapped, + }); + } + } catch (err) { + this.logger.warn( + 'Failed to get active detection programs for rule', + { + standardId: standard.id, + ruleId: rule.id, + error: err instanceof Error ? err.message : String(err), + }, + ); + } + } + + // Only include standards that have at least one rule with detection programs + if (rulesWithDetectionPrograms.length > 0) { + filteredStandards.push({ + name: standard.name, + slug: standard.slug, + scope: standard.scope ? [standard.scope] : [], + rules: rulesWithDetectionPrograms, + }); + } + } catch (error) { + this.logger.warn('Failed to get rules for standard', { + standardId: standard.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return filteredStandards; + } +} diff --git a/packages/linter/src/application/useCases/moveLinterArtefactsToNewRules/MoveLinterArtefactsToNewRulesUseCase.spec.ts b/packages/linter/src/application/useCases/moveLinterArtefactsToNewRules/MoveLinterArtefactsToNewRulesUseCase.spec.ts new file mode 100644 index 000000000..7f90fe83d --- /dev/null +++ b/packages/linter/src/application/useCases/moveLinterArtefactsToNewRules/MoveLinterArtefactsToNewRulesUseCase.spec.ts @@ -0,0 +1,184 @@ +import { + createOrganizationId, + createRuleId, + createUserId, + ILinterPort, +} from '@packmind/types'; +import { stubLogger } from '@packmind/test-utils'; +import { v4 as uuidv4 } from 'uuid'; +import { MoveLinterArtefactsToNewRulesUseCase } from './MoveLinterArtefactsToNewRulesUseCase'; +import { SoftDeleteLinterArtefactsByRuleUseCase } from '../softDeleteLinterArtefactsByRule/SoftDeleteLinterArtefactsByRuleUseCase'; + +describe('MoveLinterArtefactsToNewRulesUseCase', () => { + let useCase: MoveLinterArtefactsToNewRulesUseCase; + let linterPort: jest.Mocked<ILinterPort>; + let softDeleteUseCase: jest.Mocked<SoftDeleteLinterArtefactsByRuleUseCase>; + + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const oldRuleId1 = createRuleId(uuidv4()); + const newRuleId1 = createRuleId(uuidv4()); + const oldRuleId2 = createRuleId(uuidv4()); + const newRuleId2 = createRuleId(uuidv4()); + + beforeEach(() => { + linterPort = { + copyLinterArtefacts: jest.fn().mockResolvedValue({ + copiedProgramsCount: 1, + copiedAssessmentsCount: 1, + copiedHeuristicsCount: 1, + copiedMetadataCount: 1, + }), + } as unknown as jest.Mocked<ILinterPort>; + + softDeleteUseCase = { + execute: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked<SoftDeleteLinterArtefactsByRuleUseCase>; + + useCase = new MoveLinterArtefactsToNewRulesUseCase( + linterPort, + softDeleteUseCase, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when moving artefacts for a single rule mapping', () => { + const command = { + ruleMappings: [{ oldRuleId: oldRuleId1, newRuleId: newRuleId1 }], + organizationId, + userId, + }; + + it('calls copyLinterArtefacts with correct parameters', async () => { + await useCase.execute(command); + + expect(linterPort.copyLinterArtefacts).toHaveBeenCalledWith({ + oldRuleId: oldRuleId1, + newRuleId: newRuleId1, + organizationId, + userId, + }); + }); + + it('calls softDeleteLinterArtefactsByRule for the old rule', async () => { + await useCase.execute(command); + + expect(softDeleteUseCase.execute).toHaveBeenCalledWith({ + ruleId: oldRuleId1, + userId, + organizationId, + }); + }); + + it('returns correct counts', async () => { + const result = await useCase.execute(command); + + expect(result.copiedCount).toBe(4); + }); + + it('returns correct soft-deleted count', async () => { + const result = await useCase.execute(command); + + expect(result.softDeletedCount).toBe(1); + }); + }); + + describe('when moving artefacts for multiple rule mappings', () => { + const command = { + ruleMappings: [ + { oldRuleId: oldRuleId1, newRuleId: newRuleId1 }, + { oldRuleId: oldRuleId2, newRuleId: newRuleId2 }, + ], + organizationId, + userId, + }; + + it('calls copyLinterArtefacts for each rule mapping', async () => { + await useCase.execute(command); + + expect(linterPort.copyLinterArtefacts).toHaveBeenCalledTimes(2); + }); + + it('calls softDeleteLinterArtefactsByRule for each old rule', async () => { + await useCase.execute(command); + + expect(softDeleteUseCase.execute).toHaveBeenCalledTimes(2); + }); + + it('returns aggregated copied count', async () => { + const result = await useCase.execute(command); + + expect(result.copiedCount).toBe(8); + }); + + it('returns aggregated soft-deleted count', async () => { + const result = await useCase.execute(command); + + expect(result.softDeletedCount).toBe(2); + }); + }); + + describe('when there are no rule mappings', () => { + const command = { + ruleMappings: [], + organizationId, + userId, + }; + + it('does not call copyLinterArtefacts', async () => { + await useCase.execute(command); + + expect(linterPort.copyLinterArtefacts).not.toHaveBeenCalled(); + }); + + it('does not call softDeleteLinterArtefactsByRule', async () => { + await useCase.execute(command); + + expect(softDeleteUseCase.execute).not.toHaveBeenCalled(); + }); + + it('returns zero counts', async () => { + const result = await useCase.execute(command); + + expect(result).toEqual({ copiedCount: 0, softDeletedCount: 0 }); + }); + }); + + describe('when copy fails', () => { + it('throws the error without calling soft-delete', async () => { + linterPort.copyLinterArtefacts.mockRejectedValue( + new Error('Copy failed'), + ); + + const command = { + ruleMappings: [{ oldRuleId: oldRuleId1, newRuleId: newRuleId1 }], + organizationId, + userId, + }; + + await expect(useCase.execute(command)).rejects.toThrow('Copy failed'); + }); + }); + + describe('when soft-delete fails', () => { + it('throws the error', async () => { + softDeleteUseCase.execute.mockRejectedValue( + new Error('Soft-delete failed'), + ); + + const command = { + ruleMappings: [{ oldRuleId: oldRuleId1, newRuleId: newRuleId1 }], + organizationId, + userId, + }; + + await expect(useCase.execute(command)).rejects.toThrow( + 'Soft-delete failed', + ); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/moveLinterArtefactsToNewRules/MoveLinterArtefactsToNewRulesUseCase.ts b/packages/linter/src/application/useCases/moveLinterArtefactsToNewRules/MoveLinterArtefactsToNewRulesUseCase.ts new file mode 100644 index 000000000..bd8d67346 --- /dev/null +++ b/packages/linter/src/application/useCases/moveLinterArtefactsToNewRules/MoveLinterArtefactsToNewRulesUseCase.ts @@ -0,0 +1,83 @@ +import { PackmindLogger } from '@packmind/logger'; +import type { + IMoveLinterArtefactsToNewRules, + MoveLinterArtefactsToNewRulesCommand, + MoveLinterArtefactsToNewRulesResponse, + ILinterPort, +} from '@packmind/types'; +import type { SoftDeleteLinterArtefactsByRuleUseCase } from '../softDeleteLinterArtefactsByRule/SoftDeleteLinterArtefactsByRuleUseCase'; + +const origin = 'MoveLinterArtefactsToNewRulesUseCase'; + +// This use case is invoked from MoveLinterArtefactsDelayedJob (BullMQ background job), +// where authentication/authorization was already verified before dispatch. +// It does not extend AbstractMemberUseCase because member validation is unnecessary here. +export class MoveLinterArtefactsToNewRulesUseCase implements IMoveLinterArtefactsToNewRules { + constructor( + private readonly linterPort: ILinterPort, + private readonly softDeleteLinterArtefactsByRuleUseCase: SoftDeleteLinterArtefactsByRuleUseCase, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: MoveLinterArtefactsToNewRulesCommand, + ): Promise<MoveLinterArtefactsToNewRulesResponse> { + const { ruleMappings, organizationId, userId } = command; + + this.logger.info('Starting move of linter artefacts to new rules', { + ruleMappingsCount: ruleMappings.length, + organizationId, + userId, + }); + + try { + // Phase 1: Copy all artefacts from old rules to new rules + const copyResults = await Promise.all( + ruleMappings.map(({ oldRuleId, newRuleId }) => + this.linterPort.copyLinterArtefacts({ + oldRuleId, + newRuleId, + organizationId, + userId, + }), + ), + ); + const totalCopied = copyResults.reduce( + (sum, r) => + sum + + r.copiedProgramsCount + + r.copiedAssessmentsCount + + r.copiedHeuristicsCount + + r.copiedMetadataCount, + 0, + ); + + // Phase 2: Soft-delete all old artefacts + await Promise.all( + ruleMappings.map(({ oldRuleId }) => + this.softDeleteLinterArtefactsByRuleUseCase.execute({ + ruleId: oldRuleId, + userId, + organizationId, + }), + ), + ); + + this.logger.info('Successfully moved all linter artefacts to new rules', { + totalCopied, + softDeletedCount: ruleMappings.length, + }); + + return { + copiedCount: totalCopied, + softDeletedCount: ruleMappings.length, + }; + } catch (error) { + this.logger.error('Failed to move linter artefacts to new rules', { + ruleMappingsCount: ruleMappings.length, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/moveLinterArtefactsToNewRules/shared/MoveLinterArtefactsDelayedJob.ts b/packages/linter/src/application/useCases/moveLinterArtefactsToNewRules/shared/MoveLinterArtefactsDelayedJob.ts new file mode 100644 index 000000000..ba1aebcb1 --- /dev/null +++ b/packages/linter/src/application/useCases/moveLinterArtefactsToNewRules/shared/MoveLinterArtefactsDelayedJob.ts @@ -0,0 +1,111 @@ +import { PackmindLogger } from '@packmind/logger'; +import type { ILinterPort } from '@packmind/types'; +import { + AbstractAIDelayedJob, + IQueue, + QueueListeners, + WorkerListeners, +} from '@packmind/node-utils'; +import type { + MoveLinterArtefactsToNewRulesCommand, + MoveLinterArtefactsToNewRulesResponse, +} from '@packmind/types'; +import { Job } from 'bullmq'; +import { MoveLinterArtefactsToNewRulesUseCase } from '../MoveLinterArtefactsToNewRulesUseCase'; +import { SoftDeleteLinterArtefactsByRuleUseCase } from '../../softDeleteLinterArtefactsByRule/SoftDeleteLinterArtefactsByRuleUseCase'; +import { ILinterRepositories } from '../../../../domain/repositories/ILinterRepositories'; + +const origin = 'MoveLinterArtefactsDelayedJob'; + +export class MoveLinterArtefactsDelayedJob extends AbstractAIDelayedJob< + MoveLinterArtefactsToNewRulesCommand, + MoveLinterArtefactsToNewRulesResponse +> { + readonly origin = 'MoveLinterArtefactsJob'; + + constructor( + queueFactory: ( + queueListeners: Partial<QueueListeners>, + ) => Promise< + IQueue< + MoveLinterArtefactsToNewRulesCommand, + MoveLinterArtefactsToNewRulesResponse + > + >, + private readonly linterRepositories: ILinterRepositories, + private readonly getLinterAdapter: () => ILinterPort, + ) { + super(queueFactory, new PackmindLogger(origin)); + } + + async onFail(jobId: string): Promise<void> { + this.logger.error( + `[${this.origin}] Job ${jobId} failed - move linter artefacts did not complete`, + ); + } + + async runJob( + jobId: string, + input: MoveLinterArtefactsToNewRulesCommand, + _controller: AbortController, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise<MoveLinterArtefactsToNewRulesResponse> { + this.logger.info( + `[${this.origin}] Processing job ${jobId} for ${input.ruleMappings.length} rule mappings`, + ); + + const softDeleteUseCase = new SoftDeleteLinterArtefactsByRuleUseCase( + this.linterRepositories, + ); + + const useCase = new MoveLinterArtefactsToNewRulesUseCase( + this.getLinterAdapter(), + softDeleteUseCase, + ); + + return await useCase.execute(input); + } + + getJobName( + _input: MoveLinterArtefactsToNewRulesCommand, // eslint-disable-line @typescript-eslint/no-unused-vars + ): string { + return `move-linter-artefacts-${Date.now()}`; + } + + jobStartedInfo(input: MoveLinterArtefactsToNewRulesCommand): string { + return `ruleMappingsCount: ${input.ruleMappings.length}`; + } + + getWorkerListener(): Partial< + WorkerListeners< + MoveLinterArtefactsToNewRulesCommand, + MoveLinterArtefactsToNewRulesResponse + > + > { + return { + completed: async ( + job: Job< + MoveLinterArtefactsToNewRulesCommand, + MoveLinterArtefactsToNewRulesResponse, + string + >, + result: MoveLinterArtefactsToNewRulesResponse, + ) => { + this.logger.info( + `[${this.origin}] Job ${job.id} completed successfully`, + { + copiedCount: result.copiedCount, + softDeletedCount: result.softDeletedCount, + }, + ); + }, + failed: async (job, error) => { + this.logger.error( + `[${this.origin}] Job ${job.id} failed with error: ${error.message}`, + { + ruleMappingsCount: job.data.ruleMappings?.length ?? 0, + }, + ); + }, + }; + } +} diff --git a/packages/linter/src/application/useCases/softDeleteLinterArtefactsByRule/SoftDeleteLinterArtefactsByRuleUseCase.spec.ts b/packages/linter/src/application/useCases/softDeleteLinterArtefactsByRule/SoftDeleteLinterArtefactsByRuleUseCase.spec.ts new file mode 100644 index 000000000..c3d55d812 --- /dev/null +++ b/packages/linter/src/application/useCases/softDeleteLinterArtefactsByRule/SoftDeleteLinterArtefactsByRuleUseCase.spec.ts @@ -0,0 +1,155 @@ +import { + createDetectionProgramId, + createOrganizationId, + createRuleId, + createUserId, + ProgrammingLanguage, +} from '@packmind/types'; +import { stubLogger } from '@packmind/test-utils'; +import { v4 as uuidv4 } from 'uuid'; +import { SoftDeleteLinterArtefactsByRuleUseCase } from './SoftDeleteLinterArtefactsByRuleUseCase'; +import type { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; + +describe('SoftDeleteLinterArtefactsByRuleUseCase', () => { + let useCase: SoftDeleteLinterArtefactsByRuleUseCase; + let repositories: jest.Mocked<ILinterRepositories>; + + const ruleId = createRuleId(uuidv4()); + const userId = createUserId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + + const mockDetectionProgramRepo = { + softDeleteByRuleId: jest.fn(), + findByRuleId: jest.fn(), + }; + const mockActiveDetectionProgramRepo = { + deleteByRuleId: jest.fn(), + }; + const mockRuleDetectionAssessmentRepo = { + softDeleteByRuleId: jest.fn(), + }; + const mockRuleDetectionHeuristicsRepo = { + softDeleteByRuleId: jest.fn(), + }; + const mockDetectionProgramMetadataRepo = { + softDeleteByDetectionProgramIds: jest.fn(), + }; + + beforeEach(() => { + repositories = { + getDetectionProgramRepository: jest + .fn() + .mockReturnValue(mockDetectionProgramRepo), + getActiveDetectionProgramRepository: jest + .fn() + .mockReturnValue(mockActiveDetectionProgramRepo), + getRuleDetectionAssessmentRepository: jest + .fn() + .mockReturnValue(mockRuleDetectionAssessmentRepo), + getRuleDetectionHeuristicsRepository: jest + .fn() + .mockReturnValue(mockRuleDetectionHeuristicsRepo), + getDetectionProgramMetadataRepository: jest + .fn() + .mockReturnValue(mockDetectionProgramMetadataRepo), + } as unknown as jest.Mocked<ILinterRepositories>; + + mockDetectionProgramRepo.softDeleteByRuleId.mockResolvedValue(undefined); + mockDetectionProgramRepo.findByRuleId.mockResolvedValue([]); + mockActiveDetectionProgramRepo.deleteByRuleId.mockResolvedValue(undefined); + mockRuleDetectionAssessmentRepo.softDeleteByRuleId.mockResolvedValue( + undefined, + ); + mockRuleDetectionHeuristicsRepo.softDeleteByRuleId.mockResolvedValue( + undefined, + ); + mockDetectionProgramMetadataRepo.softDeleteByDetectionProgramIds.mockResolvedValue( + undefined, + ); + + useCase = new SoftDeleteLinterArtefactsByRuleUseCase( + repositories, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when executing soft-delete for a rule', () => { + it('calls softDeleteByRuleId on detection program repository', async () => { + await useCase.execute({ ruleId, userId, organizationId }); + + expect(mockDetectionProgramRepo.softDeleteByRuleId).toHaveBeenCalledWith( + ruleId, + ); + }); + + it('calls deleteByRuleId on active detection program repository', async () => { + await useCase.execute({ ruleId, userId, organizationId }); + + expect( + mockActiveDetectionProgramRepo.deleteByRuleId, + ).toHaveBeenCalledWith(ruleId); + }); + + it('calls softDeleteByRuleId on rule detection assessment repository', async () => { + await useCase.execute({ ruleId, userId, organizationId }); + + expect( + mockRuleDetectionAssessmentRepo.softDeleteByRuleId, + ).toHaveBeenCalledWith(ruleId); + }); + + it('calls softDeleteByRuleId on detection heuristics repository', async () => { + await useCase.execute({ ruleId, userId, organizationId }); + + expect( + mockRuleDetectionHeuristicsRepo.softDeleteByRuleId, + ).toHaveBeenCalledWith(ruleId); + }); + }); + + describe('when rule has detection programs with metadata', () => { + const programId1 = createDetectionProgramId(uuidv4()); + const programId2 = createDetectionProgramId(uuidv4()); + + beforeEach(async () => { + mockDetectionProgramRepo.findByRuleId.mockResolvedValue([ + { id: programId1, ruleId, language: ProgrammingLanguage.TYPESCRIPT }, + { id: programId2, ruleId, language: ProgrammingLanguage.PYTHON }, + ]); + + await useCase.execute({ ruleId, userId, organizationId }); + }); + + it('calls softDeleteByDetectionProgramIds with the program IDs', () => { + expect( + mockDetectionProgramMetadataRepo.softDeleteByDetectionProgramIds, + ).toHaveBeenCalledWith([programId1, programId2]); + }); + }); + + describe('when a repository operation fails', () => { + it('throws the error from detection program repository', async () => { + mockDetectionProgramRepo.softDeleteByRuleId.mockRejectedValue( + new Error('Detection program soft-delete failed'), + ); + + await expect( + useCase.execute({ ruleId, userId, organizationId }), + ).rejects.toThrow('Detection program soft-delete failed'); + }); + + it('throws the error from active detection program repository', async () => { + mockActiveDetectionProgramRepo.deleteByRuleId.mockRejectedValue( + new Error('Active detection program delete failed'), + ); + + await expect( + useCase.execute({ ruleId, userId, organizationId }), + ).rejects.toThrow('Active detection program delete failed'); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/softDeleteLinterArtefactsByRule/SoftDeleteLinterArtefactsByRuleUseCase.ts b/packages/linter/src/application/useCases/softDeleteLinterArtefactsByRule/SoftDeleteLinterArtefactsByRuleUseCase.ts new file mode 100644 index 000000000..7a521457e --- /dev/null +++ b/packages/linter/src/application/useCases/softDeleteLinterArtefactsByRule/SoftDeleteLinterArtefactsByRuleUseCase.ts @@ -0,0 +1,65 @@ +import { PackmindLogger } from '@packmind/logger'; +import type { + ISoftDeleteLinterArtefactsByRule, + SoftDeleteLinterArtefactsByRuleCommand, + SoftDeleteLinterArtefactsByRuleResponse, +} from '@packmind/types'; +import type { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; + +const origin = 'SoftDeleteLinterArtefactsByRuleUseCase'; + +export class SoftDeleteLinterArtefactsByRuleUseCase implements ISoftDeleteLinterArtefactsByRule { + constructor( + private readonly repositories: ILinterRepositories, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: SoftDeleteLinterArtefactsByRuleCommand, + ): Promise<SoftDeleteLinterArtefactsByRuleResponse> { + const { ruleId } = command; + + this.logger.info('Starting soft-delete of linter artefacts for rule', { + ruleId, + }); + + try { + // Fetch program IDs before soft-deleting, as metadata is linked by detectionProgramId + const programs = await this.repositories + .getDetectionProgramRepository() + .findByRuleId(ruleId); + const programIds = programs.map((p) => p.id); + + await Promise.all([ + this.repositories + .getDetectionProgramRepository() + .softDeleteByRuleId(ruleId), + this.repositories + .getActiveDetectionProgramRepository() + .deleteByRuleId(ruleId), + this.repositories + .getRuleDetectionAssessmentRepository() + .softDeleteByRuleId(ruleId), + this.repositories + .getRuleDetectionHeuristicsRepository() + .softDeleteByRuleId(ruleId), + this.repositories + .getDetectionProgramMetadataRepository() + .softDeleteByDetectionProgramIds(programIds), + ]); + + this.logger.info( + 'Successfully soft-deleted all linter artefacts for rule', + { ruleId }, + ); + + return {} as SoftDeleteLinterArtefactsByRuleResponse; + } catch (error) { + this.logger.error('Failed to soft-delete linter artefacts for rule', { + ruleId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/startProgramGeneration/StartGenerationProgramUseCase.spec.ts b/packages/linter/src/application/useCases/startProgramGeneration/StartGenerationProgramUseCase.spec.ts new file mode 100644 index 000000000..02f18fe29 --- /dev/null +++ b/packages/linter/src/application/useCases/startProgramGeneration/StartGenerationProgramUseCase.spec.ts @@ -0,0 +1,210 @@ +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { createOrganizationId, createUserId } from '@packmind/types'; +import { + DetectionStatus, + ILinterPort, + IStandardsPort, + ProgrammingLanguage, + RuleExample, + createActiveDetectionProgramId, + createDetectionProgramId, + createRuleId, + createRuleExampleId, +} from '@packmind/types'; +import { stubLogger } from '@packmind/test-utils'; +import { StartGenerationProgramUseCase } from './StartGenerationProgramUseCase'; +import { GenerateProgramDelayedJob } from '../generateProgramUseCase/shared/GenerateProgramDelayedJob'; +import { detectionProgramFactory } from '../../../../test'; + +const organizationId = createOrganizationId(uuidv4()); +const userId = createUserId(uuidv4()); +const ruleId = createRuleId(uuidv4()); +const language = ProgrammingLanguage.TYPESCRIPT; + +const sampleExample: RuleExample = { + id: createRuleExampleId(uuidv4()), + lang: language, + ruleId, + negative: 'console.log("bad")', + positive: 'console.log("good")', +}; + +describe('StartGenerationProgramUseCase', () => { + let logger: jest.Mocked<PackmindLogger>; + let generateProgramDelayedJob: jest.Mocked<GenerateProgramDelayedJob>; + let standardsAdapter: jest.Mocked<IStandardsPort>; + let linterAdapter: jest.Mocked<ILinterPort>; + + beforeEach(() => { + logger = stubLogger(); + + generateProgramDelayedJob = { + addJob: jest.fn().mockResolvedValue('job-1'), + } as unknown as jest.Mocked<GenerateProgramDelayedJob>; + + standardsAdapter = { + getRule: jest.fn().mockResolvedValue({ + id: ruleId, + content: 'Example rule', + standardVersionId: ruleId, + }), + getStandardVersion: jest.fn().mockResolvedValue({ + id: ruleId, + standardId: ruleId, + }), + getStandard: jest.fn().mockResolvedValue({ + id: ruleId, + name: 'Example standard', + organizationId, + }), + getRuleCodeExamples: jest.fn().mockResolvedValue([sampleExample]), + } as unknown as jest.Mocked<IStandardsPort>; + + linterAdapter = { + getActiveDetectionProgram: jest.fn(), + createNewDetectionProgramVersion: jest.fn(), + updateActiveDetectionProgram: jest.fn(), + createDetectionProgram: jest.fn(), + } as unknown as jest.Mocked<ILinterPort>; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const buildUseCase = () => + new StartGenerationProgramUseCase( + generateProgramDelayedJob, + standardsAdapter, + () => linterAdapter, + logger, + ); + + it('creates new detection program version and queues job if an active program exists', async () => { + const activeDetectionProgramId = createActiveDetectionProgramId(uuidv4()); + const existingDraftId = createDetectionProgramId(uuidv4()); + const newDetectionProgram = detectionProgramFactory({ + ruleId, + language, + }); + + linterAdapter.getActiveDetectionProgram.mockResolvedValue({ + programs: [ + { + id: activeDetectionProgramId, + detectionProgramVersion: createDetectionProgramId(uuidv4()), + detectionProgramDraftVersion: existingDraftId, + ruleId, + language, + detectionProgram: null, + draftDetectionProgram: null, + }, + ], + }); + + linterAdapter.createNewDetectionProgramVersion.mockResolvedValue( + newDetectionProgram, + ); + + const useCase = buildUseCase(); + const response = await useCase.execute({ + organizationId, + userId, + ruleId, + language, + }); + + const queuedInput = generateProgramDelayedJob.addJob.mock.calls[0]?.[0]; + const createVersionPayload = + linterAdapter.createNewDetectionProgramVersion.mock.calls[0]?.[0]; + const updateDraftPayload = + linterAdapter.updateActiveDetectionProgram.mock.calls[0]?.[0]; + + expect({ + jobsQueued: generateProgramDelayedJob.addJob.mock.calls.length, + createVersionPayload, + updateDraftPayload, + queuedInput, + responseMessage: response.message, + }).toEqual({ + jobsQueued: 1, + createVersionPayload: expect.objectContaining({ + activeDetectionProgramId, + organizationId, + userId, + status: DetectionStatus.IN_PROGRESS, + }), + updateDraftPayload: expect.objectContaining({ + newDetectionProgramDraftVersion: newDetectionProgram.id, + }), + queuedInput: expect.objectContaining({ + detectionProgramId: newDetectionProgram.id, + activeDetectionProgramId, + }), + responseMessage: expect.stringContaining( + 'Created 1 program generation job', + ), + }); + }); + + it('creates detection program and queues job if no active program exists', async () => { + const newDetectionProgram = detectionProgramFactory({ + ruleId, + language, + }); + const activeDetectionProgramId = createActiveDetectionProgramId(uuidv4()); + + linterAdapter.getActiveDetectionProgram + .mockResolvedValueOnce({ programs: [] }) + .mockResolvedValueOnce({ + programs: [ + { + id: activeDetectionProgramId, + detectionProgramVersion: null, + detectionProgramDraftVersion: newDetectionProgram.id, + ruleId, + language, + detectionProgram: null, + draftDetectionProgram: null, + }, + ], + }); + + linterAdapter.createDetectionProgram.mockResolvedValue(newDetectionProgram); + + const useCase = buildUseCase(); + const response = await useCase.execute({ + organizationId, + userId, + ruleId, + }); + + const queuedInput = generateProgramDelayedJob.addJob.mock.calls[0]?.[0]; + const createDetectionPayload = + linterAdapter.createDetectionProgram.mock.calls[0]?.[0]; + + expect({ + jobsQueued: generateProgramDelayedJob.addJob.mock.calls.length, + createDetectionPayload, + updateDraftCalls: + linterAdapter.updateActiveDetectionProgram.mock.calls.length, + queuedInput, + responseMessage: response.message, + }).toEqual({ + jobsQueued: 1, + createDetectionPayload: expect.objectContaining({ + mustBeDraftVersion: true, + status: DetectionStatus.IN_PROGRESS, + }), + updateDraftCalls: 0, + queuedInput: expect.objectContaining({ + detectionProgramId: newDetectionProgram.id, + activeDetectionProgramId, + }), + responseMessage: expect.stringContaining( + 'Created 1 program generation job', + ), + }); + }); +}); diff --git a/packages/linter/src/application/useCases/startProgramGeneration/StartGenerationProgramUseCase.ts b/packages/linter/src/application/useCases/startProgramGeneration/StartGenerationProgramUseCase.ts new file mode 100644 index 000000000..f85a113f3 --- /dev/null +++ b/packages/linter/src/application/useCases/startProgramGeneration/StartGenerationProgramUseCase.ts @@ -0,0 +1,385 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + ILinterPort, + IStandardsPort, + DetectionStatus, + Rule, + RuleExample, + RuleId, + ProgrammingLanguage, + ActiveDetectionProgram, + DetectionModeEnum, + DetectionProgram, + LanguageDetectionPrograms, + CreateDetectionProgramCommand, + CreateNewDetectionProgramVersionCommand, + UpdateActiveDetectionProgramCommand, + StartProgramGenerationCommand, + StartProgramGenerationResponse, + GenerateProgramInput, + createOrganizationId, + createUserId, +} from '@packmind/types'; +import { + NoExamplesForProgramGenerationError, + NoValidLanguagesForProgramGenerationError, + RuleNotFoundForProgramGenerationError, + StandardNotFoundForProgramGenerationError, +} from './errors'; +import { GenerateProgramDelayedJob } from '../generateProgramUseCase/shared/GenerateProgramDelayedJob'; + +const origin = 'StartGenerationProgramUseCase'; + +export class StartGenerationProgramUseCase { + constructor( + private readonly generateProgramDelayedJob: GenerateProgramDelayedJob, + private readonly standardsAdapter: IStandardsPort, + private readonly getLinterAdapter: () => ILinterPort, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: StartProgramGenerationCommand, + ): Promise<StartProgramGenerationResponse> { + this.logger.info('Starting program generation flow', { + organizationId: command.organizationId, + userId: command.userId, + ruleId: command.ruleId, + language: command.language, + }); + + try { + const organizationId = createOrganizationId(command.organizationId); + + // Fetch the rule first to get standardVersionId + const rule = await this.standardsAdapter.getRule(command.ruleId); + + if (!rule) { + throw new RuleNotFoundForProgramGenerationError(command.ruleId); + } + + // Get the standard version to derive the standardId + const standardVersion = await this.standardsAdapter.getStandardVersion( + rule.standardVersionId, + ); + + if (!standardVersion) { + throw new Error( + `Standard version with id ${String( + rule.standardVersionId, + )} not found for rule ${String(command.ruleId)}`, + ); + } + + // Fetch the standard using the derived standardId + const standard = await this.standardsAdapter.getStandard( + standardVersion.standardId, + ); + + if (!standard) { + throw new StandardNotFoundForProgramGenerationError( + standardVersion.standardId, + ); + } + + // Note: Organization validation should be done at a higher level + // Standards are now scoped to spaces, not directly to organizations + + // Fetch all rule examples + const allRuleExamples = await this.standardsAdapter.getRuleCodeExamples( + command.ruleId, + ); + + if (!allRuleExamples || allRuleExamples.length === 0) { + throw new NoExamplesForProgramGenerationError(command.ruleId); + } + + // Group examples by language + const examplesByLanguage = this.groupExamplesByLanguage(allRuleExamples); + + // Filter languages with at least one non-empty negative example + const validLanguages = this.getValidLanguages(examplesByLanguage); + + if (validLanguages.length === 0) { + throw new NoValidLanguagesForProgramGenerationError(command.ruleId); + } + + this.logger.info('Valid languages detected for program generation', { + languages: validLanguages, + selectedLanguage: command.language, + totalLanguages: examplesByLanguage.size, + }); + + const targetLanguages = command.language + ? this.filterRequestedLanguage( + validLanguages, + command.language, + command.ruleId, + ) + : validLanguages; + + // Create job for each valid language + const jobIds: string[] = []; + const userId = createUserId(command.userId); + const linterAdapter = this.getLinterAdapter(); + + for (const language of targetLanguages) { + const { detectionProgram, activeDetectionProgramId } = + await this.prepareDetectionProgram({ + linterAdapter, + command, + language, + rule, + }); + + this.logger.info('Detection program prepared for job registration', { + detectionProgramId: detectionProgram.id, + activeDetectionProgramId, + language, + }); + + const input: GenerateProgramInput = { + value: `Generating program for rule "${rule.content}" in standard "${standard.name}" (ID: ${standard.id}) for language: ${language}`, + rule, + organizationId, + userId, + language, + detectionProgramId: detectionProgram.id, + activeDetectionProgramId, + }; + + const jobId = await this.generateProgramDelayedJob.addJob(input); + jobIds.push(jobId); + + this.logger.info('Program generation job submitted for language', { + jobId, + language, + standardId: standard.id, + ruleId: rule.id, + }); + } + + const languagesList = targetLanguages.join(', '); + const message = `Created ${jobIds.length} program generation job${ + jobIds.length > 1 ? 's' : '' + } for rule "${rule.content}" in standard "${ + standard.name + }" for language${targetLanguages.length > 1 ? 's' : ''}: ${languagesList}`; + + this.logger.info('All program generation jobs submitted', { + jobIds, + languages: targetLanguages, + standardId: standard.id, + ruleId: rule.id, + }); + + return { message }; + } catch (error) { + this.logger.error('Failed to start program generation', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + private async prepareDetectionProgram({ + linterAdapter, + command, + language, + rule, + }: { + linterAdapter: ILinterPort; + command: StartProgramGenerationCommand; + language: ProgrammingLanguage; + rule: Rule; + }): Promise<{ + detectionProgram: DetectionProgram; + activeDetectionProgramId: ActiveDetectionProgram['id']; + }> { + const existingPrograms = await linterAdapter.getActiveDetectionProgram({ + ruleId: command.ruleId, + language, + organizationId: command.organizationId, + userId: command.userId, + }); + + const matchingProgram = existingPrograms.programs?.find( + (program) => program.language === language, + ); + + if (matchingProgram) { + this.logger.info('Existing active detection program found', { + activeDetectionProgramId: matchingProgram.id, + language, + }); + + const createNewVersionCommand: CreateNewDetectionProgramVersionCommand = { + activeDetectionProgramId: matchingProgram.id, + code: this.getInProgressPlaceholder(rule), + mode: DetectionModeEnum.SINGLE_AST, + status: DetectionStatus.IN_PROGRESS, + organizationId: command.organizationId, + userId: command.userId, + }; + + const detectionProgram = + await linterAdapter.createNewDetectionProgramVersion( + createNewVersionCommand, + ); + + await this.ensureDraftDetectionProgram({ + linterAdapter, + activeProgram: matchingProgram, + detectionProgramId: detectionProgram.id, + command, + }); + + return { + detectionProgram, + activeDetectionProgramId: matchingProgram.id, + }; + } + + this.logger.info( + 'No active detection program found, creating a new one for generation', + { + ruleId: command.ruleId, + language, + }, + ); + + const createDetectionProgramCommand: CreateDetectionProgramCommand = { + ruleId: command.ruleId, + code: this.getInProgressPlaceholder(rule), + language, + mode: DetectionModeEnum.SINGLE_AST, + status: DetectionStatus.IN_PROGRESS, + organizationId: command.organizationId, + userId: command.userId, + mustBeDraftVersion: true, + }; + + const detectionProgram = await linterAdapter.createDetectionProgram( + createDetectionProgramCommand, + ); + + const refreshedPrograms = await linterAdapter.getActiveDetectionProgram({ + ruleId: command.ruleId, + language, + organizationId: command.organizationId, + userId: command.userId, + }); + + const createdProgram = refreshedPrograms.programs?.find( + (program) => program.language === language, + ); + + if (!createdProgram) { + throw new Error( + 'Failed to retrieve active detection program after creation', + ); + } + + await this.ensureDraftDetectionProgram({ + linterAdapter, + activeProgram: createdProgram, + detectionProgramId: detectionProgram.id, + command, + }); + + return { + detectionProgram, + activeDetectionProgramId: createdProgram.id, + }; + } + + private async ensureDraftDetectionProgram({ + linterAdapter, + activeProgram, + detectionProgramId, + command, + }: { + linterAdapter: ILinterPort; + activeProgram: LanguageDetectionPrograms; + detectionProgramId: DetectionProgram['id']; + command: StartProgramGenerationCommand; + }): Promise<void> { + if (activeProgram.detectionProgramDraftVersion === detectionProgramId) { + return; + } + + const updateActiveDetectionProgramCommand: UpdateActiveDetectionProgramCommand = + { + activeDetectionProgram: this.toActiveDetectionProgram(activeProgram), + newDetectionProgramDraftVersion: detectionProgramId, + organizationId: command.organizationId, + userId: command.userId, + }; + + await linterAdapter.updateActiveDetectionProgram( + updateActiveDetectionProgramCommand, + ); + } + + private toActiveDetectionProgram( + program: LanguageDetectionPrograms, + ): ActiveDetectionProgram { + const { + id, + detectionProgramVersion, + detectionProgramDraftVersion, + ruleId, + language, + severity, + } = program; + + return { + id, + detectionProgramVersion, + detectionProgramDraftVersion, + ruleId, + language, + severity, + }; + } + + private getInProgressPlaceholder(rule: Rule): string { + return `// Program generation in progress for rule: ${rule.content}`; + } + + private groupExamplesByLanguage( + examples: RuleExample[], + ): Map<ProgrammingLanguage, RuleExample[]> { + const examplesByLanguage = new Map<ProgrammingLanguage, RuleExample[]>(); + + for (const example of examples) { + const existing = examplesByLanguage.get(example.lang) || []; + existing.push(example); + examplesByLanguage.set(example.lang, existing); + } + + return examplesByLanguage; + } + + private getValidLanguages( + examplesByLanguage: Map<ProgrammingLanguage, RuleExample[]>, + ): ProgrammingLanguage[] { + return Array.from(examplesByLanguage.entries()) + .filter(([, examples]) => + examples.some((ex) => ex.negative && ex.negative.trim().length > 0), + ) + .map(([lang]) => lang); + } + + private filterRequestedLanguage( + validLanguages: ProgrammingLanguage[], + requestedLanguage: ProgrammingLanguage, + ruleId: RuleId, + ): ProgrammingLanguage[] { + if (!validLanguages.includes(requestedLanguage)) { + throw new NoValidLanguagesForProgramGenerationError(ruleId); + } + + return [requestedLanguage]; + } +} diff --git a/packages/linter/src/application/useCases/startProgramGeneration/errors.ts b/packages/linter/src/application/useCases/startProgramGeneration/errors.ts new file mode 100644 index 000000000..00e19ad1c --- /dev/null +++ b/packages/linter/src/application/useCases/startProgramGeneration/errors.ts @@ -0,0 +1,59 @@ +import { RuleId, StandardId } from '@packmind/types'; + +export class StandardNotFoundForProgramGenerationError extends Error { + constructor(standardId: StandardId) { + super( + `Standard with id ${String( + standardId, + )} not found for program generation.`, + ); + this.name = 'StandardNotFoundForProgramGenerationError'; + } +} + +export class RuleNotFoundForProgramGenerationError extends Error { + constructor(ruleId: RuleId) { + super(`Rule with id ${String(ruleId)} not found for program generation.`); + this.name = 'RuleNotFoundForProgramGenerationError'; + } +} + +export class RuleNotLinkedToStandardForProgramGenerationError extends Error { + constructor(ruleId: RuleId, standardId: StandardId) { + super( + `Rule ${String(ruleId)} is not linked to standard ${String( + standardId, + )} for program generation.`, + ); + this.name = 'RuleNotLinkedToStandardForProgramGenerationError'; + } +} + +export class UnauthorizedProgramGenerationError extends Error { + constructor() { + super('You are not authorized to generate a program for this standard.'); + this.name = 'UnauthorizedProgramGenerationError'; + } +} + +export class NoValidLanguagesForProgramGenerationError extends Error { + constructor(ruleId: RuleId) { + super( + `No valid programming languages found for rule ${String( + ruleId, + )}. Each language must have at least one example with a non-empty negative case.`, + ); + this.name = 'NoValidLanguagesForProgramGenerationError'; + } +} + +export class NoExamplesForProgramGenerationError extends Error { + constructor(ruleId: RuleId) { + super( + `No examples found for rule ${String( + ruleId, + )}. Cannot generate detection program without examples.`, + ); + this.name = 'NoExamplesForProgramGenerationError'; + } +} diff --git a/packages/linter/src/application/useCases/startRuleDetectionAssessment/StartRuleDetectionAssessmentUseCase.spec.ts b/packages/linter/src/application/useCases/startRuleDetectionAssessment/StartRuleDetectionAssessmentUseCase.spec.ts new file mode 100644 index 000000000..4a51a33a4 --- /dev/null +++ b/packages/linter/src/application/useCases/startRuleDetectionAssessment/StartRuleDetectionAssessmentUseCase.spec.ts @@ -0,0 +1,361 @@ +import { StartRuleDetectionAssessmentUseCase } from './StartRuleDetectionAssessmentUseCase'; +import { IRuleDetectionAssessmentRepository } from '../../../domain/repositories/IRuleDetectionAssessmentRepository'; +import { PackmindLogger } from '@packmind/logger'; +import { createOrganizationId, createUserId } from '@packmind/types'; +import { + createRuleId, + ProgrammingLanguage, + IStandardsPort, + RuleDetectionAssessment, + RuleDetectionAssessmentStatus, + createRuleDetectionAssessmentId, + DetectionModeEnum, +} from '@packmind/types'; +import { SSEEventPublisher } from '@packmind/node-utils'; +import { ruleFactory } from '@packmind/standards/test'; +import { stubLogger } from '@packmind/test-utils'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { ILinterDelayedJobs } from '../../../domain/jobs/ILinterDelayedJobs'; +import { AssessRuleDetectionDelayedJob } from '../assessRuleDetection/shared/AssessRuleDetectionDelayedJob'; +import { v4 as uuidv4 } from 'uuid'; + +jest.mock('@packmind/node-utils', () => { + const actual = jest.requireActual('@packmind/node-utils'); + return { + ...actual, + SSEEventPublisher: { + publishAssessmentStatusEvent: jest.fn().mockResolvedValue(undefined), + }, + }; +}); + +describe('StartRuleDetectionAssessmentUseCase', () => { + let startRuleDetectionAssessmentUseCase: StartRuleDetectionAssessmentUseCase; + let ruleDetectionAssessmentRepository: jest.Mocked<IRuleDetectionAssessmentRepository>; + let linterRepositories: jest.Mocked<ILinterRepositories>; + let linterDelayedJobs: jest.Mocked<ILinterDelayedJobs>; + let standardsAdapter: jest.Mocked<IStandardsPort>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + let mockAssessRuleDetectionDelayedJob: jest.Mocked<AssessRuleDetectionDelayedJob>; + + beforeEach(() => { + ruleDetectionAssessmentRepository = { + add: jest.fn().mockResolvedValue(undefined), + findById: jest.fn(), + get: jest.fn().mockResolvedValue(null), + deleteById: jest.fn(), + restoreById: jest.fn(), + update: jest.fn(), + } as unknown as jest.Mocked<IRuleDetectionAssessmentRepository>; + + linterRepositories = { + getRuleDetectionAssessmentRepository: jest + .fn() + .mockReturnValue(ruleDetectionAssessmentRepository), + getDetectionProgramRepository: jest.fn(), + getActiveDetectionProgramRepository: jest.fn(), + getDetectionProgramMetadataRepository: jest.fn(), + getRuleDetectionHeuristicsRepository: jest.fn(), + } as unknown as jest.Mocked<ILinterRepositories>; + + mockAssessRuleDetectionDelayedJob = { + addJob: jest.fn().mockResolvedValue('job-123'), + } as unknown as jest.Mocked<AssessRuleDetectionDelayedJob>; + + linterDelayedJobs = { + assessRuleDetectionDelayedJob: mockAssessRuleDetectionDelayedJob, + generateProgramDelayedJob: jest.fn(), + } as unknown as jest.Mocked<ILinterDelayedJobs>; + + standardsAdapter = { + getStandard: jest.fn(), + getRule: jest.fn(), + getStandardVersion: jest.fn(), + getStandardVersionById: jest.fn(), + listStandardVersions: jest.fn(), + getLatestRulesByStandardId: jest.fn(), + getRulesByStandardId: jest.fn(), + listStandardsBySpace: jest.fn(), + getRuleCodeExamples: jest.fn(), + findStandardBySlug: jest.fn(), + getLatestStandardVersion: jest.fn(), + }; + + stubbedLogger = stubLogger(); + + startRuleDetectionAssessmentUseCase = + new StartRuleDetectionAssessmentUseCase( + linterRepositories, + linterDelayedJobs, + standardsAdapter, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when no assessment exists', () => { + it('creates assessment with IN_PROGRESS status', async () => { + const rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Use const instead of var', + }); + const input = { + rule, + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + }; + + const result = await startRuleDetectionAssessmentUseCase.execute(input); + + expect(result).toEqual( + expect.objectContaining({ + status: RuleDetectionAssessmentStatus.IN_PROGRESS, + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + detectionMode: DetectionModeEnum.SINGLE_AST, + details: 'Assessment in progress...', + }), + ); + }); + + it('saves assessment to database before enqueuing job', async () => { + const rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Use const instead of var', + }); + const input = { + rule, + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + }; + + await startRuleDetectionAssessmentUseCase.execute(input); + + expect(ruleDetectionAssessmentRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + status: RuleDetectionAssessmentStatus.IN_PROGRESS, + detectionMode: DetectionModeEnum.SINGLE_AST, + }), + ); + }); + + it('enqueues job with assessment ID', async () => { + const rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Use const instead of var', + }); + const input = { + rule, + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + }; + + const result = await startRuleDetectionAssessmentUseCase.execute(input); + + expect(mockAssessRuleDetectionDelayedJob.addJob).toHaveBeenCalledWith( + expect.objectContaining({ + rule, + organizationId: input.organizationId, + userId: input.userId, + language: input.language, + assessmentId: result.id, + }), + ); + }); + + it('publishes an assessment status change event', async () => { + const rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Use const instead of var', + }); + const input = { + rule, + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + }; + + const publishAssessmentStatusEventSpy = jest.mocked( + SSEEventPublisher.publishAssessmentStatusEvent, + ); + + await startRuleDetectionAssessmentUseCase.execute(input); + + expect(publishAssessmentStatusEventSpy).toHaveBeenCalledWith( + rule.id, + ProgrammingLanguage.TYPESCRIPT, + input.userId, + input.organizationId, + ); + }); + + it('returns the created assessment with expected properties', async () => { + const rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Use const instead of var', + }); + const input = { + rule, + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + }; + + const result = await startRuleDetectionAssessmentUseCase.execute(input); + + expect(result).toMatchObject({ + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + status: RuleDetectionAssessmentStatus.IN_PROGRESS, + detectionMode: DetectionModeEnum.SINGLE_AST, + details: 'Assessment in progress...', + }); + }); + + it('returns an assessment with a defined id', async () => { + const rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Use const instead of var', + }); + const input = { + rule, + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + }; + + const result = await startRuleDetectionAssessmentUseCase.execute(input); + + expect(result.id).toBeDefined(); + }); + }); + + describe('when assessment already exists', () => { + let rule: ReturnType<typeof ruleFactory>; + let input: { + rule: ReturnType<typeof ruleFactory>; + organizationId: ReturnType<typeof createOrganizationId>; + userId: ReturnType<typeof createUserId>; + language: ProgrammingLanguage; + }; + let existingAssessment: RuleDetectionAssessment; + let result: RuleDetectionAssessment; + + beforeEach(async () => { + rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Use const instead of var', + }); + input = { + rule, + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + }; + + existingAssessment = { + id: createRuleDetectionAssessmentId(uuidv4()), + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + detectionMode: DetectionModeEnum.SINGLE_AST, + status: RuleDetectionAssessmentStatus.SUCCESS, + details: 'Previous assessment completed', + clarificationQuestion: null, + clarificationAnswers: [], + }; + + ruleDetectionAssessmentRepository.get.mockResolvedValue( + existingAssessment, + ); + + result = await startRuleDetectionAssessmentUseCase.execute(input); + }); + + it('returns assessment with IN_PROGRESS status', () => { + expect(result.status).toBe(RuleDetectionAssessmentStatus.IN_PROGRESS); + }); + + it('returns assessment with in-progress details message', () => { + expect(result.details).toBe('Assessment in progress...'); + }); + + it('retrieves existing assessment by rule id and language', () => { + expect(ruleDetectionAssessmentRepository.get).toHaveBeenCalledWith( + rule.id, + ProgrammingLanguage.TYPESCRIPT, + ); + }); + + it('saves updated assessment with IN_PROGRESS status', () => { + expect(ruleDetectionAssessmentRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + id: existingAssessment.id, + status: RuleDetectionAssessmentStatus.IN_PROGRESS, + details: 'Assessment in progress...', + }), + ); + }); + + it('enqueues job with existing assessment id', () => { + expect(mockAssessRuleDetectionDelayedJob.addJob).toHaveBeenCalledWith( + expect.objectContaining({ + rule, + organizationId: input.organizationId, + userId: input.userId, + language: input.language, + assessmentId: existingAssessment.id, + }), + ); + }); + }); + + it('propagates errors from repository', async () => { + const rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Test rule', + }); + const input = { + rule, + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + }; + + ruleDetectionAssessmentRepository.add.mockRejectedValue( + new Error('Database error'), + ); + + await expect( + startRuleDetectionAssessmentUseCase.execute(input), + ).rejects.toThrow('Database error'); + }); + + it('propagates errors from job queue', async () => { + const rule = ruleFactory({ + id: createRuleId(uuidv4()), + content: 'Test rule', + }); + const input = { + rule, + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + }; + + mockAssessRuleDetectionDelayedJob.addJob.mockRejectedValue( + new Error('Queue error'), + ); + + await expect( + startRuleDetectionAssessmentUseCase.execute(input), + ).rejects.toThrow('Queue error'); + }); +}); diff --git a/packages/linter/src/application/useCases/startRuleDetectionAssessment/StartRuleDetectionAssessmentUseCase.ts b/packages/linter/src/application/useCases/startRuleDetectionAssessment/StartRuleDetectionAssessmentUseCase.ts new file mode 100644 index 000000000..835f8e139 --- /dev/null +++ b/packages/linter/src/application/useCases/startRuleDetectionAssessment/StartRuleDetectionAssessmentUseCase.ts @@ -0,0 +1,118 @@ +import { PackmindLogger } from '@packmind/logger'; +import { SSEEventPublisher } from '@packmind/node-utils'; +import { IStandardsPort } from '@packmind/types'; +import { + AssessRuleDetectionInput, + RuleDetectionAssessment, + RuleDetectionAssessmentStatus, + createRuleDetectionAssessmentId, + DetectionModeEnum, + IStartRuleDetectionAssessmentUseCase, +} from '@packmind/types'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { ILinterDelayedJobs } from '../../../domain/jobs/ILinterDelayedJobs'; +import { v4 as uuidv4 } from 'uuid'; + +const origin = 'StartRuleDetectionAssessmentUseCase'; + +export class StartRuleDetectionAssessmentUseCase implements IStartRuleDetectionAssessmentUseCase { + constructor( + private readonly linterRepositories: ILinterRepositories, + private readonly linterDelayedJobs: ILinterDelayedJobs, + private readonly standardsAdapter: IStandardsPort, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + input: Omit<AssessRuleDetectionInput, 'assessmentId'>, + ): Promise<RuleDetectionAssessment> { + this.logger.info('Starting rule detection assessment', { + ruleId: input.rule.id, + language: input.language, + userId: input.userId, + organizationId: input.organizationId, + }); + + try { + // Check if assessment already exists + let assessment = await this.linterRepositories + .getRuleDetectionAssessmentRepository() + .get(input.rule.id, input.language); + + if (!assessment) { + // Create new assessment entity with IN_PROGRESS status + const assessmentId = createRuleDetectionAssessmentId(uuidv4()); + assessment = { + id: assessmentId, + ruleId: input.rule.id, + language: input.language, + detectionMode: DetectionModeEnum.SINGLE_AST, + status: RuleDetectionAssessmentStatus.IN_PROGRESS, + details: 'Assessment in progress...', + clarificationQuestion: null, + clarificationAnswers: null, + }; + + await this.linterRepositories + .getRuleDetectionAssessmentRepository() + .add(assessment); + + this.logger.info('New assessment created with IN_PROGRESS status', { + assessmentId: assessment.id, + }); + } else { + this.logger.info('Existing assessment found, updating to IN_PROGRESS', { + assessmentId: assessment.id, + previousStatus: assessment.status, + }); + + // Update status to IN_PROGRESS when rerunning + assessment.status = RuleDetectionAssessmentStatus.IN_PROGRESS; + assessment.details = 'Assessment in progress...'; + + await this.linterRepositories + .getRuleDetectionAssessmentRepository() + .add(assessment); + } + + // Enqueue job with assessment ID + const jobInput: AssessRuleDetectionInput = { + ...input, + assessmentId: assessment.id, + }; + + const jobId = + await this.linterDelayedJobs.assessRuleDetectionDelayedJob.addJob( + jobInput, + ); + + this.logger.info('Assessment job enqueued', { + assessmentId: assessment.id, + jobId, + }); + + await SSEEventPublisher.publishAssessmentStatusEvent( + assessment.ruleId, + assessment.language, + input.userId, + input.organizationId, + ); + + this.logger.info('Assessment status change event published', { + assessmentId: assessment.id, + ruleId: assessment.ruleId, + language: assessment.language, + userId: input.userId, + organizationId: input.organizationId, + }); + + return assessment; + } catch (error) { + this.logger.error('Failed to start rule detection assessment', { + ruleId: input.rule.id, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/testProgramExecutionUseCase/TestProgramExecutionUseCase.ts b/packages/linter/src/application/useCases/testProgramExecutionUseCase/TestProgramExecutionUseCase.ts new file mode 100644 index 000000000..5241068a3 --- /dev/null +++ b/packages/linter/src/application/useCases/testProgramExecutionUseCase/TestProgramExecutionUseCase.ts @@ -0,0 +1,211 @@ +import { PackmindLogger } from '@packmind/logger'; +import { IStandardsPort } from '@packmind/types'; +import { + DetectionSeverity, + IExecuteLinterProgramsUseCase, + LinterExecutionProgram, +} from '@packmind/types'; +import { + TestProgramExecutionCommand, + TestProgramExecutionResponse, + ITestProgramExecutionUseCase, + DetectionProgram, +} from '@packmind/types'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { UnauthorizedTestProgramExecutionError } from '../../../domain/errors/UnauthorizedTestProgramExecutionError'; +import { DetectionProgramNotFoundError } from '../../../domain/errors/DetectionProgramNotFoundError'; + +const origin = 'TestProgramExecutionUseCase'; + +export class TestProgramExecutionUseCase implements ITestProgramExecutionUseCase { + constructor( + private readonly repositories: ILinterRepositories, + private readonly linterExecutionUseCase: IExecuteLinterProgramsUseCase, + private readonly standardsAdapter: IStandardsPort | undefined, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: TestProgramExecutionCommand, + ): Promise<TestProgramExecutionResponse> { + this.logger.info('Testing program execution with sandbox code', { + detectionProgramId: command.detectionProgramId, + organizationId: command.organizationId, + sandboxCodeLength: command.sandboxCode.length, + }); + + try { + // Fetch detection program + const detectionProgram = await this.repositories + .getDetectionProgramRepository() + .findById(command.detectionProgramId); + + if (!detectionProgram) { + this.logger.error('Detection program not found', { + detectionProgramId: command.detectionProgramId, + }); + throw new DetectionProgramNotFoundError(command.detectionProgramId); + } + + // Verify program belongs to organization + await this.verifyOrganizationAccess( + detectionProgram, + command.organizationId, + ); + + // Get standard slug and rule content for proper violation reporting + const { standardSlug, ruleContent } = + await this.getStandardAndRuleInfo(detectionProgram); + + // Map detection program to linter execution format + const linterExecutionProgram: LinterExecutionProgram = { + standardSlug, + ruleContent, + code: detectionProgram.code, + sourceCodeState: detectionProgram.sourceCodeState as 'AST' | 'RAW', + language: detectionProgram.language, + severity: DetectionSeverity.ERROR, + }; + + // Execute the program with sandbox code + const filePath = command.filePath || 'sandbox.test'; + const result = await this.linterExecutionUseCase.execute({ + filePath, + fileContent: command.sandboxCode, + language: detectionProgram.language, + programs: [linterExecutionProgram], + }); + + this.logger.info('Program execution completed successfully', { + detectionProgramId: command.detectionProgramId, + violationsCount: result.violations.length, + }); + + return { + violations: result.violations, + }; + } catch (error) { + this.logger.error('Failed to test program execution', { + detectionProgramId: command.detectionProgramId, + organizationId: command.organizationId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + private async verifyOrganizationAccess( + detectionProgram: DetectionProgram, + organizationId: string, + ): Promise<void> { + if (!this.standardsAdapter) { + this.logger.warn( + 'Standards adapter not available, skipping authorization check', + ); + return; + } + + try { + const rule = await this.standardsAdapter.getRule(detectionProgram.ruleId); + + if (!rule) { + throw new DetectionProgramNotFoundError(detectionProgram.id); + } + + const standardVersion = await this.standardsAdapter.getStandardVersion( + rule.standardVersionId, + ); + + if (!standardVersion) { + throw new DetectionProgramNotFoundError(detectionProgram.id); + } + + const standard = await this.standardsAdapter.getStandard( + standardVersion.standardId, + ); + + if (!standard) { + throw new DetectionProgramNotFoundError(detectionProgram.id); + } + + // Note: Organization validation should be done at a higher level + // Standards are now scoped to spaces, not directly to organizations + } catch (error) { + if ( + error instanceof DetectionProgramNotFoundError || + error instanceof UnauthorizedTestProgramExecutionError + ) { + throw error; + } + this.logger.error('Failed to verify organization access', { + detectionProgramId: detectionProgram.id, + organizationId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + private async getStandardAndRuleInfo( + detectionProgram: DetectionProgram, + ): Promise<{ standardSlug: string; ruleContent: string }> { + if (!this.standardsAdapter) { + this.logger.warn( + 'Standards adapter not available, using fallback values', + ); + return { + standardSlug: 'unknown-standard', + ruleContent: detectionProgram.ruleId, + }; + } + + try { + const rule = await this.standardsAdapter.getRule(detectionProgram.ruleId); + + if (!rule) { + this.logger.warn('Rule not found, using fallback values'); + return { + standardSlug: 'unknown-standard', + ruleContent: detectionProgram.ruleId, + }; + } + + const standardVersion = await this.standardsAdapter.getStandardVersion( + rule.standardVersionId, + ); + + if (!standardVersion) { + this.logger.warn('Standard version not found, using fallback values'); + return { + standardSlug: 'unknown-standard', + ruleContent: rule.content, + }; + } + + const standard = await this.standardsAdapter.getStandard( + standardVersion.standardId, + ); + + if (!standard) { + this.logger.warn('Standard not found, using fallback values'); + return { + standardSlug: 'unknown-standard', + ruleContent: rule.content, + }; + } + + return { + standardSlug: standard.slug, + ruleContent: rule.content, + }; + } catch (error) { + this.logger.warn('Failed to get standard and rule info, using fallback', { + error: error instanceof Error ? error.message : String(error), + }); + return { + standardSlug: 'unknown-standard', + ruleContent: detectionProgram.ruleId, + }; + } + } +} diff --git a/packages/linter/src/application/useCases/trackLinterExecution/TrackLinterExecutionUseCase.spec.ts b/packages/linter/src/application/useCases/trackLinterExecution/TrackLinterExecutionUseCase.spec.ts new file mode 100644 index 000000000..5ac602bf7 --- /dev/null +++ b/packages/linter/src/application/useCases/trackLinterExecution/TrackLinterExecutionUseCase.spec.ts @@ -0,0 +1,101 @@ +import { stubLogger } from '@packmind/test-utils'; +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { + createOrganizationId, + createUserId, + LinterCalledEvent, +} from '@packmind/types'; +import { TrackLinterExecutionUseCase } from './TrackLinterExecutionUseCase'; + +describe('TrackLinterExecutionUseCase', () => { + let useCase: TrackLinterExecutionUseCase; + let mockEventEmitterService: jest.Mocked<PackmindEventEmitterService>; + + beforeEach(() => { + mockEventEmitterService = { + emit: jest.fn(), + } as unknown as jest.Mocked<PackmindEventEmitterService>; + + useCase = new TrackLinterExecutionUseCase( + mockEventEmitterService, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute', () => { + const organizationId = createOrganizationId('org-123'); + const userId = createUserId('user-123'); + + describe('when called with valid command', () => { + const command = { + organizationId, + userId, + targetCount: 2, + standardCount: 5, + }; + + beforeEach(async () => { + await useCase.execute(command); + }); + + it('emits exactly one event', () => { + expect(mockEventEmitterService.emit).toHaveBeenCalledTimes(1); + }); + + it('emits a LinterCalledEvent instance', () => { + const emittedEvent = (mockEventEmitterService.emit as jest.Mock).mock + .calls[0][0]; + + expect(emittedEvent).toBeInstanceOf(LinterCalledEvent); + }); + + it('includes correct payload in the event', () => { + const emittedEvent = (mockEventEmitterService.emit as jest.Mock).mock + .calls[0][0]; + + expect(emittedEvent.payload).toEqual({ + userId: createUserId(userId), + organizationId: createOrganizationId(organizationId), + targetCount: 2, + standardCount: 5, + source: 'cli', + }); + }); + }); + + describe('when called with zero counts', () => { + const command = { + organizationId, + userId, + targetCount: 0, + standardCount: 0, + }; + + beforeEach(async () => { + await useCase.execute(command); + }); + + it('emits exactly one event', () => { + expect(mockEventEmitterService.emit).toHaveBeenCalledTimes(1); + }); + + it('includes zero targetCount in the payload', () => { + const emittedEvent = (mockEventEmitterService.emit as jest.Mock).mock + .calls[0][0]; + + expect(emittedEvent.payload.targetCount).toBe(0); + }); + + it('includes zero standardCount in the payload', () => { + const emittedEvent = (mockEventEmitterService.emit as jest.Mock).mock + .calls[0][0]; + + expect(emittedEvent.payload.standardCount).toBe(0); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/trackLinterExecution/TrackLinterExecutionUseCase.ts b/packages/linter/src/application/useCases/trackLinterExecution/TrackLinterExecutionUseCase.ts new file mode 100644 index 000000000..6db23f3e0 --- /dev/null +++ b/packages/linter/src/application/useCases/trackLinterExecution/TrackLinterExecutionUseCase.ts @@ -0,0 +1,40 @@ +import { PackmindLogger } from '@packmind/logger'; +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { + createOrganizationId, + createUserId, + LinterCalledEvent, + ITrackLinterExecutionUseCase, + TrackLinterExecutionCommand, +} from '@packmind/types'; + +const origin = 'TrackLinterExecutionUseCase'; + +export class TrackLinterExecutionUseCase implements ITrackLinterExecutionUseCase { + constructor( + private readonly eventEmitterService: PackmindEventEmitterService, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute(command: TrackLinterExecutionCommand) { + this.logger.info('Tracking linter execution', { + targetCount: command.targetCount, + standardCount: command.standardCount, + }); + + // Emit domain event for analytics + this.eventEmitterService.emit( + new LinterCalledEvent({ + userId: createUserId(command.userId), + organizationId: createOrganizationId(command.organizationId), + targetCount: command.targetCount, + standardCount: command.standardCount, + source: 'cli', + }), + ); + + this.logger.info('Linter execution tracked successfully'); + + return {}; + } +} diff --git a/packages/linter/src/application/useCases/trackLinterExecution/index.ts b/packages/linter/src/application/useCases/trackLinterExecution/index.ts new file mode 100644 index 000000000..96c9bb206 --- /dev/null +++ b/packages/linter/src/application/useCases/trackLinterExecution/index.ts @@ -0,0 +1 @@ +export * from './TrackLinterExecutionUseCase'; diff --git a/packages/linter/src/application/useCases/updateActiveDetectionProgram/UpdateActiveDetectionProgramUseCase.spec.ts b/packages/linter/src/application/useCases/updateActiveDetectionProgram/UpdateActiveDetectionProgramUseCase.spec.ts new file mode 100644 index 000000000..6e3fed309 --- /dev/null +++ b/packages/linter/src/application/useCases/updateActiveDetectionProgram/UpdateActiveDetectionProgramUseCase.spec.ts @@ -0,0 +1,639 @@ +import { UpdateActiveDetectionProgramUseCase } from './UpdateActiveDetectionProgramUseCase'; +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { createOrganizationId, createUserId } from '@packmind/types'; +import { + DetectionStatus, + ActiveDetectionProgram, + createActiveDetectionProgramId, + createDetectionProgramId, + UpdateActiveDetectionProgramCommand, +} from '@packmind/types'; +import { stubLogger } from '@packmind/test-utils'; +import { IActiveDetectionProgramRepository } from '../../../domain/repositories/IActiveDetectionProgramRepository'; +import { + activeDetectionProgramFactory, + detectionProgramFactory, +} from '../../../../test'; +import { IDetectionProgramRepository } from '../../../domain/repositories/IDetectionProgramRepository'; +import { InvalidDetectionProgramStatusError } from '../../../domain/errors'; + +// Add at the top, before other imports +jest.mock('@packmind/node-utils', () => { + const actual = jest.requireActual('@packmind/node-utils'); + return { + ...actual, + SSEEventPublisher: { + publishProgramStatusEvent: jest.fn().mockResolvedValue(undefined), + }, + }; +}); + +describe('UpdateActiveDetectionProgramUseCase', () => { + let updateActiveDetectionProgramUseCase: UpdateActiveDetectionProgramUseCase; + let activeDetectionProgramRepository: jest.Mocked<IActiveDetectionProgramRepository>; + let detectionProgramRepository: jest.Mocked<IDetectionProgramRepository>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + beforeEach(() => { + // Mock ActiveDetectionProgramRepository + activeDetectionProgramRepository = { + add: jest.fn(), + findById: jest.fn(), + findByRuleId: jest.fn(), + findByRuleIdAndLanguage: jest.fn(), + findByRuleIdWithPrograms: jest.fn(), + updateActiveDetectionProgram: jest.fn(), + updateSeverity: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + deleteByRuleId: jest.fn(), + } as unknown as jest.Mocked<IActiveDetectionProgramRepository>; + + // Mock DetectionProgramRepository + detectionProgramRepository = { + add: jest.fn(), + findById: jest.fn(), + findByRuleId: jest.fn(), + findByRuleIdAndVersion: jest.fn(), + getLatestVersionByRuleIdAndLanguage: jest.fn(), + updateStatus: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as unknown as jest.Mocked<IDetectionProgramRepository>; + + stubbedLogger = stubLogger(); + + updateActiveDetectionProgramUseCase = + new UpdateActiveDetectionProgramUseCase( + activeDetectionProgramRepository, + detectionProgramRepository, + stubbedLogger, + ); + }); + + describe('execute', () => { + describe('when updating detectionProgramVersion only', () => { + let activeDetectionProgramId: ReturnType< + typeof createActiveDetectionProgramId + >; + let newDetectionProgramVersion: ReturnType< + typeof createDetectionProgramId + >; + let organizationId: ReturnType<typeof createOrganizationId>; + let userId: ReturnType<typeof createUserId>; + let existingActiveProgram: ActiveDetectionProgram; + let detectionProgram: ReturnType<typeof detectionProgramFactory>; + let expectedUpdatedProgram: ActiveDetectionProgram; + let command: UpdateActiveDetectionProgramCommand; + let result: ActiveDetectionProgram; + + beforeEach(async () => { + activeDetectionProgramId = createActiveDetectionProgramId(uuidv4()); + newDetectionProgramVersion = createDetectionProgramId(uuidv4()); + organizationId = createOrganizationId(uuidv4()); + userId = createUserId(uuidv4()); + + existingActiveProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + detectionProgramVersion: createDetectionProgramId(uuidv4()), + detectionProgramDraftVersion: null, + }); + + detectionProgram = detectionProgramFactory({ + id: newDetectionProgramVersion, + status: DetectionStatus.READY, + }); + + expectedUpdatedProgram = { + ...existingActiveProgram, + detectionProgramVersion: newDetectionProgramVersion, + }; + + command = { + activeDetectionProgram: existingActiveProgram, + newDetectionProgramVersion, + organizationId, + userId, + }; + + detectionProgramRepository.findById.mockResolvedValue(detectionProgram); + activeDetectionProgramRepository.updateActiveDetectionProgram.mockResolvedValue( + expectedUpdatedProgram, + ); + + result = await updateActiveDetectionProgramUseCase.execute(command); + }); + + it('finds the detection program by id', () => { + expect(detectionProgramRepository.findById).toHaveBeenCalledWith( + newDetectionProgramVersion, + ); + }); + + it('updates the active detection program', () => { + expect( + activeDetectionProgramRepository.updateActiveDetectionProgram, + ).toHaveBeenCalledWith(expectedUpdatedProgram); + }); + + it('returns the updated program', () => { + expect(result).toEqual(expectedUpdatedProgram); + }); + }); + + describe('when updating detectionProgramDraftVersion only', () => { + let activeDetectionProgramId: ReturnType< + typeof createActiveDetectionProgramId + >; + let newDetectionProgramDraftVersion: ReturnType< + typeof createDetectionProgramId + >; + let organizationId: ReturnType<typeof createOrganizationId>; + let userId: ReturnType<typeof createUserId>; + let existingActiveProgram: ActiveDetectionProgram; + let expectedUpdatedProgram: ActiveDetectionProgram; + let command: UpdateActiveDetectionProgramCommand; + let result: ActiveDetectionProgram; + + beforeEach(async () => { + activeDetectionProgramId = createActiveDetectionProgramId(uuidv4()); + newDetectionProgramDraftVersion = createDetectionProgramId(uuidv4()); + organizationId = createOrganizationId(uuidv4()); + userId = createUserId(uuidv4()); + + existingActiveProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + detectionProgramVersion: createDetectionProgramId(uuidv4()), + detectionProgramDraftVersion: null, + }); + + expectedUpdatedProgram = { + ...existingActiveProgram, + detectionProgramDraftVersion: newDetectionProgramDraftVersion, + }; + + command = { + activeDetectionProgram: existingActiveProgram, + newDetectionProgramDraftVersion, + organizationId, + userId, + }; + + activeDetectionProgramRepository.updateActiveDetectionProgram.mockResolvedValue( + expectedUpdatedProgram, + ); + + result = await updateActiveDetectionProgramUseCase.execute(command); + }); + + it('updates the active detection program', () => { + expect( + activeDetectionProgramRepository.updateActiveDetectionProgram, + ).toHaveBeenCalledWith(expectedUpdatedProgram); + }); + + it('returns the updated program', () => { + expect(result).toEqual(expectedUpdatedProgram); + }); + }); + + describe('when updating both fields', () => { + let activeDetectionProgramId: ReturnType< + typeof createActiveDetectionProgramId + >; + let newDetectionProgramVersion: ReturnType< + typeof createDetectionProgramId + >; + let newDetectionProgramDraftVersion: ReturnType< + typeof createDetectionProgramId + >; + let organizationId: ReturnType<typeof createOrganizationId>; + let userId: ReturnType<typeof createUserId>; + let existingActiveProgram: ActiveDetectionProgram; + let detectionProgram: ReturnType<typeof detectionProgramFactory>; + let expectedUpdatedProgram: ActiveDetectionProgram; + let command: UpdateActiveDetectionProgramCommand; + let result: ActiveDetectionProgram; + + beforeEach(async () => { + activeDetectionProgramId = createActiveDetectionProgramId(uuidv4()); + newDetectionProgramVersion = createDetectionProgramId(uuidv4()); + newDetectionProgramDraftVersion = createDetectionProgramId(uuidv4()); + organizationId = createOrganizationId(uuidv4()); + userId = createUserId(uuidv4()); + + existingActiveProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + detectionProgramVersion: createDetectionProgramId(uuidv4()), + detectionProgramDraftVersion: null, + }); + + detectionProgram = detectionProgramFactory({ + id: newDetectionProgramVersion, + status: DetectionStatus.READY, + }); + + expectedUpdatedProgram = { + ...existingActiveProgram, + detectionProgramVersion: newDetectionProgramVersion, + detectionProgramDraftVersion: newDetectionProgramDraftVersion, + }; + + command = { + activeDetectionProgram: existingActiveProgram, + newDetectionProgramVersion, + newDetectionProgramDraftVersion, + organizationId, + userId, + }; + + detectionProgramRepository.findById.mockResolvedValue(detectionProgram); + activeDetectionProgramRepository.updateActiveDetectionProgram.mockResolvedValue( + expectedUpdatedProgram, + ); + + result = await updateActiveDetectionProgramUseCase.execute(command); + }); + + it('finds the detection program by id', () => { + expect(detectionProgramRepository.findById).toHaveBeenCalledWith( + newDetectionProgramVersion, + ); + }); + + it('updates the active detection program', () => { + expect( + activeDetectionProgramRepository.updateActiveDetectionProgram, + ).toHaveBeenCalledWith(expectedUpdatedProgram); + }); + + it('returns the updated program', () => { + expect(result).toEqual(expectedUpdatedProgram); + }); + }); + + describe('when setting draft version to null', () => { + it('allows setting detectionProgramDraftVersion to null', async () => { + const activeDetectionProgramId = + createActiveDetectionProgramId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + + const existingActiveProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + detectionProgramVersion: createDetectionProgramId(uuidv4()), + detectionProgramDraftVersion: createDetectionProgramId(uuidv4()), // Has a draft version + }); + + const expectedUpdatedProgram: ActiveDetectionProgram = { + ...existingActiveProgram, + detectionProgramDraftVersion: null, // Setting to null + }; + + const command: UpdateActiveDetectionProgramCommand = { + activeDetectionProgram: existingActiveProgram, + newDetectionProgramDraftVersion: null, + organizationId, + userId, + }; + + activeDetectionProgramRepository.updateActiveDetectionProgram.mockResolvedValue( + expectedUpdatedProgram, + ); + + const result = + await updateActiveDetectionProgramUseCase.execute(command); + + expect(result.detectionProgramDraftVersion).toBeNull(); + }); + }); + + describe('error cases', () => { + describe('when no update fields are provided', () => { + let activeDetectionProgramId: ReturnType< + typeof createActiveDetectionProgramId + >; + let organizationId: ReturnType<typeof createOrganizationId>; + let userId: ReturnType<typeof createUserId>; + let existingActiveProgram: ActiveDetectionProgram; + let command: UpdateActiveDetectionProgramCommand; + + beforeEach(() => { + activeDetectionProgramId = createActiveDetectionProgramId(uuidv4()); + organizationId = createOrganizationId(uuidv4()); + userId = createUserId(uuidv4()); + + existingActiveProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + }); + + command = { + activeDetectionProgram: existingActiveProgram, + organizationId, + userId, + }; + }); + + it('throws error with correct message', async () => { + await expect( + updateActiveDetectionProgramUseCase.execute(command), + ).rejects.toThrow( + 'At least one of newDetectionProgramVersion or newDetectionProgramDraftVersion must be provided', + ); + }); + + it('does not call updateActiveDetectionProgram', async () => { + try { + await updateActiveDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect( + activeDetectionProgramRepository.updateActiveDetectionProgram, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when repository update fails', () => { + it('throws error', async () => { + const activeDetectionProgramId = + createActiveDetectionProgramId(uuidv4()); + const newDetectionProgramVersion = createDetectionProgramId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + + const existingActiveProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + }); + + const detectionProgram = detectionProgramFactory({ + id: newDetectionProgramVersion, + status: DetectionStatus.READY, + }); + + const command: UpdateActiveDetectionProgramCommand = { + activeDetectionProgram: existingActiveProgram, + newDetectionProgramVersion, + organizationId, + userId, + }; + + detectionProgramRepository.findById.mockResolvedValue( + detectionProgram, + ); + + const repositoryError = new Error('Database connection failed'); + activeDetectionProgramRepository.updateActiveDetectionProgram.mockRejectedValue( + repositoryError, + ); + + await expect( + updateActiveDetectionProgramUseCase.execute(command), + ).rejects.toThrow('Database connection failed'); + }); + }); + + describe('when detection program is not found', () => { + let activeDetectionProgramId: ReturnType< + typeof createActiveDetectionProgramId + >; + let newDetectionProgramVersion: ReturnType< + typeof createDetectionProgramId + >; + let organizationId: ReturnType<typeof createOrganizationId>; + let userId: ReturnType<typeof createUserId>; + let existingActiveProgram: ActiveDetectionProgram; + let command: UpdateActiveDetectionProgramCommand; + + beforeEach(() => { + activeDetectionProgramId = createActiveDetectionProgramId(uuidv4()); + newDetectionProgramVersion = createDetectionProgramId(uuidv4()); + organizationId = createOrganizationId(uuidv4()); + userId = createUserId(uuidv4()); + + existingActiveProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + }); + + command = { + activeDetectionProgram: existingActiveProgram, + newDetectionProgramVersion, + organizationId, + userId, + }; + + detectionProgramRepository.findById.mockResolvedValue(null); + }); + + it('throws error with correct message', async () => { + await expect( + updateActiveDetectionProgramUseCase.execute(command), + ).rejects.toThrow( + `Detection program ${newDetectionProgramVersion} not found`, + ); + }); + + it('does not call updateActiveDetectionProgram', async () => { + try { + await updateActiveDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect( + activeDetectionProgramRepository.updateActiveDetectionProgram, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when detection program status is not READY', () => { + describe('when status is IN_PROGRESS', () => { + let activeDetectionProgramId: ReturnType< + typeof createActiveDetectionProgramId + >; + let newDetectionProgramVersion: ReturnType< + typeof createDetectionProgramId + >; + let organizationId: ReturnType<typeof createOrganizationId>; + let userId: ReturnType<typeof createUserId>; + let existingActiveProgram: ActiveDetectionProgram; + let detectionProgram: ReturnType<typeof detectionProgramFactory>; + let command: UpdateActiveDetectionProgramCommand; + + beforeEach(() => { + activeDetectionProgramId = createActiveDetectionProgramId(uuidv4()); + newDetectionProgramVersion = createDetectionProgramId(uuidv4()); + organizationId = createOrganizationId(uuidv4()); + userId = createUserId(uuidv4()); + + existingActiveProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + }); + + detectionProgram = detectionProgramFactory({ + id: newDetectionProgramVersion, + status: DetectionStatus.IN_PROGRESS, + }); + + command = { + activeDetectionProgram: existingActiveProgram, + newDetectionProgramVersion, + organizationId, + userId, + }; + + detectionProgramRepository.findById.mockResolvedValue( + detectionProgram, + ); + }); + + it('throws InvalidDetectionProgramStatusError', async () => { + await expect( + updateActiveDetectionProgramUseCase.execute(command), + ).rejects.toThrow(InvalidDetectionProgramStatusError); + }); + + it('throws error with correct message', async () => { + await expect( + updateActiveDetectionProgramUseCase.execute(command), + ).rejects.toThrow( + `Detection program ${newDetectionProgramVersion} cannot be promoted as active. Current status: ${DetectionStatus.IN_PROGRESS}, required status: ${DetectionStatus.READY}`, + ); + }); + + it('does not call updateActiveDetectionProgram', async () => { + try { + await updateActiveDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect( + activeDetectionProgramRepository.updateActiveDetectionProgram, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when status is TO_REVIEW', () => { + let activeDetectionProgramId: ReturnType< + typeof createActiveDetectionProgramId + >; + let newDetectionProgramVersion: ReturnType< + typeof createDetectionProgramId + >; + let organizationId: ReturnType<typeof createOrganizationId>; + let userId: ReturnType<typeof createUserId>; + let existingActiveProgram: ActiveDetectionProgram; + let detectionProgram: ReturnType<typeof detectionProgramFactory>; + let command: UpdateActiveDetectionProgramCommand; + + beforeEach(() => { + activeDetectionProgramId = createActiveDetectionProgramId(uuidv4()); + newDetectionProgramVersion = createDetectionProgramId(uuidv4()); + organizationId = createOrganizationId(uuidv4()); + userId = createUserId(uuidv4()); + + existingActiveProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + }); + + detectionProgram = detectionProgramFactory({ + id: newDetectionProgramVersion, + status: DetectionStatus.TO_REVIEW, + }); + + command = { + activeDetectionProgram: existingActiveProgram, + newDetectionProgramVersion, + organizationId, + userId, + }; + + detectionProgramRepository.findById.mockResolvedValue( + detectionProgram, + ); + }); + + it('throws InvalidDetectionProgramStatusError', async () => { + await expect( + updateActiveDetectionProgramUseCase.execute(command), + ).rejects.toThrow(InvalidDetectionProgramStatusError); + }); + + it('does not call updateActiveDetectionProgram', async () => { + try { + await updateActiveDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect( + activeDetectionProgramRepository.updateActiveDetectionProgram, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when status is ERROR', () => { + let activeDetectionProgramId: ReturnType< + typeof createActiveDetectionProgramId + >; + let newDetectionProgramVersion: ReturnType< + typeof createDetectionProgramId + >; + let organizationId: ReturnType<typeof createOrganizationId>; + let userId: ReturnType<typeof createUserId>; + let existingActiveProgram: ActiveDetectionProgram; + let detectionProgram: ReturnType<typeof detectionProgramFactory>; + let command: UpdateActiveDetectionProgramCommand; + + beforeEach(() => { + activeDetectionProgramId = createActiveDetectionProgramId(uuidv4()); + newDetectionProgramVersion = createDetectionProgramId(uuidv4()); + organizationId = createOrganizationId(uuidv4()); + userId = createUserId(uuidv4()); + + existingActiveProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + }); + + detectionProgram = detectionProgramFactory({ + id: newDetectionProgramVersion, + status: DetectionStatus.ERROR, + }); + + command = { + activeDetectionProgram: existingActiveProgram, + newDetectionProgramVersion, + organizationId, + userId, + }; + + detectionProgramRepository.findById.mockResolvedValue( + detectionProgram, + ); + }); + + it('throws InvalidDetectionProgramStatusError', async () => { + await expect( + updateActiveDetectionProgramUseCase.execute(command), + ).rejects.toThrow(InvalidDetectionProgramStatusError); + }); + + it('does not call updateActiveDetectionProgram', async () => { + try { + await updateActiveDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect( + activeDetectionProgramRepository.updateActiveDetectionProgram, + ).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/updateActiveDetectionProgram/UpdateActiveDetectionProgramUseCase.ts b/packages/linter/src/application/useCases/updateActiveDetectionProgram/UpdateActiveDetectionProgramUseCase.ts new file mode 100644 index 000000000..5f3e8bdc5 --- /dev/null +++ b/packages/linter/src/application/useCases/updateActiveDetectionProgram/UpdateActiveDetectionProgramUseCase.ts @@ -0,0 +1,144 @@ +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { SSEEventPublisher } from '@packmind/node-utils'; +import { DetectionStatus } from '@packmind/types'; +import { + IUpdateActiveDetectionProgramUseCase, + UpdateActiveDetectionProgramCommand, + ActiveDetectionProgram, + DetectionProgram, +} from '@packmind/types'; +import { IActiveDetectionProgramRepository } from '../../../domain/repositories/IActiveDetectionProgramRepository'; +import { IDetectionProgramRepository } from '../../../domain/repositories/IDetectionProgramRepository'; +import { InvalidDetectionProgramStatusError } from '../../../domain/errors'; + +export class UpdateActiveDetectionProgramUseCase implements IUpdateActiveDetectionProgramUseCase { + constructor( + private readonly activeDetectionProgramRepository: IActiveDetectionProgramRepository, + private readonly detectionProgramRepository: IDetectionProgramRepository, + private readonly logger: PackmindLogger = new PackmindLogger( + 'UpdateActiveDetectionProgramUseCase', + LogLevel.DEBUG, + ), + ) {} + + async execute( + command: UpdateActiveDetectionProgramCommand, + ): Promise<ActiveDetectionProgram> { + try { + let detectionProgramForEvent: DetectionProgram | undefined; + + // Validate that at least one update field is provided + if ( + command.newDetectionProgramVersion === undefined && + command.newDetectionProgramDraftVersion === undefined + ) { + throw new Error( + 'At least one of newDetectionProgramVersion or newDetectionProgramDraftVersion must be provided', + ); + } + + // Validate that newDetectionProgramVersion has READY status before promotion + if (command.newDetectionProgramVersion !== undefined) { + const detectionProgram = await this.detectionProgramRepository.findById( + command.newDetectionProgramVersion, + ); + + if (!detectionProgram) { + throw new Error( + `Detection program ${command.newDetectionProgramVersion} not found`, + ); + } + + if (detectionProgram.status !== DetectionStatus.READY) { + throw new InvalidDetectionProgramStatusError( + detectionProgram.id, + detectionProgram.status, + DetectionStatus.READY, + ); + } + + this.logger.info('Detection program status validated', { + detectionProgramId: detectionProgram.id, + status: detectionProgram.status, + }); + + detectionProgramForEvent = detectionProgram; + } + + this.logger.info('Updating active detection program', { + activeDetectionProgramId: command.activeDetectionProgram.id, + hasNewDetectionProgramVersion: + command.newDetectionProgramVersion !== undefined, + hasNewDetectionProgramDraftVersion: + command.newDetectionProgramDraftVersion !== undefined, + }); + + // Create updated object with conditionally updated fields + const updatedActiveDetectionProgram: ActiveDetectionProgram = { + ...command.activeDetectionProgram, + }; + + // Update detectionProgramVersion if provided + if (command.newDetectionProgramVersion !== undefined) { + updatedActiveDetectionProgram.detectionProgramVersion = + command.newDetectionProgramVersion; + } + + // Update detectionProgramDraftVersion if provided + if (command.newDetectionProgramDraftVersion !== undefined) { + updatedActiveDetectionProgram.detectionProgramDraftVersion = + command.newDetectionProgramDraftVersion; + } + + // Update in database + const result = + await this.activeDetectionProgramRepository.updateActiveDetectionProgram( + updatedActiveDetectionProgram, + ); + + this.logger.info('Active detection program updated successfully', { + activeDetectionProgramId: command.activeDetectionProgram.id, + detectionProgramVersion: result.detectionProgramVersion, + detectionProgramDraftVersion: result.detectionProgramDraftVersion, + }); + + if ( + command.newDetectionProgramVersion !== undefined && + detectionProgramForEvent + ) { + try { + await SSEEventPublisher.publishProgramStatusEvent( + command.newDetectionProgramVersion, + detectionProgramForEvent.ruleId, + detectionProgramForEvent.language, + command.userId, + command.organizationId, + ); + this.logger.info('SSE event published for active program update', { + detectionProgramId: command.newDetectionProgramVersion, + ruleId: detectionProgramForEvent.ruleId, + language: detectionProgramForEvent.language, + userId: command.userId, + }); + } catch (sseError) { + this.logger.error( + 'Failed to publish SSE event for active program update', + { + detectionProgramId: command.newDetectionProgramVersion, + error: + sseError instanceof Error ? sseError.message : String(sseError), + }, + ); + } + } + + return result; + } catch (error) { + this.logger.error('Failed to update active detection program', { + activeDetectionProgramId: command.activeDetectionProgram.id, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/updateActiveDetectionProgramSeverity/UpdateActiveDetectionProgramSeverityUseCase.spec.ts b/packages/linter/src/application/useCases/updateActiveDetectionProgramSeverity/UpdateActiveDetectionProgramSeverityUseCase.spec.ts new file mode 100644 index 000000000..ba895e587 --- /dev/null +++ b/packages/linter/src/application/useCases/updateActiveDetectionProgramSeverity/UpdateActiveDetectionProgramSeverityUseCase.spec.ts @@ -0,0 +1,306 @@ +import { UpdateActiveDetectionProgramSeverityUseCase } from './UpdateActiveDetectionProgramSeverityUseCase'; +import { v4 as uuidv4 } from 'uuid'; +import { + createActiveDetectionProgramId, + createOrganizationId, + createRuleId, + createUserId, + DetectionSeverity, + LinterRuleSeverityUpdatedEvent, + UpdateActiveDetectionProgramSeverityCommand, +} from '@packmind/types'; +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { stubLogger } from '@packmind/test-utils'; +import { PackmindLogger } from '@packmind/logger'; +import { IActiveDetectionProgramRepository } from '../../../domain/repositories/IActiveDetectionProgramRepository'; +import { activeDetectionProgramFactory } from '../../../../test'; +import { ActiveDetectionProgramNotFoundError } from '../../../domain/errors'; + +describe('UpdateActiveDetectionProgramSeverityUseCase', () => { + let useCase: UpdateActiveDetectionProgramSeverityUseCase; + let activeDetectionProgramRepository: jest.Mocked<IActiveDetectionProgramRepository>; + let mockEventEmitterService: jest.Mocked<PackmindEventEmitterService>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + beforeEach(() => { + activeDetectionProgramRepository = { + add: jest.fn(), + findById: jest.fn(), + findByRuleId: jest.fn(), + findByRuleIdAndLanguage: jest.fn(), + findByRuleIdWithPrograms: jest.fn(), + updateActiveDetectionProgram: jest.fn(), + updateSeverity: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + deleteByRuleId: jest.fn(), + } as unknown as jest.Mocked<IActiveDetectionProgramRepository>; + + mockEventEmitterService = { + emit: jest.fn(), + } as unknown as jest.Mocked<PackmindEventEmitterService>; + + stubbedLogger = stubLogger(); + + useCase = new UpdateActiveDetectionProgramSeverityUseCase( + activeDetectionProgramRepository, + mockEventEmitterService, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute', () => { + describe('when active detection program exists', () => { + const activeDetectionProgramId = createActiveDetectionProgramId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + + describe('when updating severity to WARNING', () => { + let command: UpdateActiveDetectionProgramSeverityCommand; + + beforeEach(() => { + const existingProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + ruleId, + severity: DetectionSeverity.ERROR, + }); + + activeDetectionProgramRepository.findById.mockResolvedValue( + existingProgram, + ); + + const updatedProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + ruleId, + severity: DetectionSeverity.WARNING, + }); + activeDetectionProgramRepository.updateSeverity.mockResolvedValue( + updatedProgram, + ); + + command = { + userId, + organizationId, + activeDetectionProgramId, + ruleId, + severity: DetectionSeverity.WARNING, + }; + }); + + it('returns the updated program with WARNING severity', async () => { + const result = await useCase.execute(command); + + expect(result.severity).toEqual(DetectionSeverity.WARNING); + }); + + it('calls updateSeverity on the repository', async () => { + await useCase.execute(command); + + expect( + activeDetectionProgramRepository.updateSeverity, + ).toHaveBeenCalledWith( + activeDetectionProgramId, + DetectionSeverity.WARNING, + ); + }); + + it('emits exactly one event', async () => { + await useCase.execute(command); + + expect(mockEventEmitterService.emit).toHaveBeenCalledTimes(1); + }); + + it('emits a LinterRuleSeverityUpdatedEvent instance', async () => { + await useCase.execute(command); + + const emittedEvent = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emittedEvent).toBeInstanceOf(LinterRuleSeverityUpdatedEvent); + }); + + it('includes ruleId in the event payload', async () => { + await useCase.execute(command); + + const emittedEvent = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emittedEvent.payload.ruleId).toEqual(ruleId); + }); + + it('includes the new severity in the event payload', async () => { + await useCase.execute(command); + + const emittedEvent = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emittedEvent.payload.severity).toEqual( + DetectionSeverity.WARNING, + ); + }); + }); + + describe('when updating severity to ERROR', () => { + let command: UpdateActiveDetectionProgramSeverityCommand; + + beforeEach(() => { + const existingProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + ruleId, + severity: DetectionSeverity.WARNING, + }); + + activeDetectionProgramRepository.findById.mockResolvedValue( + existingProgram, + ); + + const updatedProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + ruleId, + severity: DetectionSeverity.ERROR, + }); + activeDetectionProgramRepository.updateSeverity.mockResolvedValue( + updatedProgram, + ); + + command = { + userId, + organizationId, + activeDetectionProgramId, + ruleId, + severity: DetectionSeverity.ERROR, + }; + }); + + it('returns the updated program with ERROR severity', async () => { + const result = await useCase.execute(command); + + expect(result.severity).toEqual(DetectionSeverity.ERROR); + }); + + it('emits exactly one event', async () => { + await useCase.execute(command); + + expect(mockEventEmitterService.emit).toHaveBeenCalledTimes(1); + }); + + it('emits a LinterRuleSeverityUpdatedEvent instance', async () => { + await useCase.execute(command); + + const emittedEvent = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emittedEvent).toBeInstanceOf(LinterRuleSeverityUpdatedEvent); + }); + + it('includes ruleId in the event payload', async () => { + await useCase.execute(command); + + const emittedEvent = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emittedEvent.payload.ruleId).toEqual(ruleId); + }); + + it('includes the new severity in the event payload', async () => { + await useCase.execute(command); + + const emittedEvent = mockEventEmitterService.emit.mock.calls[0][0]; + expect(emittedEvent.payload.severity).toEqual( + DetectionSeverity.ERROR, + ); + }); + }); + + describe('when ruleId does not match', () => { + let command: UpdateActiveDetectionProgramSeverityCommand; + + beforeEach(() => { + const existingProgram = activeDetectionProgramFactory({ + id: activeDetectionProgramId, + ruleId: createRuleId(uuidv4()), + }); + + activeDetectionProgramRepository.findById.mockResolvedValue( + existingProgram, + ); + + command = { + userId, + organizationId, + activeDetectionProgramId, + ruleId, + severity: DetectionSeverity.WARNING, + }; + }); + + it('throws ActiveDetectionProgramNotFoundError', async () => { + await expect(useCase.execute(command)).rejects.toThrow( + ActiveDetectionProgramNotFoundError, + ); + }); + + it('does not call updateSeverity on the repository', async () => { + try { + await useCase.execute(command); + } catch { + // Expected to throw + } + + expect( + activeDetectionProgramRepository.updateSeverity, + ).not.toHaveBeenCalled(); + }); + + it('does not emit any event', async () => { + try { + await useCase.execute(command); + } catch { + // Expected to throw + } + + expect(mockEventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when active detection program does not exist', () => { + let command: UpdateActiveDetectionProgramSeverityCommand; + + beforeEach(() => { + activeDetectionProgramRepository.findById.mockResolvedValue(null); + + command = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + activeDetectionProgramId: createActiveDetectionProgramId(uuidv4()), + ruleId: createRuleId(uuidv4()), + severity: DetectionSeverity.WARNING, + }; + }); + + it('throws ActiveDetectionProgramNotFoundError', async () => { + await expect(useCase.execute(command)).rejects.toThrow( + ActiveDetectionProgramNotFoundError, + ); + }); + + it('does not call updateSeverity on the repository', async () => { + try { + await useCase.execute(command); + } catch { + // Expected to throw + } + + expect( + activeDetectionProgramRepository.updateSeverity, + ).not.toHaveBeenCalled(); + }); + + it('does not emit any event', async () => { + try { + await useCase.execute(command); + } catch { + // Expected to throw + } + + expect(mockEventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/updateActiveDetectionProgramSeverity/UpdateActiveDetectionProgramSeverityUseCase.ts b/packages/linter/src/application/useCases/updateActiveDetectionProgramSeverity/UpdateActiveDetectionProgramSeverityUseCase.ts new file mode 100644 index 000000000..df35f7809 --- /dev/null +++ b/packages/linter/src/application/useCases/updateActiveDetectionProgramSeverity/UpdateActiveDetectionProgramSeverityUseCase.ts @@ -0,0 +1,64 @@ +import { PackmindLogger } from '@packmind/logger'; +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { + IUpdateActiveDetectionProgramSeverityUseCase, + UpdateActiveDetectionProgramSeverityCommand, + ActiveDetectionProgram, + LinterRuleSeverityUpdatedEvent, + createUserId, + createOrganizationId, +} from '@packmind/types'; +import { IActiveDetectionProgramRepository } from '../../../domain/repositories/IActiveDetectionProgramRepository'; +import { ActiveDetectionProgramNotFoundError } from '../../../domain/errors'; + +const origin = 'UpdateActiveDetectionProgramSeverityUseCase'; + +export class UpdateActiveDetectionProgramSeverityUseCase implements IUpdateActiveDetectionProgramSeverityUseCase { + constructor( + private readonly activeDetectionProgramRepository: IActiveDetectionProgramRepository, + private readonly eventEmitterService: PackmindEventEmitterService, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: UpdateActiveDetectionProgramSeverityCommand, + ): Promise<ActiveDetectionProgram> { + this.logger.info('Updating active detection program severity', { + activeDetectionProgramId: command.activeDetectionProgramId, + severity: command.severity, + }); + + const existingProgram = + await this.activeDetectionProgramRepository.findById( + command.activeDetectionProgramId, + ); + + if (!existingProgram || existingProgram.ruleId !== command.ruleId) { + throw new ActiveDetectionProgramNotFoundError( + command.activeDetectionProgramId, + ); + } + + const result = await this.activeDetectionProgramRepository.updateSeverity( + command.activeDetectionProgramId, + command.severity, + ); + + this.eventEmitterService.emit( + new LinterRuleSeverityUpdatedEvent({ + ruleId: command.ruleId, + severity: result.severity, + userId: createUserId(command.userId), + organizationId: createOrganizationId(command.organizationId), + source: 'ui', + }), + ); + + this.logger.info('Active detection program severity updated', { + activeDetectionProgramId: command.activeDetectionProgramId, + severity: result.severity, + }); + + return result; + } +} diff --git a/packages/linter/src/application/useCases/updateDetectionProgram/UpdateDetectionProgramUseCase.spec.ts b/packages/linter/src/application/useCases/updateDetectionProgram/UpdateDetectionProgramUseCase.spec.ts new file mode 100644 index 000000000..288106bd8 --- /dev/null +++ b/packages/linter/src/application/useCases/updateDetectionProgram/UpdateDetectionProgramUseCase.spec.ts @@ -0,0 +1,337 @@ +import { UpdateDetectionProgramUseCase } from './UpdateDetectionProgramUseCase'; +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { createOrganizationId, createUserId } from '@packmind/types'; +import { + DetectionStatus, + UpdateDetectionProgramCommand, + createDetectionProgramId, + DetectionModeEnum, +} from '@packmind/types'; +import { stubLogger } from '@packmind/test-utils'; +import { IDetectionProgramRepository } from '../../../domain/repositories/IDetectionProgramRepository'; +import { detectionProgramFactory } from '../../../../test'; + +// Add at the top, before other imports +jest.mock('@packmind/node-utils', () => { + const actual = jest.requireActual('@packmind/node-utils'); + return { + ...actual, + SSEEventPublisher: { + publishProgramStatusEvent: jest.fn().mockResolvedValue(undefined), + }, + }; +}); + +describe('UpdateDetectionProgramUseCase', () => { + let updateDetectionProgramUseCase: UpdateDetectionProgramUseCase; + let detectionProgramRepository: jest.Mocked<IDetectionProgramRepository>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + beforeEach(() => { + // Mock DetectionProgramRepository + detectionProgramRepository = { + add: jest.fn(), + findById: jest.fn(), + findByRuleId: jest.fn(), + findByRuleIdAndVersion: jest.fn(), + getLatestVersionByRuleId: jest.fn(), + getLatestVersionByRuleIdAndLanguage: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as unknown as jest.Mocked<IDetectionProgramRepository>; + + stubbedLogger = stubLogger(); + + updateDetectionProgramUseCase = new UpdateDetectionProgramUseCase( + detectionProgramRepository, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute', () => { + describe('when updating existing detection program with new code', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const detectionProgramId = createDetectionProgramId(uuidv4()); + + const command: UpdateDetectionProgramCommand = { + detectionProgramId, + code: 'updated code', + organizationId, + userId, + }; + + const existingDetectionProgram = detectionProgramFactory({ + id: detectionProgramId, + code: 'old code', + mode: DetectionModeEnum.REGEXP, + status: DetectionStatus.READY, + }); + + const updatedDetectionProgram = detectionProgramFactory({ + id: detectionProgramId, + code: command.code, + mode: DetectionModeEnum.REGEXP, + status: DetectionStatus.READY, + }); + + let result: Awaited< + ReturnType<typeof updateDetectionProgramUseCase.execute> + >; + + beforeEach(async () => { + detectionProgramRepository.findById.mockResolvedValue( + existingDetectionProgram, + ); + detectionProgramRepository.add.mockResolvedValue( + updatedDetectionProgram, + ); + + result = await updateDetectionProgramUseCase.execute(command); + }); + + it('finds the detection program by id', () => { + expect(detectionProgramRepository.findById).toHaveBeenCalledWith( + detectionProgramId, + ); + }); + + it('saves the updated detection program with new code', () => { + expect(detectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + id: detectionProgramId, + code: command.code, + mode: DetectionModeEnum.REGEXP, + status: DetectionStatus.READY, + }), + ); + }); + + it('returns the updated detection program', () => { + expect(result).toEqual(updatedDetectionProgram); + }); + }); + + describe('when updating detection program with new mode and status', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const detectionProgramId = createDetectionProgramId(uuidv4()); + + const command: UpdateDetectionProgramCommand = { + detectionProgramId, + code: 'updated code', + mode: DetectionModeEnum.SINGLE_AST, + status: DetectionStatus.READY, + organizationId, + userId, + }; + + const existingDetectionProgram = detectionProgramFactory({ + id: detectionProgramId, + code: 'old code', + mode: DetectionModeEnum.REGEXP, + status: DetectionStatus.READY, + }); + + const updatedDetectionProgram = detectionProgramFactory({ + id: detectionProgramId, + code: command.code, + mode: command.mode, + status: command.status, + }); + + let result: Awaited< + ReturnType<typeof updateDetectionProgramUseCase.execute> + >; + + beforeEach(async () => { + detectionProgramRepository.findById.mockResolvedValue( + existingDetectionProgram, + ); + detectionProgramRepository.add.mockResolvedValue( + updatedDetectionProgram, + ); + + result = await updateDetectionProgramUseCase.execute(command); + }); + + it('saves the detection program with updated mode and status', () => { + expect(detectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + id: detectionProgramId, + code: command.code, + mode: command.mode, + status: command.status, + }), + ); + }); + + it('returns the updated detection program', () => { + expect(result).toEqual(updatedDetectionProgram); + }); + }); + + describe('when mode and status are not provided', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const detectionProgramId = createDetectionProgramId(uuidv4()); + + const command: UpdateDetectionProgramCommand = { + detectionProgramId, + code: 'updated code', + organizationId, + userId, + }; + + const existingDetectionProgram = detectionProgramFactory({ + id: detectionProgramId, + code: 'old code', + mode: DetectionModeEnum.FILE_SYSTEM, + status: DetectionStatus.IN_PROGRESS, + }); + + const updatedDetectionProgram = detectionProgramFactory({ + id: detectionProgramId, + code: command.code, + mode: DetectionModeEnum.FILE_SYSTEM, + status: DetectionStatus.IN_PROGRESS, + }); + + let result: Awaited< + ReturnType<typeof updateDetectionProgramUseCase.execute> + >; + + beforeEach(async () => { + detectionProgramRepository.findById.mockResolvedValue( + existingDetectionProgram, + ); + detectionProgramRepository.add.mockResolvedValue( + updatedDetectionProgram, + ); + + result = await updateDetectionProgramUseCase.execute(command); + }); + + describe('when saving', () => { + it('preserves existing mode and status', () => { + expect(detectionProgramRepository.add).toHaveBeenCalledWith( + expect.objectContaining({ + id: detectionProgramId, + code: command.code, + mode: DetectionModeEnum.FILE_SYSTEM, + status: DetectionStatus.IN_PROGRESS, + }), + ); + }); + }); + + it('returns the updated detection program', () => { + expect(result).toEqual(updatedDetectionProgram); + }); + }); + + describe('error cases', () => { + describe('when detection program is not found', () => { + const detectionProgramId = createDetectionProgramId(uuidv4()); + const command: UpdateDetectionProgramCommand = { + detectionProgramId, + code: 'test code', + organizationId: createOrganizationId(uuidv4()), + userId: createUserId(uuidv4()), + }; + + beforeEach(() => { + detectionProgramRepository.findById.mockResolvedValue(null); + }); + + it('throws detection program not found error', async () => { + await expect( + updateDetectionProgramUseCase.execute(command), + ).rejects.toThrow('Detection program not found'); + }); + + it('calls findById with the detection program id', async () => { + try { + await updateDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect(detectionProgramRepository.findById).toHaveBeenCalledWith( + detectionProgramId, + ); + }); + + it('does not call add on the repository', async () => { + try { + await updateDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect(detectionProgramRepository.add).not.toHaveBeenCalled(); + }); + }); + + describe('when repository update fails', () => { + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const detectionProgramId = createDetectionProgramId(uuidv4()); + + const command: UpdateDetectionProgramCommand = { + detectionProgramId, + code: 'updated code', + organizationId, + userId, + }; + + const existingDetectionProgram = detectionProgramFactory({ + id: detectionProgramId, + code: 'old code', + }); + + beforeEach(() => { + detectionProgramRepository.findById.mockResolvedValue( + existingDetectionProgram, + ); + detectionProgramRepository.add.mockRejectedValue( + new Error('Database error'), + ); + }); + + it('throws database error', async () => { + await expect( + updateDetectionProgramUseCase.execute(command), + ).rejects.toThrow('Database error'); + }); + + it('calls findById with the detection program id', async () => { + try { + await updateDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect(detectionProgramRepository.findById).toHaveBeenCalledWith( + detectionProgramId, + ); + }); + + it('attempts to save the detection program once', async () => { + try { + await updateDetectionProgramUseCase.execute(command); + } catch { + // Expected to throw + } + + expect(detectionProgramRepository.add).toHaveBeenCalledTimes(1); + }); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/updateDetectionProgram/UpdateDetectionProgramUseCase.ts b/packages/linter/src/application/useCases/updateDetectionProgram/UpdateDetectionProgramUseCase.ts new file mode 100644 index 000000000..7d90fa740 --- /dev/null +++ b/packages/linter/src/application/useCases/updateDetectionProgram/UpdateDetectionProgramUseCase.ts @@ -0,0 +1,91 @@ +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { SSEEventPublisher } from '@packmind/node-utils'; +import { + IUpdateDetectionProgramUseCase, + UpdateDetectionProgramCommand, + DetectionProgram, +} from '@packmind/types'; +import { IDetectionProgramRepository } from '../../../domain/repositories/IDetectionProgramRepository'; + +export class UpdateDetectionProgramUseCase implements IUpdateDetectionProgramUseCase { + constructor( + private readonly detectionProgramRepository: IDetectionProgramRepository, + private readonly logger: PackmindLogger = new PackmindLogger( + 'UpdateDetectionProgramUseCase', + LogLevel.DEBUG, + ), + ) {} + + async execute( + command: UpdateDetectionProgramCommand, + ): Promise<DetectionProgram> { + try { + // Find the existing detection program + const existingDetectionProgram = + await this.detectionProgramRepository.findById( + command.detectionProgramId, + ); + + if (!existingDetectionProgram) { + throw new Error('Detection program not found'); + } + + // Create updated detection program + const updatedDetectionProgram: DetectionProgram = { + ...existingDetectionProgram, + code: command.code, + mode: command.mode ?? existingDetectionProgram.mode, + status: command.status ?? existingDetectionProgram.status, + sourceCodeState: command.sourceCodeState ?? 'NONE', + }; + + // Save the updated detection program + // Note: This assumes the repository has an update method or add works for updates + // We might need to add an update method to the repository interface + const result = await this.detectionProgramRepository.add( + updatedDetectionProgram, + ); + + this.logger.info('Detection program updated successfully', { + detectionProgramId: command.detectionProgramId, + code: command.code, + mode: updatedDetectionProgram.mode, + status: updatedDetectionProgram.status, + sourceCodeState: updatedDetectionProgram.sourceCodeState, + }); + + // Publish SSE event for program status update + if (command.status) { + try { + await SSEEventPublisher.publishProgramStatusEvent( + command.detectionProgramId, + updatedDetectionProgram.ruleId, + updatedDetectionProgram.language, + command.userId, + command.organizationId, + ); + this.logger.info('SSE event published for program status update', { + detectionProgramId: command.detectionProgramId, + status: command.status, + userId: command.userId, + }); + } catch (sseError) { + this.logger.error('Failed to publish SSE event for program update', { + detectionProgramId: command.detectionProgramId, + error: + sseError instanceof Error ? sseError.message : String(sseError), + }); + // Don't throw here - the main operation was successful + } + } + + return result; + } catch (error) { + this.logger.error('Failed to update detection program', { + detectionProgramId: command.detectionProgramId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/application/useCases/updateDetectionProgramStatus/UpdateDetectionProgramStatusUseCase.spec.ts b/packages/linter/src/application/useCases/updateDetectionProgramStatus/UpdateDetectionProgramStatusUseCase.spec.ts new file mode 100644 index 000000000..29572e678 --- /dev/null +++ b/packages/linter/src/application/useCases/updateDetectionProgramStatus/UpdateDetectionProgramStatusUseCase.spec.ts @@ -0,0 +1,732 @@ +import { UpdateDetectionProgramStatusUseCase } from './UpdateDetectionProgramStatusUseCase'; +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { + DetectionStatus, + IStandardsPort, + ProgrammingLanguage, + createRuleId, + RuleExample, + createRuleExampleId, + createStandardVersionId, +} from '@packmind/types'; +import { stubLogger } from '@packmind/test-utils'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { IActiveDetectionProgramRepository } from '../../../domain/repositories/IActiveDetectionProgramRepository'; +import { IDetectionProgramRepository } from '../../../domain/repositories/IDetectionProgramRepository'; +import { + UpdateDetectionProgramStatusCommand, + IUpdateDetectionProgramUseCase, + IExecuteLinterProgramsUseCase, + ExecuteLinterProgramsCommand, + ExecuteLinterProgramsResult, +} from '@packmind/types'; +import { + activeDetectionProgramFactory, + detectionProgramFactory, +} from '../../../../test'; +import { + createDetectionProgramId, + createOrganizationId, +} from '@packmind/types'; +import { ruleFactory } from '@packmind/standards/test'; +import { RuleNotFoundError } from '../../../domain/errors'; + +// Mock SSE event publisher +jest.mock('@packmind/node-utils', () => { + const actual = jest.requireActual('@packmind/node-utils'); + return { + ...actual, + SSEEventPublisher: { + publishProgramStatusEvent: jest.fn().mockResolvedValue(undefined), + }, + }; +}); + +describe('UpdateDetectionProgramStatusUseCase', () => { + let useCase: UpdateDetectionProgramStatusUseCase; + let repositories: jest.Mocked<ILinterRepositories>; + let activeDetectionProgramRepository: jest.Mocked<IActiveDetectionProgramRepository>; + let detectionProgramRepository: jest.Mocked<IDetectionProgramRepository>; + let standardsAdapter: jest.Mocked<IStandardsPort>; + let executeLinterProgramsUseCase: jest.Mocked<IExecuteLinterProgramsUseCase>; + let updateDetectionProgramUseCase: jest.Mocked<IUpdateDetectionProgramUseCase>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + const ruleId = createRuleId(uuidv4()); + const standardVersionId = createStandardVersionId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + + const mockRule = ruleFactory({ + id: ruleId, + content: 'Use const instead of var', + standardVersionId, + }); + + const createMockExample = ( + positive: string, + negative: string, + ): RuleExample => ({ + id: createRuleExampleId(uuidv4()), + lang: ProgrammingLanguage.JAVASCRIPT, + positive, + negative, + ruleId, + }); + + beforeEach(() => { + activeDetectionProgramRepository = { + findByRuleIdAndLanguage: jest.fn(), + findByRuleId: jest.fn(), + findByRuleIdWithPrograms: jest.fn(), + findById: jest.fn(), + add: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + updateActiveDetectionProgram: jest.fn(), + deleteByRuleId: jest.fn(), + } as unknown as jest.Mocked<IActiveDetectionProgramRepository>; + + detectionProgramRepository = { + findById: jest.fn(), + add: jest.fn(), + findByRuleId: jest.fn(), + findByRuleIdAndVersion: jest.fn(), + getLatestVersionByRuleIdAndLanguage: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + getLatestVersionByRuleId: jest.fn(), + updateStatus: jest.fn(), + } as unknown as jest.Mocked<IDetectionProgramRepository>; + + repositories = { + getActiveDetectionProgramRepository: jest + .fn() + .mockReturnValue(activeDetectionProgramRepository), + getDetectionProgramRepository: jest + .fn() + .mockReturnValue(detectionProgramRepository), + getDetectionProgramMetadataRepository: jest.fn(), + getRuleDetectionHeuristicsRepository: jest.fn(), + } as unknown as jest.Mocked<ILinterRepositories>; + + standardsAdapter = { + getRule: jest.fn(), + getStandardVersion: jest.fn(), + getStandard: jest.fn(), + getRuleCodeExamples: jest.fn(), + getLatestRulesByStandardId: jest.fn(), + listStandardsBySpace: jest.fn(), + findStandardBySlug: jest.fn(), + getLatestStandardVersion: jest.fn(), + } as unknown as jest.Mocked<IStandardsPort>; + + executeLinterProgramsUseCase = { + execute: jest.fn(), + } as unknown as jest.Mocked<IExecuteLinterProgramsUseCase>; + + updateDetectionProgramUseCase = { + execute: jest.fn(), + } as unknown as jest.Mocked<IUpdateDetectionProgramUseCase>; + + stubbedLogger = stubLogger(); + + useCase = new UpdateDetectionProgramStatusUseCase( + repositories, + standardsAdapter, + executeLinterProgramsUseCase, + stubbedLogger, + ); + + // Default mocks + standardsAdapter.getRule.mockResolvedValue(mockRule); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute', () => { + describe('when both programs pass validation', () => { + const activeProgramId = createDetectionProgramId(uuidv4()); + const draftProgramId = createDetectionProgramId(uuidv4()); + + beforeEach(async () => { + const activeDetectionProgram = activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + detectionProgramVersion: activeProgramId, + detectionProgramDraftVersion: draftProgramId, + }); + + const activeProgram = detectionProgramFactory({ + id: activeProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + status: DetectionStatus.TO_REVIEW, + sourceCodeState: 'AST', + }); + + const draftProgram = detectionProgramFactory({ + id: draftProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + status: DetectionStatus.TO_REVIEW, + sourceCodeState: 'RAW', + }); + + const examples = [createMockExample('const x = 1;', 'var x = 1;')]; + + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + activeDetectionProgram, + ); + detectionProgramRepository.findById + .mockResolvedValueOnce(activeProgram) + .mockResolvedValueOnce(draftProgram); + standardsAdapter.getRuleCodeExamples.mockResolvedValue(examples); + + // Negative examples return violations (good) + // Positive examples return no violations (good) + executeLinterProgramsUseCase.execute.mockImplementation( + async (cmd: ExecuteLinterProgramsCommand) => { + const isNegative = cmd.filePath === 'validation-negative-example'; + return { + file: cmd.filePath, + violations: isNegative + ? [{ line: 1, character: 0, rule: 'test', standard: 'test' }] + : [], + } as ExecuteLinterProgramsResult; + }, + ); + + const command: UpdateDetectionProgramStatusCommand = { + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + organizationId: organizationId as unknown as string, + userId: organizationId as unknown as string, + }; + + await useCase.execute(command); + }); + + it('updates active program status to READY', () => { + expect(detectionProgramRepository.updateStatus).toHaveBeenCalledWith( + activeProgramId, + DetectionStatus.READY, + ); + }); + + it('updates draft program status to READY', () => { + expect(detectionProgramRepository.updateStatus).toHaveBeenCalledWith( + draftProgramId, + DetectionStatus.READY, + ); + }); + }); + + describe('when active program fails negative example validation', () => { + it('updates active program status to TO_REVIEW', async () => { + const activeProgramId = createDetectionProgramId(uuidv4()); + + const activeDetectionProgram = activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + detectionProgramVersion: activeProgramId, + detectionProgramDraftVersion: null, + }); + + const activeProgram = detectionProgramFactory({ + id: activeProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + }); + + const examples = [createMockExample('const x = 1;', 'var x = 1;')]; + + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + activeDetectionProgram, + ); + detectionProgramRepository.findById.mockResolvedValue(activeProgram); + standardsAdapter.getRuleCodeExamples.mockResolvedValue(examples); + + // Negative example returns NO violations (bad - should have violations) + executeLinterProgramsUseCase.execute.mockResolvedValue({ + file: 'validation-negative-example', + violations: [], + }); + + detectionProgramRepository.updateStatus.mockResolvedValue(undefined); + + const command: UpdateDetectionProgramStatusCommand = { + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + organizationId: organizationId as unknown as string, + userId: organizationId as unknown as string, + }; + + await useCase.execute(command); + + expect(detectionProgramRepository.updateStatus).toHaveBeenCalledWith( + activeProgramId, + DetectionStatus.TO_REVIEW, + ); + }); + }); + + describe('when active program fails positive example validation', () => { + it('updates active program status to TO_REVIEW', async () => { + const activeProgramId = createDetectionProgramId(uuidv4()); + + const activeDetectionProgram = activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + detectionProgramVersion: activeProgramId, + detectionProgramDraftVersion: null, + }); + + const activeProgram = detectionProgramFactory({ + id: activeProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + status: DetectionStatus.READY, + sourceCodeState: 'RAW', + }); + + const examples = [createMockExample('const x = 1;', 'var x = 1;')]; + + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + activeDetectionProgram, + ); + detectionProgramRepository.findById.mockResolvedValue(activeProgram); + standardsAdapter.getRuleCodeExamples.mockResolvedValue(examples); + + // First call (negative) returns violations (good) + // Second call (positive) returns violations (bad - should have none) + executeLinterProgramsUseCase.execute + .mockResolvedValueOnce({ + file: 'validation-negative-example', + violations: [ + { line: 1, character: 0, rule: 'test', standard: 'test' }, + ], + }) + .mockResolvedValueOnce({ + file: 'validation-positive-example', + violations: [ + { line: 1, character: 0, rule: 'test', standard: 'test' }, + ], + }); + + detectionProgramRepository.updateStatus.mockResolvedValue(undefined); + + const command: UpdateDetectionProgramStatusCommand = { + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + organizationId: organizationId as unknown as string, + userId: organizationId as unknown as string, + }; + + await useCase.execute(command); + + expect(detectionProgramRepository.updateStatus).toHaveBeenCalledWith( + activeProgramId, + DetectionStatus.TO_REVIEW, + ); + }); + }); + + describe('when draft program fails validation independently', () => { + const activeProgramId = createDetectionProgramId(uuidv4()); + const draftProgramId = createDetectionProgramId(uuidv4()); + + beforeEach(async () => { + const activeDetectionProgram = activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + detectionProgramVersion: activeProgramId, + detectionProgramDraftVersion: draftProgramId, + }); + + const activeProgram = detectionProgramFactory({ + id: activeProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + }); + + const draftProgram = detectionProgramFactory({ + id: draftProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + status: DetectionStatus.READY, + sourceCodeState: 'RAW', + }); + + const examples = [createMockExample('const x = 1;', 'var x = 1;')]; + + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + activeDetectionProgram, + ); + detectionProgramRepository.findById + .mockResolvedValueOnce(activeProgram) + .mockResolvedValueOnce(draftProgram); + standardsAdapter.getRuleCodeExamples.mockResolvedValue(examples); + + let callCount = 0; + executeLinterProgramsUseCase.execute.mockImplementation(async () => { + callCount++; + // First 2 calls are for active program (pass) + if (callCount <= 2) { + return { + file: + callCount === 1 + ? 'validation-negative-example' + : 'validation-positive-example', + violations: + callCount === 1 + ? [{ line: 1, character: 0, rule: 'test', standard: 'test' }] + : [], + } as ExecuteLinterProgramsResult; + } + // Third call is for draft program negative (fail) + return { + file: 'validation-negative-example', + violations: [], + } as ExecuteLinterProgramsResult; + }); + + updateDetectionProgramUseCase.execute.mockResolvedValue({ + ...draftProgram, + status: DetectionStatus.TO_REVIEW, + }); + + const command: UpdateDetectionProgramStatusCommand = { + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + organizationId: organizationId as unknown as string, + userId: organizationId as unknown as string, + }; + + await useCase.execute(command); + }); + + it('calls updateStatus twice', () => { + expect(detectionProgramRepository.updateStatus).toHaveBeenCalledTimes( + 2, + ); + }); + + it('updates active program status to READY', () => { + expect(detectionProgramRepository.updateStatus).toHaveBeenCalledWith( + activeProgramId, + DetectionStatus.READY, + ); + }); + + it('updates draft program status to TO_REVIEW', () => { + expect(detectionProgramRepository.updateStatus).toHaveBeenCalledWith( + draftProgramId, + DetectionStatus.TO_REVIEW, + ); + }); + }); + + describe('when both programs fail validation', () => { + const activeProgramId = createDetectionProgramId(uuidv4()); + const draftProgramId = createDetectionProgramId(uuidv4()); + + beforeEach(async () => { + const activeDetectionProgram = activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + detectionProgramVersion: activeProgramId, + detectionProgramDraftVersion: draftProgramId, + }); + + const activeProgram = detectionProgramFactory({ + id: activeProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + }); + + const draftProgram = detectionProgramFactory({ + id: draftProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + status: DetectionStatus.READY, + sourceCodeState: 'RAW', + }); + + const examples = [createMockExample('const x = 1;', 'var x = 1;')]; + + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + activeDetectionProgram, + ); + detectionProgramRepository.findById + .mockResolvedValueOnce(activeProgram) + .mockResolvedValueOnce(draftProgram); + standardsAdapter.getRuleCodeExamples.mockResolvedValue(examples); + + // Both programs fail negative validation (no violations found) + executeLinterProgramsUseCase.execute.mockResolvedValue({ + file: 'validation-negative-example', + violations: [], + }); + + detectionProgramRepository.updateStatus.mockResolvedValue(undefined); + + const command: UpdateDetectionProgramStatusCommand = { + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + organizationId: organizationId as unknown as string, + userId: organizationId as unknown as string, + }; + + await useCase.execute(command); + }); + + it('calls updateStatus twice', () => { + expect(detectionProgramRepository.updateStatus).toHaveBeenCalledTimes( + 2, + ); + }); + + it('updates active program status to TO_REVIEW', () => { + expect(detectionProgramRepository.updateStatus).toHaveBeenCalledWith( + activeProgramId, + DetectionStatus.TO_REVIEW, + ); + }); + + it('updates draft program status to TO_REVIEW', () => { + expect(detectionProgramRepository.updateStatus).toHaveBeenCalledWith( + draftProgramId, + DetectionStatus.TO_REVIEW, + ); + }); + }); + + describe('when program has sourceCodeState NONE', () => { + beforeEach(async () => { + const activeProgramId = createDetectionProgramId(uuidv4()); + + const activeDetectionProgram = activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + detectionProgramVersion: activeProgramId, + detectionProgramDraftVersion: null, + }); + + const activeProgram = detectionProgramFactory({ + id: activeProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + status: DetectionStatus.READY, + sourceCodeState: 'NONE', + }); + + const examples = [createMockExample('const x = 1;', 'var x = 1;')]; + + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + activeDetectionProgram, + ); + detectionProgramRepository.findById.mockResolvedValue(activeProgram); + standardsAdapter.getRuleCodeExamples.mockResolvedValue(examples); + + const command: UpdateDetectionProgramStatusCommand = { + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + organizationId: organizationId as unknown as string, + userId: organizationId as unknown as string, + }; + + await useCase.execute(command); + }); + + it('does not execute linter programs', () => { + expect(executeLinterProgramsUseCase.execute).not.toHaveBeenCalled(); + }); + + it('does not add detection program', () => { + expect(detectionProgramRepository.add).not.toHaveBeenCalled(); + }); + }); + + describe('when no examples exist for language', () => { + const activeProgramId = createDetectionProgramId(uuidv4()); + const draftProgramId = createDetectionProgramId(uuidv4()); + + beforeEach(async () => { + const activeDetectionProgram = activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + detectionProgramVersion: activeProgramId, + detectionProgramDraftVersion: draftProgramId, + }); + + const activeProgram = detectionProgramFactory({ + id: activeProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + }); + + const draftProgram = detectionProgramFactory({ + id: draftProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + status: DetectionStatus.READY, + sourceCodeState: 'RAW', + }); + + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + activeDetectionProgram, + ); + detectionProgramRepository.findById + .mockResolvedValueOnce(activeProgram) + .mockResolvedValueOnce(draftProgram); + standardsAdapter.getRuleCodeExamples.mockResolvedValue([]); + + detectionProgramRepository.updateStatus.mockResolvedValue(undefined); + + const command: UpdateDetectionProgramStatusCommand = { + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + organizationId: organizationId as unknown as string, + userId: organizationId as unknown as string, + }; + + await useCase.execute(command); + }); + + it('calls updateStatus twice', () => { + expect(detectionProgramRepository.updateStatus).toHaveBeenCalledTimes( + 2, + ); + }); + + it('updates active program status to TO_REVIEW', () => { + expect(detectionProgramRepository.updateStatus).toHaveBeenCalledWith( + activeProgramId, + DetectionStatus.TO_REVIEW, + ); + }); + + it('updates draft program status to TO_REVIEW', () => { + expect(detectionProgramRepository.updateStatus).toHaveBeenCalledWith( + draftProgramId, + DetectionStatus.TO_REVIEW, + ); + }); + + it('does not execute linter programs', () => { + expect(executeLinterProgramsUseCase.execute).not.toHaveBeenCalled(); + }); + }); + + describe('when active detection program is not found', () => { + beforeEach(() => { + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + }); + + it('resolves without error', async () => { + const command: UpdateDetectionProgramStatusCommand = { + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + organizationId: organizationId as unknown as string, + userId: organizationId as unknown as string, + }; + + await expect(useCase.execute(command)).resolves.not.toThrow(); + }); + + it('does not update detection program status', async () => { + const command: UpdateDetectionProgramStatusCommand = { + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + organizationId: organizationId as unknown as string, + userId: organizationId as unknown as string, + }; + + await useCase.execute(command); + + expect(detectionProgramRepository.updateStatus).not.toHaveBeenCalled(); + }); + }); + + describe('when rule is not found', () => { + it('throws an error', async () => { + const activeDetectionProgram = activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + activeDetectionProgram, + ); + standardsAdapter.getRule.mockResolvedValue(null); + + const command: UpdateDetectionProgramStatusCommand = { + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + organizationId: organizationId as unknown as string, + userId: organizationId as unknown as string, + }; + + await expect(useCase.execute(command)).rejects.toThrow( + RuleNotFoundError, + ); + }); + }); + + describe('when execution throws an error', () => { + it('propagates the error', async () => { + const activeProgramId = createDetectionProgramId(uuidv4()); + + const activeDetectionProgram = activeDetectionProgramFactory({ + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + detectionProgramVersion: activeProgramId, + detectionProgramDraftVersion: null, + }); + + const activeProgram = detectionProgramFactory({ + id: activeProgramId, + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + }); + + const examples = [createMockExample('const x = 1;', 'var x = 1;')]; + + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + activeDetectionProgram, + ); + detectionProgramRepository.findById.mockResolvedValue(activeProgram); + standardsAdapter.getRuleCodeExamples.mockResolvedValue(examples); + + const executionError = new Error('Execution failed'); + executeLinterProgramsUseCase.execute.mockRejectedValue(executionError); + + const command: UpdateDetectionProgramStatusCommand = { + ruleId, + language: ProgrammingLanguage.JAVASCRIPT, + organizationId: organizationId as unknown as string, + userId: organizationId as unknown as string, + }; + + await expect(useCase.execute(command)).rejects.toThrow( + 'Execution failed', + ); + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/updateDetectionProgramStatus/UpdateDetectionProgramStatusUseCase.ts b/packages/linter/src/application/useCases/updateDetectionProgramStatus/UpdateDetectionProgramStatusUseCase.ts new file mode 100644 index 000000000..cbe2f1b19 --- /dev/null +++ b/packages/linter/src/application/useCases/updateDetectionProgramStatus/UpdateDetectionProgramStatusUseCase.ts @@ -0,0 +1,397 @@ +import { PackmindLogger } from '@packmind/logger'; +import { IStandardsPort } from '@packmind/types'; +import { DetectionSeverity, DetectionStatus, RuleId } from '@packmind/types'; +import { + IExecuteLinterProgramsUseCase, + ExecuteLinterProgramsCommand, + ExecuteLinterProgramsResult, + LinterExecutionProgram, +} from '@packmind/types'; +import { ProgrammingLanguage } from '@packmind/types'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { + UpdateDetectionProgramStatusCommand, + IUpdateDetectionProgramStatusUseCase, + UpdateRuleDetectionAssessmentAfterUpdateResponse, + DetectionProgram, + ActiveDetectionProgram, +} from '@packmind/types'; +import { RuleNotFoundError } from '../../../domain/errors'; + +const origin = 'UpdateDetectionProgramStatusUseCase'; + +export class UpdateDetectionProgramStatusUseCase implements IUpdateDetectionProgramStatusUseCase { + constructor( + private readonly repositories: ILinterRepositories, + private readonly standardsAdapter: IStandardsPort, + private readonly executeLinterProgramsUseCase: IExecuteLinterProgramsUseCase, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: UpdateDetectionProgramStatusCommand, + ): Promise<UpdateRuleDetectionAssessmentAfterUpdateResponse> { + this.logger.info('Updating detection program status', { + ruleId: command.ruleId, + language: command.language, + }); + + // Get ActiveDetectionProgram by ruleId and language + const activeDetectionProgram = await this.repositories + .getActiveDetectionProgramRepository() + .findByRuleIdAndLanguage(command.ruleId, command.language); + + if (!activeDetectionProgram) { + this.logger.info( + 'No active detection program found, skipping validation', + { + ruleId: command.ruleId, + language: command.language, + }, + ); + return { + action: 'STATUS_UPDATED', + message: `0 programs have been updated`, + }; + } + + // Verify rule exists + const rule = await this.standardsAdapter.getRule(command.ruleId); + if (!rule) { + throw new RuleNotFoundError(command.ruleId); + } + + // Fetch rule examples and filter by language + const allRuleExamples = await this.standardsAdapter.getRuleCodeExamples( + command.ruleId, + ); + + const filteredExamples = allRuleExamples.filter( + (example) => example.lang === command.language, + ); + + // Collect programs first + const programsToValidate = await this.collectProgramsToValidate( + activeDetectionProgram, + ); + + this.logger.info('Programs collected for validation', { + ruleId: command.ruleId, + language: command.language, + programCount: programsToValidate.length, + hasActiveProgram: !!activeDetectionProgram.detectionProgramVersion, + hasDraftProgram: !!activeDetectionProgram.detectionProgramDraftVersion, + }); + + // If no examples exist, mark all programs as TO_REVIEW + if (filteredExamples.length === 0) { + this.logger.info('No examples found for language', { + ruleId: command.ruleId, + language: command.language, + programCount: programsToValidate.length, + }); + await this.updateProgramsToReviewDueToMissingExamples( + programsToValidate, + command.ruleId, + command.language, + ); + return { + action: 'STATUS_UPDATED', + message: 'Update status to TO-REVIEW', + }; + } + + this.logger.info('Starting validation of programs against examples', { + ruleId: command.ruleId, + language: command.language, + programCount: programsToValidate.length, + exampleCount: filteredExamples.length, + }); + + // Validate all programs in parallel and count updates + const updateResults = await Promise.all( + programsToValidate.map((program) => + this.validateAndUpdateProgram(program, filteredExamples), + ), + ); + + const programsUpdated = updateResults.filter((updated) => updated).length; + + this.logger.info('Detection program status update completed', { + ruleId: command.ruleId, + language: command.language, + programCount: programsToValidate.length, + programsUpdated, + }); + + return { + action: 'STATUS_UPDATED', + message: `${programsUpdated} successfully updated`, + }; + } + + public async collectProgramsToValidate( + activeDetectionProgram: ActiveDetectionProgram, + ): Promise<DetectionProgram[]> { + const programsToValidate: DetectionProgram[] = []; + + if (activeDetectionProgram.detectionProgramVersion) { + const activeProgram = await this.repositories + .getDetectionProgramRepository() + .findById(activeDetectionProgram.detectionProgramVersion); + + if (activeProgram) { + programsToValidate.push(activeProgram); + } + } + + if (activeDetectionProgram.detectionProgramDraftVersion) { + const draftProgram = await this.repositories + .getDetectionProgramRepository() + .findById(activeDetectionProgram.detectionProgramDraftVersion); + + if (draftProgram) { + programsToValidate.push(draftProgram); + } + } + + return programsToValidate; + } + + public async updateProgramsToReviewDueToMissingExamples( + programs: DetectionProgram[], + ruleId: RuleId, + language: ProgrammingLanguage, + ): Promise<void> { + this.logger.info( + 'No examples found for language, marking programs as TO_REVIEW', + { + ruleId, + language, + programCount: programs.length, + }, + ); + + await Promise.all( + programs.map((program) => this.updateProgramToReview(program)), + ); + + this.logger.info('Programs marked as TO_REVIEW due to missing examples', { + ruleId, + language, + }); + } + + private async validateAndUpdateProgram( + program: DetectionProgram, + examples: Array<{ positive: string; negative: string }>, + ): Promise<boolean> { + this.logger.info('Validating detection program', { + programId: program.id, + ruleId: program.ruleId, + language: program.language, + version: program.version, + sourceCodeState: program.sourceCodeState, + currentStatus: program.status, + }); + + // Skip programs with sourceCodeState 'NONE' + if (program.sourceCodeState === 'NONE') { + this.logger.info('Skipping program with sourceCodeState NONE', { + programId: program.id, + ruleId: program.ruleId, + }); + return false; + } + + this.logger.info('Checking negative examples', { + programId: program.id, + exampleCount: examples.length, + }); + + const negativeValidationFailed = await this.checkNegativeExamples( + program, + examples, + ); + + if (negativeValidationFailed) { + this.logger.info( + 'Negative validation failed, marking program as TO_REVIEW', + { + programId: program.id, + ruleId: program.ruleId, + }, + ); + await this.updateProgramToReview(program); + return true; + } + + this.logger.info( + 'Negative examples validated successfully, checking positive examples', + { + programId: program.id, + }, + ); + + const positiveValidationFailed = await this.checkPositiveExamples( + program, + examples, + ); + + if (positiveValidationFailed) { + this.logger.info( + 'Positive validation failed, marking program as TO_REVIEW', + { + programId: program.id, + ruleId: program.ruleId, + }, + ); + await this.updateProgramToReview(program); + return true; + } + + this.logger.info('Program validation completed successfully', { + programId: program.id, + ruleId: program.ruleId, + status: 'VALID', + }); + + await this.updateProgramToSuccess(program); + return true; + } + + private async checkNegativeExamples( + program: DetectionProgram, + examples: Array<{ positive: string; negative: string }>, + ): Promise<boolean> { + return this.checkExamples( + program, + examples, + 'negative', + 'validation-negative-example', + (violations) => violations.length === 0, + ); + } + + private async checkPositiveExamples( + program: DetectionProgram, + examples: Array<{ positive: string; negative: string }>, + ): Promise<boolean> { + return this.checkExamples( + program, + examples, + 'positive', + 'validation-positive-example', + (violations) => violations.length > 0, + ); + } + + private async checkExamples( + program: DetectionProgram, + examples: Array<{ positive: string; negative: string }>, + exampleType: 'positive' | 'negative', + filePath: string, + shouldFail: (violations: unknown[]) => boolean, + ): Promise<boolean> { + this.logger.info(`Checking ${exampleType} examples`, { + programId: program.id, + exampleCount: examples.length, + exampleType, + }); + + for (let i = 0; i < examples.length; i++) { + const example = examples[i]; + const content = example[exampleType]; + const result = await this.executeProgram(program, content, filePath); + + if (shouldFail(result.violations)) { + this.logger.info(`${exampleType} example validation failed`, { + programId: program.id, + exampleIndex: i + 1, + expectedViolations: exampleType === 'negative', + foundViolations: result.violations.length > 0, + violationCount: result.violations.length, + }); + return true; + } + } + + this.logger.info(`All ${exampleType} examples passed validation`, { + programId: program.id, + exampleCount: examples.length, + exampleType, + }); + + return false; + } + + private async executeProgram( + program: DetectionProgram, + fileContent: string, + filePath: string, + ): Promise<ExecuteLinterProgramsResult> { + const command: ExecuteLinterProgramsCommand = { + filePath, + fileContent, + language: program.language, + programs: [this.createLinterExecutionProgram(program)], + }; + + return await this.executeLinterProgramsUseCase.execute(command); + } + + private async updateProgramToReview( + program: DetectionProgram, + ): Promise<void> { + this.logger.info('Updating program status to TO_REVIEW', { + programId: program.id, + ruleId: program.ruleId, + language: program.language, + currentStatus: program.status, + newStatus: DetectionStatus.TO_REVIEW, + }); + + await this.repositories + .getDetectionProgramRepository() + .updateStatus(program.id, DetectionStatus.TO_REVIEW); + + this.logger.info('Program status updated to TO_REVIEW successfully', { + programId: program.id, + ruleId: program.ruleId, + }); + } + + private async updateProgramToSuccess( + program: DetectionProgram, + ): Promise<void> { + this.logger.info('Updating program status to SUCCESS', { + programId: program.id, + ruleId: program.ruleId, + language: program.language, + currentStatus: program.status, + newStatus: DetectionStatus.READY, + }); + + await this.repositories + .getDetectionProgramRepository() + .updateStatus(program.id, DetectionStatus.READY); + + this.logger.info('Program status updated to SUCCESS successfully', { + programId: program.id, + ruleId: program.ruleId, + }); + } + + private createLinterExecutionProgram( + program: DetectionProgram, + ): LinterExecutionProgram { + return { + standardSlug: 'validation-standard', + ruleContent: 'validation-rule', + code: program.code, + sourceCodeState: program.sourceCodeState as 'AST' | 'RAW', + language: program.language, + severity: DetectionSeverity.ERROR, + }; + } +} diff --git a/packages/linter/src/application/useCases/updateRuleDetectionHeuristics/UpdateRuleDetectionHeuristicsUseCase.spec.ts b/packages/linter/src/application/useCases/updateRuleDetectionHeuristics/UpdateRuleDetectionHeuristicsUseCase.spec.ts new file mode 100644 index 000000000..e17d6ae71 --- /dev/null +++ b/packages/linter/src/application/useCases/updateRuleDetectionHeuristics/UpdateRuleDetectionHeuristicsUseCase.spec.ts @@ -0,0 +1,633 @@ +import { UpdateRuleDetectionHeuristicsUseCase } from './UpdateRuleDetectionHeuristicsUseCase'; +import { IRuleDetectionHeuristicsRepository } from '../../../domain/repositories/IRuleDetectionHeuristicsRepository'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { + createDetectionHeuristicsId, + createRuleId, + createOrganizationId, + createUserId, + DetectionHeuristics, + ProgrammingLanguage, + UpdateRuleDetectionHeuristicsCommand, + IStandardsPort, + ILinterPort, + Rule, + RuleDetectionAssessment, +} from '@packmind/types'; +import { PackmindLogger } from '@packmind/logger'; +import { stubLogger } from '@packmind/test-utils'; +import { v4 as uuidv4 } from 'uuid'; + +// Mock SSEEventPublisher +jest.mock('@packmind/node-utils', () => ({ + ...jest.requireActual('@packmind/node-utils'), + SSEEventPublisher: { + publishDetectionHeuristicsUpdatedEvent: jest.fn(), + }, +})); + +describe('UpdateRuleDetectionHeuristicsUseCase', () => { + let useCase: UpdateRuleDetectionHeuristicsUseCase; + let heuristicsRepository: jest.Mocked<IRuleDetectionHeuristicsRepository>; + let linterRepositories: jest.Mocked<ILinterRepositories>; + let standardsAdapter: jest.Mocked<IStandardsPort>; + let linterAdapter: jest.Mocked<ILinterPort>; + let getLinterAdapter: jest.Mock<ILinterPort, []>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + + beforeEach(() => { + heuristicsRepository = { + getHeuristicsById: jest.fn(), + updateHeuristics: jest.fn(), + upsertHeuristics: jest.fn(), + getHeuristicsForRule: jest.fn(), + } as unknown as jest.Mocked<IRuleDetectionHeuristicsRepository>; + + linterRepositories = { + getRuleDetectionHeuristicsRepository: jest + .fn() + .mockReturnValue(heuristicsRepository), + getRuleDetectionAssessmentRepository: jest.fn(), + getDetectionProgramRepository: jest.fn(), + getActiveDetectionProgramRepository: jest.fn(), + getDetectionProgramMetadataRepository: jest.fn(), + } as unknown as jest.Mocked<ILinterRepositories>; + + standardsAdapter = { + getRule: jest.fn(), + getRuleCodeExamples: jest.fn(), + } as unknown as jest.Mocked<IStandardsPort>; + + linterAdapter = { + startRuleDetectionAssessment: jest.fn(), + updateHeuristicsFollowingChatbotInput: jest + .fn() + .mockResolvedValue({ newHeuristic: 'generated heuristic' }), + } as unknown as jest.Mocked<ILinterPort>; + + getLinterAdapter = jest.fn().mockReturnValue(linterAdapter); + + stubbedLogger = stubLogger(); + + useCase = new UpdateRuleDetectionHeuristicsUseCase( + linterRepositories, + standardsAdapter, + getLinterAdapter, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when updating heuristics successfully', () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const userId = createUserId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + let existingHeuristics: DetectionHeuristics; + let updatedHeuristics: DetectionHeuristics; + let command: UpdateRuleDetectionHeuristicsCommand; + let mockRule: Rule; + + beforeEach(() => { + existingHeuristics = { + id: heuristicsId, + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['old heuristics'], + }; + + updatedHeuristics = { + ...existingHeuristics, + heuristics: ['new heuristics'], + }; + + mockRule = { + id: ruleId, + content: 'Test rule content', + } as Rule; + + heuristicsRepository.getHeuristicsById + .mockResolvedValueOnce(existingHeuristics) + .mockResolvedValueOnce(updatedHeuristics); + heuristicsRepository.updateHeuristics.mockResolvedValue(); + standardsAdapter.getRule.mockResolvedValue(mockRule); + linterAdapter.startRuleDetectionAssessment.mockResolvedValue( + {} as RuleDetectionAssessment, + ); + + command = { + userId, + organizationId, + detectionHeuristicsId: heuristicsId, + heuristics: ['new heuristics'], + }; + }); + + it('retrieves existing heuristics by id', async () => { + await useCase.execute(command); + + expect(heuristicsRepository.getHeuristicsById).toHaveBeenCalledWith( + heuristicsId, + ); + }); + + it('calls update with correct parameters', async () => { + await useCase.execute(command); + + expect(heuristicsRepository.updateHeuristics).toHaveBeenCalledWith( + heuristicsId, + ['new heuristics'], + ); + }); + + it('returns updated detection heuristics', async () => { + const result = await useCase.execute(command); + + expect(result.detectionHeuristics).toEqual(updatedHeuristics); + }); + + it('fetches the rule for assessment', async () => { + await useCase.execute(command); + + expect(standardsAdapter.getRule).toHaveBeenCalledWith(ruleId); + }); + + it('triggers rule detection assessment with correct parameters', async () => { + await useCase.execute(command); + + expect(linterAdapter.startRuleDetectionAssessment).toHaveBeenCalledWith({ + rule: mockRule, + organizationId, + userId, + language: ProgrammingLanguage.TYPESCRIPT, + }); + }); + }); + + describe('when detection heuristics not found', () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + let command: UpdateRuleDetectionHeuristicsCommand; + + beforeEach(() => { + heuristicsRepository.getHeuristicsById.mockResolvedValue(null); + + command = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + detectionHeuristicsId: heuristicsId, + heuristics: ['new heuristics'], + }; + }); + + it('throws error indicating heuristics not found', async () => { + await expect(useCase.execute(command)).rejects.toThrow( + `Detection heuristics with id ${heuristicsId} not found`, + ); + }); + + it('does not call update', async () => { + try { + await useCase.execute(command); + } catch { + // Expected to throw + } + + expect(heuristicsRepository.updateHeuristics).not.toHaveBeenCalled(); + }); + }); + + describe('when updating empty heuristics', () => { + it('allows updating to empty string', async () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const existingHeuristics: DetectionHeuristics = { + id: heuristicsId, + ruleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['existing heuristics'], + }; + + const updatedHeuristics: DetectionHeuristics = { + ...existingHeuristics, + heuristics: [], + }; + + const mockRule: Rule = { + id: ruleId, + content: 'Test rule content', + } as Rule; + + heuristicsRepository.getHeuristicsById + .mockResolvedValueOnce(existingHeuristics) + .mockResolvedValueOnce(updatedHeuristics); + heuristicsRepository.updateHeuristics.mockResolvedValue(); + standardsAdapter.getRule.mockResolvedValue(mockRule); + linterAdapter.startRuleDetectionAssessment.mockResolvedValue( + {} as RuleDetectionAssessment, + ); + + const command: UpdateRuleDetectionHeuristicsCommand = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + detectionHeuristicsId: heuristicsId, + heuristics: [], + }; + + const result = await useCase.execute(command); + + expect(result.detectionHeuristics.heuristics).toEqual([]); + }); + }); + + describe('when retrieval after update fails', () => { + it('throws error indicating retrieval failure', async () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const existingHeuristics: DetectionHeuristics = { + id: heuristicsId, + ruleId, + language: ProgrammingLanguage.JAVA, + heuristics: ['old heuristics'], + }; + + const mockRule: Rule = { + id: ruleId, + content: 'Test rule content', + } as Rule; + + heuristicsRepository.getHeuristicsById + .mockResolvedValueOnce(existingHeuristics) + .mockResolvedValueOnce(null); + heuristicsRepository.updateHeuristics.mockResolvedValue(); + standardsAdapter.getRule.mockResolvedValue(mockRule); + linterAdapter.startRuleDetectionAssessment.mockResolvedValue( + {} as RuleDetectionAssessment, + ); + + const command: UpdateRuleDetectionHeuristicsCommand = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + detectionHeuristicsId: heuristicsId, + heuristics: ['new heuristics'], + }; + + await expect(useCase.execute(command)).rejects.toThrow( + 'Failed to retrieve updated heuristics', + ); + }); + }); + + describe('when heuristics property preserves other fields', () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + let existingHeuristics: DetectionHeuristics; + let updatedHeuristics: DetectionHeuristics; + let command: UpdateRuleDetectionHeuristicsCommand; + let mockRule: Rule; + + beforeEach(() => { + existingHeuristics = { + id: heuristicsId, + ruleId, + language: ProgrammingLanguage.GO, + heuristics: ['old heuristics'], + }; + + updatedHeuristics = { + ...existingHeuristics, + heuristics: ['new heuristics'], + }; + + mockRule = { + id: ruleId, + content: 'Test rule content', + } as Rule; + + heuristicsRepository.getHeuristicsById + .mockResolvedValueOnce(existingHeuristics) + .mockResolvedValueOnce(updatedHeuristics); + heuristicsRepository.updateHeuristics.mockResolvedValue(); + standardsAdapter.getRule.mockResolvedValue(mockRule); + linterAdapter.startRuleDetectionAssessment.mockResolvedValue( + {} as RuleDetectionAssessment, + ); + + command = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + detectionHeuristicsId: heuristicsId, + heuristics: ['new heuristics'], + }; + }); + + it('preserves ruleId field', async () => { + const result = await useCase.execute(command); + + expect(result.detectionHeuristics.ruleId).toBe(ruleId); + }); + + it('preserves language field', async () => { + const result = await useCase.execute(command); + + expect(result.detectionHeuristics.language).toBe(ProgrammingLanguage.GO); + }); + }); + + describe('when rule not found for assessment', () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + let existingHeuristics: DetectionHeuristics; + let updatedHeuristics: DetectionHeuristics; + let command: UpdateRuleDetectionHeuristicsCommand; + + beforeEach(() => { + existingHeuristics = { + id: heuristicsId, + ruleId, + language: ProgrammingLanguage.PYTHON, + heuristics: ['old heuristics'], + }; + + updatedHeuristics = { + ...existingHeuristics, + heuristics: ['new heuristics'], + }; + + heuristicsRepository.getHeuristicsById + .mockResolvedValueOnce(existingHeuristics) + .mockResolvedValueOnce(updatedHeuristics); + heuristicsRepository.updateHeuristics.mockResolvedValue(); + standardsAdapter.getRule.mockResolvedValue(null); + + command = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + detectionHeuristicsId: heuristicsId, + heuristics: ['new heuristics'], + }; + }); + + it('returns updated heuristics despite missing rule', async () => { + const result = await useCase.execute(command); + + expect(result.detectionHeuristics).toEqual(updatedHeuristics); + }); + + it('does not call startRuleDetectionAssessment', async () => { + await useCase.execute(command); + + expect(linterAdapter.startRuleDetectionAssessment).not.toHaveBeenCalled(); + }); + }); + + describe('when assessment fails', () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + let existingHeuristics: DetectionHeuristics; + let updatedHeuristics: DetectionHeuristics; + let command: UpdateRuleDetectionHeuristicsCommand; + let mockRule: Rule; + + beforeEach(() => { + existingHeuristics = { + id: heuristicsId, + ruleId, + language: ProgrammingLanguage.JAVA, + heuristics: ['old heuristics'], + }; + + updatedHeuristics = { + ...existingHeuristics, + heuristics: ['new heuristics'], + }; + + mockRule = { + id: ruleId, + content: 'Test rule content', + } as Rule; + + heuristicsRepository.getHeuristicsById + .mockResolvedValueOnce(existingHeuristics) + .mockResolvedValueOnce(updatedHeuristics); + heuristicsRepository.updateHeuristics.mockResolvedValue(); + standardsAdapter.getRule.mockResolvedValue(mockRule); + linterAdapter.startRuleDetectionAssessment.mockRejectedValue( + new Error('Assessment service unavailable'), + ); + + command = { + userId: createUserId(uuidv4()), + organizationId: createOrganizationId(uuidv4()), + detectionHeuristicsId: heuristicsId, + heuristics: ['new heuristics'], + }; + }); + + it('returns updated heuristics despite assessment failure', async () => { + const result = await useCase.execute(command); + + expect(result.detectionHeuristics).toEqual(updatedHeuristics); + }); + + it('does not throw error', async () => { + await expect(useCase.execute(command)).resolves.not.toThrow(); + }); + }); + + describe('when skipAssessmentTrigger is true', () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const userId = createUserId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + let existingHeuristics: DetectionHeuristics; + let updatedHeuristics: DetectionHeuristics; + let command: UpdateRuleDetectionHeuristicsCommand; + + beforeEach(() => { + existingHeuristics = { + id: heuristicsId, + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['old heuristics'], + }; + + updatedHeuristics = { + ...existingHeuristics, + heuristics: ['new heuristics'], + }; + + heuristicsRepository.getHeuristicsById + .mockResolvedValueOnce(existingHeuristics) + .mockResolvedValueOnce(updatedHeuristics); + heuristicsRepository.updateHeuristics.mockResolvedValue(); + + command = { + userId, + organizationId, + detectionHeuristicsId: heuristicsId, + heuristics: ['new heuristics'], + skipAssessmentTrigger: true, + }; + }); + + it('does not fetch rule for assessment', async () => { + await useCase.execute(command); + + expect(standardsAdapter.getRule).not.toHaveBeenCalled(); + }); + + it('does not start rule detection assessment', async () => { + await useCase.execute(command); + + expect(linterAdapter.startRuleDetectionAssessment).not.toHaveBeenCalled(); + }); + + it('returns updated heuristics', async () => { + const result = await useCase.execute(command); + + expect(result.detectionHeuristics).toEqual(updatedHeuristics); + }); + }); + + describe('when clarification question is provided', () => { + const heuristicsId = createDetectionHeuristicsId(uuidv4()); + const ruleId = createRuleId(uuidv4()); + const userId = createUserId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + let existingHeuristics: DetectionHeuristics; + let updatedHeuristics: DetectionHeuristics; + let command: UpdateRuleDetectionHeuristicsCommand; + let mockRule: Rule; + + beforeEach(() => { + existingHeuristics = { + id: heuristicsId, + ruleId, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['old heuristics'], + }; + + updatedHeuristics = { + ...existingHeuristics, + heuristics: ['old heuristics', 'generated heuristic'], + }; + + mockRule = { + id: ruleId, + content: 'Test rule content', + } as Rule; + + heuristicsRepository.getHeuristicsById + .mockResolvedValueOnce(existingHeuristics) + .mockResolvedValueOnce(updatedHeuristics); + heuristicsRepository.updateHeuristics.mockResolvedValue(); + standardsAdapter.getRule.mockResolvedValue(mockRule); + linterAdapter.startRuleDetectionAssessment.mockResolvedValue( + {} as RuleDetectionAssessment, + ); + + command = { + userId, + organizationId, + detectionHeuristicsId: heuristicsId, + heuristics: ['old heuristics'], + clarificationQuestion: { + question: 'What should we detect?', + answer: 'Variables declared with var', + }, + }; + }); + + describe('when question is empty', () => { + beforeEach(() => { + command.clarificationQuestion = { + question: '', + answer: 'test', + }; + }); + + it('does not call chatbot use case', async () => { + await useCase.execute(command); + + expect( + linterAdapter.updateHeuristicsFollowingChatbotInput, + ).not.toHaveBeenCalled(); + }); + + it('updates heuristics without generated content', async () => { + await useCase.execute(command); + + expect(heuristicsRepository.updateHeuristics).toHaveBeenCalledWith( + heuristicsId, + ['old heuristics'], + ); + }); + }); + + describe('when answer is empty', () => { + beforeEach(() => { + command.clarificationQuestion = { + question: 'test question', + answer: '', + }; + }); + + it('does not call chatbot use case', async () => { + await useCase.execute(command); + + expect( + linterAdapter.updateHeuristicsFollowingChatbotInput, + ).not.toHaveBeenCalled(); + }); + + it('updates heuristics without generated content', async () => { + await useCase.execute(command); + + expect(heuristicsRepository.updateHeuristics).toHaveBeenCalledWith( + heuristicsId, + ['old heuristics'], + ); + }); + }); + + it('appends generated heuristic to heuristics array', async () => { + await useCase.execute(command); + + expect(heuristicsRepository.updateHeuristics).toHaveBeenCalledWith( + heuristicsId, + ['old heuristics', 'generated heuristic'], + ); + }); + + it('returns updated heuristics with generated heuristic', async () => { + const result = await useCase.execute(command); + + expect(result.detectionHeuristics).toEqual(updatedHeuristics); + }); + + it('skips appending if generated heuristic is empty', async () => { + // Mock empty heuristic generation + linterAdapter.updateHeuristicsFollowingChatbotInput.mockResolvedValueOnce( + { newHeuristic: '' }, + ); + + heuristicsRepository.getHeuristicsById + .mockReset() + .mockResolvedValueOnce(existingHeuristics) + .mockResolvedValueOnce({ + ...existingHeuristics, + heuristics: ['old heuristics'], + }); + + await useCase.execute(command); + + expect(heuristicsRepository.updateHeuristics).toHaveBeenCalledWith( + heuristicsId, + ['old heuristics'], + ); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/updateRuleDetectionHeuristics/UpdateRuleDetectionHeuristicsUseCase.ts b/packages/linter/src/application/useCases/updateRuleDetectionHeuristics/UpdateRuleDetectionHeuristicsUseCase.ts new file mode 100644 index 000000000..d3ee4fc22 --- /dev/null +++ b/packages/linter/src/application/useCases/updateRuleDetectionHeuristics/UpdateRuleDetectionHeuristicsUseCase.ts @@ -0,0 +1,187 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + IUpdateRuleDetectionHeuristics, + UpdateRuleDetectionHeuristicsCommand, + UpdateRuleDetectionHeuristicsResponse, + IStandardsPort, + ILinterPort, + RuleId, + ProgrammingLanguage, + OrganizationId, + UserId, + createUserId, + createOrganizationId, + UpdateHeuristicsFollowingChatbotInputCommand, +} from '@packmind/types'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { SSEEventPublisher } from '@packmind/node-utils'; + +const origin = 'UpdateRuleDetectionHeuristicsUseCase'; + +export class UpdateRuleDetectionHeuristicsUseCase implements IUpdateRuleDetectionHeuristics { + constructor( + private readonly linterRepositories: ILinterRepositories, + private readonly standardsAdapter: IStandardsPort, + private readonly getLinterAdapter: () => ILinterPort, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: UpdateRuleDetectionHeuristicsCommand, + ): Promise<UpdateRuleDetectionHeuristicsResponse> { + this.logger.info('Updating rule detection heuristics', { + detectionHeuristicsId: command.detectionHeuristicsId, + }); + + const heuristicsRepo = + this.linterRepositories.getRuleDetectionHeuristicsRepository(); + + // Retrieve existing heuristics + const existingHeuristics = await heuristicsRepo.getHeuristicsById( + command.detectionHeuristicsId, + ); + + if (!existingHeuristics) { + const errorMessage = `Detection heuristics with id ${command.detectionHeuristicsId} not found`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + + // Check if clarification question is provided with valid values + if ( + command.clarificationQuestion && + command.clarificationQuestion.question?.trim() && + command.clarificationQuestion.answer?.trim() + ) { + const { question, answer } = command.clarificationQuestion; + + this.logger.info('Processing clarification question', { + detectionHeuristicsId: command.detectionHeuristicsId, + }); + + // Reuse the chatbot heuristic generation through the linter port + const chatbotCommand: UpdateHeuristicsFollowingChatbotInputCommand = { + detectionHeuristicsId: command.detectionHeuristicsId, + question, + answer, + userId: command.userId, + organizationId: command.organizationId, + }; + + const chatbotResponse = + await this.getLinterAdapter().updateHeuristicsFollowingChatbotInput( + chatbotCommand, + ); + + // Append the new heuristic to the command's heuristics array if not empty + if (chatbotResponse.newHeuristic?.trim()) { + command.heuristics = [ + ...command.heuristics, + chatbotResponse.newHeuristic, + ]; + this.logger.info('New heuristic generated and appended', { + detectionHeuristicsId: command.detectionHeuristicsId, + newHeuristic: chatbotResponse.newHeuristic, + }); + } + } + + // Update heuristics + await heuristicsRepo.updateHeuristics( + command.detectionHeuristicsId, + command.heuristics, + ); + + // Retrieve updated heuristics + const updatedHeuristics = await heuristicsRepo.getHeuristicsById( + command.detectionHeuristicsId, + ); + + if (!updatedHeuristics) { + throw new Error('Failed to retrieve updated heuristics'); + } + + this.logger.info('Rule detection heuristics updated successfully', { + detectionHeuristicsId: command.detectionHeuristicsId, + }); + + // Publish SSE event after successful update + try { + await SSEEventPublisher.publishDetectionHeuristicsUpdatedEvent( + existingHeuristics.ruleId, + existingHeuristics.language, + command.detectionHeuristicsId, + command.userId, + command.organizationId, + ); + this.logger.info('SSE event published for heuristics update', { + detectionHeuristicsId: command.detectionHeuristicsId, + ruleId: existingHeuristics.ruleId, + language: existingHeuristics.language, + }); + } catch (sseError) { + this.logger.error('Failed to publish SSE event for heuristics update', { + detectionHeuristicsId: command.detectionHeuristicsId, + error: sseError instanceof Error ? sseError.message : String(sseError), + }); + // Don't throw here - the main operation was successful + } + + // Trigger rule detection assessment after heuristics update (unless skipped) + if (!command.skipAssessmentTrigger) { + await this.triggerRuleDetectionAssessment( + existingHeuristics.ruleId, + existingHeuristics.language, + createOrganizationId(command.organizationId), + createUserId(command.userId), + ); + } + + return { + detectionHeuristics: updatedHeuristics, + }; + } + + private async triggerRuleDetectionAssessment( + ruleId: RuleId, + language: ProgrammingLanguage, + organizationId: OrganizationId, + userId: UserId, + ): Promise<void> { + try { + this.logger.info('Triggering rule detection assessment', { + ruleId, + language, + organizationId, + userId, + }); + + // Fetch the full rule + const rule = await this.standardsAdapter.getRule(ruleId); + if (!rule) { + this.logger.error('Rule not found for assessment', { ruleId }); + return; + } + + // Start the assessment + await this.getLinterAdapter().startRuleDetectionAssessment({ + rule, + organizationId, + userId, + language, + }); + + this.logger.info('Rule detection assessment triggered successfully', { + ruleId, + language, + }); + } catch (error) { + // Log error but don't throw - heuristics update should still succeed + this.logger.error('Failed to trigger rule detection assessment', { + ruleId, + language, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} diff --git a/packages/linter/src/application/useCases/updateRuleDetectionStatusAfterUpdate/UpdateRuleDetectionStatusAfterUpdateUseCase.spec.ts b/packages/linter/src/application/useCases/updateRuleDetectionStatusAfterUpdate/UpdateRuleDetectionStatusAfterUpdateUseCase.spec.ts new file mode 100644 index 000000000..6c3ad5987 --- /dev/null +++ b/packages/linter/src/application/useCases/updateRuleDetectionStatusAfterUpdate/UpdateRuleDetectionStatusAfterUpdateUseCase.spec.ts @@ -0,0 +1,454 @@ +import { UpdateRuleDetectionStatusAfterUpdateUseCase } from './UpdateRuleDetectionStatusAfterUpdateUseCase'; +import { PackmindLogger } from '@packmind/logger'; +import { createOrganizationId, createUserId } from '@packmind/types'; +import { + createRuleId, + ProgrammingLanguage, + IStandardsPort, + DetectionStatus, + ILinterPort, + ActiveDetectionProgram, + createActiveDetectionProgramId, + DetectionProgram, + DetectionModeEnum, + createDetectionProgramId, + createRuleDetectionAssessmentId, + RuleDetectionAssessmentStatus, +} from '@packmind/types'; +import { ruleFactory } from '@packmind/standards/test'; +import { stubLogger } from '@packmind/test-utils'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { IActiveDetectionProgramRepository } from '../../../domain/repositories/IActiveDetectionProgramRepository'; +import { IRuleDetectionAssessmentRepository } from '../../../domain/repositories/IRuleDetectionAssessmentRepository'; +import { IDetectionProgramRepository } from '../../../domain/repositories/IDetectionProgramRepository'; +import { v4 as uuidv4 } from 'uuid'; + +describe('UpdateRuleDetectionStatusAfterUpdateUseCase', () => { + let useCase: UpdateRuleDetectionStatusAfterUpdateUseCase; + let linterRepositories: jest.Mocked<ILinterRepositories>; + let standardsAdapter: jest.Mocked<IStandardsPort>; + let stubbedLogger: jest.Mocked<PackmindLogger>; + let activeDetectionProgramRepository: jest.Mocked<IActiveDetectionProgramRepository>; + let ruleDetectionAssessmentRepository: jest.Mocked<IRuleDetectionAssessmentRepository>; + let detectionProgramRepository: jest.Mocked<IDetectionProgramRepository>; + let linterAdapter: jest.Mocked<ILinterPort>; + + const ruleId = createRuleId(uuidv4()); + const organizationId = createOrganizationId(uuidv4()); + const userId = createUserId(uuidv4()); + const language = ProgrammingLanguage.TYPESCRIPT; + + beforeEach(() => { + activeDetectionProgramRepository = { + findByRuleIdAndLanguage: jest.fn(), + findByRuleId: jest.fn(), + findById: jest.fn(), + add: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + findByRuleIdWithPrograms: jest.fn(), + updateActiveDetectionProgram: jest.fn(), + deleteByRuleId: jest.fn(), + } as unknown as jest.Mocked<IActiveDetectionProgramRepository>; + + ruleDetectionAssessmentRepository = { + add: jest.fn().mockResolvedValue(undefined), + findById: jest.fn(), + get: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as unknown as jest.Mocked<IRuleDetectionAssessmentRepository>; + + detectionProgramRepository = { + findById: jest.fn(), + findByRuleId: jest.fn(), + findByRuleIdAndVersion: jest.fn(), + findByRuleIdAndLanguage: jest.fn(), + getLatestVersionByRuleIdAndLanguage: jest.fn(), + updateStatus: jest.fn(), + add: jest.fn(), + deleteById: jest.fn(), + restoreById: jest.fn(), + } as unknown as jest.Mocked<IDetectionProgramRepository>; + + linterRepositories = { + getActiveDetectionProgramRepository: jest + .fn() + .mockReturnValue(activeDetectionProgramRepository), + getRuleDetectionAssessmentRepository: jest + .fn() + .mockReturnValue(ruleDetectionAssessmentRepository), + getDetectionProgramRepository: jest + .fn() + .mockReturnValue(detectionProgramRepository), + getDetectionProgramMetadataRepository: jest.fn(), + getRuleDetectionHeuristicsRepository: jest.fn(), + } as unknown as jest.Mocked<ILinterRepositories>; + + standardsAdapter = { + getStandard: jest.fn(), + getRule: jest.fn(), + getStandardVersion: jest.fn(), + getStandardVersionById: jest.fn(), + listStandardVersions: jest.fn(), + getLatestRulesByStandardId: jest.fn(), + getRulesByStandardId: jest.fn(), + listStandardsBySpace: jest.fn(), + getRuleCodeExamples: jest.fn().mockResolvedValue([]), + findStandardBySlug: jest.fn(), + getLatestStandardVersion: jest.fn(), + }; + + linterAdapter = { + updateDetectionProgramStatus: jest.fn().mockResolvedValue({ + action: 'STATUS_UPDATED', + message: '', + }), + startRuleDetectionAssessment: jest.fn().mockResolvedValue({ + id: 'assessment-id', + ruleId, + language, + detectionMode: DetectionModeEnum.SINGLE_AST, + status: 'NOT_STARTED', + details: '', + }), + } as unknown as jest.Mocked<ILinterPort>; + + stubbedLogger = stubLogger(); + + useCase = new UpdateRuleDetectionStatusAfterUpdateUseCase( + linterRepositories, + standardsAdapter, + () => linterAdapter, + stubbedLogger, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when no active detection program exists', () => { + describe('when detection program exists', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + const detectionProgram: DetectionProgram = { + id: createDetectionProgramId(uuidv4()), + ruleId, + language, + version: 1, + code: 'test code', + status: DetectionStatus.FAILURE, + mode: DetectionModeEnum.REGEXP, + sourceCodeState: 'RAW', + }; + + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + detectionProgram, + ); + + result = await useCase.execute({ + ruleId, + organizationId, + userId, + language, + }); + }); + + it('returns NO_ACTION action', () => { + expect(result.action).toBe('NO_ACTION'); + }); + + it('returns detection program exists message', () => { + expect(result.message).toBe( + 'Detection program exists, no action taken', + ); + }); + + it('does not start rule detection assessment', () => { + expect( + linterAdapter.startRuleDetectionAssessment, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when no detection program exists', () => { + describe('when assessment already exists', () => { + describe('when non-FAILED assessment', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + const existingAssessment = { + id: createRuleDetectionAssessmentId(uuidv4()), + ruleId, + language, + detectionMode: DetectionModeEnum.SINGLE_AST, + status: RuleDetectionAssessmentStatus.SUCCESS, + details: 'Assessment completed', + clarificationQuestion: null, + clarificationAnswers: null, + }; + + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + ruleDetectionAssessmentRepository.get.mockResolvedValue( + existingAssessment, + ); + + result = await useCase.execute({ + ruleId, + organizationId, + userId, + language, + }); + }); + + it('returns NO_ACTION action', () => { + expect(result.action).toBe('NO_ACTION'); + }); + + it('returns assessment already exists message', () => { + expect(result.message).toBe( + 'Assessment already exists, no action taken', + ); + }); + + it('gets the assessment by rule id and language', () => { + expect(ruleDetectionAssessmentRepository.get).toHaveBeenCalledWith( + ruleId, + language, + ); + }); + + it('does not start rule detection assessment', () => { + expect( + linterAdapter.startRuleDetectionAssessment, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when FAILED assessment exists', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + let rule: ReturnType<typeof ruleFactory>; + + beforeEach(async () => { + const failedAssessment = { + id: createRuleDetectionAssessmentId(uuidv4()), + ruleId, + language, + detectionMode: DetectionModeEnum.SINGLE_AST, + status: RuleDetectionAssessmentStatus.FAILED, + details: 'Assessment failed: Not detectable', + clarificationQuestion: null, + clarificationAnswers: null, + }; + + rule = ruleFactory({ + id: ruleId, + content: 'Use const instead of var', + }); + + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + ruleDetectionAssessmentRepository.get.mockResolvedValue( + failedAssessment, + ); + standardsAdapter.getRule.mockResolvedValue(rule); + + result = await useCase.execute({ + ruleId, + organizationId, + userId, + language, + }); + }); + + it('returns ASSESSMENT_STARTED action', () => { + expect(result.action).toBe('ASSESSMENT_STARTED'); + }); + + it('returns failed assessment restarted message', () => { + expect(result.message).toBe('Failed assessment restarted'); + }); + + it('gets the assessment by rule id and language', () => { + expect(ruleDetectionAssessmentRepository.get).toHaveBeenCalledWith( + ruleId, + language, + ); + }); + + it('starts rule detection assessment with correct parameters', () => { + expect( + linterAdapter.startRuleDetectionAssessment, + ).toHaveBeenCalledWith({ + rule, + organizationId, + userId, + language, + }); + }); + }); + }); + + describe('when no assessment exists', () => { + describe('when rule exists', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + let rule: ReturnType<typeof ruleFactory>; + + beforeEach(async () => { + rule = ruleFactory({ + id: ruleId, + content: 'Use const instead of var', + }); + + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + ruleDetectionAssessmentRepository.get.mockResolvedValue(null); + standardsAdapter.getRule.mockResolvedValue(rule); + + result = await useCase.execute({ + ruleId, + organizationId, + userId, + language, + }); + }); + + it('returns ASSESSMENT_STARTED action', () => { + expect(result.action).toBe('ASSESSMENT_STARTED'); + }); + + it('returns new rule detection assessment started message', () => { + expect(result.message).toBe( + 'New rule detection assessment started', + ); + }); + + it('gets the assessment by rule id and language', () => { + expect(ruleDetectionAssessmentRepository.get).toHaveBeenCalledWith( + ruleId, + language, + ); + }); + + it('starts rule detection assessment with correct parameters', () => { + expect( + linterAdapter.startRuleDetectionAssessment, + ).toHaveBeenCalledWith({ + rule, + organizationId, + userId, + language, + }); + }); + }); + + describe('when rule not found', () => { + beforeEach(() => { + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + detectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + null, + ); + ruleDetectionAssessmentRepository.get.mockResolvedValue(null); + standardsAdapter.getRule.mockResolvedValue(null); + }); + + it('throws error', async () => { + await expect( + useCase.execute({ + ruleId, + organizationId, + userId, + language, + }), + ).rejects.toThrow(`Rule not found: ${ruleId}`); + }); + + it('does not start rule detection assessment', async () => { + try { + await useCase.execute({ + ruleId, + organizationId, + userId, + language, + }); + } catch { + // Expected to throw + } + + expect( + linterAdapter.startRuleDetectionAssessment, + ).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); + + describe('when active detection program exists', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + const activeProgram: ActiveDetectionProgram = { + id: createActiveDetectionProgramId(uuidv4()), + ruleId, + language, + detectionProgramVersion: null, + detectionProgramDraftVersion: null, + }; + + activeDetectionProgramRepository.findByRuleIdAndLanguage.mockResolvedValue( + activeProgram, + ); + + result = await useCase.execute({ + ruleId, + organizationId, + userId, + language, + }); + }); + + it('returns STATUS_UPDATED action', () => { + expect(result.action).toBe('STATUS_UPDATED'); + }); + + it('returns detection program status updated message', () => { + expect(result.message).toBe('Detection program status updated'); + }); + + it('finds active detection program by rule id and language', () => { + expect( + activeDetectionProgramRepository.findByRuleIdAndLanguage, + ).toHaveBeenCalledWith(ruleId, language); + }); + + it('updates detection program status with correct parameters', () => { + expect(linterAdapter.updateDetectionProgramStatus).toHaveBeenCalledWith({ + ruleId, + organizationId, + userId, + language, + }); + }); + }); +}); diff --git a/packages/linter/src/application/useCases/updateRuleDetectionStatusAfterUpdate/UpdateRuleDetectionStatusAfterUpdateUseCase.ts b/packages/linter/src/application/useCases/updateRuleDetectionStatusAfterUpdate/UpdateRuleDetectionStatusAfterUpdateUseCase.ts new file mode 100644 index 000000000..8c30712e4 --- /dev/null +++ b/packages/linter/src/application/useCases/updateRuleDetectionStatusAfterUpdate/UpdateRuleDetectionStatusAfterUpdateUseCase.ts @@ -0,0 +1,183 @@ +import { PackmindLogger } from '@packmind/logger'; +import { ILinterRepositories } from '../../../domain/repositories/ILinterRepositories'; +import { + UpdateRuleDetectionStatusAfterUpdateCommand, + UpdateRuleDetectionStatusAfterUpdateResponse, + IUpdateRuleDetectionStatusAfterUpdateUseCase, + ActiveDetectionProgram, + IStandardsPort, + ILinterPort, + RuleDetectionAssessmentStatus, +} from '@packmind/types'; + +const origin = 'UpdateRuleDetectionStatusAfterUpdateUseCase'; + +export class UpdateRuleDetectionStatusAfterUpdateUseCase implements IUpdateRuleDetectionStatusAfterUpdateUseCase { + constructor( + private readonly linterRepositories: ILinterRepositories, + private readonly standardsAdapter: IStandardsPort, + private readonly getLinterAdapter: () => ILinterPort, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async execute( + command: UpdateRuleDetectionStatusAfterUpdateCommand, + ): Promise<UpdateRuleDetectionStatusAfterUpdateResponse> { + this.logger.info('Updating rule detection status after rule update', { + ruleId: command.ruleId, + language: command.language, + organizationId: command.organizationId, + userId: command.userId, + }); + + // Step 1: Check if active detection program exists + const activeProgram = await this.linterRepositories + .getActiveDetectionProgramRepository() + .findByRuleIdAndLanguage(command.ruleId, command.language); + + if (activeProgram) { + return await this.updateDetectionProgramStatus(command, activeProgram); + } + + // Step 2: Check if any detection program exists (not just active) + const detectionProgram = await this.linterRepositories + .getDetectionProgramRepository() + .findByRuleIdAndLanguage(command.ruleId, command.language); + + if (detectionProgram) { + this.logger.info( + 'Detection program exists but is not active, no action taken', + { + ruleId: command.ruleId, + language: command.language, + detectionProgramId: detectionProgram.id, + status: detectionProgram.status, + }, + ); + return { + action: 'NO_ACTION', + message: 'Detection program exists, no action taken', + }; + } + + // Step 3: No detection program exists - start new assessment + return await this.startRuleDetectionAssessment(command); + } + + private async updateDetectionProgramStatus( + command: UpdateRuleDetectionStatusAfterUpdateCommand, + activeProgram: ActiveDetectionProgram, + ): Promise<UpdateRuleDetectionStatusAfterUpdateResponse> { + // Active program exists - update status + this.logger.info('Active detection program found, updating status', { + ruleId: command.ruleId, + language: command.language, + activeProgramId: activeProgram.id, + }); + + await this.getLinterAdapter().updateDetectionProgramStatus({ + ruleId: command.ruleId, + language: command.language, + organizationId: command.organizationId, + userId: command.userId, + }); + + this.logger.info('Detection program status updated successfully', { + ruleId: command.ruleId, + language: command.language, + }); + + return { + action: 'STATUS_UPDATED', + message: 'Detection program status updated', + }; + } + + private async startRuleDetectionAssessment( + command: UpdateRuleDetectionStatusAfterUpdateCommand, + ): Promise<UpdateRuleDetectionStatusAfterUpdateResponse> { + // No active program exists - check if assessment already exists + this.logger.info( + 'No active detection program found, checking for existing assessment', + { + ruleId: command.ruleId, + language: command.language, + }, + ); + + // Step 3.1: Check if assessment already exists for this rule and language + const existingAssessment = await this.linterRepositories + .getRuleDetectionAssessmentRepository() + .get(command.ruleId, command.language); + + if (existingAssessment) { + // If assessment has FAILED, restart it with updated examples + if (existingAssessment.status === RuleDetectionAssessmentStatus.FAILED) { + this.logger.info( + 'Failed assessment found, restarting assessment with updated examples', + { + ruleId: command.ruleId, + language: command.language, + assessmentId: existingAssessment.id, + previousStatus: existingAssessment.status, + }, + ); + // Continue to step 3.2 to restart the assessment + } else { + // For non-FAILED statuses, no action needed + this.logger.info( + 'Assessment already exists for this rule and language, no action taken', + { + ruleId: command.ruleId, + language: command.language, + assessmentId: existingAssessment.id, + assessmentStatus: existingAssessment.status, + }, + ); + return { + action: 'NO_ACTION', + message: 'Assessment already exists, no action taken', + }; + } + } + + // Step 3.2: No assessment exists or FAILED assessment - start/restart assessment + const action = existingAssessment ? 'Restarting' : 'Starting'; + this.logger.info(`${action} rule detection assessment`, { + ruleId: command.ruleId, + language: command.language, + isRestart: !!existingAssessment, + }); + + const rule = await this.standardsAdapter.getRule(command.ruleId); + if (!rule) { + this.logger.error('Rule not found', { + ruleId: command.ruleId, + }); + throw new Error(`Rule not found: ${command.ruleId}`); + } + + await this.getLinterAdapter().startRuleDetectionAssessment({ + rule, + organizationId: command.organizationId, + userId: command.userId, + language: command.language, + }); + + const successMessage = existingAssessment + ? 'Failed assessment restarted successfully' + : 'New rule detection assessment started successfully'; + this.logger.info(successMessage, { + ruleId: command.ruleId, + language: command.language, + isRestart: !!existingAssessment, + }); + + return { + action: 'ASSESSMENT_STARTED', + message: existingAssessment + ? 'Failed assessment restarted' + : 'New rule detection assessment started', + }; + } +} diff --git a/packages/linter/src/domain/entities/AIRequestEmitter.ts b/packages/linter/src/domain/entities/AIRequestEmitter.ts new file mode 100644 index 000000000..4b15cb75d --- /dev/null +++ b/packages/linter/src/domain/entities/AIRequestEmitter.ts @@ -0,0 +1,95 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + AI_RESPONSE_FORMAT, + AIPromptOptions, + AIService, + PromptConversation, + TokensUsedByOperation, +} from '@packmind/types'; +import { getErrorMessage } from '@packmind/node-utils'; + +const origin = 'AIRequestEmitter'; + +export default abstract class AIRequestEmitter { + private readonly ERROR_ICON: string = '🟥'; + private readonly _tokens: TokensUsedByOperation[] = []; + + constructor( + protected _taskId: string, + protected readonly _aiService: AIService, + protected readonly _logger = new PackmindLogger(origin), + ) {} + + public abstract getOperationType(): string; + + public async callAiProvider( + prompt: PromptConversation[], + options: AIPromptOptions = {}, + ) { + const MAX_RETRY = 2; + let i = 0; + + // Set default responseFormat to PLAIN_TEXT if not specified + const optionsWithDefaults: AIPromptOptions = { + responseFormat: AI_RESPONSE_FORMAT.PLAIN_TEXT, + ...options, + }; + + while (i <= MAX_RETRY) { + try { + const response = await this.getAiService().executePromptWithHistory( + prompt, + optionsWithDefaults, + ); + this._tokens.push({ + input: response.tokensUsed?.input || 0, + output: response.tokensUsed?.output || 0, + operation: this.getOperationType(), + }); + return response; + } catch (error) { + // It might be a temporary error, especially if we are hitting too many requests within a minute + // Most AI Providers have Token Per Minute limits + // So we will retry the request at most 3 times, and will wait 10 seconds between each retry + let message = `${this.ERROR_ICON} Error when calling AI Provider - ${getErrorMessage(error)}`; + this._logger.error( + `[${this._taskId}] Error when calling AI Provider ${message}`, + ); + if (hasInnerError(error)) { + //This display errors from Azure Open AI in case of content filtering + this._logger.error( + `[${this._taskId}] Inner Error : ${JSON.stringify(error.innererror)}`, + ); + message = `${message} ${JSON.stringify(error.innererror)}`; + } + if (i === MAX_RETRY) { + throw new Error( + `Multiple errors after requesting AI provider - ${message}`, + ); + } + i++; + } + } + } + + public tokensUsed(): TokensUsedByOperation[] { + return this._tokens.filter( + (token) => + !isNaN(token.input) && + !isNaN(token.output) && + (token.input > 0 || token.output > 0), + ); + } + + public getAiService(): AIService { + return this._aiService; + } +} + +function hasInnerError(tbd: unknown): tbd is { innererror: unknown } { + return ( + tbd !== null && + typeof tbd !== 'object' && + (tbd as { innererror: unknown })?.innererror !== undefined + ); +} diff --git a/packages/linter/src/domain/entities/FileToAnalyze.ts b/packages/linter/src/domain/entities/FileToAnalyze.ts new file mode 100644 index 000000000..3adb840a6 --- /dev/null +++ b/packages/linter/src/domain/entities/FileToAnalyze.ts @@ -0,0 +1,12 @@ +import { ProgrammingLanguage, RuleId } from '@packmind/types'; + +export type FileToAnalyze = { + sourceCode: string; + language: ProgrammingLanguage; + path: string; +}; + +export type FileWithApplicablePractices = { + file: FileToAnalyze; + ruleIds: RuleId[]; +}; diff --git a/packages/linter/src/domain/entities/index.ts b/packages/linter/src/domain/entities/index.ts new file mode 100644 index 000000000..5e879546b --- /dev/null +++ b/packages/linter/src/domain/entities/index.ts @@ -0,0 +1,34 @@ +export { + ActiveDetectionProgram, + ActiveDetectionProgramId, + ActiveDetectionProgramWithRelations, + LanguageDetectionPrograms, + createActiveDetectionProgramId, +} from '@packmind/types'; +export { + DetectionProgram, + DetectionProgramId, + DetectionModeEnum, + SourceCodeState, + createDetectionProgramId, +} from '@packmind/types'; +export { + RuleDetectionAssessment, + RuleDetectionAssessmentId, + RuleDetectionAssessmentStatus, + createRuleDetectionAssessmentId, +} from '@packmind/types'; +export { + DetectionProgramMetadata, + ExecutionLog, + ExecutionLogMetadata, +} from '@packmind/types'; +export { + DetectionHeuristics, + DetectionHeuristicsId, + AssessmentDetectionReadiness, + RuleFeasibility, + createDetectionHeuristicsId, +} from '@packmind/types'; + +export { DetectionProgramRuleInput } from '@packmind/types'; diff --git a/packages/linter/src/domain/errors/ActiveDetectionProgramNotFoundError.ts b/packages/linter/src/domain/errors/ActiveDetectionProgramNotFoundError.ts new file mode 100644 index 000000000..c3e16afe0 --- /dev/null +++ b/packages/linter/src/domain/errors/ActiveDetectionProgramNotFoundError.ts @@ -0,0 +1,8 @@ +export class ActiveDetectionProgramNotFoundError extends Error { + constructor(activeDetectionProgramId: string) { + super( + `Active detection program with id ${activeDetectionProgramId} not found`, + ); + this.name = 'ActiveDetectionProgramNotFoundError'; + } +} diff --git a/packages/linter/src/domain/errors/DetectionProgramNotFoundError.ts b/packages/linter/src/domain/errors/DetectionProgramNotFoundError.ts new file mode 100644 index 000000000..3d70989ff --- /dev/null +++ b/packages/linter/src/domain/errors/DetectionProgramNotFoundError.ts @@ -0,0 +1,6 @@ +export class DetectionProgramNotFoundError extends Error { + constructor(detectionProgramId: string) { + super(`Detection program with id ${detectionProgramId} not found`); + this.name = 'DetectionProgramNotFoundError'; + } +} diff --git a/packages/linter/src/domain/errors/InvalidDetectionProgramStatusError.ts b/packages/linter/src/domain/errors/InvalidDetectionProgramStatusError.ts new file mode 100644 index 000000000..7e0dbb043 --- /dev/null +++ b/packages/linter/src/domain/errors/InvalidDetectionProgramStatusError.ts @@ -0,0 +1,15 @@ +import { DetectionStatus } from '@packmind/types'; +import { DetectionProgramId } from '@packmind/types'; + +export class InvalidDetectionProgramStatusError extends Error { + constructor( + detectionProgramId: DetectionProgramId, + currentStatus: DetectionStatus, + requiredStatus: DetectionStatus, + ) { + super( + `Detection program ${detectionProgramId} cannot be promoted as active. Current status: ${currentStatus}, required status: ${requiredStatus}`, + ); + this.name = 'InvalidDetectionProgramStatusError'; + } +} diff --git a/packages/linter/src/domain/errors/RuleNotFoundError.ts b/packages/linter/src/domain/errors/RuleNotFoundError.ts new file mode 100644 index 000000000..78ff13c06 --- /dev/null +++ b/packages/linter/src/domain/errors/RuleNotFoundError.ts @@ -0,0 +1,8 @@ +import { RuleId } from '@packmind/types'; + +export class RuleNotFoundError extends Error { + constructor(ruleId: RuleId) { + super(`Rule with id ${ruleId} not found`); + this.name = 'RuleNotFoundError'; + } +} diff --git a/packages/linter/src/domain/errors/UnauthorizedTestProgramExecutionError.ts b/packages/linter/src/domain/errors/UnauthorizedTestProgramExecutionError.ts new file mode 100644 index 000000000..61787dced --- /dev/null +++ b/packages/linter/src/domain/errors/UnauthorizedTestProgramExecutionError.ts @@ -0,0 +1,8 @@ +export class UnauthorizedTestProgramExecutionError extends Error { + constructor(detectionProgramId: string, organizationId: string) { + super( + `Unauthorized to test detection program ${detectionProgramId} for organization ${organizationId}`, + ); + this.name = 'UnauthorizedTestProgramExecutionError'; + } +} diff --git a/packages/linter/src/domain/errors/index.ts b/packages/linter/src/domain/errors/index.ts new file mode 100644 index 000000000..491ac93eb --- /dev/null +++ b/packages/linter/src/domain/errors/index.ts @@ -0,0 +1,5 @@ +export * from './ActiveDetectionProgramNotFoundError'; +export * from './InvalidDetectionProgramStatusError'; +export * from './RuleNotFoundError'; +export * from './UnauthorizedTestProgramExecutionError'; +export * from './DetectionProgramNotFoundError'; diff --git a/packages/linter/src/domain/index.ts b/packages/linter/src/domain/index.ts new file mode 100644 index 000000000..c7bcc76c0 --- /dev/null +++ b/packages/linter/src/domain/index.ts @@ -0,0 +1,9 @@ +export { + AssessRuleDetectionInput, + AssessRuleDetectionOutput, + GenerateProgramInput, + GenerateProgramOutput, +} from '@packmind/types'; +export * from './entities'; +export * from './jobs/ILinterDelayedJobs'; +export type { ILinterPort } from '@packmind/types'; diff --git a/packages/linter/src/domain/jobs/ILinterDelayedJobs.ts b/packages/linter/src/domain/jobs/ILinterDelayedJobs.ts new file mode 100644 index 000000000..3950c3655 --- /dev/null +++ b/packages/linter/src/domain/jobs/ILinterDelayedJobs.ts @@ -0,0 +1,9 @@ +import { GenerateProgramDelayedJob } from '../../application/useCases/generateProgramUseCase/shared/GenerateProgramDelayedJob'; +import { AssessRuleDetectionDelayedJob } from '../../application/useCases/assessRuleDetection/shared/AssessRuleDetectionDelayedJob'; +import { MoveLinterArtefactsDelayedJob } from '../../application/useCases/moveLinterArtefactsToNewRules/shared/MoveLinterArtefactsDelayedJob'; + +export interface ILinterDelayedJobs { + generateProgramDelayedJob: GenerateProgramDelayedJob; + assessRuleDetectionDelayedJob: AssessRuleDetectionDelayedJob; + moveLinterArtefactsDelayedJob: MoveLinterArtefactsDelayedJob; +} diff --git a/packages/linter/src/domain/repositories/IActiveDetectionProgramRepository.ts b/packages/linter/src/domain/repositories/IActiveDetectionProgramRepository.ts new file mode 100644 index 000000000..7a5fbf968 --- /dev/null +++ b/packages/linter/src/domain/repositories/IActiveDetectionProgramRepository.ts @@ -0,0 +1,33 @@ +import { IRepository } from '@packmind/types'; +import { QueryOption } from '@packmind/types'; +import type { + ActiveDetectionProgram, + ActiveDetectionProgramId, + DetectionSeverity, + LanguageDetectionPrograms, +} from '@packmind/types'; +import { RuleId } from '@packmind/types'; + +export interface IActiveDetectionProgramRepository extends IRepository<ActiveDetectionProgram> { + findByRuleId( + ruleId: RuleId, + opts?: QueryOption, + ): Promise<ActiveDetectionProgram[]>; + findByRuleIdAndLanguage( + ruleId: RuleId, + language: string, + opts?: QueryOption, + ): Promise<ActiveDetectionProgram | null>; + findByRuleIdWithPrograms( + ruleId: RuleId, + opts?: QueryOption, + ): Promise<LanguageDetectionPrograms[]>; + updateActiveDetectionProgram( + activeDetectionProgram: ActiveDetectionProgram, + ): Promise<ActiveDetectionProgram>; + updateSeverity( + activeDetectionProgramId: ActiveDetectionProgramId, + severity: DetectionSeverity, + ): Promise<ActiveDetectionProgram>; + deleteByRuleId(ruleId: RuleId): Promise<void>; +} diff --git a/packages/linter/src/domain/repositories/IDetectionProgramMetadataRepository.ts b/packages/linter/src/domain/repositories/IDetectionProgramMetadataRepository.ts new file mode 100644 index 000000000..0beb4241a --- /dev/null +++ b/packages/linter/src/domain/repositories/IDetectionProgramMetadataRepository.ts @@ -0,0 +1,37 @@ +import { IRepository } from '@packmind/types'; +import { QueryOption, TokensUsed } from '@packmind/types'; +import type { + DetectionProgramId, + DetectionProgramMetadata, + ExecutionLog, +} from '@packmind/types'; + +export interface IDetectionProgramMetadataRepository extends IRepository<DetectionProgramMetadata> { + findByDetectionProgramId( + detectionProgramId: DetectionProgramId, + opts?: QueryOption, + ): Promise<DetectionProgramMetadata | null>; + + findByDetectionProgramIds( + detectionProgramIds: DetectionProgramId[], + ): Promise<DetectionProgramMetadata[]>; + + addLog( + log: ExecutionLog, + detectionProgramId: DetectionProgramId, + ): Promise<void>; + + updateProgramDescription( + programDescription: string, + detectionProgramId: DetectionProgramId, + ): Promise<void>; + + updateTokensUsed( + tokens: TokensUsed, + detectionProgramId: DetectionProgramId, + ): Promise<void>; + + softDeleteByDetectionProgramIds( + detectionProgramIds: DetectionProgramId[], + ): Promise<void>; +} diff --git a/packages/linter/src/domain/repositories/IDetectionProgramRepository.ts b/packages/linter/src/domain/repositories/IDetectionProgramRepository.ts new file mode 100644 index 000000000..222d86038 --- /dev/null +++ b/packages/linter/src/domain/repositories/IDetectionProgramRepository.ts @@ -0,0 +1,28 @@ +import { IRepository } from '@packmind/types'; +import { DetectionStatus } from '@packmind/types'; +import { QueryOption } from '@packmind/types'; +import type { DetectionProgram, DetectionProgramId } from '@packmind/types'; +import { RuleId } from '@packmind/types'; + +export interface IDetectionProgramRepository extends IRepository<DetectionProgram> { + findByRuleId(ruleId: RuleId, opts?: QueryOption): Promise<DetectionProgram[]>; + findByRuleIdAndVersion( + ruleId: RuleId, + version: number, + opts?: QueryOption, + ): Promise<DetectionProgram | null>; + findByRuleIdAndLanguage( + ruleId: RuleId, + language: string, + opts?: QueryOption, + ): Promise<DetectionProgram | null>; + getLatestVersionByRuleIdAndLanguage( + ruleId: RuleId, + language: string, + ): Promise<number>; + updateStatus( + detectionProgramId: DetectionProgramId, + status: DetectionStatus, + ): Promise<void>; + softDeleteByRuleId(ruleId: RuleId): Promise<void>; +} diff --git a/packages/linter/src/domain/repositories/ILinterRepositories.ts b/packages/linter/src/domain/repositories/ILinterRepositories.ts new file mode 100644 index 000000000..c9d794ccf --- /dev/null +++ b/packages/linter/src/domain/repositories/ILinterRepositories.ts @@ -0,0 +1,13 @@ +import { IActiveDetectionProgramRepository } from './IActiveDetectionProgramRepository'; +import { IDetectionProgramRepository } from './IDetectionProgramRepository'; +import { IDetectionProgramMetadataRepository } from './IDetectionProgramMetadataRepository'; +import { IRuleDetectionHeuristicsRepository } from './IRuleDetectionHeuristicsRepository'; +import { IRuleDetectionAssessmentRepository } from './IRuleDetectionAssessmentRepository'; + +export interface ILinterRepositories { + getActiveDetectionProgramRepository(): IActiveDetectionProgramRepository; + getDetectionProgramRepository(): IDetectionProgramRepository; + getDetectionProgramMetadataRepository(): IDetectionProgramMetadataRepository; + getRuleDetectionHeuristicsRepository(): IRuleDetectionHeuristicsRepository; + getRuleDetectionAssessmentRepository(): IRuleDetectionAssessmentRepository; +} diff --git a/packages/linter/src/domain/repositories/IRuleDetectionAssessmentRepository.ts b/packages/linter/src/domain/repositories/IRuleDetectionAssessmentRepository.ts new file mode 100644 index 000000000..a9ec58435 --- /dev/null +++ b/packages/linter/src/domain/repositories/IRuleDetectionAssessmentRepository.ts @@ -0,0 +1,14 @@ +import { IRepository } from '@packmind/types'; +import { RuleId } from '@packmind/types'; +import { ProgrammingLanguage } from '@packmind/types'; +import type { RuleDetectionAssessment } from '@packmind/types'; + +export interface IRuleDetectionAssessmentRepository extends IRepository<RuleDetectionAssessment> { + get( + ruleId: RuleId, + language: ProgrammingLanguage, + ): Promise<RuleDetectionAssessment | null>; + findByRuleId(ruleId: RuleId): Promise<RuleDetectionAssessment[]>; + update(assessment: RuleDetectionAssessment): Promise<RuleDetectionAssessment>; + softDeleteByRuleId(ruleId: RuleId): Promise<void>; +} diff --git a/packages/linter/src/domain/repositories/IRuleDetectionHeuristicsRepository.ts b/packages/linter/src/domain/repositories/IRuleDetectionHeuristicsRepository.ts new file mode 100644 index 000000000..2e5f730b2 --- /dev/null +++ b/packages/linter/src/domain/repositories/IRuleDetectionHeuristicsRepository.ts @@ -0,0 +1,28 @@ +import { + RuleId, + DetectionHeuristics, + ProgrammingLanguage, + DetectionHeuristicsId, +} from '@packmind/types'; + +export interface IRuleDetectionHeuristicsRepository { + upsertHeuristics(heuristic: DetectionHeuristics): Promise<void>; + + getHeuristicsForRule( + ruleId: RuleId, + language: ProgrammingLanguage, + ): Promise<DetectionHeuristics | null>; + + getAllHeuristicsForRule(ruleId: RuleId): Promise<DetectionHeuristics[]>; + + updateHeuristics( + id: DetectionHeuristicsId, + heuristics: string[], + ): Promise<void>; + + getHeuristicsById( + id: DetectionHeuristicsId, + ): Promise<DetectionHeuristics | null>; + + softDeleteByRuleId(ruleId: RuleId): Promise<void>; +} diff --git a/packages/linter/src/index.spec.ts b/packages/linter/src/index.spec.ts new file mode 100644 index 000000000..af19fbbb0 --- /dev/null +++ b/packages/linter/src/index.spec.ts @@ -0,0 +1,9 @@ +// TO BE REMOVED +describe('Linter Package', () => { + describe('Hello World Test', () => { + it('passes a basic hello world test', () => { + const message = 'Hello, World!'; + expect(message).toBe('Hello, World!'); + }); + }); +}); diff --git a/packages/linter/src/index.ts b/packages/linter/src/index.ts new file mode 100644 index 000000000..e8c6ac532 --- /dev/null +++ b/packages/linter/src/index.ts @@ -0,0 +1,5 @@ +export { LinterHexa, LinterHexaOpts } from './LinterHexa'; +export { LinterAdapter } from './application/adapter/LinterAdapter'; +export * from './domain'; +export { LinterModule } from './nest-api/linter/linter.module'; +export { linterSchemas } from './infra/schemas'; diff --git a/packages/linter/src/infra/AssessRuleDetectionJobFactory.ts b/packages/linter/src/infra/AssessRuleDetectionJobFactory.ts new file mode 100644 index 000000000..2ebb749a8 --- /dev/null +++ b/packages/linter/src/infra/AssessRuleDetectionJobFactory.ts @@ -0,0 +1,63 @@ +import { IJobFactory, IJobQueue, queueFactory } from '@packmind/node-utils'; +import { PackmindLogger } from '@packmind/logger'; +import { + AssessRuleDetectionInput, + IStandardsPort, + ILinterPort, + ILlmPort, +} from '@packmind/types'; +import { AssessRuleDetectionDelayedJob } from '../application/useCases/assessRuleDetection/shared/AssessRuleDetectionDelayedJob'; +import { ILinterRepositories } from '../domain/repositories/ILinterRepositories'; + +const origin = 'AssessRuleDetectionJobFactory'; + +export class AssessRuleDetectionJobFactory implements IJobFactory<AssessRuleDetectionInput> { + public delayedJob: AssessRuleDetectionDelayedJob | null = null; + + constructor( + private readonly linterRepositories: ILinterRepositories, + private readonly getStandardsAdapter: () => IStandardsPort, + private readonly getLinterAdapter: () => ILinterPort, + private readonly getLlmPort: () => ILlmPort, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async createQueue(): Promise<IJobQueue<AssessRuleDetectionInput>> { + this.logger.info('Creating AssessRuleDetection job queue'); + + this.delayedJob = new AssessRuleDetectionDelayedJob( + (listeners) => queueFactory(this.getQueueName(), listeners), + this.linterRepositories, + this.getStandardsAdapter, + this.getLinterAdapter, + this.getLlmPort, + ); + + // Wrap the delayed job to implement IJobQueue interface + return { + addJob: async (input: AssessRuleDetectionInput): Promise<string> => { + if (!this.delayedJob) { + throw new Error('Queue not initialized. Call initialize() first.'); + } + const jobId = await this.delayedJob.addJob(input); + return jobId; + }, + initialize: async (): Promise<void> => { + if (!this.delayedJob) { + throw new Error('DelayedJob not created. Call createQueue() first.'); + } + // Initialize the queue using the public initialize method + await this.delayedJob.initialize(); + this.logger.info('AssessRuleDetection queue initialized'); + }, + destroy: async (): Promise<void> => { + // AbstractAIDelayedJob doesn't expose destroy method + this.logger.info('AssessRuleDetection queue destroyed'); + }, + }; + } + + getQueueName(): string { + return 'assess-rule-detection'; + } +} diff --git a/packages/linter/src/infra/GenerateProgramJobFactory.ts b/packages/linter/src/infra/GenerateProgramJobFactory.ts new file mode 100644 index 000000000..1fddffec0 --- /dev/null +++ b/packages/linter/src/infra/GenerateProgramJobFactory.ts @@ -0,0 +1,66 @@ +import { IJobFactory, IJobQueue, queueFactory } from '@packmind/node-utils'; +import { PackmindLogger } from '@packmind/logger'; +import { + IStandardsPort, + ILinterAstPort, + ILinterPort, + ILlmPort, +} from '@packmind/types'; +import { GenerateProgramInput } from '../domain'; +import { GenerateProgramDelayedJob } from '../application/useCases/generateProgramUseCase/shared/GenerateProgramDelayedJob'; +import { ILinterRepositories } from '../domain/repositories/ILinterRepositories'; + +const origin = 'GenerateProgramJobFactory'; + +export class GenerateProgramJobFactory implements IJobFactory<GenerateProgramInput> { + public delayedJob: GenerateProgramDelayedJob | null = null; + + constructor( + private readonly linterRepositories: ILinterRepositories, + private readonly getStandardsAdapter: () => IStandardsPort, + private readonly getLinterAstAdapter: () => ILinterAstPort | null, + private readonly getLinterAdapter: () => ILinterPort, + private readonly getLlmPort: () => ILlmPort, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async createQueue(): Promise<IJobQueue<GenerateProgramInput>> { + this.logger.info('Creating GenerateProgram job queue'); + + this.delayedJob = new GenerateProgramDelayedJob( + (listeners) => queueFactory(this.getQueueName(), listeners), + this.linterRepositories, + this.getStandardsAdapter, + this.getLinterAstAdapter, + this.getLinterAdapter, + this.getLlmPort, + ); + + // Wrap the delayed job to implement IJobQueue interface + return { + addJob: async (input: GenerateProgramInput): Promise<string> => { + if (!this.delayedJob) { + throw new Error('Queue not initialized. Call initialize() first.'); + } + const jobId = await this.delayedJob.addJob(input); + return jobId; + }, + initialize: async (): Promise<void> => { + if (!this.delayedJob) { + throw new Error('DelayedJob not created. Call createQueue() first.'); + } + // Initialize the queue using the public initialize method + await this.delayedJob.initialize(); + this.logger.info('GenerateProgram queue initialized'); + }, + destroy: async (): Promise<void> => { + // AbstractAIDelayedJob doesn't expose destroy method + this.logger.info('GenerateProgram queue destroyed'); + }, + }; + } + + getQueueName(): string { + return 'generate-program'; + } +} diff --git a/packages/linter/src/infra/MoveLinterArtefactsJobFactory.ts b/packages/linter/src/infra/MoveLinterArtefactsJobFactory.ts new file mode 100644 index 000000000..1af42b81e --- /dev/null +++ b/packages/linter/src/infra/MoveLinterArtefactsJobFactory.ts @@ -0,0 +1,59 @@ +import { IJobFactory, IJobQueue, queueFactory } from '@packmind/node-utils'; +import { PackmindLogger } from '@packmind/logger'; +import type { + MoveLinterArtefactsToNewRulesCommand, + ILinterPort, +} from '@packmind/types'; +import { MoveLinterArtefactsDelayedJob } from '../application/useCases/moveLinterArtefactsToNewRules/shared/MoveLinterArtefactsDelayedJob'; +import { ILinterRepositories } from '../domain/repositories/ILinterRepositories'; + +const origin = 'MoveLinterArtefactsJobFactory'; + +export class MoveLinterArtefactsJobFactory implements IJobFactory<MoveLinterArtefactsToNewRulesCommand> { + public delayedJob: MoveLinterArtefactsDelayedJob | null = null; + + constructor( + private readonly linterRepositories: ILinterRepositories, + private readonly getLinterAdapter: () => ILinterPort, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async createQueue(): Promise< + IJobQueue<MoveLinterArtefactsToNewRulesCommand> + > { + this.logger.info('Creating MoveLinterArtefacts job queue'); + + this.delayedJob = new MoveLinterArtefactsDelayedJob( + (listeners) => queueFactory(this.getQueueName(), listeners), + this.linterRepositories, + this.getLinterAdapter, + ); + + // Wrap the delayed job to implement IJobQueue interface + return { + addJob: async ( + input: MoveLinterArtefactsToNewRulesCommand, + ): Promise<string> => { + if (!this.delayedJob) { + throw new Error('Queue not initialized. Call initialize() first.'); + } + const jobId = await this.delayedJob.addJob(input); + return jobId; + }, + initialize: async (): Promise<void> => { + if (!this.delayedJob) { + throw new Error('DelayedJob not created. Call createQueue() first.'); + } + await this.delayedJob.initialize(); + this.logger.info('MoveLinterArtefacts queue initialized'); + }, + destroy: async (): Promise<void> => { + this.logger.info('MoveLinterArtefacts queue destroyed'); + }, + }; + } + + getQueueName(): string { + return 'move-linter-artefacts'; + } +} diff --git a/packages/linter/src/infra/repositories/ActiveDetectionProgramRepository.spec.ts b/packages/linter/src/infra/repositories/ActiveDetectionProgramRepository.spec.ts new file mode 100644 index 000000000..6fc67e5da --- /dev/null +++ b/packages/linter/src/infra/repositories/ActiveDetectionProgramRepository.spec.ts @@ -0,0 +1,859 @@ +import { ActiveDetectionProgramRepository } from './ActiveDetectionProgramRepository'; +import { ActiveDetectionProgramSchema } from '../schemas/ActiveDetectionProgramSchema'; +import { DetectionProgramSchema } from '../schemas/DetectionProgramSchema'; +import { DataSource, Repository } from 'typeorm'; +import { makeTestDatasource, stubLogger } from '@packmind/test-utils'; +import { itHandlesSoftDelete } from '@packmind/test-utils'; +import { + ActiveDetectionProgram, + createActiveDetectionProgramId, + DetectionProgram, + createRuleId, + ProgrammingLanguage, + Rule, + Standard, + StandardVersion, +} from '@packmind/types'; +import { WithSoftDelete } from '@packmind/node-utils'; +import { RuleSchema } from '@packmind/standards/src/infra/schemas/RuleSchema'; +import { StandardSchema } from '@packmind/standards/src/infra/schemas/StandardSchema'; +import { StandardVersionSchema } from '@packmind/standards/src/infra/schemas/StandardVersionSchema'; +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { ruleFactory } from '@packmind/standards/test/ruleFactory'; +import { standardFactory } from '@packmind/standards/test/standardFactory'; +import { standardVersionFactory } from '@packmind/standards/test/standardVersionFactory'; +import { GitCommitSchema } from '@packmind/git'; +import { + activeDetectionProgramFactory, + detectionProgramFactory, +} from '../../../test'; + +describe('ActiveDetectionProgramRepository', () => { + let datasource: DataSource; + let activeDetectionProgramRepository: ActiveDetectionProgramRepository; + let stubbedLogger: jest.Mocked<PackmindLogger>; + let typeormRepo: Repository<ActiveDetectionProgram>; + let detectionProgramRepo: Repository<DetectionProgram>; + let ruleRepo: Repository<Rule>; + let standardRepo: Repository<Standard>; + let standardVersionRepo: Repository<StandardVersion>; + + const createRuleWithHierarchy = async (): Promise<Rule> => { + const standard = await standardRepo.save( + standardFactory({ slug: `standard-${uuidv4()}` }), + ); + const standardVersion = await standardVersionRepo.save( + standardVersionFactory({ standardId: standard.id }), + ); + return await ruleRepo.save( + ruleFactory({ standardVersionId: standardVersion.id }), + ); + }; + + beforeEach(async () => { + datasource = await makeTestDatasource([ + ActiveDetectionProgramSchema, + DetectionProgramSchema, + RuleSchema, + StandardSchema, + StandardVersionSchema, + GitCommitSchema, + ]); + await datasource.initialize(); + await datasource.synchronize(); + + stubbedLogger = stubLogger(); + typeormRepo = datasource.getRepository(ActiveDetectionProgramSchema); + detectionProgramRepo = datasource.getRepository(DetectionProgramSchema); + ruleRepo = datasource.getRepository(RuleSchema); + standardRepo = datasource.getRepository(StandardSchema); + standardVersionRepo = datasource.getRepository(StandardVersionSchema); + + activeDetectionProgramRepository = new ActiveDetectionProgramRepository( + typeormRepo, + stubbedLogger, + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await datasource.destroy(); + }); + + it('can store and retrieve active detection programs', async () => { + const rule = await createRuleWithHierarchy(); + const detectionProgram = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule.id }), + ); + + const activeProgram = activeDetectionProgramFactory({ + ruleId: rule.id, + detectionProgramVersion: detectionProgram.id, + }); + await activeDetectionProgramRepository.add(activeProgram); + + const foundProgram = await activeDetectionProgramRepository.findById( + activeProgram.id, + ); + expect(foundProgram).toMatchObject({ + id: activeProgram.id, + detectionProgramVersion: activeProgram.detectionProgramVersion, + ruleId: activeProgram.ruleId, + language: activeProgram.language, + }); + }); + + describe('when finding active detection programs by rule id', () => { + let rule1: Rule; + let rule2: Rule; + let activeProgram1: ActiveDetectionProgram; + let activeProgram2: ActiveDetectionProgram; + let activeProgram3: ActiveDetectionProgram; + let foundPrograms: ActiveDetectionProgram[]; + + beforeEach(async () => { + rule1 = await createRuleWithHierarchy(); + rule2 = await createRuleWithHierarchy(); + const detectionProgram1 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule1.id, version: 1 }), + ); + const detectionProgram2 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule1.id, version: 2 }), + ); + const detectionProgram3 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule2.id, version: 1 }), + ); + + activeProgram1 = activeDetectionProgramFactory({ + ruleId: rule1.id, + detectionProgramVersion: detectionProgram1.id, + language: ProgrammingLanguage.JAVASCRIPT, + }); + activeProgram2 = activeDetectionProgramFactory({ + ruleId: rule1.id, + detectionProgramVersion: detectionProgram2.id, + language: ProgrammingLanguage.TYPESCRIPT, + }); + activeProgram3 = activeDetectionProgramFactory({ + ruleId: rule2.id, + detectionProgramVersion: detectionProgram3.id, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + await activeDetectionProgramRepository.add(activeProgram1); + await activeDetectionProgramRepository.add(activeProgram2); + await activeDetectionProgramRepository.add(activeProgram3); + + foundPrograms = await activeDetectionProgramRepository.findByRuleId( + rule1.id, + ); + }); + + it('returns the correct number of programs for the rule', async () => { + expect(foundPrograms).toHaveLength(2); + }); + + it('includes the first program for the rule', async () => { + expect(foundPrograms.map((p: ActiveDetectionProgram) => p.id)).toContain( + activeProgram1.id, + ); + }); + + it('includes the second program for the rule', async () => { + expect(foundPrograms.map((p: ActiveDetectionProgram) => p.id)).toContain( + activeProgram2.id, + ); + }); + + it('excludes programs from other rules', async () => { + expect( + foundPrograms.map((p: ActiveDetectionProgram) => p.id), + ).not.toContain(activeProgram3.id); + }); + }); + + it('can find active detection program by rule id and language', async () => { + // Create rule and detection programs first + const rule = await createRuleWithHierarchy(); + const detectionProgram1 = await detectionProgramRepo.save( + detectionProgramFactory({ + ruleId: rule.id, + version: 1, + language: ProgrammingLanguage.JAVASCRIPT, + }), + ); + const detectionProgram2 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule.id, version: 2 }), + ); + + const activeProgram1 = activeDetectionProgramFactory({ + ruleId: rule.id, + detectionProgramVersion: detectionProgram1.id, + language: ProgrammingLanguage.JAVASCRIPT, + }); + const activeProgram2 = activeDetectionProgramFactory({ + ruleId: rule.id, + detectionProgramVersion: detectionProgram2.id, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + await activeDetectionProgramRepository.add(activeProgram1); + await activeDetectionProgramRepository.add(activeProgram2); + + const foundProgram = + await activeDetectionProgramRepository.findByRuleIdAndLanguage( + rule.id, + ProgrammingLanguage.TYPESCRIPT, + ); + + expect(foundProgram).toMatchObject({ + id: activeProgram2.id, + language: ProgrammingLanguage.TYPESCRIPT, + ruleId: rule.id, + }); + }); + + describe('when finding active detection programs with associated detection programs', () => { + let rule: Rule; + let detectionProgram: DetectionProgram; + let draftDetectionProgram: DetectionProgram; + let activeProgram: ActiveDetectionProgram; + let foundPrograms: ActiveDetectionProgram[]; + + beforeEach(async () => { + rule = await createRuleWithHierarchy(); + detectionProgram = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule.id, version: 1 }), + ); + draftDetectionProgram = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule.id, version: 2 }), + ); + + activeProgram = activeDetectionProgramFactory({ + ruleId: rule.id, + detectionProgramVersion: detectionProgram.id, + detectionProgramDraftVersion: draftDetectionProgram.id, + language: ProgrammingLanguage.JAVASCRIPT, + }); + await activeDetectionProgramRepository.add(activeProgram); + + foundPrograms = + await activeDetectionProgramRepository.findByRuleIdWithPrograms( + rule.id, + ); + }); + + it('returns the correct number of programs', async () => { + expect(foundPrograms).toHaveLength(1); + }); + + it('includes the associated detection program and draft', async () => { + expect(foundPrograms[0]).toMatchObject({ + id: activeProgram.id, + language: activeProgram.language, + ruleId: activeProgram.ruleId, + detectionProgram: { + id: detectionProgram.id, + code: detectionProgram.code, + version: detectionProgram.version, + mode: detectionProgram.mode, + }, + draftDetectionProgram: { + id: draftDetectionProgram.id, + code: draftDetectionProgram.code, + version: draftDetectionProgram.version, + mode: draftDetectionProgram.mode, + }, + }); + }); + }); + + describe('when deleting active detection programs by rule id', () => { + let activeProgram3: ActiveDetectionProgram; + let remainingPrograms: ActiveDetectionProgram[]; + + beforeEach(async () => { + const rule1 = await createRuleWithHierarchy(); + const rule2 = await createRuleWithHierarchy(); + const detectionProgram1 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule1.id, version: 1 }), + ); + const detectionProgram2 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule1.id, version: 2 }), + ); + const detectionProgram3 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule2.id, version: 1 }), + ); + + const activeProgram1 = activeDetectionProgramFactory({ + ruleId: rule1.id, + detectionProgramVersion: detectionProgram1.id, + language: ProgrammingLanguage.JAVASCRIPT, + }); + const activeProgram2 = activeDetectionProgramFactory({ + ruleId: rule1.id, + detectionProgramVersion: detectionProgram2.id, + language: ProgrammingLanguage.TYPESCRIPT, + }); + activeProgram3 = activeDetectionProgramFactory({ + ruleId: rule2.id, + detectionProgramVersion: detectionProgram3.id, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + await activeDetectionProgramRepository.add(activeProgram1); + await activeDetectionProgramRepository.add(activeProgram2); + await activeDetectionProgramRepository.add(activeProgram3); + + await activeDetectionProgramRepository.deleteByRuleId(rule1.id); + + remainingPrograms = await activeDetectionProgramRepository.list(); + }); + + it('removes programs for the specified rule', async () => { + expect(remainingPrograms).toHaveLength(1); + }); + + it('keeps programs from other rules', async () => { + expect(remainingPrograms[0].id).toBe(activeProgram3.id); + }); + }); + + it('can list all active detection programs', async () => { + // Create rules and detection programs first + const rule1 = await createRuleWithHierarchy(); + const rule2 = await createRuleWithHierarchy(); + const detectionProgram1 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule1.id, version: 1 }), + ); + const detectionProgram2 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule2.id, version: 1 }), + ); + + await activeDetectionProgramRepository.add( + activeDetectionProgramFactory({ + ruleId: rule1.id, + detectionProgramVersion: detectionProgram1.id, + }), + ); + await activeDetectionProgramRepository.add( + activeDetectionProgramFactory({ + ruleId: rule2.id, + detectionProgramVersion: detectionProgram2.id, + }), + ); + + const allPrograms = await activeDetectionProgramRepository.list(); + expect(allPrograms).toHaveLength(2); + }); + + describe('when finding non-existent active detection programs', () => { + it('returns null for non-existent id', async () => { + const foundProgram = await activeDetectionProgramRepository.findById( + createActiveDetectionProgramId(uuidv4()), + ); + expect(foundProgram).toBeNull(); + }); + + it('returns empty array for non-existent rule', async () => { + const foundPrograms = await activeDetectionProgramRepository.findByRuleId( + createRuleId(uuidv4()), + ); + expect(foundPrograms).toEqual([]); + }); + + it('returns null for non-existent rule id and language', async () => { + const foundProgram = + await activeDetectionProgramRepository.findByRuleIdAndLanguage( + createRuleId(uuidv4()), + ProgrammingLanguage.JAVASCRIPT, + ); + expect(foundProgram).toBeNull(); + }); + + it('returns empty array for non-existent rule with programs', async () => { + const foundPrograms = + await activeDetectionProgramRepository.findByRuleIdWithPrograms( + createRuleId(uuidv4()), + ); + expect(foundPrograms).toEqual([]); + }); + }); + + describe('when enforcing business rule constraints', () => { + it('prevents duplicate rule id and language combinations', async () => { + const rule = await createRuleWithHierarchy(); + const detectionProgram1 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule.id, version: 1 }), + ); + const detectionProgram2 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule.id, version: 2 }), + ); + + const activeProgram1 = activeDetectionProgramFactory({ + ruleId: rule.id, + detectionProgramVersion: detectionProgram1.id, + language: ProgrammingLanguage.JAVASCRIPT, + }); + const activeProgram2 = activeDetectionProgramFactory({ + ruleId: rule.id, + detectionProgramVersion: detectionProgram2.id, + language: ProgrammingLanguage.JAVASCRIPT, // Same language + }); + + await activeDetectionProgramRepository.add(activeProgram1); + + await expect( + activeDetectionProgramRepository.add(activeProgram2), + ).rejects.toThrow(); + }); + + describe('when same rule has different languages', () => { + let foundPrograms: ActiveDetectionProgram[]; + + beforeEach(async () => { + const rule = await createRuleWithHierarchy(); + const detectionProgram1 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule.id, version: 1 }), + ); + const detectionProgram2 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule.id, version: 2 }), + ); + + const javascriptActiveProgram = activeDetectionProgramFactory({ + ruleId: rule.id, + detectionProgramVersion: detectionProgram1.id, + language: ProgrammingLanguage.JAVASCRIPT, + }); + const typescriptActiveProgram = activeDetectionProgramFactory({ + ruleId: rule.id, + detectionProgramVersion: detectionProgram2.id, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + await activeDetectionProgramRepository.add(javascriptActiveProgram); + await activeDetectionProgramRepository.add(typescriptActiveProgram); + + foundPrograms = await activeDetectionProgramRepository.findByRuleId( + rule.id, + ); + }); + + it('allows storing both programs', async () => { + expect(foundPrograms).toHaveLength(2); + }); + + it('includes the JavaScript program', async () => { + expect(foundPrograms.map((p) => p.language)).toContain( + ProgrammingLanguage.JAVASCRIPT, + ); + }); + + it('includes the TypeScript program', async () => { + expect(foundPrograms.map((p) => p.language)).toContain( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + }); + + describe('when different rules have the same language', () => { + let foundPrograms1: ActiveDetectionProgram[]; + let foundPrograms2: ActiveDetectionProgram[]; + + beforeEach(async () => { + const rule1 = await createRuleWithHierarchy(); + const rule2 = await createRuleWithHierarchy(); + const detectionProgram1 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule1.id, version: 1 }), + ); + const detectionProgram2 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule2.id, version: 1 }), + ); + + const activeProgram1 = activeDetectionProgramFactory({ + ruleId: rule1.id, + detectionProgramVersion: detectionProgram1.id, + language: ProgrammingLanguage.JAVASCRIPT, + }); + const activeProgram2 = activeDetectionProgramFactory({ + ruleId: rule2.id, + detectionProgramVersion: detectionProgram2.id, + language: ProgrammingLanguage.JAVASCRIPT, + }); + + await activeDetectionProgramRepository.add(activeProgram1); + await activeDetectionProgramRepository.add(activeProgram2); + + foundPrograms1 = await activeDetectionProgramRepository.findByRuleId( + rule1.id, + ); + foundPrograms2 = await activeDetectionProgramRepository.findByRuleId( + rule2.id, + ); + }); + + it('returns one program for the first rule', async () => { + expect(foundPrograms1).toHaveLength(1); + }); + + it('returns one program for the second rule', async () => { + expect(foundPrograms2).toHaveLength(1); + }); + + it('first rule program has JavaScript language', async () => { + expect(foundPrograms1[0].language).toBe(ProgrammingLanguage.JAVASCRIPT); + }); + + it('second rule program has JavaScript language', async () => { + expect(foundPrograms2[0].language).toBe(ProgrammingLanguage.JAVASCRIPT); + }); + }); + }); + + describe('when using soft delete functionality', () => { + it('excludes soft deleted programs from findByRuleId by default', async () => { + const rule = await createRuleWithHierarchy(); + const detectionProgram = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule.id, version: 1 }), + ); + + const activeProgram = activeDetectionProgramFactory({ + ruleId: rule.id, + detectionProgramVersion: detectionProgram.id, + }); + await activeDetectionProgramRepository.add(activeProgram); + await activeDetectionProgramRepository.deleteById(activeProgram.id); + + const foundPrograms = await activeDetectionProgramRepository.findByRuleId( + rule.id, + ); + expect(foundPrograms).toEqual([]); + }); + + describe('when includeDeleted option is used', () => { + let activeProgram: ActiveDetectionProgram; + let foundPrograms: ActiveDetectionProgram[]; + + beforeEach(async () => { + const rule = await createRuleWithHierarchy(); + const detectionProgram = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: rule.id, version: 1 }), + ); + + activeProgram = activeDetectionProgramFactory({ + ruleId: rule.id, + detectionProgramVersion: detectionProgram.id, + }); + await activeDetectionProgramRepository.add(activeProgram); + await activeDetectionProgramRepository.deleteById(activeProgram.id); + + foundPrograms = await activeDetectionProgramRepository.findByRuleId( + rule.id, + { includeDeleted: true }, + ); + }); + + it('includes the soft deleted program', async () => { + expect(foundPrograms).toHaveLength(1); + }); + + it('returns the correct program', async () => { + expect(foundPrograms[0].id).toBe(activeProgram.id); + }); + }); + }); + + describe('multi-language query scenarios', () => { + let testRule: Rule; + let detectionProgram1: DetectionProgram; + let detectionProgram2: DetectionProgram; + let detectionProgram3: DetectionProgram; + + beforeEach(async () => { + testRule = await createRuleWithHierarchy(); + detectionProgram1 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: testRule.id, version: 1 }), + ); + detectionProgram2 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: testRule.id, version: 2 }), + ); + detectionProgram3 = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: testRule.id, version: 3 }), + ); + }); + + describe('when finding active programs for multiple languages', () => { + let foundPrograms: ActiveDetectionProgram[]; + + beforeEach(async () => { + const javascriptProgram = activeDetectionProgramFactory({ + ruleId: testRule.id, + detectionProgramVersion: detectionProgram1.id, + language: ProgrammingLanguage.JAVASCRIPT, + }); + const typescriptProgram = activeDetectionProgramFactory({ + ruleId: testRule.id, + detectionProgramVersion: detectionProgram2.id, + language: ProgrammingLanguage.TYPESCRIPT, + }); + const pythonProgram = activeDetectionProgramFactory({ + ruleId: testRule.id, + detectionProgramVersion: detectionProgram3.id, + language: ProgrammingLanguage.PYTHON, + }); + + await activeDetectionProgramRepository.add(javascriptProgram); + await activeDetectionProgramRepository.add(typescriptProgram); + await activeDetectionProgramRepository.add(pythonProgram); + + foundPrograms = await activeDetectionProgramRepository.findByRuleId( + testRule.id, + ); + }); + + it('returns all three programs', async () => { + expect(foundPrograms).toHaveLength(3); + }); + + it('includes JavaScript program', async () => { + const languages = foundPrograms.map((p) => p.language); + expect(languages).toContain(ProgrammingLanguage.JAVASCRIPT); + }); + + it('includes TypeScript program', async () => { + const languages = foundPrograms.map((p) => p.language); + expect(languages).toContain(ProgrammingLanguage.TYPESCRIPT); + }); + + it('includes Python program', async () => { + const languages = foundPrograms.map((p) => p.language); + expect(languages).toContain(ProgrammingLanguage.PYTHON); + }); + }); + + describe('when finding specific language program', () => { + let javascriptProgram: ActiveDetectionProgram; + let typescriptProgram: ActiveDetectionProgram; + let foundJavaScript: ActiveDetectionProgram | null; + let foundTypeScript: ActiveDetectionProgram | null; + let foundPython: ActiveDetectionProgram | null; + + beforeEach(async () => { + javascriptProgram = activeDetectionProgramFactory({ + ruleId: testRule.id, + detectionProgramVersion: detectionProgram1.id, + language: ProgrammingLanguage.JAVASCRIPT, + }); + typescriptProgram = activeDetectionProgramFactory({ + ruleId: testRule.id, + detectionProgramVersion: detectionProgram2.id, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + await activeDetectionProgramRepository.add(javascriptProgram); + await activeDetectionProgramRepository.add(typescriptProgram); + + foundJavaScript = + await activeDetectionProgramRepository.findByRuleIdAndLanguage( + testRule.id, + ProgrammingLanguage.JAVASCRIPT, + ); + foundTypeScript = + await activeDetectionProgramRepository.findByRuleIdAndLanguage( + testRule.id, + ProgrammingLanguage.TYPESCRIPT, + ); + foundPython = + await activeDetectionProgramRepository.findByRuleIdAndLanguage( + testRule.id, + ProgrammingLanguage.PYTHON, + ); + }); + + it('returns the JavaScript program', async () => { + expect(foundJavaScript).toMatchObject({ + id: javascriptProgram.id, + language: ProgrammingLanguage.JAVASCRIPT, + detectionProgramVersion: detectionProgram1.id, + }); + }); + + it('returns the TypeScript program', async () => { + expect(foundTypeScript).toMatchObject({ + id: typescriptProgram.id, + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgramVersion: detectionProgram2.id, + }); + }); + + it('returns null for non-existent Python program', async () => { + expect(foundPython).toBeNull(); + }); + }); + + describe('when finding programs with detection program data for multiple languages', () => { + let javascriptProgram: ActiveDetectionProgram; + let typescriptProgram: ActiveDetectionProgram; + let foundPrograms: ActiveDetectionProgram[]; + + beforeEach(async () => { + javascriptProgram = activeDetectionProgramFactory({ + ruleId: testRule.id, + detectionProgramVersion: detectionProgram1.id, + language: ProgrammingLanguage.JAVASCRIPT, + }); + typescriptProgram = activeDetectionProgramFactory({ + ruleId: testRule.id, + detectionProgramVersion: detectionProgram2.id, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + await activeDetectionProgramRepository.add(javascriptProgram); + await activeDetectionProgramRepository.add(typescriptProgram); + + foundPrograms = + await activeDetectionProgramRepository.findByRuleIdWithPrograms( + testRule.id, + ); + }); + + it('returns both programs', async () => { + expect(foundPrograms).toHaveLength(2); + }); + + it('includes JavaScript program with detection program data', async () => { + const jsProgram = foundPrograms.find( + (p) => p.language === ProgrammingLanguage.JAVASCRIPT, + ); + expect(jsProgram).toMatchObject({ + id: javascriptProgram.id, + language: ProgrammingLanguage.JAVASCRIPT, + detectionProgram: { + id: detectionProgram1.id, + version: detectionProgram1.version, + }, + }); + }); + + it('includes TypeScript program with detection program data', async () => { + const tsProgram = foundPrograms.find( + (p) => p.language === ProgrammingLanguage.TYPESCRIPT, + ); + expect(tsProgram).toMatchObject({ + id: typescriptProgram.id, + language: ProgrammingLanguage.TYPESCRIPT, + detectionProgram: { + id: detectionProgram2.id, + version: detectionProgram2.version, + }, + }); + }); + }); + + describe('when handling language-specific soft delete', () => { + let typescriptProgram: ActiveDetectionProgram; + let foundPrograms: ActiveDetectionProgram[]; + let foundJavaScript: ActiveDetectionProgram | null; + let foundTypeScript: ActiveDetectionProgram | null; + + beforeEach(async () => { + const javascriptProgram = activeDetectionProgramFactory({ + ruleId: testRule.id, + detectionProgramVersion: detectionProgram1.id, + language: ProgrammingLanguage.JAVASCRIPT, + }); + typescriptProgram = activeDetectionProgramFactory({ + ruleId: testRule.id, + detectionProgramVersion: detectionProgram2.id, + language: ProgrammingLanguage.TYPESCRIPT, + }); + + await activeDetectionProgramRepository.add(javascriptProgram); + await activeDetectionProgramRepository.add(typescriptProgram); + + await activeDetectionProgramRepository.deleteById(javascriptProgram.id); + + foundPrograms = await activeDetectionProgramRepository.findByRuleId( + testRule.id, + ); + foundJavaScript = + await activeDetectionProgramRepository.findByRuleIdAndLanguage( + testRule.id, + ProgrammingLanguage.JAVASCRIPT, + ); + foundTypeScript = + await activeDetectionProgramRepository.findByRuleIdAndLanguage( + testRule.id, + ProgrammingLanguage.TYPESCRIPT, + ); + }); + + it('excludes the deleted program from findByRuleId', async () => { + expect(foundPrograms).toHaveLength(1); + }); + + it('only returns the non-deleted TypeScript program', async () => { + expect(foundPrograms[0].language).toBe(ProgrammingLanguage.TYPESCRIPT); + }); + + it('returns null for the deleted JavaScript program', async () => { + expect(foundJavaScript).toBeNull(); + }); + + it('returns the non-deleted TypeScript program', async () => { + expect(foundTypeScript).toMatchObject({ + id: typescriptProgram.id, + language: ProgrammingLanguage.TYPESCRIPT, + }); + }); + }); + + it('validates language constraint in unique index', async () => { + const program1 = activeDetectionProgramFactory({ + ruleId: testRule.id, + detectionProgramVersion: detectionProgram1.id, + language: ProgrammingLanguage.JAVASCRIPT, + }); + const program2 = activeDetectionProgramFactory({ + ruleId: testRule.id, + detectionProgramVersion: detectionProgram2.id, + language: ProgrammingLanguage.JAVASCRIPT, // Same rule + language combination + }); + + await activeDetectionProgramRepository.add(program1); + + // Should fail due to unique constraint on (rule_id, language) + await expect( + activeDetectionProgramRepository.add(program2), + ).rejects.toThrow(); + }); + }); + + describe('Soft-delete with valid foreign keys', () => { + let testRule: Rule; + let testDetectionProgram: DetectionProgram; + + beforeEach(async () => { + // Create rule and detection program for soft delete tests + testRule = await createRuleWithHierarchy(); + testDetectionProgram = await detectionProgramRepo.save( + detectionProgramFactory({ ruleId: testRule.id, version: 1 }), + ); + }); + + itHandlesSoftDelete<ActiveDetectionProgram>({ + entityFactory: () => + activeDetectionProgramFactory({ + ruleId: testRule.id, + detectionProgramVersion: testDetectionProgram.id, + }), + getRepository: () => activeDetectionProgramRepository, + queryDeletedEntity: async (id) => + typeormRepo.findOne({ + where: { id }, + withDeleted: true, + }) as unknown as WithSoftDelete<ActiveDetectionProgram>, + }); + }); +}); diff --git a/packages/linter/src/infra/repositories/ActiveDetectionProgramRepository.ts b/packages/linter/src/infra/repositories/ActiveDetectionProgramRepository.ts new file mode 100644 index 000000000..35d57f372 --- /dev/null +++ b/packages/linter/src/infra/repositories/ActiveDetectionProgramRepository.ts @@ -0,0 +1,293 @@ +import { Repository } from 'typeorm'; +import { PackmindLogger } from '@packmind/logger'; +import { AbstractRepository, localDataSource } from '@packmind/node-utils'; +import { + RuleId, + QueryOption, + ProgrammingLanguage, + ActiveDetectionProgramId, + DetectionSeverity, +} from '@packmind/types'; +import { + ActiveDetectionProgram, + LanguageDetectionPrograms, +} from '@packmind/types'; +import { IActiveDetectionProgramRepository } from '../../domain/repositories/IActiveDetectionProgramRepository'; +import { ActiveDetectionProgramNotFoundError } from '../../domain/errors'; +import { ActiveDetectionProgramSchema } from '../schemas'; + +const origin = 'ActiveDetectionProgramRepository'; + +export class ActiveDetectionProgramRepository + extends AbstractRepository<ActiveDetectionProgram> + implements IActiveDetectionProgramRepository +{ + constructor( + repository: Repository<ActiveDetectionProgram> = localDataSource.getRepository<ActiveDetectionProgram>( + ActiveDetectionProgramSchema, + ), + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super( + 'activeDetectionProgram', + repository, + ActiveDetectionProgramSchema, + logger, + ); + this.logger.info('ActiveDetectionProgramRepository initialized'); + } + + protected override loggableEntity( + entity: ActiveDetectionProgram, + ): Partial<ActiveDetectionProgram> { + return { + id: entity.id, + ruleId: entity.ruleId, + language: entity.language, + detectionProgramVersion: entity.detectionProgramVersion, + }; + } + + async list(): Promise<ActiveDetectionProgram[]> { + this.logger.info('Listing all active detection programs from database'); + + try { + const programs = await this.repository.find(); + this.logger.info('Active detection programs listed successfully', { + count: programs.length, + }); + return programs; + } catch (error) { + this.logger.error( + 'Failed to list active detection programs from database', + { + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async findByRuleId( + ruleId: RuleId, + opts?: QueryOption, + ): Promise<ActiveDetectionProgram[]> { + this.logger.info('Finding active detection programs by rule ID', { + ruleId, + }); + + try { + const programs = await this.repository.find({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + where: { ruleId: ruleId as any }, // TypeORM compatibility with branded types + withDeleted: opts?.includeDeleted ?? false, + }); + + this.logger.info('Active detection programs found by rule ID', { + ruleId, + count: programs.length, + }); + return programs; + } catch (error) { + this.logger.error('Failed to find active detection programs by rule ID', { + ruleId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async findByRuleIdAndLanguage( + ruleId: RuleId, + language: ProgrammingLanguage, + opts?: QueryOption, + ): Promise<ActiveDetectionProgram | null> { + this.logger.info( + 'Finding active detection program by rule ID and language', + { + ruleId, + language, + }, + ); + + try { + const program = await this.repository.findOne({ + where: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ruleId: ruleId as any, // TypeORM compatibility with branded types + language, + }, + withDeleted: opts?.includeDeleted ?? false, + }); + + if (program) { + this.logger.info( + 'Active detection program found by rule ID and language', + { + ruleId, + language, + programId: program.id, + }, + ); + } else { + this.logger.warn( + 'Active detection program not found by rule ID and language', + { + ruleId, + language, + }, + ); + } + return program; + } catch (error) { + this.logger.error( + 'Failed to find active detection program by rule ID and language', + { + ruleId, + language, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async findByRuleIdWithPrograms( + ruleId: RuleId, + opts?: QueryOption, + ): Promise<LanguageDetectionPrograms[]> { + this.logger.info( + 'Finding active detection programs with programs by rule ID', + { + ruleId, + }, + ); + + try { + const queryBuilder = this.repository + .createQueryBuilder('active_detection_program') + .leftJoinAndSelect( + 'active_detection_program.detectionProgram', + 'detection_program', + ) + .leftJoinAndSelect( + 'active_detection_program.draftDetectionProgram', + 'draft_detection_program', + ) + .where('active_detection_program.ruleId = :ruleId', { ruleId }); + + if (opts?.includeDeleted) { + queryBuilder.withDeleted(); + } + + const programs = await queryBuilder.getMany(); + + this.logger.info( + 'Active detection programs with programs found by rule ID', + { + ruleId, + count: programs.length, + }, + ); + + // TypeScript type assertion to match the expected return type + return programs as LanguageDetectionPrograms[]; + } catch (error) { + this.logger.error( + 'Failed to find active detection programs with programs by rule ID', + { + ruleId, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async updateActiveDetectionProgram( + activeDetectionProgram: ActiveDetectionProgram, + ): Promise<ActiveDetectionProgram> { + this.logger.info('Updating active detection program', { + id: activeDetectionProgram.id, + }); + + try { + const updatedProgram = await this.repository.save(activeDetectionProgram); + this.logger.info('Active detection program updated', { + id: updatedProgram.id, + }); + return updatedProgram; + } catch (error) { + this.logger.error('Failed to update active detection program', { + id: activeDetectionProgram.id, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async updateSeverity( + activeDetectionProgramId: ActiveDetectionProgramId, + severity: DetectionSeverity, + ): Promise<ActiveDetectionProgram> { + this.logger.info('Updating severity for active detection program', { + id: activeDetectionProgramId, + severity, + }); + + try { + const existing = await this.findById(activeDetectionProgramId); + + if (!existing) { + throw new ActiveDetectionProgramNotFoundError(activeDetectionProgramId); + } + + existing.severity = severity; + const updated = await this.repository.save(existing); + + this.logger.info('Severity updated for active detection program', { + id: activeDetectionProgramId, + severity, + }); + + return updated; + } catch (error) { + this.logger.error( + 'Failed to update severity for active detection program', + { + id: activeDetectionProgramId, + severity, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async deleteByRuleId(ruleId: RuleId): Promise<void> { + this.logger.info('Deleting active detection programs by rule ID', { + ruleId, + }); + + try { + const result = await this.repository.softDelete({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ruleId: ruleId as any, // TypeORM compatibility with branded types + }); + + this.logger.info('Active detection programs deleted by rule ID', { + ruleId, + affectedRows: result.affected, + }); + } catch (error) { + this.logger.error( + 'Failed to delete active detection programs by rule ID', + { + ruleId, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } +} diff --git a/packages/linter/src/infra/repositories/DetectionProgramMetadataRepository.spec.ts b/packages/linter/src/infra/repositories/DetectionProgramMetadataRepository.spec.ts new file mode 100644 index 000000000..1d7249553 --- /dev/null +++ b/packages/linter/src/infra/repositories/DetectionProgramMetadataRepository.spec.ts @@ -0,0 +1,344 @@ +import { DetectionProgramMetadataRepository } from './DetectionProgramMetadataRepository'; +import { DetectionProgramMetadataSchema } from '../schemas/DetectionProgramMetadataSchema'; +import { ExecutionLogSchema } from '../schemas/ExecutionLogSchema'; +import { DetectionProgramSchema } from '../schemas/DetectionProgramSchema'; +import { DataSource, Repository } from 'typeorm'; +import { makeTestDatasource, stubLogger } from '@packmind/test-utils'; +import { itHandlesSoftDelete } from '@packmind/test-utils'; +import { + DetectionProgramMetadata, + ExecutionLog, + DetectionProgram, + createDetectionProgramId, + Standard, + StandardVersion, + Rule, +} from '@packmind/types'; +import { WithSoftDelete } from '@packmind/node-utils'; +import { RuleSchema } from '@packmind/standards/src/infra/schemas/RuleSchema'; +import { StandardSchema } from '@packmind/standards/src/infra/schemas/StandardSchema'; +import { StandardVersionSchema } from '@packmind/standards/src/infra/schemas/StandardVersionSchema'; +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { ruleFactory } from '@packmind/standards/test/ruleFactory'; +import { standardFactory } from '@packmind/standards/test/standardFactory'; +import { standardVersionFactory } from '@packmind/standards/test/standardVersionFactory'; +import { GitCommitSchema } from '@packmind/git'; +import { + detectionProgramFactory, + detectionProgramMetadataFactory, + executionLogFactory, +} from '../../../test'; + +describe('DetectionProgramMetadataRepository', () => { + let datasource: DataSource; + let metadataRepository: DetectionProgramMetadataRepository; + let stubbedLogger: jest.Mocked<PackmindLogger>; + let typeormMetadataRepo: Repository<DetectionProgramMetadata>; + let typeormExecutionLogRepo: Repository< + ExecutionLog & { id: string; detectionProgramMetadataId: string } + >; + let detectionProgramRepo: Repository<DetectionProgram>; + let ruleRepo: Repository<Rule>; + let standardRepo: Repository<Standard>; + let standardVersionRepo: Repository<StandardVersion>; + + const createRuleWithHierarchy = async (): Promise<Rule> => { + const standard = await standardRepo.save( + standardFactory({ slug: `standard-${uuidv4()}` }), + ); + const standardVersion = await standardVersionRepo.save( + standardVersionFactory({ standardId: standard.id }), + ); + return await ruleRepo.save( + ruleFactory({ standardVersionId: standardVersion.id }), + ); + }; + + beforeEach(async () => { + datasource = await makeTestDatasource([ + DetectionProgramMetadataSchema, + ExecutionLogSchema, + DetectionProgramSchema, + RuleSchema, + StandardSchema, + StandardVersionSchema, + GitCommitSchema, + ]); + await datasource.initialize(); + await datasource.synchronize(); + + stubbedLogger = stubLogger(); + typeormMetadataRepo = datasource.getRepository( + DetectionProgramMetadataSchema, + ); + typeormExecutionLogRepo = datasource.getRepository(ExecutionLogSchema); + detectionProgramRepo = datasource.getRepository(DetectionProgramSchema); + ruleRepo = datasource.getRepository(RuleSchema); + standardRepo = datasource.getRepository(StandardSchema); + standardVersionRepo = datasource.getRepository(StandardVersionSchema); + + metadataRepository = new DetectionProgramMetadataRepository( + typeormMetadataRepo, + typeormExecutionLogRepo, + stubbedLogger, + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await datasource.destroy(); + }); + + it('can store and retrieve detection program metadata', async () => { + const rule = await createRuleWithHierarchy(); + const detectionProgram = detectionProgramFactory({ ruleId: rule.id }); + await detectionProgramRepo.save(detectionProgram); + + const metadata = detectionProgramMetadataFactory({ + detectionProgramId: detectionProgram.id, + }); + await metadataRepository.add(metadata); + + const foundMetadata = await metadataRepository.findById(metadata.id); + expect(foundMetadata).toMatchObject({ + id: metadata.id, + detectionProgramId: metadata.detectionProgramId, + taskId: metadata.taskId, + programDescription: metadata.programDescription, + }); + }); + + it('can find metadata by detection program id', async () => { + const rule = await createRuleWithHierarchy(); + const detectionProgram = detectionProgramFactory({ ruleId: rule.id }); + await detectionProgramRepo.save(detectionProgram); + + const metadata = detectionProgramMetadataFactory({ + detectionProgramId: detectionProgram.id, + }); + await metadataRepository.add(metadata); + + const foundMetadata = await metadataRepository.findByDetectionProgramId( + detectionProgram.id, + ); + expect(foundMetadata).toMatchObject({ + id: metadata.id, + detectionProgramId: detectionProgram.id, + }); + }); + + it('returns null for non-existent detection program id', async () => { + const nonExistentId = createDetectionProgramId(uuidv4()); + + const foundMetadata = + await metadataRepository.findByDetectionProgramId(nonExistentId); + expect(foundMetadata).toBeNull(); + }); + + describe('addLog', () => { + describe('when metadata already exists', () => { + let detectionProgram: DetectionProgram; + let foundMetadata: DetectionProgramMetadata | null; + + beforeEach(async () => { + const rule = await createRuleWithHierarchy(); + detectionProgram = detectionProgramFactory({ ruleId: rule.id }); + await detectionProgramRepo.save(detectionProgram); + + const metadata = detectionProgramMetadataFactory({ + detectionProgramId: detectionProgram.id, + }); + await metadataRepository.add(metadata); + + const log = executionLogFactory({ message: 'Test log entry' }); + await metadataRepository.addLog(log, detectionProgram.id); + + foundMetadata = await metadataRepository.findByDetectionProgramId( + detectionProgram.id, + ); + }); + + it('adds one execution log', async () => { + expect(foundMetadata?.logs).toHaveLength(1); + }); + + it('stores the log message correctly', async () => { + expect(foundMetadata?.logs?.[0]).toMatchObject({ + message: 'Test log entry', + }); + }); + }); + + describe('when metadata does not exist', () => { + let detectionProgram: DetectionProgram; + let foundMetadata: DetectionProgramMetadata | null; + + beforeEach(async () => { + const rule = await createRuleWithHierarchy(); + detectionProgram = detectionProgramFactory({ ruleId: rule.id }); + await detectionProgramRepo.save(detectionProgram); + + const log = executionLogFactory({ message: 'Auto-created log' }); + await metadataRepository.addLog(log, detectionProgram.id); + + foundMetadata = await metadataRepository.findByDetectionProgramId( + detectionProgram.id, + ); + }); + + it('creates metadata automatically', async () => { + expect(foundMetadata).not.toBeNull(); + }); + + it('links metadata to the detection program', async () => { + expect(foundMetadata?.detectionProgramId).toBe(detectionProgram.id); + }); + + it('adds one execution log', async () => { + expect(foundMetadata?.logs).toHaveLength(1); + }); + + it('stores the log message correctly', async () => { + expect(foundMetadata?.logs?.[0]).toMatchObject({ + message: 'Auto-created log', + }); + }); + }); + }); + + describe('updateProgramDescription', () => { + describe('when metadata already exists', () => { + it('updates program description', async () => { + const rule = await createRuleWithHierarchy(); + const detectionProgram = detectionProgramFactory({ ruleId: rule.id }); + await detectionProgramRepo.save(detectionProgram); + + const metadata = detectionProgramMetadataFactory({ + detectionProgramId: detectionProgram.id, + programDescription: 'Original description', + }); + await metadataRepository.add(metadata); + + await metadataRepository.updateProgramDescription( + 'Updated description', + detectionProgram.id, + ); + + const foundMetadata = await metadataRepository.findByDetectionProgramId( + detectionProgram.id, + ); + expect(foundMetadata?.programDescription).toBe('Updated description'); + }); + }); + + describe('when metadata does not exist', () => { + let detectionProgram: DetectionProgram; + let foundMetadata: DetectionProgramMetadata | null; + + beforeEach(async () => { + const rule = await createRuleWithHierarchy(); + detectionProgram = detectionProgramFactory({ ruleId: rule.id }); + await detectionProgramRepo.save(detectionProgram); + + await metadataRepository.updateProgramDescription( + 'New description', + detectionProgram.id, + ); + + foundMetadata = await metadataRepository.findByDetectionProgramId( + detectionProgram.id, + ); + }); + + it('creates metadata automatically', async () => { + expect(foundMetadata).not.toBeNull(); + }); + + it('links metadata to the detection program', async () => { + expect(foundMetadata?.detectionProgramId).toBe(detectionProgram.id); + }); + + it('sets the program description correctly', async () => { + expect(foundMetadata?.programDescription).toBe('New description'); + }); + }); + }); + + describe('updateTokensUsed', () => { + describe('when metadata already exists', () => { + it('updates tokens used', async () => { + const rule = await createRuleWithHierarchy(); + const detectionProgram = detectionProgramFactory({ ruleId: rule.id }); + await detectionProgramRepo.save(detectionProgram); + + const metadata = detectionProgramMetadataFactory({ + detectionProgramId: detectionProgram.id, + tokens: null, + }); + await metadataRepository.add(metadata); + + const tokens = { input: 100, output: 50 }; + await metadataRepository.updateTokensUsed(tokens, detectionProgram.id); + + const foundMetadata = await metadataRepository.findByDetectionProgramId( + detectionProgram.id, + ); + expect(foundMetadata?.tokens).toEqual(tokens); + }); + }); + + describe('when metadata does not exist', () => { + let detectionProgram: DetectionProgram; + let foundMetadata: DetectionProgramMetadata | null; + const tokens = { input: 100, output: 50 }; + + beforeEach(async () => { + const rule = await createRuleWithHierarchy(); + detectionProgram = detectionProgramFactory({ ruleId: rule.id }); + await detectionProgramRepo.save(detectionProgram); + + await metadataRepository.updateTokensUsed(tokens, detectionProgram.id); + + foundMetadata = await metadataRepository.findByDetectionProgramId( + detectionProgram.id, + ); + }); + + it('creates metadata automatically', async () => { + expect(foundMetadata).not.toBeNull(); + }); + + it('links metadata to the detection program', async () => { + expect(foundMetadata?.detectionProgramId).toBe(detectionProgram.id); + }); + + it('sets the tokens correctly', async () => { + expect(foundMetadata?.tokens).toEqual(tokens); + }); + }); + }); + + describe('Soft-delete with valid foreign keys', () => { + let testDetectionProgram: DetectionProgram; + + beforeEach(async () => { + const rule = await createRuleWithHierarchy(); + testDetectionProgram = detectionProgramFactory({ ruleId: rule.id }); + await detectionProgramRepo.save(testDetectionProgram); + }); + + itHandlesSoftDelete<DetectionProgramMetadata>({ + entityFactory: () => + detectionProgramMetadataFactory({ + detectionProgramId: testDetectionProgram.id, + }), + getRepository: () => metadataRepository, + queryDeletedEntity: async (id) => + typeormMetadataRepo.findOne({ + where: { id }, + withDeleted: true, + }) as unknown as WithSoftDelete<DetectionProgramMetadata>, + }); + }); +}); diff --git a/packages/linter/src/infra/repositories/DetectionProgramMetadataRepository.ts b/packages/linter/src/infra/repositories/DetectionProgramMetadataRepository.ts new file mode 100644 index 000000000..c8e8ab5dd --- /dev/null +++ b/packages/linter/src/infra/repositories/DetectionProgramMetadataRepository.ts @@ -0,0 +1,345 @@ +import { In, Repository } from 'typeorm'; +import { PackmindLogger } from '@packmind/logger'; +import { AbstractRepository, localDataSource } from '@packmind/node-utils'; +import { + QueryOption, + DetectionProgramMetadata, + DetectionProgramId, + ExecutionLog, + TokensUsed, +} from '@packmind/types'; +import { IDetectionProgramMetadataRepository } from '../../domain/repositories/IDetectionProgramMetadataRepository'; +import { DetectionProgramMetadataSchema } from '../schemas/DetectionProgramMetadataSchema'; +import { ExecutionLogSchema } from '../schemas/ExecutionLogSchema'; +import { v4 as uuidv4 } from 'uuid'; + +const origin = 'DetectionProgramMetadataRepository'; + +export class DetectionProgramMetadataRepository + extends AbstractRepository<DetectionProgramMetadata> + implements IDetectionProgramMetadataRepository +{ + private readonly executionLogRepository: Repository< + ExecutionLog & { id: string; detectionProgramMetadataId: string } + >; + + constructor( + repository: Repository<DetectionProgramMetadata> = localDataSource.getRepository<DetectionProgramMetadata>( + DetectionProgramMetadataSchema, + ), + executionLogRepository: Repository< + ExecutionLog & { id: string; detectionProgramMetadataId: string } + > = localDataSource.getRepository(ExecutionLogSchema), + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super( + 'detectionProgramMetadata', + repository, + DetectionProgramMetadataSchema, + logger, + ); + this.executionLogRepository = executionLogRepository; + this.logger.info('DetectionProgramMetadataRepository initialized'); + } + + protected override loggableEntity( + entity: DetectionProgramMetadata, + ): Partial<DetectionProgramMetadata> { + return { + id: entity.id, + detectionProgramId: entity.detectionProgramId, + taskId: entity.taskId, + }; + } + + async findByDetectionProgramIds( + detectionProgramIds: DetectionProgramId[], + ): Promise<DetectionProgramMetadata[]> { + this.logger.info('Finding metadata by detection program IDs', { + count: detectionProgramIds.length, + }); + + if (detectionProgramIds.length === 0) { + return []; + } + + try { + const metadata = await this.repository.find({ + where: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + detectionProgramId: In(detectionProgramIds as any[]), + }, + relations: ['logs'], + }); + + this.logger.info('Metadata found by detection program IDs', { + requestedCount: detectionProgramIds.length, + foundCount: metadata.length, + }); + + return metadata; + } catch (error) { + this.logger.error('Failed to find metadata by detection program IDs', { + count: detectionProgramIds.length, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async softDeleteByDetectionProgramIds( + detectionProgramIds: DetectionProgramId[], + ): Promise<void> { + this.logger.info( + 'Soft-deleting metadata and execution logs by detection program IDs', + { count: detectionProgramIds.length }, + ); + + if (detectionProgramIds.length === 0) { + return; + } + + try { + // Find metadata IDs first to soft-delete associated execution logs + const metadata = await this.repository.find({ + where: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + detectionProgramId: In(detectionProgramIds as any[]), + }, + select: ['id'], + }); + + if (metadata.length > 0) { + const metadataIds = metadata.map((m) => m.id); + + // Soft-delete execution logs first + await this.executionLogRepository.softDelete({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + detectionProgramMetadataId: In(metadataIds as any[]), + }); + + // Then soft-delete metadata + await this.repository.softDelete({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + detectionProgramId: In(detectionProgramIds as any[]), + }); + } + + this.logger.info( + 'Successfully soft-deleted metadata and execution logs', + { + metadataCount: metadata.length, + detectionProgramIdsCount: detectionProgramIds.length, + }, + ); + } catch (error) { + this.logger.error( + 'Failed to soft-delete metadata by detection program IDs', + { + count: detectionProgramIds.length, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async findByDetectionProgramId( + detectionProgramId: DetectionProgramId, + opts?: QueryOption, + ): Promise<DetectionProgramMetadata | null> { + this.logger.info('Finding metadata by detection program ID', { + detectionProgramId, + }); + + try { + const metadata = await this.repository.findOne({ + where: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + detectionProgramId: detectionProgramId as any, + }, + relations: ['logs'], + withDeleted: opts?.includeDeleted ?? false, + }); + + if (metadata) { + this.logger.info('Metadata found by detection program ID', { + detectionProgramId, + metadataId: metadata.id, + }); + } else { + this.logger.info('Metadata not found by detection program ID', { + detectionProgramId, + }); + } + + return metadata; + } catch (error) { + this.logger.error('Failed to find metadata by detection program ID', { + detectionProgramId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async addLog( + log: ExecutionLog, + detectionProgramId: DetectionProgramId, + ): Promise<void> { + this.logger.info('Adding execution log to metadata', { + detectionProgramId, + }); + + try { + let metadata = await this.findByDetectionProgramId(detectionProgramId); + + if (!metadata) { + this.logger.info( + 'Detection program metadata not found, creating new entry', + { + detectionProgramId, + }, + ); + + metadata = { + id: uuidv4(), + detectionProgramId, + taskId: '', + tokens: null, + logs: null, + programDescription: '', + }; + + await this.add(metadata); + } + + await this.executionLogRepository.save({ + ...log, + id: uuidv4(), + detectionProgramMetadataId: metadata.id, + }); + + this.logger.info('Execution log added successfully', { + detectionProgramId, + metadataId: metadata.id, + }); + } catch (error) { + this.logger.error('Failed to add execution log', { + detectionProgramId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async updateProgramDescription( + programDescription: string, + detectionProgramId: DetectionProgramId, + ): Promise<void> { + this.logger.info('Updating program description', { + detectionProgramId, + }); + + try { + let metadata = await this.findByDetectionProgramId(detectionProgramId); + + if (!metadata) { + this.logger.info( + 'Detection program metadata not found, creating new entry', + { + detectionProgramId, + }, + ); + + metadata = { + id: uuidv4(), + detectionProgramId, + taskId: '', + tokens: null, + logs: null, + programDescription, + }; + + await this.add(metadata); + this.logger.info('Program description set successfully on new entry', { + detectionProgramId, + metadataId: metadata.id, + }); + return; + } + + await this.repository.update( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { id: metadata.id as any }, + { programDescription }, + ); + + this.logger.info('Program description updated successfully', { + detectionProgramId, + metadataId: metadata.id, + }); + } catch (error) { + this.logger.error('Failed to update program description', { + detectionProgramId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async updateTokensUsed( + tokens: TokensUsed, + detectionProgramId: DetectionProgramId, + ): Promise<void> { + this.logger.info('Updating tokens used', { + detectionProgramId, + }); + + try { + let metadata = await this.findByDetectionProgramId(detectionProgramId); + + if (!metadata) { + this.logger.info( + 'Detection program metadata not found, creating new entry', + { + detectionProgramId, + }, + ); + + metadata = { + id: uuidv4(), + detectionProgramId, + taskId: '', + tokens, + logs: null, + programDescription: '', + }; + + await this.add(metadata); + this.logger.info('Tokens used set successfully on new entry', { + detectionProgramId, + metadataId: metadata.id, + }); + return; + } + + await this.repository.update( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { id: metadata.id as any }, + { tokens }, + ); + + this.logger.info('Tokens used updated successfully', { + detectionProgramId, + metadataId: metadata.id, + }); + } catch (error) { + this.logger.error('Failed to update tokens used', { + detectionProgramId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/infra/repositories/DetectionProgramRepository.spec.ts b/packages/linter/src/infra/repositories/DetectionProgramRepository.spec.ts new file mode 100644 index 000000000..ccd94e737 --- /dev/null +++ b/packages/linter/src/infra/repositories/DetectionProgramRepository.spec.ts @@ -0,0 +1,344 @@ +import { DetectionProgramRepository } from './DetectionProgramRepository'; +import { DetectionProgramSchema } from '../schemas/DetectionProgramSchema'; +import { DataSource, Repository } from 'typeorm'; +import { makeTestDatasource, stubLogger } from '@packmind/test-utils'; +import { itHandlesSoftDelete } from '@packmind/test-utils'; +import { + createDetectionProgramId, + DetectionProgram, + createRuleId, + ProgrammingLanguage, + Standard, + StandardVersion, + Rule, +} from '@packmind/types'; +import { WithSoftDelete } from '@packmind/node-utils'; +import { RuleSchema } from '@packmind/standards/src/infra/schemas/RuleSchema'; +import { StandardSchema } from '@packmind/standards/src/infra/schemas/StandardSchema'; +import { StandardVersionSchema } from '@packmind/standards/src/infra/schemas/StandardVersionSchema'; +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { ruleFactory } from '@packmind/standards/test/ruleFactory'; +import { standardFactory } from '@packmind/standards/test/standardFactory'; +import { standardVersionFactory } from '@packmind/standards/test/standardVersionFactory'; +import { GitCommitSchema } from '@packmind/git'; +import { detectionProgramFactory } from '../../../test'; + +describe('DetectionProgramRepository', () => { + let datasource: DataSource; + let detectionProgramRepository: DetectionProgramRepository; + let stubbedLogger: jest.Mocked<PackmindLogger>; + let typeormRepo: Repository<DetectionProgram>; + let ruleRepo: Repository<Rule>; + let standardRepo: Repository<Standard>; + let standardVersionRepo: Repository<StandardVersion>; + + const createRuleWithHierarchy = async (): Promise<Rule> => { + const standard = await standardRepo.save( + standardFactory({ slug: `standard-${uuidv4()}` }), + ); + const standardVersion = await standardVersionRepo.save( + standardVersionFactory({ standardId: standard.id }), + ); + return await ruleRepo.save( + ruleFactory({ standardVersionId: standardVersion.id }), + ); + }; + + beforeEach(async () => { + datasource = await makeTestDatasource([ + DetectionProgramSchema, + RuleSchema, + StandardSchema, + StandardVersionSchema, + GitCommitSchema, + ]); + await datasource.initialize(); + await datasource.synchronize(); + + stubbedLogger = stubLogger(); + typeormRepo = datasource.getRepository(DetectionProgramSchema); + ruleRepo = datasource.getRepository(RuleSchema); + standardRepo = datasource.getRepository(StandardSchema); + standardVersionRepo = datasource.getRepository(StandardVersionSchema); + + detectionProgramRepository = new DetectionProgramRepository( + typeormRepo, + stubbedLogger, + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await datasource.destroy(); + }); + + it('can store and retrieve detection programs', async () => { + const rule = await createRuleWithHierarchy(); + + const detectionProgram = detectionProgramFactory({ ruleId: rule.id }); + await detectionProgramRepository.add(detectionProgram); + + const foundProgram = await detectionProgramRepository.findById( + detectionProgram.id, + ); + expect(foundProgram).toMatchObject({ + id: detectionProgram.id, + code: detectionProgram.code, + version: detectionProgram.version, + mode: detectionProgram.mode, + ruleId: detectionProgram.ruleId, + }); + }); + + describe('when finding detection programs by rule id', () => { + let rule: Rule; + let otherRule: Rule; + let program1: DetectionProgram; + let program2: DetectionProgram; + let program3: DetectionProgram; + let foundPrograms: DetectionProgram[]; + + beforeEach(async () => { + rule = await createRuleWithHierarchy(); + otherRule = await createRuleWithHierarchy(); + + program1 = detectionProgramFactory({ + ruleId: rule.id, + version: 1, + }); + program2 = detectionProgramFactory({ + ruleId: rule.id, + version: 2, + }); + program3 = detectionProgramFactory({ ruleId: otherRule.id }); + + await detectionProgramRepository.add(program1); + await detectionProgramRepository.add(program2); + await detectionProgramRepository.add(program3); + + foundPrograms = await detectionProgramRepository.findByRuleId(rule.id); + }); + + it('returns the correct number of programs', async () => { + expect(foundPrograms).toHaveLength(2); + }); + + it('includes first program for the rule', async () => { + expect(foundPrograms.map((p: DetectionProgram) => p.id)).toContain( + program1.id, + ); + }); + + it('includes second program for the rule', async () => { + expect(foundPrograms.map((p: DetectionProgram) => p.id)).toContain( + program2.id, + ); + }); + + it('excludes programs from other rules', async () => { + expect(foundPrograms.map((p: DetectionProgram) => p.id)).not.toContain( + program3.id, + ); + }); + }); + + it('can find detection program by rule id and version', async () => { + const rule = await createRuleWithHierarchy(); + + const program1 = detectionProgramFactory({ + ruleId: rule.id, + version: 1, + }); + const program2 = detectionProgramFactory({ + ruleId: rule.id, + version: 2, + }); + + await detectionProgramRepository.add(program1); + await detectionProgramRepository.add(program2); + + const foundProgram = + await detectionProgramRepository.findByRuleIdAndVersion(rule.id, 2); + + expect(foundProgram).toMatchObject({ + id: program2.id, + version: 2, + ruleId: rule.id, + }); + }); + + it('can get latest version by rule id', async () => { + const rule = await createRuleWithHierarchy(); + + await detectionProgramRepository.add( + detectionProgramFactory({ ruleId: rule.id, version: 1 }), + ); + await detectionProgramRepository.add( + detectionProgramFactory({ ruleId: rule.id, version: 3 }), + ); + await detectionProgramRepository.add( + detectionProgramFactory({ ruleId: rule.id, version: 2 }), + ); + + const latestVersion = + await detectionProgramRepository.getLatestVersionByRuleIdAndLanguage( + rule.id, + ProgrammingLanguage.JAVASCRIPT, + ); + + expect(latestVersion).toBe(3); + }); + + describe('when multiple versions exist for same rule', () => { + describe('when listing programs by rule id', () => { + let rule: Rule; + let foundPrograms: DetectionProgram[]; + + beforeEach(async () => { + rule = await createRuleWithHierarchy(); + + await detectionProgramRepository.add( + detectionProgramFactory({ ruleId: rule.id, version: 1 }), + ); + await detectionProgramRepository.add( + detectionProgramFactory({ ruleId: rule.id, version: 3 }), + ); + await detectionProgramRepository.add( + detectionProgramFactory({ ruleId: rule.id, version: 2 }), + ); + + foundPrograms = await detectionProgramRepository.findByRuleId(rule.id); + }); + + it('returns correct number of programs', async () => { + expect(foundPrograms).toHaveLength(3); + }); + + it('returns programs ordered by version descending', async () => { + expect(foundPrograms.map((p: DetectionProgram) => p.version)).toEqual([ + 3, 2, 1, + ]); + }); + }); + + describe('when finding by specific version', () => { + let rule: Rule; + let program1: DetectionProgram; + let program2: DetectionProgram; + let program3: DetectionProgram; + + beforeEach(async () => { + rule = await createRuleWithHierarchy(); + + program1 = await detectionProgramRepository.add( + detectionProgramFactory({ ruleId: rule.id, version: 1 }), + ); + program2 = await detectionProgramRepository.add( + detectionProgramFactory({ ruleId: rule.id, version: 2 }), + ); + program3 = await detectionProgramRepository.add( + detectionProgramFactory({ ruleId: rule.id, version: 3 }), + ); + }); + + it('returns program with version 1', async () => { + const foundProgram = + await detectionProgramRepository.findByRuleIdAndVersion(rule.id, 1); + + expect(foundProgram).toMatchObject({ + id: program1.id, + version: 1, + }); + }); + + it('returns program with version 2', async () => { + const foundProgram = + await detectionProgramRepository.findByRuleIdAndVersion(rule.id, 2); + + expect(foundProgram).toMatchObject({ + id: program2.id, + version: 2, + }); + }); + + it('returns program with version 3', async () => { + const foundProgram = + await detectionProgramRepository.findByRuleIdAndVersion(rule.id, 3); + + expect(foundProgram).toMatchObject({ + id: program3.id, + version: 3, + }); + }); + }); + }); + + it('can list all detection programs', async () => { + const rule1 = await createRuleWithHierarchy(); + const rule2 = await createRuleWithHierarchy(); + + await detectionProgramRepository.add( + detectionProgramFactory({ ruleId: rule1.id }), + ); + await detectionProgramRepository.add( + detectionProgramFactory({ ruleId: rule2.id }), + ); + + const allPrograms = await detectionProgramRepository.list(); + expect(allPrograms).toHaveLength(2); + }); + + describe('when finding non-existent detection programs', () => { + it('returns null for non-existent id', async () => { + const foundProgram = await detectionProgramRepository.findById( + createDetectionProgramId(uuidv4()), + ); + expect(foundProgram).toBeNull(); + }); + + it('returns empty array for non-existent rule', async () => { + const foundPrograms = await detectionProgramRepository.findByRuleId( + createRuleId(uuidv4()), + ); + expect(foundPrograms).toEqual([]); + }); + + it('returns null for non-existent rule id and version', async () => { + const foundProgram = + await detectionProgramRepository.findByRuleIdAndVersion( + createRuleId(uuidv4()), + 1, + ); + expect(foundProgram).toBeNull(); + }); + + it('returns 0 for non-existent rule latest version', async () => { + const latestVersion = + await detectionProgramRepository.getLatestVersionByRuleIdAndLanguage( + createRuleId(uuidv4()), + ProgrammingLanguage.JAVASCRIPT, + ); + expect(latestVersion).toBe(0); + }); + }); + + describe('Soft-delete with valid foreign keys', () => { + let testRule: Rule; + + beforeEach(async () => { + // Create rule for soft delete tests + testRule = await createRuleWithHierarchy(); + }); + + itHandlesSoftDelete<DetectionProgram>({ + entityFactory: () => detectionProgramFactory({ ruleId: testRule.id }), + getRepository: () => detectionProgramRepository, + queryDeletedEntity: async (id) => + typeormRepo.findOne({ + where: { id }, + withDeleted: true, + }) as unknown as WithSoftDelete<DetectionProgram>, + }); + }); +}); diff --git a/packages/linter/src/infra/repositories/DetectionProgramRepository.ts b/packages/linter/src/infra/repositories/DetectionProgramRepository.ts new file mode 100644 index 000000000..06d0c19d6 --- /dev/null +++ b/packages/linter/src/infra/repositories/DetectionProgramRepository.ts @@ -0,0 +1,265 @@ +import { Repository } from 'typeorm'; +import { PackmindLogger } from '@packmind/logger'; +import { AbstractRepository, localDataSource } from '@packmind/node-utils'; +import { + QueryOption, + ProgrammingLanguage, + RuleId, + DetectionStatus, + DetectionProgram, + DetectionProgramId, +} from '@packmind/types'; +import { IDetectionProgramRepository } from '../../domain/repositories/IDetectionProgramRepository'; +import { DetectionProgramSchema } from '../schemas/DetectionProgramSchema'; + +const origin = 'DetectionProgramRepository'; + +export class DetectionProgramRepository + extends AbstractRepository<DetectionProgram> + implements IDetectionProgramRepository +{ + constructor( + repository: Repository<DetectionProgram> = localDataSource.getRepository<DetectionProgram>( + DetectionProgramSchema, + ), + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super('detectionProgram', repository, DetectionProgramSchema, logger); + this.logger.info('DetectionProgramRepository initialized'); + } + + protected override loggableEntity( + entity: DetectionProgram, + ): Partial<DetectionProgram> { + return { + id: entity.id, + ruleId: entity.ruleId, + version: entity.version, + mode: entity.mode, + }; + } + + async list(): Promise<DetectionProgram[]> { + this.logger.info('Listing all detection programs from database'); + + try { + const programs = await this.repository.find(); + this.logger.info('Detection programs listed successfully', { + count: programs.length, + }); + return programs; + } catch (error) { + this.logger.error('Failed to list detection programs from database', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async findByRuleId( + ruleId: RuleId, + opts?: QueryOption, + ): Promise<DetectionProgram[]> { + this.logger.info('Finding detection programs by rule ID', { ruleId }); + + try { + const programs = await this.repository.find({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + where: { ruleId: ruleId as any }, // TypeORM compatibility with branded types + order: { version: 'DESC' }, // Order by version descending (latest first) + withDeleted: opts?.includeDeleted ?? false, + }); + + this.logger.info('Detection programs found by rule ID', { + ruleId, + count: programs.length, + }); + return programs; + } catch (error) { + this.logger.error('Failed to find detection programs by rule ID', { + ruleId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async findByRuleIdAndVersion( + ruleId: RuleId, + version: number, + opts?: QueryOption, + ): Promise<DetectionProgram | null> { + this.logger.info('Finding detection program by rule ID and version', { + ruleId, + version, + }); + + try { + const program = await this.repository.findOne({ + where: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ruleId: ruleId as any, // TypeORM compatibility with branded types + version, + }, + withDeleted: opts?.includeDeleted ?? false, + }); + + if (program) { + this.logger.info('Detection program found by rule ID and version', { + ruleId, + version, + programId: program.id, + }); + } else { + this.logger.warn('Detection program not found by rule ID and version', { + ruleId, + version, + }); + } + return program; + } catch (error) { + this.logger.error( + 'Failed to find detection program by rule ID and version', + { + ruleId, + version, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async findByRuleIdAndLanguage( + ruleId: RuleId, + language: string, + opts?: QueryOption, + ): Promise<DetectionProgram | null> { + this.logger.info('Finding detection program by rule ID and language', { + ruleId, + language, + }); + + try { + const program = await this.repository.findOne({ + where: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ruleId: ruleId as any, // TypeORM compatibility with branded types + // eslint-disable-next-line @typescript-eslint/no-explicit-any + language: language as any, + }, + order: { version: 'DESC' }, + withDeleted: opts?.includeDeleted ?? false, + }); + + if (program) { + this.logger.info('Detection program found by rule ID and language', { + ruleId, + language, + programId: program.id, + version: program.version, + }); + } else { + this.logger.info('No detection program found by rule ID and language', { + ruleId, + language, + }); + } + return program; + } catch (error) { + this.logger.error( + 'Failed to find detection program by rule ID and language', + { + ruleId, + language, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + + async getLatestVersionByRuleIdAndLanguage( + ruleId: RuleId, + language: ProgrammingLanguage, + ): Promise<number> { + this.logger.info('Getting latest version by rule ID', { ruleId }); + + try { + const result = await this.repository + .createQueryBuilder('detection_program') + .select('MAX(detection_program.version)', 'maxVersion') + .where('detection_program.ruleId = :ruleId', { ruleId }) + .andWhere('detection_program.language = :language', { language }) + .getRawOne(); + + const latestVersion = result?.maxVersion ?? 0; + + this.logger.info('Latest version found by rule ID', { + ruleId, + latestVersion, + }); + return latestVersion; + } catch (error) { + this.logger.error('Failed to get latest version by rule ID', { + ruleId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async updateStatus( + detectionProgramId: DetectionProgramId, + status: DetectionStatus, + ): Promise<void> { + this.logger.info('Updating detection program status', { + detectionProgramId, + status, + }); + + try { + await this.repository.update( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { id: detectionProgramId as any }, // TypeORM compatibility with branded types + { status }, + ); + + this.logger.info('Detection program status updated successfully', { + detectionProgramId, + status, + }); + } catch (error) { + this.logger.error('Failed to update detection program status', { + detectionProgramId, + status, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async softDeleteByRuleId(ruleId: RuleId): Promise<void> { + this.logger.info('Soft-deleting detection programs by rule ID', { + ruleId, + }); + + try { + const result = await this.repository.softDelete({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ruleId: ruleId as any, // TypeORM compatibility with branded types + }); + + this.logger.info('Detection programs soft-deleted by rule ID', { + ruleId, + affectedRows: result.affected, + }); + } catch (error) { + this.logger.error('Failed to soft-delete detection programs by rule ID', { + ruleId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} diff --git a/packages/linter/src/infra/repositories/LinterRepositories.ts b/packages/linter/src/infra/repositories/LinterRepositories.ts new file mode 100644 index 000000000..5070025b9 --- /dev/null +++ b/packages/linter/src/infra/repositories/LinterRepositories.ts @@ -0,0 +1,69 @@ +import { ILinterRepositories } from '../../domain/repositories/ILinterRepositories'; +import { IActiveDetectionProgramRepository } from '../../domain/repositories/IActiveDetectionProgramRepository'; +import { IDetectionProgramRepository } from '../../domain/repositories/IDetectionProgramRepository'; +import { DataSource } from 'typeorm'; +import { ActiveDetectionProgramRepository } from './ActiveDetectionProgramRepository'; +import { ActiveDetectionProgramSchema } from '../schemas/ActiveDetectionProgramSchema'; +import { DetectionProgramRepository } from './DetectionProgramRepository'; +import { DetectionProgramSchema } from '../schemas/DetectionProgramSchema'; +import { IDetectionProgramMetadataRepository } from '../../domain/repositories/IDetectionProgramMetadataRepository'; +import { IRuleDetectionHeuristicsRepository } from '../../domain/repositories/IRuleDetectionHeuristicsRepository'; +import { RuleDetectionHeuristicsRepository } from './RuleDetectionHeuristicsRepository'; +import { IRuleDetectionAssessmentRepository } from '../../domain/repositories/IRuleDetectionAssessmentRepository'; +import { RuleDetectionAssessmentRepository } from './RuleDetectionAssessmentRepository'; +import { RuleDetectionAssessmentSchema } from '../schemas/RuleDetectionAssessmentSchema'; +import { DetectionHeuristicsSchema } from '../schemas/DetectionHeuristicsSchema'; +import { DetectionProgramMetadataRepository } from './DetectionProgramMetadataRepository'; +import { DetectionProgramMetadataSchema } from '../schemas/DetectionProgramMetadataSchema'; +import { ExecutionLogSchema } from '../schemas/ExecutionLogSchema'; + +export class LinterRepositories implements ILinterRepositories { + private readonly activeDetectionProgramRepository: IActiveDetectionProgramRepository; + private readonly detectionProgramRepository: IDetectionProgramRepository; + private readonly detectionProgramMetadataRepository: IDetectionProgramMetadataRepository; + private readonly ruleDetectionHeuristicsRepository: IRuleDetectionHeuristicsRepository; + private readonly ruleDetectionAssessmentRepository: IRuleDetectionAssessmentRepository; + + constructor(private readonly dataSource: DataSource) { + this.activeDetectionProgramRepository = + new ActiveDetectionProgramRepository( + this.dataSource.getRepository(ActiveDetectionProgramSchema), + ); + this.detectionProgramRepository = new DetectionProgramRepository( + this.dataSource.getRepository(DetectionProgramSchema), + ); + this.detectionProgramMetadataRepository = + new DetectionProgramMetadataRepository( + this.dataSource.getRepository(DetectionProgramMetadataSchema), + this.dataSource.getRepository(ExecutionLogSchema), + ); + this.ruleDetectionHeuristicsRepository = + new RuleDetectionHeuristicsRepository( + this.dataSource.getRepository(DetectionHeuristicsSchema), + ); + this.ruleDetectionAssessmentRepository = + new RuleDetectionAssessmentRepository( + this.dataSource.getRepository(RuleDetectionAssessmentSchema), + ); + } + + getActiveDetectionProgramRepository(): IActiveDetectionProgramRepository { + return this.activeDetectionProgramRepository; + } + + getDetectionProgramRepository(): IDetectionProgramRepository { + return this.detectionProgramRepository; + } + + getDetectionProgramMetadataRepository(): IDetectionProgramMetadataRepository { + return this.detectionProgramMetadataRepository; + } + + getRuleDetectionHeuristicsRepository(): IRuleDetectionHeuristicsRepository { + return this.ruleDetectionHeuristicsRepository; + } + + getRuleDetectionAssessmentRepository(): IRuleDetectionAssessmentRepository { + return this.ruleDetectionAssessmentRepository; + } +} diff --git a/packages/linter/src/infra/repositories/RuleDetectionAssessmentRepository.ts b/packages/linter/src/infra/repositories/RuleDetectionAssessmentRepository.ts new file mode 100644 index 000000000..2730ab75a --- /dev/null +++ b/packages/linter/src/infra/repositories/RuleDetectionAssessmentRepository.ts @@ -0,0 +1,173 @@ +import { Repository } from 'typeorm'; +import { PackmindLogger } from '@packmind/logger'; +import { AbstractRepository, localDataSource } from '@packmind/node-utils'; +import { RuleId } from '@packmind/types'; +import { ProgrammingLanguage } from '@packmind/types'; +import { RuleDetectionAssessment } from '@packmind/types'; +import { IRuleDetectionAssessmentRepository } from '../../domain/repositories/IRuleDetectionAssessmentRepository'; +import { RuleDetectionAssessmentSchema } from '../schemas/RuleDetectionAssessmentSchema'; + +const origin = 'RuleDetectionAssessmentRepository'; + +export class RuleDetectionAssessmentRepository + extends AbstractRepository<RuleDetectionAssessment> + implements IRuleDetectionAssessmentRepository +{ + constructor( + repository: Repository<RuleDetectionAssessment> = localDataSource.getRepository<RuleDetectionAssessment>( + RuleDetectionAssessmentSchema, + ), + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super( + 'ruleDetectionAssessment', + repository, + RuleDetectionAssessmentSchema, + logger, + ); + this.logger.info('RuleDetectionAssessmentRepository initialized'); + } + + protected override loggableEntity( + entity: RuleDetectionAssessment, + ): Partial<RuleDetectionAssessment> { + return { + id: entity.id, + ruleId: entity.ruleId, + language: entity.language, + status: entity.status, + }; + } + + async get( + ruleId: RuleId, + language: ProgrammingLanguage, + ): Promise<RuleDetectionAssessment | null> { + this.logger.info( + 'Getting rule detection assessment by ruleId and language', + { + ruleId, + language, + }, + ); + + try { + const assessment = await this.repository.findOne({ + where: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ruleId: ruleId as any, // TypeORM compatibility with branded types + // eslint-disable-next-line @typescript-eslint/no-explicit-any + language: language as any, + }, + }); + + if (assessment) { + this.logger.info('Rule detection assessment found', { + ruleId, + language, + assessmentId: assessment.id, + }); + } else { + this.logger.info('Rule detection assessment not found', { + ruleId, + language, + }); + } + + return assessment; + } catch (error) { + this.logger.error('Failed to get rule detection assessment', { + ruleId, + language, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async findByRuleId(ruleId: RuleId): Promise<RuleDetectionAssessment[]> { + this.logger.info('Finding all rule detection assessments by ruleId', { + ruleId, + }); + + try { + const assessments = await this.repository.find({ + where: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ruleId: ruleId as any, // TypeORM compatibility with branded types + }, + }); + + this.logger.info('Rule detection assessments found', { + ruleId, + count: assessments.length, + }); + + return assessments; + } catch (error) { + this.logger.error('Failed to find rule detection assessments', { + ruleId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async update( + assessment: RuleDetectionAssessment, + ): Promise<RuleDetectionAssessment> { + this.logger.info('Updating rule detection assessment', { + id: assessment.id, + }); + + try { + // Verify assessment exists first + const existing = await this.findById(assessment.id); + if (!existing) { + throw new Error( + `Rule detection assessment with id ${assessment.id} not found`, + ); + } + + const updatedAssessment = await this.repository.save(assessment); + this.logger.info( + 'Updated rule detection assessment successfully', + this.loggableEntity(updatedAssessment), + ); + return updatedAssessment; + } catch (error) { + this.logger.error('Failed to update rule detection assessment', { + id: assessment.id, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async softDeleteByRuleId(ruleId: RuleId): Promise<void> { + this.logger.info('Soft-deleting rule detection assessments by rule ID', { + ruleId, + }); + + try { + const result = await this.repository.softDelete({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ruleId: ruleId as any, // TypeORM compatibility with branded types + }); + + this.logger.info('Rule detection assessments soft-deleted by rule ID', { + ruleId, + affectedRows: result.affected, + }); + } catch (error) { + this.logger.error( + 'Failed to soft-delete rule detection assessments by rule ID', + { + ruleId, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } +} diff --git a/packages/linter/src/infra/repositories/RuleDetectionHeuristicsRepository.spec.ts b/packages/linter/src/infra/repositories/RuleDetectionHeuristicsRepository.spec.ts new file mode 100644 index 000000000..cbcce8e86 --- /dev/null +++ b/packages/linter/src/infra/repositories/RuleDetectionHeuristicsRepository.spec.ts @@ -0,0 +1,441 @@ +import { RuleDetectionHeuristicsRepository } from './RuleDetectionHeuristicsRepository'; +import { DetectionHeuristicsSchema } from '../schemas/DetectionHeuristicsSchema'; +import { DataSource, Repository } from 'typeorm'; +import { makeTestDatasource, stubLogger } from '@packmind/test-utils'; +import { itHandlesSoftDelete } from '@packmind/test-utils'; +import { + createDetectionHeuristicsId, + DetectionHeuristics, + createRuleId, + ProgrammingLanguage, + Standard, + StandardVersion, + Rule, +} from '@packmind/types'; +import { WithSoftDelete } from '@packmind/node-utils'; +import { RuleSchema } from '@packmind/standards/src/infra/schemas/RuleSchema'; +import { StandardSchema } from '@packmind/standards/src/infra/schemas/StandardSchema'; +import { StandardVersionSchema } from '@packmind/standards/src/infra/schemas/StandardVersionSchema'; +import { v4 as uuidv4 } from 'uuid'; +import { PackmindLogger } from '@packmind/logger'; +import { ruleFactory } from '@packmind/standards/test/ruleFactory'; +import { standardFactory } from '@packmind/standards/test/standardFactory'; +import { standardVersionFactory } from '@packmind/standards/test/standardVersionFactory'; +import { GitCommitSchema } from '@packmind/git'; +import { detectionHeuristicsFactory } from '../../../test'; + +describe('RuleDetectionHeuristicsRepository', () => { + let datasource: DataSource; + let ruleDetectionHeuristicsRepository: RuleDetectionHeuristicsRepository; + let stubbedLogger: jest.Mocked<PackmindLogger>; + let typeormRepo: Repository<DetectionHeuristics>; + let ruleRepo: Repository<Rule>; + let standardRepo: Repository<Standard>; + let standardVersionRepo: Repository<StandardVersion>; + + const createRuleWithHierarchy = async (): Promise<Rule> => { + const standard = await standardRepo.save( + standardFactory({ slug: `standard-${uuidv4()}` }), + ); + const standardVersion = await standardVersionRepo.save( + standardVersionFactory({ standardId: standard.id }), + ); + return await ruleRepo.save( + ruleFactory({ standardVersionId: standardVersion.id }), + ); + }; + + beforeEach(async () => { + datasource = await makeTestDatasource([ + DetectionHeuristicsSchema, + RuleSchema, + StandardSchema, + StandardVersionSchema, + GitCommitSchema, + ]); + await datasource.initialize(); + await datasource.synchronize(); + + stubbedLogger = stubLogger(); + typeormRepo = datasource.getRepository(DetectionHeuristicsSchema); + ruleRepo = datasource.getRepository(RuleSchema); + standardRepo = datasource.getRepository(StandardSchema); + standardVersionRepo = datasource.getRepository(StandardVersionSchema); + + ruleDetectionHeuristicsRepository = new RuleDetectionHeuristicsRepository( + typeormRepo, + stubbedLogger, + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await datasource.destroy(); + }); + + it('can store and retrieve detection heuristics', async () => { + const rule = await createRuleWithHierarchy(); + + const heuristics = detectionHeuristicsFactory({ ruleId: rule.id }); + await ruleDetectionHeuristicsRepository.upsertHeuristics(heuristics); + + const foundHeuristics = await ruleDetectionHeuristicsRepository.findById( + heuristics.id, + ); + expect(foundHeuristics).toMatchObject({ + id: heuristics.id, + ruleId: heuristics.ruleId, + language: heuristics.language, + heuristics: heuristics.heuristics, + }); + }); + + it('can find heuristics by rule id and language', async () => { + const rule = await createRuleWithHierarchy(); + + const heuristics = detectionHeuristicsFactory({ + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['heuristic1', 'heuristic2'], + }); + await ruleDetectionHeuristicsRepository.upsertHeuristics(heuristics); + + const foundHeuristics = + await ruleDetectionHeuristicsRepository.getHeuristicsForRule( + rule.id, + ProgrammingLanguage.TYPESCRIPT, + ); + + expect(foundHeuristics).toMatchObject({ + id: heuristics.id, + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['heuristic1', 'heuristic2'], + }); + }); + + describe('when getting all heuristics for a rule across languages', () => { + let rule: Rule; + let allHeuristics: DetectionHeuristics[]; + + beforeEach(async () => { + rule = await createRuleWithHierarchy(); + + const heuristics1 = detectionHeuristicsFactory({ + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['typescript-heuristic'], + }); + const heuristics2 = detectionHeuristicsFactory({ + ruleId: rule.id, + language: ProgrammingLanguage.JAVASCRIPT, + heuristics: ['javascript-heuristic'], + }); + const heuristics3 = detectionHeuristicsFactory({ + ruleId: rule.id, + language: ProgrammingLanguage.PYTHON, + heuristics: ['python-heuristic'], + }); + + await ruleDetectionHeuristicsRepository.upsertHeuristics(heuristics1); + await ruleDetectionHeuristicsRepository.upsertHeuristics(heuristics2); + await ruleDetectionHeuristicsRepository.upsertHeuristics(heuristics3); + + allHeuristics = + await ruleDetectionHeuristicsRepository.getAllHeuristicsForRule( + rule.id, + ); + }); + + it('returns all three heuristics', async () => { + expect(allHeuristics).toHaveLength(3); + }); + + it('includes TypeScript heuristics', async () => { + expect(allHeuristics.map((h) => h.language)).toContain( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + + it('includes JavaScript heuristics', async () => { + expect(allHeuristics.map((h) => h.language)).toContain( + ProgrammingLanguage.JAVASCRIPT, + ); + }); + + it('includes Python heuristics', async () => { + expect(allHeuristics.map((h) => h.language)).toContain( + ProgrammingLanguage.PYTHON, + ); + }); + }); + + it('can update existing heuristics', async () => { + const rule = await createRuleWithHierarchy(); + + const heuristics = detectionHeuristicsFactory({ + ruleId: rule.id, + heuristics: ['original-heuristic'], + }); + await ruleDetectionHeuristicsRepository.upsertHeuristics(heuristics); + + const updatedHeuristics = ['updated-heuristic-1', 'updated-heuristic-2']; + await ruleDetectionHeuristicsRepository.updateHeuristics( + heuristics.id, + updatedHeuristics, + ); + + const foundHeuristics = + await ruleDetectionHeuristicsRepository.getHeuristicsById(heuristics.id); + + expect(foundHeuristics?.heuristics).toEqual(updatedHeuristics); + }); + + it('returns null for non-existent heuristics by id', async () => { + const foundHeuristics = + await ruleDetectionHeuristicsRepository.getHeuristicsById( + createDetectionHeuristicsId(uuidv4()), + ); + expect(foundHeuristics).toBeNull(); + }); + + it('returns null for non-existent heuristics by rule and language', async () => { + const foundHeuristics = + await ruleDetectionHeuristicsRepository.getHeuristicsForRule( + createRuleId(uuidv4()), + ProgrammingLanguage.TYPESCRIPT, + ); + expect(foundHeuristics).toBeNull(); + }); + + describe('when no heuristics exist for rule', () => { + it('returns empty array', async () => { + const allHeuristics = + await ruleDetectionHeuristicsRepository.getAllHeuristicsForRule( + createRuleId(uuidv4()), + ); + expect(allHeuristics).toEqual([]); + }); + }); + + describe('when multiple heuristics exist for same rule', () => { + let rule: Rule; + let allHeuristics: DetectionHeuristics[]; + + beforeEach(async () => { + rule = await createRuleWithHierarchy(); + + const heuristics1 = detectionHeuristicsFactory({ + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['ts-heuristic-1', 'ts-heuristic-2'], + }); + const heuristics2 = detectionHeuristicsFactory({ + ruleId: rule.id, + language: ProgrammingLanguage.JAVASCRIPT, + heuristics: ['js-heuristic-1'], + }); + const heuristics3 = detectionHeuristicsFactory({ + ruleId: rule.id, + language: ProgrammingLanguage.PYTHON, + heuristics: ['py-heuristic-1', 'py-heuristic-2', 'py-heuristic-3'], + }); + + await ruleDetectionHeuristicsRepository.upsertHeuristics(heuristics1); + await ruleDetectionHeuristicsRepository.upsertHeuristics(heuristics2); + await ruleDetectionHeuristicsRepository.upsertHeuristics(heuristics3); + + allHeuristics = + await ruleDetectionHeuristicsRepository.getAllHeuristicsForRule( + rule.id, + ); + }); + + it('returns all three heuristics', async () => { + expect(allHeuristics).toHaveLength(3); + }); + + it('returns correct TypeScript heuristics content', async () => { + const tsHeuristic = allHeuristics.find( + (h) => h.language === ProgrammingLanguage.TYPESCRIPT, + ); + expect(tsHeuristic?.heuristics).toEqual([ + 'ts-heuristic-1', + 'ts-heuristic-2', + ]); + }); + + it('returns correct JavaScript heuristics content', async () => { + const jsHeuristic = allHeuristics.find( + (h) => h.language === ProgrammingLanguage.JAVASCRIPT, + ); + expect(jsHeuristic?.heuristics).toEqual(['js-heuristic-1']); + }); + + it('returns correct Python heuristics content', async () => { + const pyHeuristic = allHeuristics.find( + (h) => h.language === ProgrammingLanguage.PYTHON, + ); + expect(pyHeuristic?.heuristics).toEqual([ + 'py-heuristic-1', + 'py-heuristic-2', + 'py-heuristic-3', + ]); + }); + }); + + describe('when updating non-existent heuristics', () => { + it('throws error', async () => { + const nonExistentId = createDetectionHeuristicsId(uuidv4()); + + await expect( + ruleDetectionHeuristicsRepository.updateHeuristics(nonExistentId, [ + 'new-heuristic', + ]), + ).rejects.toThrow( + `Detection heuristics with id ${nonExistentId} not found`, + ); + }); + }); + + describe('when inserting duplicate rule + language combination', () => { + it('throws error', async () => { + const rule = await createRuleWithHierarchy(); + + const heuristics1 = detectionHeuristicsFactory({ + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['heuristic-1'], + }); + const heuristics2 = detectionHeuristicsFactory({ + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, // Same rule + language + heuristics: ['heuristic-2'], + }); + + await ruleDetectionHeuristicsRepository.upsertHeuristics(heuristics1); + + await expect( + ruleDetectionHeuristicsRepository.upsertHeuristics(heuristics2), + ).rejects.toThrow(); + }); + + describe('when same rule has different languages', () => { + let allHeuristics: DetectionHeuristics[]; + + beforeEach(async () => { + const rule = await createRuleWithHierarchy(); + + const typescriptHeuristics = detectionHeuristicsFactory({ + ruleId: rule.id, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['ts-heuristic'], + }); + const javascriptHeuristics = detectionHeuristicsFactory({ + ruleId: rule.id, + language: ProgrammingLanguage.JAVASCRIPT, // Different language + heuristics: ['js-heuristic'], + }); + + await ruleDetectionHeuristicsRepository.upsertHeuristics( + typescriptHeuristics, + ); + await ruleDetectionHeuristicsRepository.upsertHeuristics( + javascriptHeuristics, + ); + + allHeuristics = + await ruleDetectionHeuristicsRepository.getAllHeuristicsForRule( + rule.id, + ); + }); + + it('allows both heuristics to be stored', async () => { + expect(allHeuristics).toHaveLength(2); + }); + + it('includes TypeScript heuristics', async () => { + expect(allHeuristics.map((h) => h.language)).toContain( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + + it('includes JavaScript heuristics', async () => { + expect(allHeuristics.map((h) => h.language)).toContain( + ProgrammingLanguage.JAVASCRIPT, + ); + }); + }); + + describe('when different rules have same language', () => { + let rule1Heuristics: DetectionHeuristics[]; + let rule2Heuristics: DetectionHeuristics[]; + + beforeEach(async () => { + const rule1 = await createRuleWithHierarchy(); + const rule2 = await createRuleWithHierarchy(); + + const heuristics1 = detectionHeuristicsFactory({ + ruleId: rule1.id, + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['heuristic-rule-1'], + }); + const heuristics2 = detectionHeuristicsFactory({ + ruleId: rule2.id, + language: ProgrammingLanguage.TYPESCRIPT, // Same language, different rule + heuristics: ['heuristic-rule-2'], + }); + + await ruleDetectionHeuristicsRepository.upsertHeuristics(heuristics1); + await ruleDetectionHeuristicsRepository.upsertHeuristics(heuristics2); + + rule1Heuristics = + await ruleDetectionHeuristicsRepository.getAllHeuristicsForRule( + rule1.id, + ); + rule2Heuristics = + await ruleDetectionHeuristicsRepository.getAllHeuristicsForRule( + rule2.id, + ); + }); + + it('stores exactly one heuristic for rule1', async () => { + expect(rule1Heuristics).toHaveLength(1); + }); + + it('stores exactly one heuristic for rule2', async () => { + expect(rule2Heuristics).toHaveLength(1); + }); + + it('assigns TypeScript language to rule1 heuristic', async () => { + expect(rule1Heuristics[0].language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + + it('assigns TypeScript language to rule2 heuristic', async () => { + expect(rule2Heuristics[0].language).toBe( + ProgrammingLanguage.TYPESCRIPT, + ); + }); + }); + }); + + describe('Soft-delete with valid foreign keys', () => { + let testRule: Rule; + + beforeEach(async () => { + testRule = await createRuleWithHierarchy(); + }); + + itHandlesSoftDelete<DetectionHeuristics>({ + entityFactory: () => detectionHeuristicsFactory({ ruleId: testRule.id }), + getRepository: () => ruleDetectionHeuristicsRepository, + queryDeletedEntity: async (id) => + typeormRepo.findOne({ + where: { id }, + withDeleted: true, + }) as unknown as WithSoftDelete<DetectionHeuristics>, + }); + }); +}); diff --git a/packages/linter/src/infra/repositories/RuleDetectionHeuristicsRepository.ts b/packages/linter/src/infra/repositories/RuleDetectionHeuristicsRepository.ts new file mode 100644 index 000000000..0808434e3 --- /dev/null +++ b/packages/linter/src/infra/repositories/RuleDetectionHeuristicsRepository.ts @@ -0,0 +1,193 @@ +import { Repository } from 'typeorm'; +import { PackmindLogger } from '@packmind/logger'; +import { AbstractRepository, localDataSource } from '@packmind/node-utils'; +import { + RuleId, + DetectionHeuristics, + ProgrammingLanguage, + DetectionHeuristicsId, +} from '@packmind/types'; +import { IRuleDetectionHeuristicsRepository } from '../../domain/repositories/IRuleDetectionHeuristicsRepository'; +import { DetectionHeuristicsSchema } from '../schemas/DetectionHeuristicsSchema'; + +const origin = 'RuleDetectionHeuristicsRepository'; + +export class RuleDetectionHeuristicsRepository + extends AbstractRepository<DetectionHeuristics> + implements IRuleDetectionHeuristicsRepository +{ + constructor( + repository: Repository<DetectionHeuristics> = localDataSource.getRepository<DetectionHeuristics>( + DetectionHeuristicsSchema, + ), + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super('detectionHeuristics', repository, DetectionHeuristicsSchema, logger); + this.logger.info('RuleDetectionHeuristicsRepository initialized'); + } + + protected override loggableEntity( + entity: DetectionHeuristics, + ): Partial<DetectionHeuristics> { + return { + id: entity.id, + ruleId: entity.ruleId, + language: entity.language, + }; + } + + async upsertHeuristics(heuristic: DetectionHeuristics): Promise<void> { + this.logger.info('Upserting detection heuristics', { + ruleId: heuristic.ruleId, + language: heuristic.language, + }); + + try { + await this.repository.save(heuristic); + this.logger.info('Detection heuristics upserted successfully', { + id: heuristic.id, + ruleId: heuristic.ruleId, + language: heuristic.language, + }); + } catch (error) { + this.logger.error('Failed to upsert detection heuristics', { + ruleId: heuristic.ruleId, + language: heuristic.language, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async getHeuristicsForRule( + ruleId: RuleId, + language: ProgrammingLanguage, + ): Promise<DetectionHeuristics | null> { + this.logger.info('Getting detection heuristics by ruleId and language', { + ruleId, + language, + }); + + try { + const heuristics = await this.repository.findOne({ + where: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ruleId: ruleId as any, // TypeORM compatibility with branded types + // eslint-disable-next-line @typescript-eslint/no-explicit-any + language: language as any, + }, + }); + + if (heuristics) { + this.logger.info('Detection heuristics found', { + ruleId, + language, + heuristicsId: heuristics.id, + }); + } else { + this.logger.info('Detection heuristics not found', { + ruleId, + language, + }); + } + + return heuristics; + } catch (error) { + this.logger.error('Failed to get detection heuristics', { + ruleId, + language, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async getAllHeuristicsForRule( + ruleId: RuleId, + ): Promise<DetectionHeuristics[]> { + this.logger.info('Getting all detection heuristics for rule', { ruleId }); + + try { + const heuristics = await this.repository.find({ + where: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ruleId: ruleId as any, // TypeORM compatibility with branded types + }, + }); + + this.logger.info('Detection heuristics found for rule', { + ruleId, + count: heuristics.length, + }); + + return heuristics; + } catch (error) { + this.logger.error('Failed to get all detection heuristics for rule', { + ruleId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async updateHeuristics( + id: DetectionHeuristicsId, + heuristics: string[], + ): Promise<void> { + this.logger.info('Updating detection heuristics', { id }); + + try { + const existing = await this.findById(id); + if (!existing) { + throw new Error(`Detection heuristics with id ${id} not found`); + } + + const updated: DetectionHeuristics = { + ...existing, + heuristics, + }; + + await this.repository.save(updated); + this.logger.info('Detection heuristics updated successfully', { id }); + } catch (error) { + this.logger.error('Failed to update detection heuristics', { + id, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async getHeuristicsById( + id: DetectionHeuristicsId, + ): Promise<DetectionHeuristics | null> { + return this.findById(id); + } + + async softDeleteByRuleId(ruleId: RuleId): Promise<void> { + this.logger.info('Soft-deleting detection heuristics by rule ID', { + ruleId, + }); + + try { + const result = await this.repository.softDelete({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ruleId: ruleId as any, // TypeORM compatibility with branded types + }); + + this.logger.info('Detection heuristics soft-deleted by rule ID', { + ruleId, + affectedRows: result.affected, + }); + } catch (error) { + this.logger.error( + 'Failed to soft-delete detection heuristics by rule ID', + { + ruleId, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } +} diff --git a/packages/linter/src/infra/schemas/ActiveDetectionProgramSchema.ts b/packages/linter/src/infra/schemas/ActiveDetectionProgramSchema.ts new file mode 100644 index 000000000..d22a84492 --- /dev/null +++ b/packages/linter/src/infra/schemas/ActiveDetectionProgramSchema.ts @@ -0,0 +1,92 @@ +import { EntitySchema } from 'typeorm'; +import { + WithTimestamps, + WithSoftDelete, + uuidSchema, + timestampsSchemas, + softDeleteSchemas, +} from '@packmind/node-utils'; +import { Rule } from '@packmind/types'; +import { ActiveDetectionProgram, DetectionProgram } from '@packmind/types'; + +export const ActiveDetectionProgramSchema = new EntitySchema< + WithSoftDelete< + WithTimestamps< + ActiveDetectionProgram & { + rule?: Rule; + detectionProgram?: DetectionProgram; + draftDetectionProgram?: DetectionProgram; + } + > + > +>({ + name: 'ActiveDetectionProgram', + tableName: 'active_detection_programs', + columns: { + detectionProgramVersion: { + name: 'detection_program_version', + type: 'uuid', + nullable: true, + }, + ruleId: { + name: 'rule_id', + type: 'uuid', + }, + language: { + type: 'varchar', + length: 50, + }, + detectionProgramDraftVersion: { + name: 'detection_program_draft_version', + type: 'uuid', + nullable: true, + }, + severity: { + name: 'severity', + type: 'varchar', + length: 10, + default: 'error', + }, + ...uuidSchema, + ...timestampsSchemas, + ...softDeleteSchemas, + }, + relations: { + rule: { + type: 'many-to-one', + target: 'Rule', + joinColumn: { + name: 'rule_id', + }, + onDelete: 'CASCADE', + }, + detectionProgram: { + type: 'many-to-one', + target: 'DetectionProgram', + joinColumn: { + name: 'detection_program_version', + }, + onDelete: 'CASCADE', + }, + draftDetectionProgram: { + type: 'many-to-one', + target: 'DetectionProgram', + joinColumn: { + name: 'detection_program_draft_version', + }, + onDelete: 'SET NULL', + }, + }, + indices: [ + { + name: 'idx_active_detection_programs_rule_id', + columns: ['ruleId'], + }, + ], + uniques: [ + { + name: 'uq_active_detection_programs_rule_language', + columns: ['ruleId', 'language'], + }, + ], +}); diff --git a/packages/linter/src/infra/schemas/DetectionHeuristicsSchema.ts b/packages/linter/src/infra/schemas/DetectionHeuristicsSchema.ts new file mode 100644 index 000000000..5f9a51ba1 --- /dev/null +++ b/packages/linter/src/infra/schemas/DetectionHeuristicsSchema.ts @@ -0,0 +1,63 @@ +import { EntitySchema } from 'typeorm'; +import { + WithTimestamps, + WithSoftDelete, + uuidSchema, + timestampsSchemas, + softDeleteSchemas, +} from '@packmind/node-utils'; +import { DetectionHeuristics, Rule } from '@packmind/types'; + +export const DetectionHeuristicsSchema = new EntitySchema< + WithSoftDelete< + WithTimestamps< + DetectionHeuristics & { + rule?: Rule; + } + > + > +>({ + name: 'DetectionHeuristics', + tableName: 'detection_heuristics', + columns: { + ruleId: { + name: 'rule_id', + type: 'uuid', + nullable: false, + }, + language: { + type: 'varchar', + nullable: false, + }, + heuristics: { + type: 'text', + array: true, + nullable: false, + }, + ...uuidSchema, + ...timestampsSchemas, + ...softDeleteSchemas, + }, + relations: { + rule: { + type: 'many-to-one', + target: 'Rule', + joinColumn: { + name: 'rule_id', + }, + onDelete: 'CASCADE', + }, + }, + indices: [ + { + name: 'idx_detection_heuristics_rule_id', + columns: ['ruleId'], + }, + { + name: 'uq_detection_heuristics_rule_language', + columns: ['ruleId', 'language'], + unique: true, + where: 'deleted_at IS NULL', + }, + ], +}); diff --git a/packages/linter/src/infra/schemas/DetectionProgramMetadataSchema.ts b/packages/linter/src/infra/schemas/DetectionProgramMetadataSchema.ts new file mode 100644 index 000000000..86cc3b02d --- /dev/null +++ b/packages/linter/src/infra/schemas/DetectionProgramMetadataSchema.ts @@ -0,0 +1,71 @@ +import { EntitySchema } from 'typeorm'; +import { + WithTimestamps, + WithSoftDelete, + uuidSchema, + timestampsSchemas, + softDeleteSchemas, +} from '@packmind/node-utils'; +import { DetectionProgramMetadata, DetectionProgram } from '@packmind/types'; + +export const DetectionProgramMetadataSchema = new EntitySchema< + WithSoftDelete< + WithTimestamps< + DetectionProgramMetadata & { + detectionProgram?: DetectionProgram; + } + > + > +>({ + name: 'DetectionProgramMetadata', + tableName: 'detection_program_metadata', + columns: { + detectionProgramId: { + name: 'detection_program_id', + type: 'uuid', + nullable: false, + }, + taskId: { + name: 'task_id', + type: 'varchar', + nullable: false, + }, + tokens: { + type: 'jsonb', + nullable: true, + }, + programDescription: { + name: 'program_description', + type: 'text', + nullable: false, + }, + ...uuidSchema, + ...timestampsSchemas, + ...softDeleteSchemas, + }, + relations: { + detectionProgram: { + type: 'many-to-one', + target: 'DetectionProgram', + joinColumn: { + name: 'detection_program_id', + }, + onDelete: 'CASCADE', + }, + logs: { + type: 'one-to-many', + target: 'ExecutionLog', + inverseSide: 'detectionProgramMetadata', + }, + }, + indices: [ + { + name: 'idx_detection_program_metadata_detection_program_id', + columns: ['detectionProgramId'], + }, + { + name: 'idx_detection_program_metadata_task_id', + columns: ['taskId'], + }, + ], +}); diff --git a/packages/linter/src/infra/schemas/DetectionProgramSchema.ts b/packages/linter/src/infra/schemas/DetectionProgramSchema.ts new file mode 100644 index 000000000..8fb893f38 --- /dev/null +++ b/packages/linter/src/infra/schemas/DetectionProgramSchema.ts @@ -0,0 +1,81 @@ +import { EntitySchema } from 'typeorm'; +import { + DetectionProgram, + DetectionModeEnum, + DetectionStatus, + Rule, +} from '@packmind/types'; +import { + WithTimestamps, + WithSoftDelete, + uuidSchema, + timestampsSchemas, + softDeleteSchemas, +} from '@packmind/node-utils'; + +export const DetectionProgramSchema = new EntitySchema< + WithSoftDelete< + WithTimestamps< + DetectionProgram & { + rule?: Rule; + } + > + > +>({ + name: 'DetectionProgram', + tableName: 'detection_programs', + columns: { + code: { + type: 'text', + }, + version: { + type: 'int', + }, + mode: { + type: 'enum', + enum: DetectionModeEnum, + }, + ruleId: { + name: 'rule_id', + type: 'uuid', + }, + language: { + type: 'varchar', + nullable: false, + }, + status: { + type: 'enum', + enum: DetectionStatus, + nullable: false, + }, + sourceCodeState: { + name: 'source_code_state', + type: 'varchar', + nullable: false, + }, + ...uuidSchema, + ...timestampsSchemas, + ...softDeleteSchemas, + }, + relations: { + rule: { + type: 'many-to-one', + target: 'Rule', + joinColumn: { + name: 'rule_id', + }, + onDelete: 'CASCADE', + }, + }, + indices: [ + { + name: 'idx_detection_programs_rule_id', + columns: ['ruleId'], + }, + { + name: 'idx_detection_programs_rule_language_unique_version', + columns: ['ruleId', 'language', 'version'], + unique: true, + }, + ], +}); diff --git a/packages/linter/src/infra/schemas/ExecutionLogSchema.ts b/packages/linter/src/infra/schemas/ExecutionLogSchema.ts new file mode 100644 index 000000000..d8517b5a1 --- /dev/null +++ b/packages/linter/src/infra/schemas/ExecutionLogSchema.ts @@ -0,0 +1,66 @@ +import { EntitySchema } from 'typeorm'; +import { + WithTimestamps, + WithSoftDelete, + uuidSchema, + timestampsSchemas, + softDeleteSchemas, +} from '@packmind/node-utils'; +import { ExecutionLog, DetectionProgramMetadata } from '@packmind/types'; + +export const ExecutionLogSchema = new EntitySchema< + WithSoftDelete< + WithTimestamps< + ExecutionLog & { + id: string; + detectionProgramMetadataId: string; + detectionProgramMetadata?: DetectionProgramMetadata; + } + > + > +>({ + name: 'ExecutionLog', + tableName: 'execution_logs', + columns: { + detectionProgramMetadataId: { + name: 'detection_program_metadata_id', + type: 'uuid', + nullable: false, + }, + timestamp: { + type: 'bigint', + nullable: false, + }, + message: { + type: 'text', + nullable: false, + }, + metadata: { + type: 'jsonb', + nullable: true, + }, + ...uuidSchema, + ...timestampsSchemas, + ...softDeleteSchemas, + }, + relations: { + detectionProgramMetadata: { + type: 'many-to-one', + target: 'DetectionProgramMetadata', + joinColumn: { + name: 'detection_program_metadata_id', + }, + onDelete: 'CASCADE', + }, + }, + indices: [ + { + name: 'idx_execution_logs_detection_program_metadata_id', + columns: ['detectionProgramMetadataId'], + }, + { + name: 'idx_execution_logs_timestamp', + columns: ['timestamp'], + }, + ], +}); diff --git a/packages/linter/src/infra/schemas/RuleDetectionAssessmentSchema.ts b/packages/linter/src/infra/schemas/RuleDetectionAssessmentSchema.ts new file mode 100644 index 000000000..ba068e9cb --- /dev/null +++ b/packages/linter/src/infra/schemas/RuleDetectionAssessmentSchema.ts @@ -0,0 +1,79 @@ +import { EntitySchema } from 'typeorm'; +import { + WithTimestamps, + WithSoftDelete, + uuidSchema, + timestampsSchemas, + softDeleteSchemas, +} from '@packmind/node-utils'; +import { Rule } from '@packmind/types'; +import { + RuleDetectionAssessment, + RuleDetectionAssessmentStatus, + DetectionModeEnum, +} from '@packmind/types'; + +export const RuleDetectionAssessmentSchema = new EntitySchema< + WithSoftDelete< + WithTimestamps< + RuleDetectionAssessment & { + rule?: Rule; + } + > + > +>({ + name: 'RuleDetectionAssessment', + tableName: 'rule_detection_assessments', + columns: { + ruleId: { + name: 'rule_id', + type: 'uuid', + }, + language: { + type: 'varchar', + nullable: false, + }, + detectionMode: { + name: 'detection_mode', + type: 'enum', + enum: DetectionModeEnum, + nullable: false, + }, + status: { + type: 'enum', + enum: RuleDetectionAssessmentStatus, + nullable: false, + }, + details: { + type: 'text', + nullable: false, + }, + clarificationQuestion: { + name: 'clarification_question', + type: 'text', + nullable: true, + }, + clarificationAnswers: { + name: 'clarification_answers', + type: 'text', + nullable: true, + transformer: { + to: (value: string[] | null) => (value ? JSON.stringify(value) : null), + from: (value: string | null) => (value ? JSON.parse(value) : null), + }, + }, + ...uuidSchema, + ...timestampsSchemas, + ...softDeleteSchemas, + }, + relations: { + rule: { + type: 'many-to-one', + target: 'Rule', + joinColumn: { + name: 'rule_id', + }, + onDelete: 'CASCADE', + }, + }, +}); diff --git a/packages/linter/src/infra/schemas/index.ts b/packages/linter/src/infra/schemas/index.ts new file mode 100644 index 000000000..4bff39fc8 --- /dev/null +++ b/packages/linter/src/infra/schemas/index.ts @@ -0,0 +1,24 @@ +import { DetectionProgramSchema } from './DetectionProgramSchema'; +import { ActiveDetectionProgramSchema } from './ActiveDetectionProgramSchema'; +import { RuleDetectionAssessmentSchema } from './RuleDetectionAssessmentSchema'; +import { DetectionHeuristicsSchema } from './DetectionHeuristicsSchema'; +import { DetectionProgramMetadataSchema } from './DetectionProgramMetadataSchema'; +import { ExecutionLogSchema } from './ExecutionLogSchema'; + +export { + DetectionProgramSchema, + ActiveDetectionProgramSchema, + RuleDetectionAssessmentSchema, + DetectionHeuristicsSchema, + DetectionProgramMetadataSchema, + ExecutionLogSchema, +}; + +export const linterSchemas = [ + DetectionProgramSchema, + ActiveDetectionProgramSchema, + RuleDetectionAssessmentSchema, + DetectionHeuristicsSchema, + DetectionProgramMetadataSchema, + ExecutionLogSchema, +]; diff --git a/packages/linter/src/nest-api/linter/linter.controller.ts b/packages/linter/src/nest-api/linter/linter.controller.ts new file mode 100644 index 000000000..ee8efc419 --- /dev/null +++ b/packages/linter/src/nest-api/linter/linter.controller.ts @@ -0,0 +1,1900 @@ +import { + BadRequestException, + Body, + Controller, + ForbiddenException, + Get, + NotFoundException, + Param, + Patch, + Post, + Put, + Query, + Req, +} from '@nestjs/common'; +import { PackmindLogger } from '@packmind/logger'; +import { + stringToProgrammingLanguage, + ProgrammingLanguage, +} from '@packmind/types'; +import { AuthenticatedRequest } from '@packmind/node-utils'; +import { + RuleId, + StandardId, + DetectionProgram, + DetectionProgramId, + DetectionModeEnum, + CreateNewDetectionProgramVersionCommand, + ActiveDetectionProgram, + ActiveDetectionProgramId, + LanguageDetectionPrograms, + GetActiveDetectionProgramCommand, + CreateDetectionProgramCommand, + StartProgramGenerationCommand, + ListDetectionProgramCommand, + ListDetectionProgramResponse, + GetAllDetectionProgramsByRuleCommand, + GetDetectionProgramMetadataCommand, + DetectionProgramMetadata, + UpdateActiveDetectionProgramCommand, + GetRuleDetectionAssessmentCommand, + RuleDetectionAssessment, + GetDraftDetectionProgramForRuleCommand, + GetActiveDetectionProgramForRuleCommand, + ComputeRuleLanguageDetectionStatusCommand, + GetStandardRulesDetectionStatusCommand, + RuleDetectionStatusSummary, + TestProgramExecutionCommand, + LinterExecutionViolation, + DetectionHeuristicsId, + UpdateRuleDetectionHeuristicsCommand, + GetDetectionHeuristicsCommand, + DetectionHeuristics, + GetDetectionProgramsForPackagesCommand, + GetDetectionProgramsForPackagesResponse, + DetectionSeverity, + UpdateActiveDetectionProgramSeverityCommand, + GetActiveDetectionProgramForRuleResponse, + GetDraftDetectionProgramForRuleResponse, +} from '@packmind/types'; +import { LinterHexa } from '../../LinterHexa'; +import { LinterService } from './linter.service'; +import { + RuleNotFoundForProgramGenerationError, + RuleNotLinkedToStandardForProgramGenerationError, + StandardNotFoundForProgramGenerationError, + UnauthorizedProgramGenerationError, +} from '../../application/useCases/generateProgramUseCase/shared/errors'; +import { ActiveDetectionProgramNotFoundError } from '../../domain/errors/ActiveDetectionProgramNotFoundError'; +import { DetectionProgramNotFoundError } from '../../domain/errors/DetectionProgramNotFoundError'; +import { UnauthorizedTestProgramExecutionError } from '../../domain/errors/UnauthorizedTestProgramExecutionError'; + +const origin = 'LinterController'; + +@Controller('') +export class LinterController { + constructor( + private readonly linterService: LinterService, + private readonly linterHexa: LinterHexa, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) { + this.logger.info('RulesController initialized'); + } + + @Post('standards/:standardId/rules/:ruleId/detection-program/generate') + async generateProgram( + @Param('standardId') standardId: StandardId, + @Param('ruleId') ruleId: RuleId, + @Req() request: AuthenticatedRequest, + @Body() body: { language?: string } = {}, + ): Promise<void> { + const organizationId = request.organization.id; + this.logger.info( + 'POST /standards/:standardId/rules/:ruleId/detection-program/generate - Generating program for rule', + { + organizationId, + standardId, + ruleId, + language: body?.language, + }, + ); + + try { + const requestOrganizationId = request.organization?.id; + const requestUserId = request.user?.userId; + + if (!requestOrganizationId || !requestUserId) { + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program/generate - Missing user or organization context', + { + organizationId: requestOrganizationId, + userId: requestUserId, + standardId, + ruleId, + }, + ); + throw new BadRequestException('User authentication required'); + } + + let requestedLanguage = undefined; + if (body?.language) { + try { + requestedLanguage = stringToProgrammingLanguage(body.language); + } catch (conversionError) { + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program/generate - Invalid language provided', + { + standardId, + ruleId, + organizationId, + language: body.language, + error: + conversionError instanceof Error + ? conversionError.message + : String(conversionError), + }, + ); + throw new BadRequestException('Invalid programming language'); + } + } + + // Create command for LinterHexa + const command: StartProgramGenerationCommand = { + userId: requestUserId, + organizationId: requestOrganizationId, + ruleId, + language: requestedLanguage, + }; + + // Use LinterHexa to generate the program + const response = await this.linterHexa + .getAdapter() + .startGenerateProgram(command); + + this.logger.info( + 'POST /standards/:standardId/rules/:ruleId/detection-program/generate - Program generation job submitted successfully', + { + organizationId, + standardId, + ruleId, + message: response.message, + }, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program/generate - Failed to submit program generation job', + { + organizationId, + standardId, + ruleId, + error: errorMessage, + }, + ); + + if (error instanceof StandardNotFoundForProgramGenerationError) { + throw new NotFoundException(errorMessage); + } + if (error instanceof RuleNotFoundForProgramGenerationError) { + throw new NotFoundException(errorMessage); + } + if (error instanceof RuleNotLinkedToStandardForProgramGenerationError) { + throw new BadRequestException(errorMessage); + } + if (error instanceof UnauthorizedProgramGenerationError) { + throw new ForbiddenException(errorMessage); + } + + if ( + error instanceof BadRequestException || + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + throw new BadRequestException(errorMessage); + } + } + + @Post('standards/:standardId/rules/:ruleId/detection-program') + async createDetectionProgram( + @Param('standardId') standardId: StandardId, + @Param('ruleId') ruleId: RuleId, + @Body() + body: { + code: string; + language: string; + mode: DetectionModeEnum; + }, + @Req() request: AuthenticatedRequest, + ): Promise<DetectionProgram> { + this.logger.info( + 'POST /standards/:standardId/rules/:ruleId/detection-program - Creating detection program', + { + standardId, + ruleId, + language: body.language, + mode: body.mode, + userId: request.user?.userId, + organizationId: request.organization?.id, + }, + ); + + try { + // Extract user and organization context from authenticated request + const organizationId = request.organization?.id; + const userId = request.user?.userId; + + if (!organizationId || !userId) { + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program - Missing user or organization context', + { + standardId, + ruleId, + userId, + organizationId, + }, + ); + throw new BadRequestException('User authentication required'); + } + + // Validate request body + if (!body.code || typeof body.code !== 'string') { + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program - Detection program code is required', + ); + throw new BadRequestException('Detection program code is required'); + } + + if (!body.language || typeof body.language !== 'string') { + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program - Language is required', + ); + throw new BadRequestException('Language is required'); + } + + if (!body.mode || !Object.values(DetectionModeEnum).includes(body.mode)) { + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program - Valid mode is required', + { + providedMode: body.mode, + validModes: Object.values(DetectionModeEnum), + }, + ); + throw new BadRequestException( + `Valid mode is required. Supported modes: ${Object.values(DetectionModeEnum).join(', ')}`, + ); + } + + const command: CreateDetectionProgramCommand = { + organizationId: request.organization.id, + userId: request.user.userId, + ruleId, + language: stringToProgrammingLanguage(body.language), + mode: body.mode, + code: body.code, + }; + + // const command = this.authService.makePackmindCommand(request, { + // ruleId, + // language: body.language, + // mode: body.mode, + // code: body.code, + // }) as CreateDetectionProgramCommand; + + // Create detection program using actual use case + const result = await this.linterService.createDetectionProgram(command); + + this.logger.info( + 'POST /standards/:standardId/rules/:ruleId/detection-program - Detection program created successfully', + { + standardId, + ruleId, + language: body.language, + mode: body.mode, + detectionProgramId: result.id, + }, + ); + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program - Failed to create detection program', + { + standardId, + ruleId, + language: body?.language, + mode: body?.mode, + error: errorMessage, + }, + ); + // Return the use case error to the caller rather than a generic 500 + if ( + error instanceof BadRequestException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException(errorMessage); + } + } + + @Put( + 'standards/:standardId/rules/:ruleId/detection-program/:detectionProgramId', + ) + async updateDetectionProgram( + @Param('standardId') standardId: StandardId, + @Param('ruleId') ruleId: RuleId, + @Param('detectionProgramId') detectionProgramId: ActiveDetectionProgramId, + @Body() + body: { + code: string; + mode?: DetectionModeEnum; + }, + @Req() request: AuthenticatedRequest, + ): Promise<DetectionProgram> { + this.logger.info( + 'PUT /standards/:standardId/rules/:ruleId/detection-program/:detectionProgramId - Updating detection program', + { + standardId, + ruleId, + detectionProgramId, + mode: body.mode, + code: body.code, + }, + ); + + try { + // Validate request body + if (!body.code || typeof body.code !== 'string') { + this.logger.error( + 'PUT /standards/:standardId/rules/:ruleId/detection-program/:detectionProgramId - Detection program code is required', + ); + throw new BadRequestException('Detection program code is required'); + } + + if (body.mode && !Object.values(DetectionModeEnum).includes(body.mode)) { + this.logger.error( + 'PUT /standards/:standardId/rules/:ruleId/detection-program/:detectionProgramId - Valid mode is required', + { + providedMode: body.mode, + validModes: Object.values(DetectionModeEnum), + }, + ); + throw new BadRequestException( + `Valid mode is required. Supported modes: ${Object.values(DetectionModeEnum).join(', ')}`, + ); + } + + const command: CreateNewDetectionProgramVersionCommand = { + activeDetectionProgramId: detectionProgramId, + code: body.code, + mode: body.mode, + organizationId: request.organization.id, + userId: request.user.userId, + }; + // const command = this.authService.makePackmindCommand(request, { + // activeDetectionProgramId: detectionProgramId, + // code: body.code, + // mode: body.mode, + // }) as CreateNewDetectionProgramVersionCommand; + + const result = + await this.linterService.createNewDetectionProgramVersion(command); + + this.logger.info( + 'PUT /standards/:standardId/rules/:ruleId/detection-program/:detectionProgramId - Detection program updated successfully', + { + standardId, + ruleId, + mode: body.mode, + code: body.code, + detectionProgramId: result.id, + }, + ); + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'PUT /standards/:standardId/rules/:ruleId/detection-program/:detectionProgramId - Failed to update detection program', + { + standardId, + ruleId, + mode: body.mode, + code: body.code, + }, + ); + // Return the use case error to the caller rather than a generic 500 + if ( + error instanceof BadRequestException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException(errorMessage); + } + } + + @Post( + 'standards/:standardId/rules/:ruleId/detection-program/:activeDetectionProgramId/activate', + ) + async activateDetectionProgram( + @Param('standardId') standardId: StandardId, + @Param('ruleId') ruleId: RuleId, + @Param('activeDetectionProgramId') + activeDetectionProgramId: ActiveDetectionProgramId, + @Body() + body: { + detectionProgramId: string; + }, + @Req() request: AuthenticatedRequest, + ): Promise<ActiveDetectionProgram> { + this.logger.info( + 'POST /standards/:standardId/rules/:ruleId/detection-program/:activeDetectionProgramId/activate - Activating detection program', + { + standardId, + ruleId, + activeDetectionProgramId, + detectionProgramId: body?.detectionProgramId, + }, + ); + + try { + const organizationId = request.organization?.id; + const userId = request.user?.userId; + + if (!organizationId || !userId) { + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program/:activeDetectionProgramId/activate - Missing user or organization context', + { + standardId, + ruleId, + activeDetectionProgramId, + }, + ); + throw new BadRequestException('User authentication required'); + } + + const { detectionProgramId } = body; + + if (!detectionProgramId || typeof detectionProgramId !== 'string') { + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program/:activeDetectionProgramId/activate - detectionProgramId is required', + { + standardId, + ruleId, + activeDetectionProgramId, + }, + ); + throw new BadRequestException('detectionProgramId is required'); + } + + const activeDetectionProgram = + await this.linterService.getActiveDetectionProgramById( + activeDetectionProgramId, + ); + + if (!activeDetectionProgram || activeDetectionProgram.ruleId !== ruleId) { + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program/:activeDetectionProgramId/activate - Active detection program not found', + { + standardId, + ruleId, + activeDetectionProgramId, + }, + ); + throw new NotFoundException('Active detection program not found'); + } + + const command: UpdateActiveDetectionProgramCommand = { + organizationId, + userId, + activeDetectionProgram, + newDetectionProgramVersion: detectionProgramId as DetectionProgramId, + newDetectionProgramDraftVersion: null, + }; + + const result = + await this.linterService.updateActiveDetectionProgram(command); + + this.logger.info( + 'POST /standards/:standardId/rules/:ruleId/detection-program/:activeDetectionProgramId/activate - Detection program activated successfully', + { + standardId, + ruleId, + activeDetectionProgramId, + detectionProgramId, + }, + ); + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program/:activeDetectionProgramId/activate - Failed to activate detection program', + { + standardId, + ruleId, + activeDetectionProgramId, + detectionProgramId: body?.detectionProgramId, + error: errorMessage, + }, + ); + + if ( + error instanceof BadRequestException || + error instanceof NotFoundException + ) { + throw error; + } + + throw new BadRequestException(errorMessage); + } + } + + @Patch( + 'standards/:standardId/rules/:ruleId/detection-program/:activeDetectionProgramId/severity', + ) + async updateActiveDetectionProgramSeverity( + @Param('standardId') standardId: StandardId, + @Param('ruleId') ruleId: RuleId, + @Param('activeDetectionProgramId') + activeDetectionProgramId: ActiveDetectionProgramId, + @Body() body: { severity: string }, + @Req() request: AuthenticatedRequest, + ): Promise<ActiveDetectionProgram> { + this.logger.info( + 'PATCH /standards/:standardId/rules/:ruleId/detection-program/:activeDetectionProgramId/severity - Updating severity', + { + standardId, + ruleId, + activeDetectionProgramId, + }, + ); + + try { + const organizationId = request.organization?.id; + const userId = request.user?.userId; + + if (!organizationId || !userId) { + throw new BadRequestException('User authentication required'); + } + + const { severity } = body; + + if ( + severity !== DetectionSeverity.ERROR && + severity !== DetectionSeverity.WARNING + ) { + throw new BadRequestException( + `Invalid severity value: ${severity}. Must be '${DetectionSeverity.ERROR}' or '${DetectionSeverity.WARNING}'`, + ); + } + + const command: UpdateActiveDetectionProgramSeverityCommand = { + organizationId, + userId, + activeDetectionProgramId, + ruleId, + severity, + }; + + const result = + await this.linterService.updateActiveDetectionProgramSeverity(command); + + this.logger.info( + 'PATCH /standards/:standardId/rules/:ruleId/detection-program/:activeDetectionProgramId/severity - Severity updated', + { + standardId, + ruleId, + activeDetectionProgramId, + severity: result.severity, + }, + ); + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'PATCH /standards/:standardId/rules/:ruleId/detection-program/:activeDetectionProgramId/severity - Failed', + { + standardId, + ruleId, + activeDetectionProgramId, + error: errorMessage, + }, + ); + + if (error instanceof ActiveDetectionProgramNotFoundError) { + throw new NotFoundException(errorMessage); + } + + if ( + error instanceof BadRequestException || + error instanceof NotFoundException + ) { + throw error; + } + + throw new BadRequestException(errorMessage); + } + } + + @Get('standards/:standardId/rules/:ruleId/detection-program') + async getActiveDetectionProgram( + @Param('standardId') standardId: StandardId, + @Param('ruleId') ruleId: RuleId, + @Req() request: AuthenticatedRequest, + @Query('language') language?: string, + ): Promise<LanguageDetectionPrograms[] | LanguageDetectionPrograms | null> { + this.logger.info( + 'GET /standards/:standardId/rules/:ruleId/detection-program - Getting active detection program', + { + standardId, + ruleId, + language, + userId: request.user?.userId, + organizationId: request.organization?.id, + }, + ); + + try { + const command: GetActiveDetectionProgramCommand = { + organizationId: request.organization.id, + userId: request.user.userId, + ruleId, + language, + }; + // const command = this.authService.makePackmindCommand(request, { + // ruleId, + // language, + // }) as GetActiveDetectionProgramCommand; + + // Get active detection programs using actual use case + const response = + await this.linterService.getActiveDetectionProgram(command); + + // If language is specified, return single object; otherwise return array + const result = + language && response.programs && response.programs.length > 0 + ? response.programs[0] + : response.programs || []; + + this.logger.info( + 'GET /standards/:standardId/rules/:ruleId/detection-program - Active detection program retrieved successfully', + { + standardId, + ruleId, + language, + resultType: language ? 'single' : 'array', + programsCount: Array.isArray(result) ? result.length : result ? 1 : 0, + }, + ); + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'GET /standards/:standardId/rules/:ruleId/detection-program - Failed to get active detection program', + { + standardId, + ruleId, + language, + error: errorMessage, + }, + ); + // Return the use case error to the caller rather than a generic 500 + if ( + error instanceof BadRequestException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException(errorMessage); + } + } + + @Post('list-detection-program') + async listDetectionPrograms( + @Body() body: { gitRemoteUrl: string; branches: string[] }, + @Req() request: AuthenticatedRequest, + ): Promise<ListDetectionProgramResponse> { + const { gitRemoteUrl, branches } = body; + this.logger.info( + 'GET /standards/list-detection-program - Listing detection programs', + { + gitRemoteUrl, + branches, + userId: request.user?.userId, + organizationId: request.organization?.id, + }, + ); + + try { + // Extract user and organization context from authenticated request + const organizationId = request.organization?.id; + const userId = request.user?.userId; + + // Validate gitRemoteUrl parameter + if (!gitRemoteUrl || gitRemoteUrl.trim() === '') { + this.logger.error( + 'GET /standards/list-detection-program - gitRemoteUrl parameter is required', + { + gitRemoteUrl, + organizationId, + userId, + }, + ); + throw new BadRequestException( + 'gitRemoteUrl parameter is required and cannot be empty', + ); + } + + // Validate branches parameter + if (!branches || !Array.isArray(branches) || branches.length === 0) { + this.logger.error( + 'GET /standards/list-detection-program - branches parameter is required', + { + branches, + organizationId, + userId, + }, + ); + throw new BadRequestException( + 'branches parameter is required and must be a non-empty array', + ); + } + + const command: ListDetectionProgramCommand = { + organizationId: request.organization.id, + userId: request.user.userId, + gitRemoteUrl: gitRemoteUrl.trim(), + branches, + }; + // const command = this.authService.makePackmindCommand(request, { + // gitRemoteUrl: gitRemoteUrl.trim(), + // }) as ListDetectionProgramCommand; + + // Call the service method to list detection programs + const result = await this.linterService.listDetectionPrograms(command); + + this.logger.info( + 'GET /standards/list-detection-program - Detection programs listed successfully', + { + gitRemoteUrl, + organizationId, + userId, + targetsCount: result.targets.length, + totalStandards: result.targets.reduce( + (sum, target) => sum + target.standards.length, + 0, + ), + }, + ); + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'GET /standards/list-detection-program - Failed to list detection programs', + { + gitRemoteUrl, + organizationId: request.organization?.id, + userId: request.user?.userId, + error: errorMessage, + }, + ); + + // Return appropriate HTTP status codes for different error scenarios + if (error instanceof BadRequestException) { + throw error; + } + + // For other errors, wrap in BadRequestException to maintain consistency + throw new BadRequestException(errorMessage); + } + } + + @Post('track-execution') + async trackLinterExecution( + @Body() + body: { + targetCount: number; + standardCount: number; + }, + @Req() request: AuthenticatedRequest, + ): Promise<{ success: boolean }> { + const { targetCount, standardCount } = body; + this.logger.info('POST /track-execution - Tracking linter execution', { + targetCount, + standardCount, + userId: request.user?.userId, + organizationId: request.organization?.id, + }); + + try { + const organizationId = request.organization?.id; + const userId = request.user?.userId; + + if (!organizationId || !userId) { + this.logger.error( + 'POST /track-execution - Missing user or organization context', + ); + throw new BadRequestException('User authentication required'); + } + + // Track the linter execution + await this.linterService.trackLinterExecution({ + organizationId, + userId, + targetCount, + standardCount, + }); + + this.logger.info( + 'POST /track-execution - Execution tracked successfully', + ); + + return { success: true }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error('POST /track-execution - Failed to track execution', { + error: errorMessage, + }); + throw error; + } + } + + @Post('detection-programs-for-packages') + async getDetectionProgramsForPackages( + @Body() body: { packagesSlugs: string[] }, + @Req() request: AuthenticatedRequest, + ): Promise<GetDetectionProgramsForPackagesResponse> { + const { packagesSlugs } = body; + this.logger.info( + 'POST /detection-programs-for-packages - Getting detection programs for packages', + { + packagesSlugs, + userId: request.user?.userId, + organizationId: request.organization?.id, + }, + ); + + try { + // Extract user and organization context from authenticated request + const organizationId = request.organization?.id; + const userId = request.user?.userId; + + // Validate packagesSlugs parameter + if ( + !packagesSlugs || + !Array.isArray(packagesSlugs) || + packagesSlugs.length === 0 + ) { + this.logger.error( + 'POST /detection-programs-for-packages - packagesSlugs parameter is required', + { + packagesSlugs, + organizationId, + userId, + }, + ); + throw new BadRequestException( + 'packagesSlugs parameter is required and must be a non-empty array', + ); + } + + const command: GetDetectionProgramsForPackagesCommand = { + organizationId: request.organization.id, + userId: request.user.userId, + packagesSlugs, + }; + + // Call the service method to get detection programs for packages + const result = + await this.linterService.getDetectionProgramsForPackages(command); + + this.logger.info( + 'POST /detection-programs-for-packages - Detection programs retrieved successfully', + { + packagesSlugs, + organizationId, + userId, + targetsCount: result.targets.length, + totalStandards: result.targets.reduce( + (sum, target) => sum + target.standards.length, + 0, + ), + }, + ); + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /detection-programs-for-packages - Failed to get detection programs for packages', + { + packagesSlugs, + organizationId: request.organization?.id, + userId: request.user?.userId, + error: errorMessage, + }, + ); + + // Return appropriate HTTP status codes for different error scenarios + if (error instanceof BadRequestException) { + throw error; + } + + // For other errors, wrap in BadRequestException to maintain consistency + throw new BadRequestException(errorMessage); + } + } + + @Get('standards/:standardId/rules/:ruleId/detection-programs/all') + async getAllDetectionProgramsByRule( + @Param('standardId') standardId: StandardId, + @Param('ruleId') ruleId: RuleId, + @Req() request: AuthenticatedRequest, + ): Promise<DetectionProgram[]> { + this.logger.info( + 'GET /standards/:standardId/rules/:ruleId/detection-programs/all - Getting all detection programs', + { + standardId, + ruleId, + userId: request.user?.userId, + organizationId: request.organization?.id, + }, + ); + + try { + const command: GetAllDetectionProgramsByRuleCommand = { + organizationId: request.organization.id, + userId: request.user.userId, + ruleId, + }; + + const response = + await this.linterService.getAllDetectionProgramsByRule(command); + + this.logger.info( + 'GET /standards/:standardId/rules/:ruleId/detection-programs/all - All detection programs retrieved successfully', + { + standardId, + ruleId, + programsCount: response.programs.length, + }, + ); + + return response.programs; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'GET /standards/:standardId/rules/:ruleId/detection-programs/all - Failed to get all detection programs', + { + standardId, + ruleId, + error: errorMessage, + }, + ); + + if ( + error instanceof BadRequestException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException(errorMessage); + } + } + + @Get('standards/:standardId/rules/:ruleId/detection-assessment') + async getRuleDetectionAssessment( + @Param('standardId') standardId: StandardId, + @Param('ruleId') ruleId: RuleId, + @Query('language') language: string, + @Req() request: AuthenticatedRequest, + ): Promise<RuleDetectionAssessment | null> { + this.logger.info( + 'GET /standards/:standardId/rules/:ruleId/detection-assessment - Getting rule detection assessment', + { + standardId, + ruleId, + language, + userId: request.user?.userId, + organizationId: request.organization?.id, + }, + ); + + try { + // Validate language parameter + if (!language || language.trim() === '') { + throw new BadRequestException('language query parameter is required'); + } + + const programmingLanguage = stringToProgrammingLanguage(language); + if (!programmingLanguage) { + throw new BadRequestException( + `Invalid language: ${language}. Must be a valid programming language.`, + ); + } + + const command: GetRuleDetectionAssessmentCommand = { + organizationId: request.organization.id, + userId: request.user.userId, + ruleId, + language: programmingLanguage as ProgrammingLanguage, + }; + + const response = + await this.linterService.getRuleDetectionAssessment(command); + + this.logger.info( + 'GET /standards/:standardId/rules/:ruleId/detection-assessment - Assessment retrieved successfully', + { + standardId, + ruleId, + language, + hasAssessment: response.assessment !== null, + }, + ); + + return response.assessment; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'GET /standards/:standardId/rules/:ruleId/detection-assessment - Failed to get rule detection assessment', + { + standardId, + ruleId, + language, + error: errorMessage, + }, + ); + + if ( + error instanceof BadRequestException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException(errorMessage); + } + } + + @Post('standards/:standardId/rules/:ruleId/detection-assessment') + async startRuleDetectionAssessment( + @Param('standardId') standardId: StandardId, + @Param('ruleId') ruleId: RuleId, + @Body() body: { language: string }, + @Req() request: AuthenticatedRequest, + ): Promise<RuleDetectionAssessment> { + this.logger.info( + 'POST /standards/:standardId/rules/:ruleId/detection-assessment - Starting rule detection assessment', + { + standardId, + ruleId, + language: body.language, + userId: request.user?.userId, + organizationId: request.organization?.id, + }, + ); + + try { + const organizationId = request.organization?.id; + const userId = request.user?.userId; + + if (!organizationId || !userId) { + throw new BadRequestException('User authentication required'); + } + + // Validate language parameter + if (!body.language || body.language.trim() === '') { + throw new BadRequestException('language is required in request body'); + } + + const programmingLanguage = stringToProgrammingLanguage(body.language); + if (!programmingLanguage) { + throw new BadRequestException( + `Invalid language: ${body.language}. Must be a valid programming language.`, + ); + } + + const assessment = await this.linterService.startRuleDetectionAssessment({ + ruleId, + language: programmingLanguage as ProgrammingLanguage, + userId, + organizationId, + }); + + this.logger.info( + 'POST /standards/:standardId/rules/:ruleId/detection-assessment - Assessment started successfully', + { + standardId, + ruleId, + language: body.language, + assessmentId: assessment.id, + }, + ); + + return assessment; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-assessment - Failed to start assessment', + { + standardId, + ruleId, + language: body.language, + error: errorMessage, + }, + ); + + if ( + error instanceof BadRequestException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException(errorMessage); + } + } + + @Get('standards/:standardId/rules/:ruleId/detection-status') + async getRuleLanguageDetectionStatus( + @Param('standardId') standardId: StandardId, + @Param('ruleId') ruleId: RuleId, + @Query('language') language: string, + @Req() request: AuthenticatedRequest, + ): Promise<{ status: string }> { + this.logger.info( + 'GET /standards/:standardId/rules/:ruleId/detection-status - Getting rule language detection status', + { + standardId, + ruleId, + language, + userId: request.user?.userId, + organizationId: request.organization?.id, + }, + ); + + try { + // Validate language parameter + if (!language || language.trim() === '') { + throw new BadRequestException('language query parameter is required'); + } + + const programmingLanguage = stringToProgrammingLanguage(language); + if (!programmingLanguage) { + throw new BadRequestException( + `Invalid language: ${language}. Must be a valid programming language.`, + ); + } + + const command: ComputeRuleLanguageDetectionStatusCommand = { + ruleId, + language: programmingLanguage as ProgrammingLanguage, + }; + + const response = + await this.linterService.computeRuleLanguageDetectionStatus(command); + + this.logger.info( + 'GET /standards/:standardId/rules/:ruleId/detection-status - Detection status retrieved successfully', + { + standardId, + ruleId, + language, + status: response.status, + }, + ); + + return { status: response.status }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'GET /standards/:standardId/rules/:ruleId/detection-status - Failed to get detection status', + { + standardId, + ruleId, + language, + error: errorMessage, + }, + ); + + if ( + error instanceof BadRequestException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException(errorMessage); + } + } + + @Get('standards/:standardId/detection-status') + async getStandardRulesDetectionStatus( + @Param('standardId') standardId: StandardId, + @Req() request: AuthenticatedRequest, + ): Promise<RuleDetectionStatusSummary[]> { + this.logger.info( + 'GET /standards/:standardId/detection-status - Getting standard rules detection status', + { + standardId, + userId: request.user?.userId, + organizationId: request.organization?.id, + }, + ); + + try { + const command: GetStandardRulesDetectionStatusCommand = { + organizationId: request.organization.id, + userId: request.user.userId, + standardId, + }; + + const response = + await this.linterService.getStandardRulesDetectionStatus(command); + + this.logger.info( + 'GET /standards/:standardId/detection-status - Standard rules detection status retrieved successfully', + { + standardId, + rulesCount: response.rules.length, + }, + ); + + return response.rules; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'GET /standards/:standardId/detection-status - Failed to get standard rules detection status', + { + standardId, + error: errorMessage, + }, + ); + + if ( + error instanceof BadRequestException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException(errorMessage); + } + } + + @Get( + 'standards/:standardId/rules/:ruleId/detection-programs/:detectionProgramId/metadata', + ) + async getDetectionProgramMetadata( + @Param('standardId') standardId: StandardId, + @Param('ruleId') ruleId: RuleId, + @Param('detectionProgramId') detectionProgramId: DetectionProgramId, + @Req() request: AuthenticatedRequest, + ): Promise<DetectionProgramMetadata | null> { + this.logger.info( + 'GET /standards/:standardId/rules/:ruleId/detection-programs/:detectionProgramId/metadata - Getting detection program metadata', + { + standardId, + ruleId, + detectionProgramId, + userId: request.user?.userId, + organizationId: request.organization?.id, + }, + ); + + try { + const command: GetDetectionProgramMetadataCommand = { + organizationId: request.organization.id, + userId: request.user.userId, + detectionProgramId, + }; + + const response = + await this.linterService.getDetectionProgramMetadata(command); + + this.logger.info( + 'GET /standards/:standardId/rules/:ruleId/detection-programs/:detectionProgramId/metadata - Metadata retrieved successfully', + { + standardId, + ruleId, + detectionProgramId, + hasMetadata: response.metadata !== null, + }, + ); + + return response.metadata; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'GET /standards/:standardId/rules/:ruleId/detection-programs/:detectionProgramId/metadata - Failed to get metadata', + { + standardId, + ruleId, + detectionProgramId, + error: errorMessage, + }, + ); + + if ( + error instanceof BadRequestException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException(errorMessage); + } + } + + @Post('list-draft-detection-program') + async getDraftDetectionProgramForRule( + @Body() body: { standardSlug: string; ruleId: string; language?: string }, + @Req() request: AuthenticatedRequest, + ): Promise<GetDraftDetectionProgramForRuleResponse> { + this.logger.info( + 'POST /list-draft-detection-program - Getting draft detection programs', + { + standardSlug: body?.standardSlug, + ruleId: body?.ruleId, + language: body?.language, + userId: request.user?.userId, + organizationId: request.organization?.id, + }, + ); + + try { + // Extract user and organization context from authenticated request + const organizationId = request.organization?.id; + const userId = request.user?.userId; + + if (!organizationId || !userId) { + this.logger.error( + 'POST /list-draft-detection-program - Missing user or organization context', + { + userId, + organizationId, + }, + ); + throw new BadRequestException('User authentication required'); + } + + // Validate request body + if (!body.standardSlug || typeof body.standardSlug !== 'string') { + this.logger.error( + 'POST /list-draft-detection-program - standardSlug is required', + ); + throw new BadRequestException('standardSlug is required'); + } + + if (!body.ruleId || typeof body.ruleId !== 'string') { + this.logger.error( + 'POST /list-draft-detection-program - ruleId is required', + ); + throw new BadRequestException('ruleId is required'); + } + + // Trim the inputs + const standardSlug = body.standardSlug.trim(); + const ruleId = body.ruleId.trim(); + + if (standardSlug === '' || ruleId === '') { + this.logger.error( + 'POST /list-draft-detection-program - standardSlug and ruleId cannot be empty', + ); + throw new BadRequestException( + 'standardSlug and ruleId cannot be empty', + ); + } + + // Validate language if provided + if (body.language) { + try { + stringToProgrammingLanguage(body.language); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /list-draft-detection-program - Invalid language parameter', + { + language: body.language, + error: errorMessage, + }, + ); + throw new BadRequestException(errorMessage); + } + } + + const command: GetDraftDetectionProgramForRuleCommand = { + standardSlug, + ruleId: ruleId as RuleId, + organizationId, + userId, + language: body.language, + }; + + const response = + await this.linterService.getDraftDetectionProgramForRule(command); + + this.logger.info( + 'POST /list-draft-detection-program - Draft detection programs retrieved successfully', + { + standardSlug, + ruleId, + language: body.language, + organizationId: request.organization.id, + programsCount: response.programs.length, + ruleContent: response.ruleContent, + scope: response.scope, + }, + ); + + return { + programs: response.programs, + ruleContent: response.ruleContent, + scope: response.scope, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /list-draft-detection-program - Failed to get draft detection programs', + { + standardSlug: body?.standardSlug, + ruleId: body?.ruleId, + language: body?.language, + organizationId: request.organization?.id, + userId: request.user?.userId, + error: errorMessage, + }, + ); + + // Handle specific error cases + if (errorMessage.includes('not found')) { + throw new NotFoundException(errorMessage); + } + + if ( + error instanceof BadRequestException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException(errorMessage); + } + } + + @Post( + 'standards/:standardId/rules/:ruleId/detection-program/:detectionProgramId/test', + ) + async testDetectionProgram( + @Param('standardId') standardId: StandardId, + @Param('ruleId') ruleId: RuleId, + @Param('detectionProgramId') detectionProgramId: DetectionProgramId, + @Body() body: { sandboxCode: string; filePath?: string }, + @Req() request: AuthenticatedRequest, + ): Promise<LinterExecutionViolation[]> { + this.logger.info( + 'POST /standards/:standardId/rules/:ruleId/detection-program/:detectionProgramId/test - Testing detection program', + { + standardId, + ruleId, + detectionProgramId, + sandboxCodeLength: body?.sandboxCode?.length, + userId: request.user?.userId, + organizationId: request.organization?.id, + }, + ); + + try { + // Extract user and organization context from authenticated request + const organizationId = request.organization?.id; + const userId = request.user?.userId; + + if (!organizationId || !userId) { + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program/:detectionProgramId/test - Missing user or organization context', + { + standardId, + ruleId, + detectionProgramId, + }, + ); + throw new BadRequestException('User authentication required'); + } + + // Validate request body + if (!body.sandboxCode || typeof body.sandboxCode !== 'string') { + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program/:detectionProgramId/test - sandboxCode is required', + ); + throw new BadRequestException('sandboxCode is required'); + } + + if (body.sandboxCode.trim() === '') { + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program/:detectionProgramId/test - sandboxCode cannot be empty', + ); + throw new BadRequestException('sandboxCode cannot be empty'); + } + + const command: TestProgramExecutionCommand = { + organizationId, + userId, + detectionProgramId, + sandboxCode: body.sandboxCode, + filePath: body.filePath, + }; + + const response = await this.linterService.testProgramExecution(command); + + this.logger.info( + 'POST /standards/:standardId/rules/:ruleId/detection-program/:detectionProgramId/test - Program tested successfully', + { + standardId, + ruleId, + detectionProgramId, + violationsCount: response.violations.length, + }, + ); + + return response.violations; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /standards/:standardId/rules/:ruleId/detection-program/:detectionProgramId/test - Failed to test detection program', + { + standardId, + ruleId, + detectionProgramId, + sandboxCodeLength: body?.sandboxCode?.length, + error: errorMessage, + }, + ); + + if (error instanceof DetectionProgramNotFoundError) { + throw new NotFoundException(errorMessage); + } + + if (error instanceof UnauthorizedTestProgramExecutionError) { + throw new ForbiddenException(errorMessage); + } + + if ( + error instanceof BadRequestException || + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + + throw new BadRequestException(errorMessage); + } + } + + @Post('list-active-detection-program') + async getActiveDetectionProgramForRule( + @Body() body: { standardSlug: string; ruleId: string; language?: string }, + @Req() request: AuthenticatedRequest, + ): Promise<GetActiveDetectionProgramForRuleResponse> { + this.logger.info( + 'POST /list-active-detection-program - Getting active detection programs', + { + standardSlug: body?.standardSlug, + ruleId: body?.ruleId, + language: body?.language, + userId: request.user?.userId, + organizationId: request.organization?.id, + }, + ); + + try { + // Extract user and organization context from authenticated request + const organizationId = request.organization?.id; + const userId = request.user?.userId; + + if (!organizationId || !userId) { + this.logger.error( + 'POST /list-active-detection-program - Missing user or organization context', + { + userId, + organizationId, + }, + ); + throw new BadRequestException('User authentication required'); + } + + // Validate request body + if (!body.standardSlug || typeof body.standardSlug !== 'string') { + this.logger.error( + 'POST /list-active-detection-program - standardSlug is required', + ); + throw new BadRequestException('standardSlug is required'); + } + + if (!body.ruleId || typeof body.ruleId !== 'string') { + this.logger.error( + 'POST /list-active-detection-program - ruleId is required', + ); + throw new BadRequestException('ruleId is required'); + } + + // Trim the inputs + const standardSlug = body.standardSlug.trim(); + const ruleId = body.ruleId.trim(); + + if (standardSlug === '' || ruleId === '') { + this.logger.error( + 'POST /list-active-detection-program - standardSlug and ruleId cannot be empty', + ); + throw new BadRequestException( + 'standardSlug and ruleId cannot be empty', + ); + } + + // Validate language if provided + if (body.language) { + try { + stringToProgrammingLanguage(body.language); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /list-active-detection-program - Invalid language parameter', + { + language: body.language, + error: errorMessage, + }, + ); + throw new BadRequestException(errorMessage); + } + } + + const command: GetActiveDetectionProgramForRuleCommand = { + standardSlug, + ruleId: ruleId as RuleId, + organizationId, + userId, + language: body.language, + }; + + const response = + await this.linterService.getActiveDetectionProgramForRule(command); + + this.logger.info( + 'POST /list-active-detection-program - Active detection programs retrieved successfully', + { + standardSlug, + ruleId, + language: body.language, + organizationId: request.organization.id, + programsCount: response.programs.length, + ruleContent: response.ruleContent, + scope: response.scope, + }, + ); + + return { + programs: response.programs, + ruleContent: response.ruleContent, + scope: response.scope, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /list-active-detection-program - Failed to get active detection programs', + { + standardSlug: body?.standardSlug, + ruleId: body?.ruleId, + language: body?.language, + organizationId: request.organization?.id, + userId: request.user?.userId, + error: errorMessage, + }, + ); + + // Handle specific error cases + if (errorMessage.includes('not found')) { + throw new NotFoundException(errorMessage); + } + + if ( + error instanceof BadRequestException || + error instanceof NotFoundException + ) { + throw error; + } + throw new BadRequestException(errorMessage); + } + } + + @Put( + 'standards/:standardId/rules/:ruleId/detection-heuristics/:detectionHeuristicsId', + ) + async updateRuleDetectionHeuristics( + @Param('standardId') standardId: StandardId, + @Param('ruleId') ruleId: RuleId, + @Param('detectionHeuristicsId') + detectionHeuristicsId: DetectionHeuristicsId, + @Req() request: AuthenticatedRequest, + @Body() + body: { + heuristics: string[]; + clarificationQuestion?: { + question: string; + answer: string; + }; + }, + ): Promise<DetectionHeuristics> { + const organizationId = request.organization.id; + const userId = request.user.userId; + + this.logger.info('Updating rule detection heuristics', { + standardId, + ruleId, + detectionHeuristicsId, + organizationId, + userId, + }); + + try { + // Update the heuristics + const command: UpdateRuleDetectionHeuristicsCommand = { + userId, + organizationId, + detectionHeuristicsId, + heuristics: body.heuristics, + clarificationQuestion: body.clarificationQuestion, + }; + + const response = + await this.linterService.updateRuleDetectionHeuristics(command); + + this.logger.info('Rule detection heuristics updated successfully', { + detectionHeuristicsId, + ruleId, + }); + + return response.detectionHeuristics; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.logger.error('Failed to update rule detection heuristics', { + standardId, + ruleId, + detectionHeuristicsId, + organizationId, + userId, + error: errorMessage, + }); + + if (errorMessage.includes('not found')) { + throw new NotFoundException(errorMessage); + } + + if ( + error instanceof BadRequestException || + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + + throw new BadRequestException(errorMessage); + } + } + + @Get('standards/:standardId/rules/:ruleId/detection-heuristics') + async getDetectionHeuristics( + @Param('standardId') standardId: StandardId, + @Param('ruleId') ruleId: RuleId, + @Query('language') languageParam: string, + @Req() request: AuthenticatedRequest, + ): Promise<DetectionHeuristics | null> { + const organizationId = request.organization.id; + const userId = request.user.userId; + + this.logger.info('Getting detection heuristics', { + standardId, + ruleId, + language: languageParam, + organizationId, + userId, + }); + + try { + // Parse and validate language parameter + if (!languageParam) { + throw new BadRequestException('Language query parameter is required'); + } + + let language: ProgrammingLanguage; + try { + language = stringToProgrammingLanguage(languageParam); + } catch (error) { + throw new BadRequestException( + `Invalid language: ${languageParam}. ${error instanceof Error ? error.message : String(error)}`, + ); + } + + // Get the heuristics + const command: GetDetectionHeuristicsCommand = { + userId, + organizationId, + ruleId, + language, + }; + + const response = await this.linterService.getDetectionHeuristics(command); + + if (response.detectionHeuristics) { + this.logger.info('Detection heuristics found', { + ruleId, + language, + detectionHeuristicsId: response.detectionHeuristics.id, + }); + return response.detectionHeuristics; + } + + // If heuristics don't exist, create them + this.logger.info('Detection heuristics not found, creating new', { + ruleId, + language, + }); + + const createResponse = + await this.linterService.createDetectionHeuristics(command); + + this.logger.info('Detection heuristics created', { + ruleId, + language, + detectionHeuristicsId: createResponse.detectionHeuristics.id, + }); + + return createResponse.detectionHeuristics; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.logger.error('Failed to get detection heuristics', { + standardId, + ruleId, + language: languageParam, + organizationId, + userId, + error: errorMessage, + }); + + if ( + error instanceof BadRequestException || + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } + + throw new BadRequestException(errorMessage); + } + } +} diff --git a/packages/linter/src/nest-api/linter/linter.module.ts b/packages/linter/src/nest-api/linter/linter.module.ts new file mode 100644 index 000000000..53676d8e9 --- /dev/null +++ b/packages/linter/src/nest-api/linter/linter.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { LinterController } from './linter.controller'; +import { LinterService } from './linter.service'; +import { PackmindLogger } from '@packmind/logger'; + +@Module({ + imports: [], + controllers: [LinterController], + providers: [ + LinterService, + { + provide: PackmindLogger, + useFactory: () => new PackmindLogger('LinterModule'), + }, + ], +}) +export class LinterModule {} diff --git a/packages/linter/src/nest-api/linter/linter.service.ts b/packages/linter/src/nest-api/linter/linter.service.ts new file mode 100644 index 000000000..a75b22b5b --- /dev/null +++ b/packages/linter/src/nest-api/linter/linter.service.ts @@ -0,0 +1,198 @@ +import { Injectable } from '@nestjs/common'; +import { LinterHexa } from '../../LinterHexa'; +import { + ActiveDetectionProgram, + ActiveDetectionProgramId, + DetectionProgram, + ILinterPort, +} from '../../'; +import { + ComputeRuleLanguageDetectionStatusCommand, + ComputeRuleLanguageDetectionStatusResponse, + GetStandardRulesDetectionStatusCommand, + GetStandardRulesDetectionStatusResponse, + TestProgramExecutionCommand, + TestProgramExecutionResponse, + CreateDetectionProgramCommand, + GetActiveDetectionProgramCommand, + GetActiveDetectionProgramResponse, + CreateNewDetectionProgramVersionCommand, + ListDetectionProgramCommand, + ListDetectionProgramResponse, + GetAllDetectionProgramsByRuleCommand, + GetAllDetectionProgramsByRuleResponse, + GetDetectionProgramMetadataCommand, + GetDetectionProgramMetadataResponse, + UpdateActiveDetectionProgramCommand, + GetDraftDetectionProgramForRuleCommand, + GetDraftDetectionProgramForRuleResponse, + GetActiveDetectionProgramForRuleCommand, + GetActiveDetectionProgramForRuleResponse, + GetRuleDetectionAssessmentCommand, + GetRuleDetectionAssessmentResponse, + UpdateRuleDetectionHeuristicsCommand, + UpdateRuleDetectionHeuristicsResponse, + GetDetectionHeuristicsCommand, + GetDetectionHeuristicsResponse, + CreateDetectionHeuristicsCommand, + CreateDetectionHeuristicsResponse, + GetDetectionProgramsForPackagesCommand, + GetDetectionProgramsForPackagesResponse, + RuleDetectionAssessment, + OrganizationId, + UserId, + RuleId, + ProgrammingLanguage, + TrackLinterExecutionCommand, + UpdateActiveDetectionProgramSeverityCommand, +} from '@packmind/types'; + +@Injectable() +export class LinterService { + constructor(private readonly linterHexa: LinterHexa) {} + + private get linterAdapter(): ILinterPort { + return this.linterHexa.getAdapter(); + } + + async createDetectionProgram( + command: CreateDetectionProgramCommand, + ): Promise<DetectionProgram> { + return this.linterAdapter.createDetectionProgram(command); + } + + async getActiveDetectionProgram( + command: GetActiveDetectionProgramCommand, + ): Promise<GetActiveDetectionProgramResponse> { + return this.linterAdapter.getActiveDetectionProgram(command); + } + + async createNewDetectionProgramVersion( + command: CreateNewDetectionProgramVersionCommand, + ): Promise<DetectionProgram> { + return this.linterAdapter.createNewDetectionProgramVersion(command); + } + + async listDetectionPrograms( + command: ListDetectionProgramCommand, + ): Promise<ListDetectionProgramResponse> { + return this.linterAdapter.listDetectionPrograms(command); + } + + async getAllDetectionProgramsByRule( + command: GetAllDetectionProgramsByRuleCommand, + ): Promise<GetAllDetectionProgramsByRuleResponse> { + return this.linterAdapter.getAllDetectionProgramsByRule(command); + } + + async getDetectionProgramMetadata( + command: GetDetectionProgramMetadataCommand, + ): Promise<GetDetectionProgramMetadataResponse> { + return this.linterAdapter.getDetectionProgramMetadata(command); + } + + async updateActiveDetectionProgram( + command: UpdateActiveDetectionProgramCommand, + ): Promise<ActiveDetectionProgram> { + return this.linterAdapter.updateActiveDetectionProgram(command); + } + + async updateActiveDetectionProgramSeverity( + command: UpdateActiveDetectionProgramSeverityCommand, + ): Promise<ActiveDetectionProgram> { + return this.linterAdapter.updateActiveDetectionProgramSeverity(command); + } + + async getActiveDetectionProgramById( + activeDetectionProgramId: ActiveDetectionProgramId, + ): Promise<ActiveDetectionProgram | null> { + return this.linterAdapter.getActiveDetectionProgramById( + activeDetectionProgramId, + ); + } + + async getDraftDetectionProgramForRule( + command: GetDraftDetectionProgramForRuleCommand, + ): Promise<GetDraftDetectionProgramForRuleResponse> { + return this.linterAdapter.getDraftDetectionProgramForRule(command); + } + + async getActiveDetectionProgramForRule( + command: GetActiveDetectionProgramForRuleCommand, + ): Promise<GetActiveDetectionProgramForRuleResponse> { + return this.linterAdapter.getActiveDetectionProgramForRule(command); + } + + async getRuleDetectionAssessment( + command: GetRuleDetectionAssessmentCommand, + ): Promise<GetRuleDetectionAssessmentResponse> { + return this.linterAdapter.getRuleDetectionAssessment(command); + } + + async computeRuleLanguageDetectionStatus( + command: ComputeRuleLanguageDetectionStatusCommand, + ): Promise<ComputeRuleLanguageDetectionStatusResponse> { + return this.linterAdapter.computeRuleLanguageDetectionStatus(command); + } + + async getStandardRulesDetectionStatus( + command: GetStandardRulesDetectionStatusCommand, + ): Promise<GetStandardRulesDetectionStatusResponse> { + return this.linterAdapter.getStandardRulesDetectionStatus(command); + } + + async testProgramExecution( + command: TestProgramExecutionCommand, + ): Promise<TestProgramExecutionResponse> { + return this.linterAdapter.testProgramExecution(command); + } + + async updateRuleDetectionHeuristics( + command: UpdateRuleDetectionHeuristicsCommand, + ): Promise<UpdateRuleDetectionHeuristicsResponse> { + return this.linterAdapter.updateRuleDetectionHeuristics(command); + } + + async getDetectionHeuristics( + command: GetDetectionHeuristicsCommand, + ): Promise<GetDetectionHeuristicsResponse> { + return this.linterAdapter.getDetectionHeuristics(command); + } + + async createDetectionHeuristics( + command: CreateDetectionHeuristicsCommand, + ): Promise<CreateDetectionHeuristicsResponse> { + return this.linterAdapter.createDetectionHeuristics(command); + } + + async getDetectionProgramsForPackages( + command: GetDetectionProgramsForPackagesCommand, + ): Promise<GetDetectionProgramsForPackagesResponse> { + return this.linterAdapter.getDetectionProgramsForPackages(command); + } + + async startRuleDetectionAssessment(command: { + ruleId: RuleId; + language: ProgrammingLanguage; + userId: UserId; + organizationId: OrganizationId; + }): Promise<RuleDetectionAssessment> { + const rule = await this.linterHexa + .getStandardsPort() + .getRule(command.ruleId); + if (!rule) { + throw new Error(`Rule not found: ${command.ruleId}`); + } + + return this.linterAdapter.startRuleDetectionAssessment({ + rule, + language: command.language, + userId: command.userId, + organizationId: command.organizationId, + }); + } + + async trackLinterExecution(command: TrackLinterExecutionCommand) { + return this.linterAdapter.trackLinterExecution(command); + } +} diff --git a/packages/linter/test/activeDetectionProgramFactory.ts b/packages/linter/test/activeDetectionProgramFactory.ts new file mode 100644 index 000000000..e937de217 --- /dev/null +++ b/packages/linter/test/activeDetectionProgramFactory.ts @@ -0,0 +1,24 @@ +import { Factory } from '@packmind/test-utils'; +import { + ActiveDetectionProgram, + createActiveDetectionProgramId, + createDetectionProgramId, + createRuleId, + DetectionSeverity, + ProgrammingLanguage, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; + +export const activeDetectionProgramFactory: Factory<ActiveDetectionProgram> = ( + activeProgram?: Partial<ActiveDetectionProgram>, +) => { + return { + id: createActiveDetectionProgramId(uuidv4()), + detectionProgramVersion: createDetectionProgramId(uuidv4()), + ruleId: createRuleId(uuidv4()), + language: ProgrammingLanguage.JAVASCRIPT, + detectionProgramDraftVersion: null, + severity: DetectionSeverity.ERROR, + ...activeProgram, + }; +}; diff --git a/packages/linter/test/detectionHeuristicsFactory.ts b/packages/linter/test/detectionHeuristicsFactory.ts new file mode 100644 index 000000000..6e5719349 --- /dev/null +++ b/packages/linter/test/detectionHeuristicsFactory.ts @@ -0,0 +1,19 @@ +import { v4 as uuidv4 } from 'uuid'; +import { + DetectionHeuristics, + createDetectionHeuristicsId, + createRuleId, + ProgrammingLanguage, +} from '@packmind/types'; + +export const detectionHeuristicsFactory = ( + overrides?: Partial<DetectionHeuristics>, +): DetectionHeuristics => { + return { + id: createDetectionHeuristicsId(uuidv4()), + ruleId: createRuleId(uuidv4()), + language: ProgrammingLanguage.TYPESCRIPT, + heuristics: ['Default heuristics content'], + ...overrides, + }; +}; diff --git a/packages/linter/test/detectionProgramFactory.ts b/packages/linter/test/detectionProgramFactory.ts new file mode 100644 index 000000000..bf69d8267 --- /dev/null +++ b/packages/linter/test/detectionProgramFactory.ts @@ -0,0 +1,27 @@ +import { Factory } from '@packmind/test-utils'; +import { + createRuleId, + DetectionStatus, + ProgrammingLanguage, + createDetectionProgramId, + DetectionModeEnum, + type DetectionProgram, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; + +export const detectionProgramFactory: Factory<DetectionProgram> = ( + program?: Partial<DetectionProgram>, +) => { + return { + id: createDetectionProgramId(uuidv4()), + code: 'if (ast.node.type === "ClassDeclaration") { return { line: ast.node.loc.start.line, message: "Class found" }; }', + version: 1, + mode: DetectionModeEnum.SINGLE_AST, + ruleId: createRuleId(uuidv4()), + language: ProgrammingLanguage.JAVASCRIPT, + status: DetectionStatus.READY, + sourceCodeState: 'AST', + createdAt: new Date(), + ...program, + }; +}; diff --git a/packages/linter/test/detectionProgramMetadataFactory.ts b/packages/linter/test/detectionProgramMetadataFactory.ts new file mode 100644 index 000000000..1d6419962 --- /dev/null +++ b/packages/linter/test/detectionProgramMetadataFactory.ts @@ -0,0 +1,20 @@ +import { Factory } from '@packmind/test-utils'; +import { + DetectionProgramMetadata, + createDetectionProgramId, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; + +export const detectionProgramMetadataFactory: Factory< + DetectionProgramMetadata +> = (metadata?: Partial<DetectionProgramMetadata>) => { + return { + id: uuidv4(), + detectionProgramId: createDetectionProgramId(uuidv4()), + taskId: `task-${uuidv4()}`, + tokens: null, + logs: null, + programDescription: 'Test program description', + ...metadata, + }; +}; diff --git a/packages/linter/test/executionLogFactory.ts b/packages/linter/test/executionLogFactory.ts new file mode 100644 index 000000000..8b3512bb9 --- /dev/null +++ b/packages/linter/test/executionLogFactory.ts @@ -0,0 +1,13 @@ +import { Factory } from '@packmind/test-utils'; +import { ExecutionLog } from '@packmind/types'; + +export const executionLogFactory: Factory<ExecutionLog> = ( + log?: Partial<ExecutionLog>, +) => { + return { + timestamp: Date.now(), + message: 'Test execution log message', + metadata: undefined, + ...log, + }; +}; diff --git a/packages/linter/test/index.ts b/packages/linter/test/index.ts new file mode 100644 index 000000000..7748db3f8 --- /dev/null +++ b/packages/linter/test/index.ts @@ -0,0 +1,5 @@ +export * from './detectionProgramFactory'; +export * from './activeDetectionProgramFactory'; +export * from './detectionHeuristicsFactory'; +export * from './detectionProgramMetadataFactory'; +export * from './executionLogFactory'; diff --git a/packages/linter/test/ruleDetectionAssessmentFactory.ts b/packages/linter/test/ruleDetectionAssessmentFactory.ts new file mode 100644 index 000000000..1ad6405ba --- /dev/null +++ b/packages/linter/test/ruleDetectionAssessmentFactory.ts @@ -0,0 +1,26 @@ +import { Factory } from '@packmind/test-utils'; +import { + createRuleId, + ProgrammingLanguage, + DetectionModeEnum, + createRuleDetectionAssessmentId, + type RuleDetectionAssessment, + RuleDetectionAssessmentStatus, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; + +export const ruleDetectionAssessmentFactory: Factory< + RuleDetectionAssessment +> = (assessment?: Partial<RuleDetectionAssessment>) => { + return { + id: createRuleDetectionAssessmentId(uuidv4()), + ruleId: createRuleId(uuidv4()), + language: ProgrammingLanguage.JAVASCRIPT, + detectionMode: DetectionModeEnum.SINGLE_AST, + status: RuleDetectionAssessmentStatus.NOT_STARTED, + details: '', + clarificationQuestion: null, + clarificationAnswers: null, + ...assessment, + }; +}; diff --git a/packages/linter/tsconfig.json b/packages/linter/tsconfig.json new file mode 100644 index 000000000..57937963b --- /dev/null +++ b/packages/linter/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.effective.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/linter/tsconfig.lib.json b/packages/linter/tsconfig.lib.json new file mode 100644 index 000000000..33eca2c2c --- /dev/null +++ b/packages/linter/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/linter/tsconfig.spec.json b/packages/linter/tsconfig.spec.json new file mode 100644 index 000000000..0d3c604ea --- /dev/null +++ b/packages/linter/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/packages/llm/src/LlmHexa.ts b/packages/llm/src/LlmHexa.ts index 1a4279fc7..b817fdf49 100644 --- a/packages/llm/src/LlmHexa.ts +++ b/packages/llm/src/LlmHexa.ts @@ -83,6 +83,11 @@ export class LlmHexa extends BaseHexa<BaseHexaOpts, ILlmPort> { * The adapter is available immediately after construction. */ public getAdapter(): ILlmPort { + if (!this.isInitialized) { + this.logger.warn( + 'LlmHexa.getAdapter() called before initialization completed', + ); + } return this.adapter.getPort(); } diff --git a/packages/migrations/.claude/commands/create-or-update-model-and-typeorm-schemas.md b/packages/migrations/.claude/commands/create-or-update-model-and-typeorm-schemas.md index 6cf04d85e..421f9b24f 100644 --- a/packages/migrations/.claude/commands/create-or-update-model-and-typeorm-schemas.md +++ b/packages/migrations/.claude/commands/create-or-update-model-and-typeorm-schemas.md @@ -1,5 +1,5 @@ --- -description: 'Create and evolve TypeORM-backed domain models, schemas, repositories, and migrations to keep your database structure consistent, maintainable, and backward compatible when business requirements or existing entities change.' +description: 'Create or update model and TypeORM schemas' --- Create new models and update existing ones with TypeORM to ensure proper database structure and maintainability when adapting to evolving business requirements. diff --git a/packages/migrations/.github/prompts/create-or-update-model-and-typeorm-schemas.prompt.md b/packages/migrations/.github/prompts/create-or-update-model-and-typeorm-schemas.prompt.md index ac203e863..cb49087f6 100644 --- a/packages/migrations/.github/prompts/create-or-update-model-and-typeorm-schemas.prompt.md +++ b/packages/migrations/.github/prompts/create-or-update-model-and-typeorm-schemas.prompt.md @@ -1,5 +1,5 @@ --- -description: 'Create and evolve TypeORM-backed domain models, schemas, repositories, and migrations to keep your database structure consistent, maintainable, and backward compatible when business requirements or existing entities change.' +description: 'Create or update model and TypeORM schemas' agent: 'agent' --- diff --git a/packages/migrations/.opencode/commands/create-or-update-model-and-typeorm-schemas.md b/packages/migrations/.opencode/commands/create-or-update-model-and-typeorm-schemas.md new file mode 100644 index 000000000..11bfbceb2 --- /dev/null +++ b/packages/migrations/.opencode/commands/create-or-update-model-and-typeorm-schemas.md @@ -0,0 +1,101 @@ +Create new models and update existing ones with TypeORM to ensure proper database structure and maintainability when adapting to evolving business requirements. + +## When to Use + +- When you need to create a new entity in your hexagonal domain +- When adding new properties to existing models +- When modifying existing property types or relationships +- When business logic requirements change and database schema needs updating + +## Context Validation Checkpoints + +* [ ] Have you identified which hexagon package the model belongs to? +* [ ] Do you know the TypeORM column types needed for each property? +* [ ] Have you considered backward compatibility when adding required fields? +* [ ] Is there an existing test factory for similar entities to use as reference? + +## Recipe Steps + +### Step 1: Define Business Model + +Create the business model in packages/<hexagon>/src/domain/entities/<MyModel>.ts and define the corresponding repository interface in packages/<hexagon>/src/domain/repositories/I<MyModel>Repository.ts. Export both from the hexagon. + +```typescript +// packages/<hexagon>/src/domain/entities/MyModel.ts +export type MyModel = { + id: string; + name: string; + description?: string; +}; +``` + +### Step 2: Define TypeORM Schema + +Create the TypeORM schema in packages/<hexagon>/src/infra/schemas/<MyModel>Schema.ts using EntitySchema with proper column definitions, UUID schema, and timestamp schemas from @packmind/node-utils. + +```typescript +import { EntitySchema } from 'typeorm'; +import { MyModel } from '../../domain/entities/MyModel'; +import { WithTimestamps, uuidSchema, timestampsSchemas } from '@packmind/node-utils'; + +export const MyModelSchema = new EntitySchema<WithTimestamps<MyModel>>({ + name: 'MyModel', + tableName: 'mymodels', + columns: { + ...uuidSchema, + name: { type: 'varchar' }, + description: { type: 'text', nullable: true }, + ...timestampsSchemas, + }, +}); +``` + +### Step 3: Make Schema Available to App + +Register the new schema in apps/api/src/app/app.module.ts to ensure TypeORM recognizes it during database operations. + +### Step 4: Implement Repository + +Create repository implementation in packages/<hexagon>/src/infra/repository/<MyModel>Repository.ts that implements the repository interface and uses the TypeORM schema. + +```typescript +import { MyModel } from '../../domain/entities/MyModel'; +import { IMyModelRepository } from '../../domain/repositories/IMyModelRepository'; +import dataSource from '../datasource'; +import { MyModelSchema } from '../schemas/MyModelSchema'; +import { Repository } from 'typeorm'; + +export class MyModelRepository implements IMyModelRepository { + constructor( + private readonly repository: Repository<MyModel> = dataSource.getRepository<MyModel>(MyModelSchema) + ) {} + + async add(model: MyModel): Promise<MyModel> { + return this.repository.save(model); + } + + async list(): Promise<MyModel[]> { + return this.repository.find(); + } +} +``` + +### Step 5: Create Migration + +Create a new migration using the TypeORM CLI command and implement the up/down methods to define all required fields. Always use the CLI to create migrations, never create them manually. + +```bash +npm run typeorm migration:create ./migrations/MyModel +``` + +### Step 6: Run Migration + +Execute the migration to apply database changes using the TypeORM CLI with the appropriate datasource configuration. + +```bash +npm run typeorm migration:run -- --dataSource=datasource.ts +``` + +### Step 7: Update Existing Models (When Needed) + +When updating existing models, always add new fields as nullable first to avoid breaking existing data. Update the entity type definition, TypeORM schema, create a migration with ALTER TABLE statements, update test factories, and update services/use cases that create or update the model. Consider backward compatibility and test migrations on a copy of production data before deploying. \ No newline at end of file diff --git a/packages/migrations/.packmind/commands-index.md b/packages/migrations/.packmind/commands-index.md index a7573b618..c8fa3f3c9 100644 --- a/packages/migrations/.packmind/commands-index.md +++ b/packages/migrations/.packmind/commands-index.md @@ -4,7 +4,7 @@ This file contains all available coding commands that can be used by AI agents ( ## Available Commands -- [Create or update model and TypeORM schemas](commands/create-or-update-model-and-typeorm-schemas.md) : Create and evolve TypeORM-backed domain models, schemas, repositories, and migrations to keep your database structure consistent, maintainable, and backward compatible when business requirements or existing entities change. +- [Create or update model and TypeORM schemas](commands/create-or-update-model-and-typeorm-schemas.md) : Create or update model and TypeORM schemas --- diff --git a/packages/migrations/jest.config.ts b/packages/migrations/jest.config.ts index 9687b0dff..b70b36370 100644 --- a/packages/migrations/jest.config.ts +++ b/packages/migrations/jest.config.ts @@ -10,4 +10,7 @@ module.exports = { transform: swcTransform, moduleFileExtensions: standardModuleFileExtensions, coverageDirectory: '../../coverage/packages/migrations', + moduleNameMapper: { + '^@packmind/logger$': '<rootDir>/../../packages/logger/src/index.ts', + }, }; diff --git a/packages/migrations/packmind-lock.json b/packages/migrations/packmind-lock.json index ef1b77963..229dab2d4 100644 --- a/packages/migrations/packmind-lock.json +++ b/packages/migrations/packmind-lock.json @@ -6,12 +6,14 @@ "agents": [ "agents_md", "claude", + "codex", "copilot", "cursor", "gitlab_duo", + "opencode", "packmind" ], - "installedAt": "2026-06-16T03:12:28.638Z", + "installedAt": "2026-07-10T03:07:54.938Z", "artifacts": { "user:command:create-or-update-model-and-typeorm-schemas": { "name": "Create or update model and TypeORM schemas", @@ -36,6 +38,10 @@ "path": ".github/prompts/create-or-update-model-and-typeorm-schemas.prompt.md", "agent": "copilot" }, + { + "path": ".opencode/commands/create-or-update-model-and-typeorm-schemas.md", + "agent": "opencode" + }, { "path": ".packmind/commands/create-or-update-model-and-typeorm-schemas.md", "agent": "packmind" diff --git a/packages/node-utils/src/application/AbstractSpaceAdminUseCase.spec.ts b/packages/node-utils/src/application/AbstractSpaceAdminUseCase.spec.ts index d260df5ef..789e4b2ef 100644 --- a/packages/node-utils/src/application/AbstractSpaceAdminUseCase.spec.ts +++ b/packages/node-utils/src/application/AbstractSpaceAdminUseCase.spec.ts @@ -100,6 +100,7 @@ describe('AbstractSpaceAdminUseCase', () => { userId, spaceId, role: UserSpaceRole.MEMBER, + pinned: false, createdBy: userId, updatedBy: userId, ...overrides, diff --git a/packages/node-utils/src/application/AbstractSpaceMemberUseCase.spec.ts b/packages/node-utils/src/application/AbstractSpaceMemberUseCase.spec.ts index f69df9ead..d0e6805c4 100644 --- a/packages/node-utils/src/application/AbstractSpaceMemberUseCase.spec.ts +++ b/packages/node-utils/src/application/AbstractSpaceMemberUseCase.spec.ts @@ -100,6 +100,7 @@ describe('AbstractSpaceMemberUseCase', () => { userId, spaceId, role: UserSpaceRole.MEMBER, + pinned: false, createdBy: userId, updatedBy: userId, ...overrides, diff --git a/packages/node-utils/src/jobs/application/JobRegistry.spec.ts b/packages/node-utils/src/jobs/application/JobRegistry.spec.ts new file mode 100644 index 000000000..4eb3491ce --- /dev/null +++ b/packages/node-utils/src/jobs/application/JobRegistry.spec.ts @@ -0,0 +1,63 @@ +import { stubLogger } from '@packmind/test-utils'; +import { IJobFactory, IJobQueue } from '../domain/IJobQueue'; +import { JobRegistry } from './JobRegistry'; + +function makeFactory(name: string): { + factory: IJobFactory<unknown>; + createQueue: jest.Mock; + initialize: jest.Mock; + destroy: jest.Mock; +} { + const initialize = jest.fn().mockResolvedValue(undefined); + const destroy = jest.fn().mockResolvedValue(undefined); + const addJob = jest.fn().mockResolvedValue('job-id'); + const queue: IJobQueue<unknown> = { addJob, initialize, destroy }; + const createQueue = jest.fn().mockResolvedValue(queue); + const factory: IJobFactory<unknown> = { + createQueue, + getQueueName: () => name, + }; + return { factory, createQueue, initialize, destroy }; +} + +describe('JobRegistry', () => { + describe('initializeAllQueues', () => { + describe('when a queue was already initialized on a prior call', () => { + let registry: JobRegistry; + let first: ReturnType<typeof makeFactory>; + let second: ReturnType<typeof makeFactory>; + + beforeEach(async () => { + registry = new JobRegistry(stubLogger()); + first = makeFactory('queue-a'); + second = makeFactory('queue-b'); + + registry.registerQueue('queue-a', first.factory); + await registry.initializeAllQueues(); + + registry.registerQueue('queue-b', second.factory); + await registry.initializeAllQueues(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('creates the previously initialized queue only once', () => { + expect(first.createQueue).toHaveBeenCalledTimes(1); + }); + + it('initializes the previously initialized queue only once', () => { + expect(first.initialize).toHaveBeenCalledTimes(1); + }); + + it('creates the newly registered queue exactly once', () => { + expect(second.createQueue).toHaveBeenCalledTimes(1); + }); + + it('initializes the newly registered queue exactly once', () => { + expect(second.initialize).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/packages/node-utils/src/jobs/application/JobRegistry.ts b/packages/node-utils/src/jobs/application/JobRegistry.ts index 492fb4608..fba7a9d0b 100644 --- a/packages/node-utils/src/jobs/application/JobRegistry.ts +++ b/packages/node-utils/src/jobs/application/JobRegistry.ts @@ -41,6 +41,16 @@ export class JobRegistry implements IJobRegistry { const initPromises: Promise<void>[] = []; for (const [queueName, factory] of this.factories.entries()) { + // Idempotency: each registered factory's queue is created and its worker + // started at most once per process. Without this guard, multiple Hexas + // calling `initJobQueues()` re-invoke `factory.createQueue()` for every + // previously-initialized queue and spin up duplicate BullMQ workers. + if (this.queues.has(queueName)) { + this.logger.info('Job queue already initialized, skipping', { + queueName, + }); + continue; + } const initPromise = this.initializeQueue(queueName, factory); initPromises.push(initPromise); } diff --git a/packages/node-utils/src/jobs/domain/IQueue.ts b/packages/node-utils/src/jobs/domain/IQueue.ts index c9ed261ac..7a0301047 100644 --- a/packages/node-utils/src/jobs/domain/IQueue.ts +++ b/packages/node-utils/src/jobs/domain/IQueue.ts @@ -7,6 +7,15 @@ export interface IQueue<Input, Output> { jobsOptions?: JobsOptions, ): Promise<string>; cancelJob(jobId: string): Promise<void>; + /** + * Remove a repeatable job matching the given cron pattern + jobId. + * + * Implementations that don't support repeatable jobs may treat this as a + * no-op. Used by use cases that need to cancel a recurring schedule (for + * example, `UnlinkMarketplaceUseCase` removing the marketplace + * reconciliation cron when an admin unlinks a marketplace). + */ + removeRepeatable(name: string, pattern: string, jobId: string): Promise<void>; addWorker( runner: Runner<Input, Output>, listeners?: Partial<WorkerListeners<Input, Output>>, diff --git a/packages/node-utils/src/jobs/infra/bullMQ/AbstractQueue.ts b/packages/node-utils/src/jobs/infra/bullMQ/AbstractQueue.ts index e127c51e1..21db73864 100644 --- a/packages/node-utils/src/jobs/infra/bullMQ/AbstractQueue.ts +++ b/packages/node-utils/src/jobs/infra/bullMQ/AbstractQueue.ts @@ -65,6 +65,38 @@ export abstract class AbstractQueue<Input, Output> implements IQueue< return job.id; } + async removeRepeatable( + name: string, + pattern: string, + jobId: string, + ): Promise<void> { + if (!this.queue) { + throw new Error(`Queue ${this.QUEUE_ID} not initialized`); + } + + try { + const removed = await this.queue.removeRepeatable(name, { + pattern, + jobId, + }); + if (removed) { + this._logger.info( + `[${this.QUEUE_ID}] Removed repeatable job ${jobId} with pattern ${pattern}`, + ); + } else { + this._logger.info( + `[${this.QUEUE_ID}] No repeatable job ${jobId} with pattern ${pattern} to remove`, + ); + } + } catch (error) { + this._logger.warn( + `[${this.QUEUE_ID}] Failed to remove repeatable job ${jobId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + async cancelJob(jobId: string): Promise<void> { const job = await this.queue.getJob(jobId); if (job) { diff --git a/packages/node-utils/src/jobs/test/MockJobQueue.ts b/packages/node-utils/src/jobs/test/MockJobQueue.ts index 8152c409c..85aa23984 100644 --- a/packages/node-utils/src/jobs/test/MockJobQueue.ts +++ b/packages/node-utils/src/jobs/test/MockJobQueue.ts @@ -35,6 +35,21 @@ export class MockJobQueue<Input, Output> implements IQueue<Input, Output> { } } + async removeRepeatable( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _name: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _pattern: string, + jobId: string, + ): Promise<void> { + // Mock implementation — remove any queued job tagged with this id so + // assertions in tests reflect a cleared schedule. + const jobIndex = this.jobs.findIndex((job) => job.id === jobId); + if (jobIndex >= 0) { + this.jobs.splice(jobIndex, 1); + } + } + async addWorker( // eslint-disable-next-line @typescript-eslint/no-unused-vars runner: Runner<Input, Output>, diff --git a/packages/node-utils/src/sse/SSEEventPublisher.ts b/packages/node-utils/src/sse/SSEEventPublisher.ts index 728e25b45..144dbdf81 100644 --- a/packages/node-utils/src/sse/SSEEventPublisher.ts +++ b/packages/node-utils/src/sse/SSEEventPublisher.ts @@ -13,6 +13,9 @@ import { createUserContextChangeEvent, createDistributionStatusChangeEvent, createChangeProposalUpdateEvent, + createMarketplacePublishCompletedEvent, + type MarketplacePublishCompletedStatus, + type PublishFailureReason, type UserContextChangeType, } from '@packmind/types'; import { UserOrganizationRole } from '@packmind/types'; @@ -352,6 +355,84 @@ export class SSEEventPublisher { } } + /** + * Publish a marketplace publish completed event so the user who triggered + * the publish receives a terminal-state notification with the rolling PR + * URL (when available). Targets the publishing user only — other org + * members do not need this toast. + */ + static async publishMarketplacePublishCompletedEvent(params: { + marketplaceDistributionId: string; + marketplaceId: string; + packageId: string; + pluginSlug: string; + packageName: string; + marketplaceName: string; + status: MarketplacePublishCompletedStatus; + userId: string; + prUrl?: string; + failureReason?: PublishFailureReason; + }): Promise<void> { + const { + marketplaceDistributionId, + marketplaceId, + packageId, + pluginSlug, + packageName, + marketplaceName, + status, + userId, + prUrl, + failureReason, + } = params; + + SSEEventPublisher.getInstance().logger.info( + 'Publishing marketplace publish completed event', + { + marketplaceDistributionId, + marketplaceId, + packageId, + status, + }, + ); + + try { + const event = createMarketplacePublishCompletedEvent({ + marketplaceDistributionId, + marketplaceId, + packageId, + pluginSlug, + packageName, + marketplaceName, + status, + prUrl, + failureReason, + }); + + await SSEEventPublisher.publishEvent( + 'MARKETPLACE_PUBLISH_COMPLETED', + [], + event, + [userId], + ); + + SSEEventPublisher.getInstance().logger.debug( + 'Successfully published marketplace publish completed event', + { marketplaceDistributionId, status }, + ); + } catch (error) { + SSEEventPublisher.getInstance().logger.error( + 'Failed to publish marketplace publish completed event', + { + marketplaceDistributionId, + status, + error: error instanceof Error ? error.message : String(error), + }, + ); + throw error; + } + } + /** * Generic method to publish any SSE event type to Redis pub/sub */ diff --git a/packages/packmind-lock.json b/packages/packmind-lock.json index bdce2ef48..f91a3b0f1 100644 --- a/packages/packmind-lock.json +++ b/packages/packmind-lock.json @@ -6,12 +6,14 @@ "agents": [ "agents_md", "claude", + "codex", "copilot", "cursor", "gitlab_duo", + "opencode", "packmind" ], - "installedAt": "2026-06-16T03:12:28.041Z", + "installedAt": "2026-07-10T03:07:54.373Z", "artifacts": { "user:standard:back-end-repositories-sql-queries-using-typeorm": { "name": "Back-end repositories SQL queries using TypeORM", diff --git a/packages/playbook-change-management/.swcrc b/packages/playbook-change-management/.swcrc new file mode 100644 index 000000000..83bfbeb3f --- /dev/null +++ b/packages/playbook-change-management/.swcrc @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "jsc": { + "parser": { "syntax": "typescript", "decorators": true }, + "transform": { "legacyDecorator": true, "decoratorMetadata": true }, + "target": "es2022", + "keepClassNames": true + }, + "module": { "type": "commonjs" }, + "sourceMaps": true +} diff --git a/packages/playbook-change-management/README.md b/packages/playbook-change-management/README.md new file mode 100644 index 000000000..f5f083a18 --- /dev/null +++ b/packages/playbook-change-management/README.md @@ -0,0 +1,11 @@ +# playbook-change-management + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build playbook-change-management` to build the library. + +## Running unit tests + +Run `nx test playbook-change-management` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/packages/playbook-change-management/eslint.config.mjs b/packages/playbook-change-management/eslint.config.mjs new file mode 100644 index 000000000..c334bc0bc --- /dev/null +++ b/packages/playbook-change-management/eslint.config.mjs @@ -0,0 +1,19 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, +]; diff --git a/packages/playbook-change-management/jest.config.ts b/packages/playbook-change-management/jest.config.ts new file mode 100644 index 000000000..cba3b66ec --- /dev/null +++ b/packages/playbook-change-management/jest.config.ts @@ -0,0 +1,23 @@ +const { compilerOptions } = require('../../tsconfig.base.effective.json'); + +const { + pathsToModuleNameMapper, + swcTransformWithDecorators, + standardTransformIgnorePatterns, + standardModuleFileExtensions, +} = require('../../jest-utils.ts'); + +module.exports = { + displayName: 'playbook-change-management', + preset: '../../jest.preset.ts', + testEnvironment: 'node', + transform: swcTransformWithDecorators, + transformIgnorePatterns: standardTransformIgnorePatterns, + moduleFileExtensions: standardModuleFileExtensions, + coverageDirectory: '../../coverage/packages/playbook-change-management', + moduleNameMapper: pathsToModuleNameMapper( + compilerOptions.paths, + '<rootDir>/../../', + ), + passWithNoTests: true, +}; diff --git a/packages/playbook-change-management/package.json b/packages/playbook-change-management/package.json new file mode 100644 index 000000000..882e79b03 --- /dev/null +++ b/packages/playbook-change-management/package.json @@ -0,0 +1,25 @@ +{ + "name": "@packmind/playbook-change-management", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "@nestjs/common": "^11.0.1", + "express": "^5.2.1", + "@packmind/accounts": "workspace:*", + "@packmind/deployments": "workspace:*", + "@packmind/logger": "workspace:*", + "@packmind/node-utils": "workspace:*", + "@packmind/recipes": "workspace:*", + "@packmind/skills": "workspace:*", + "@packmind/spaces": "workspace:*", + "@packmind/standards": "workspace:*", + "@packmind/test-utils": "workspace:*", + "@packmind/types": "workspace:*", + "@teppeis/multimaps": "^3.0.0", + "typeorm": "0.3.28", + "uuid": "^11.1.0" + } +} diff --git a/packages/playbook-change-management/project.json b/packages/playbook-change-management/project.json new file mode 100644 index 000000000..14f8a7c16 --- /dev/null +++ b/packages/playbook-change-management/project.json @@ -0,0 +1,26 @@ +{ + "name": "playbook-change-management", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/playbook-change-management/src", + "projectType": "library", + "tags": ["env:node"], + "targets": { + "typecheck": { + "executor": "nx:run-commands", + "options": { + "command": "tsc --noEmit --project packages/playbook-change-management/tsconfig.lib.json" + } + }, + "build": { + "executor": "@nx/js:swc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/playbook-change-management", + "tsConfig": "packages/playbook-change-management/tsconfig.lib.json", + "packageJson": "packages/playbook-change-management/package.json", + "main": "packages/playbook-change-management/src/index.ts", + "assets": ["packages/playbook-change-management/*.md"] + } + } + } +} diff --git a/packages/playbook-change-management/src/PlaybookChangeManagementHexa.ts b/packages/playbook-change-management/src/PlaybookChangeManagementHexa.ts new file mode 100644 index 000000000..81c480e70 --- /dev/null +++ b/packages/playbook-change-management/src/PlaybookChangeManagementHexa.ts @@ -0,0 +1,140 @@ +import { DataSource } from 'typeorm'; +import { PackmindLogger } from '@packmind/logger'; +import { + BaseHexa, + BaseHexaOpts, + HexaRegistry, + PackmindEventEmitterService, +} from '@packmind/node-utils'; +import { + IAccountsPort, + IAccountsPortName, + IDeploymentPort, + IDeploymentPortName, + IPlaybookChangeManagementPort, + IPlaybookChangeManagementPortName, + IRecipesPort, + IRecipesPortName, + ISkillsPort, + ISkillsPortName, + ISpacesPort, + ISpacesPortName, + IStandardsPort, + IStandardsPortName, +} from '@packmind/types'; +import { PlaybookChangeManagementAdapter } from './application/adapters/PlaybookChangeManagementAdapter'; +import { ChangeManagementListener } from './application/listeners/ChangeManagementListener'; +import { PlaybookChangeManagementRepositories } from './infra/repositories/PlaybookChangeManagementRepositories'; +import { PlaybookChangeManagementServices } from './application/services/PlaybookChangeManagementServices'; + +const origin = 'PlaybookChangeManagementHexa'; + +/** + * PlaybookChangeManagementHexa - Facade for the Playbook Change Management domain + * following the Port/Adapter pattern. + * + * This class serves as the main entry point for playbook change management functionality. + * It exposes the adapter for cross-domain access following DDD standards. + * + * Uses database repository with localDataSource for persistence. + */ +export class PlaybookChangeManagementHexa extends BaseHexa< + BaseHexaOpts, + IPlaybookChangeManagementPort +> { + private readonly playbookChangeManagementRepositories: PlaybookChangeManagementRepositories; + private readonly playbookChangeManagementServices: PlaybookChangeManagementServices; + private readonly playbookChangeManagementAdapter: PlaybookChangeManagementAdapter; + private readonly changeManagementListener: ChangeManagementListener; + + constructor( + dataSource: DataSource, + opts: Partial<BaseHexaOpts> = { logger: new PackmindLogger(origin) }, + ) { + super(dataSource, opts); + this.logger.info('Constructing PlaybookChangeManagementHexa'); + + try { + this.playbookChangeManagementRepositories = + new PlaybookChangeManagementRepositories(this.dataSource); + this.playbookChangeManagementServices = + new PlaybookChangeManagementServices( + this.playbookChangeManagementRepositories, + this.dataSource, + ); + this.playbookChangeManagementAdapter = + new PlaybookChangeManagementAdapter( + this.playbookChangeManagementServices, + ); + + this.changeManagementListener = new ChangeManagementListener( + this.playbookChangeManagementServices.getChangeProposalService(), + this.playbookChangeManagementAdapter, + ); + + this.logger.info('PlaybookChangeManagementHexa construction completed'); + } catch (error) { + this.logger.error('Failed to construct PlaybookChangeManagementHexa', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + public async initialize(registry: HexaRegistry): Promise<void> { + this.logger.info( + 'Initializing PlaybookChangeManagementHexa (adapter retrieval phase)', + ); + + try { + const accountsPort = + registry.getAdapter<IAccountsPort>(IAccountsPortName); + const recipesPort = registry.getAdapter<IRecipesPort>(IRecipesPortName); + const spacesPort = registry.getAdapter<ISpacesPort>(ISpacesPortName); + const skillsPort = registry.getAdapter<ISkillsPort>(ISkillsPortName); + const standardsPort = + registry.getAdapter<IStandardsPort>(IStandardsPortName); + const deploymentPort = + registry.getAdapter<IDeploymentPort>(IDeploymentPortName); + const eventEmitterService = registry.getService( + PackmindEventEmitterService, + ); + + if (!eventEmitterService) { + throw new Error('PackmindEventEmitterService not found in registry'); + } + + await this.playbookChangeManagementAdapter.initialize({ + [IAccountsPortName]: accountsPort, + [IRecipesPortName]: recipesPort, + [ISpacesPortName]: spacesPort, + [ISkillsPortName]: skillsPort, + [IStandardsPortName]: standardsPort, + [IDeploymentPortName]: deploymentPort, + eventEmitterService, + }); + + this.changeManagementListener.initialize(eventEmitterService); + + this.logger.info('PlaybookChangeManagementHexa initialized successfully'); + } catch (error) { + this.logger.error('Failed to initialize PlaybookChangeManagementHexa', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + destroy(): void { + this.logger.info('Destroying PlaybookChangeManagementHexa'); + this.logger.info('PlaybookChangeManagementHexa destroyed'); + } + + public getAdapter(): IPlaybookChangeManagementPort { + return this.playbookChangeManagementAdapter.getPort(); + } + + public getPortName(): string { + return IPlaybookChangeManagementPortName; + } +} diff --git a/packages/playbook-change-management/src/application/adapters/PlaybookChangeManagementAdapter.ts b/packages/playbook-change-management/src/application/adapters/PlaybookChangeManagementAdapter.ts new file mode 100644 index 000000000..c137eb55e --- /dev/null +++ b/packages/playbook-change-management/src/application/adapters/PlaybookChangeManagementAdapter.ts @@ -0,0 +1,320 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + IBaseAdapter, + PackmindEventEmitterService, +} from '@packmind/node-utils'; +import { + ApplyChangeProposalsCommand, + ApplyChangeProposalsResponse, + ApplyCreationChangeProposalsCommand, + ApplyCreationChangeProposalsResponse, + BatchCreateChangeProposalsCommand, + BatchCreateChangeProposalsResponse, + ChangeProposalType, + CheckChangeProposalsCommand, + CheckChangeProposalsResponse, + CreateChangeProposalCommand, + CreateChangeProposalResponse, + IAccountsPort, + IAccountsPortName, + IDeploymentPort, + IDeploymentPortName, + IPlaybookChangeManagementPort, + IRecipesPort, + IRecipesPortName, + ISkillsPort, + ISkillsPortName, + ISpacesPort, + ISpacesPortName, + IStandardsPort, + IStandardsPortName, + ListChangeProposalsByArtefactCommand, + ListChangeProposalsByArtefactResponse, + ListChangeProposalsBySpaceCommand, + ListChangeProposalsBySpaceResponse, + MigrateChangeProposalsForMovedArtefactCommand, + MigrateChangeProposalsForMovedArtefactResponse, + RecomputeConflictsCommand, + RecomputeConflictsResponse, + RecipeId, + SkillId, + StandardId, +} from '@packmind/types'; +import { PlaybookChangeManagementServices } from '../services/PlaybookChangeManagementServices'; +import { ApplyChangeProposalsUseCase } from '../useCases/applyChangeProposals/ApplyChangeProposalsUseCase'; +import { ApplyCreationChangeProposalsUseCase } from '../useCases/applyCreationChangeProposals/ApplyCreationChangeProposalsUseCase'; +import { CreateChangeProposalUseCase } from '../useCases/createChangeProposal/CreateChangeProposalUseCase'; +import { ListChangeProposalsByArtefactUseCase } from '../useCases/listChangeProposalsByArtefact/ListChangeProposalsByArtefactUseCase'; +import { ListChangeProposalsBySpaceUseCase } from '../useCases/listChangeProposalsBySpace/ListChangeProposalsBySpaceUseCase'; +import { BatchCreateChangeProposalsUseCase } from '../useCases/batchCreateChangeProposals/BatchCreateChangeProposalsUseCase'; +import { CheckChangeProposalsUseCase } from '../useCases/checkChangeProposals/CheckChangeProposalsUseCase'; +import { MigrateChangeProposalsForMovedArtefactUseCase } from '../useCases/migrateChangeProposalsForMovedArtefact/MigrateChangeProposalsForMovedArtefactUseCase'; +import { RecomputeConflictsUseCase } from '../useCases/recomputeConflicts/RecomputeConflictsUseCase'; +import { IChangeProposalValidator } from '../validators/IChangeProposalValidator'; +import { CommandChangeProposalValidator } from '../validators/CommandChangeProposalValidator'; +import { SkillChangeProposalValidator } from '../validators/SkillChangeProposalValidator'; +import { StandardChangeProposalValidator } from '../validators/StandardChangeProposalValidator'; +import { RemovalChangeProposalValidator } from '../validators/RemovalChangeProposalValidator'; + +const origin = 'PlaybookChangeManagementAdapter'; + +export class PlaybookChangeManagementAdapter + implements + IBaseAdapter<IPlaybookChangeManagementPort>, + IPlaybookChangeManagementPort +{ + private _applyChangeProposals!: ApplyChangeProposalsUseCase< + StandardId | RecipeId | SkillId + >; + private _applyCreationChangeProposals!: ApplyCreationChangeProposalsUseCase; + private _batchCreateChangeProposals!: BatchCreateChangeProposalsUseCase; + private _checkChangeProposals!: CheckChangeProposalsUseCase; + private _createChangeProposal!: CreateChangeProposalUseCase; + private _listChangeProposalsByArtefact!: ListChangeProposalsByArtefactUseCase< + StandardId | RecipeId | SkillId + >; + private _listChangeProposalsBySpace!: ListChangeProposalsBySpaceUseCase; + private _migrateChangeProposalsForMovedArtefact!: MigrateChangeProposalsForMovedArtefactUseCase; + private _recomputeConflicts!: RecomputeConflictsUseCase; + + constructor( + private readonly services: PlaybookChangeManagementServices, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async applyChangeProposals<T extends StandardId | RecipeId | SkillId>( + command: ApplyChangeProposalsCommand<T>, + ): Promise<ApplyChangeProposalsResponse<T>> { + return this._applyChangeProposals.execute(command); + } + + async applyCreationChangeProposals( + command: ApplyCreationChangeProposalsCommand, + ): Promise<ApplyCreationChangeProposalsResponse> { + return this._applyCreationChangeProposals.execute(command); + } + + async batchCreateChangeProposals( + command: BatchCreateChangeProposalsCommand, + ): Promise<BatchCreateChangeProposalsResponse> { + return this._batchCreateChangeProposals.execute(command); + } + + async checkChangeProposals( + command: CheckChangeProposalsCommand, + ): Promise<CheckChangeProposalsResponse> { + return this._checkChangeProposals.execute(command); + } + + async createChangeProposal<T extends ChangeProposalType>( + command: CreateChangeProposalCommand<T>, + ): Promise<CreateChangeProposalResponse<T>> { + return this._createChangeProposal.execute( + command as CreateChangeProposalCommand<ChangeProposalType>, + ) as Promise<CreateChangeProposalResponse<T>>; + } + + async listChangeProposalsByArtefact< + T extends StandardId | RecipeId | SkillId, + >( + command: ListChangeProposalsByArtefactCommand<T>, + ): Promise<ListChangeProposalsByArtefactResponse> { + return this._listChangeProposalsByArtefact.execute(command); + } + + async listChangeProposalsBySpace( + command: ListChangeProposalsBySpaceCommand, + ): Promise<ListChangeProposalsBySpaceResponse> { + return this._listChangeProposalsBySpace.execute(command); + } + + async recomputeConflicts( + command: RecomputeConflictsCommand, + ): Promise<RecomputeConflictsResponse> { + return this._recomputeConflicts.execute(command); + } + + async migrateChangeProposalsForMovedArtefact( + command: MigrateChangeProposalsForMovedArtefactCommand, + ): Promise<MigrateChangeProposalsForMovedArtefactResponse> { + return this._migrateChangeProposalsForMovedArtefact.execute(command); + } + + public async initialize(ports: { + [IAccountsPortName]: IAccountsPort; + [IRecipesPortName]: IRecipesPort; + [ISpacesPortName]: ISpacesPort; + [ISkillsPortName]: ISkillsPort; + [IStandardsPortName]: IStandardsPort; + [IDeploymentPortName]: IDeploymentPort; + eventEmitterService: PackmindEventEmitterService; + }): Promise<void> { + this.logger.info('Initializing PlaybookChangeManagementAdapter with ports'); + + const accountsPort = ports[IAccountsPortName]; + + if (!accountsPort) { + throw new Error( + 'PlaybookChangeManagementAdapter: IAccountsPort not provided', + ); + } + + const recipesPort = ports[IRecipesPortName]; + + if (!recipesPort) { + throw new Error( + 'PlaybookChangeManagementAdapter: IRecipesPort not provided', + ); + } + + const spacesPort = ports[ISpacesPortName]; + + if (!spacesPort) { + throw new Error( + 'PlaybookChangeManagementAdapter: ISpacesPort not provided', + ); + } + + const skillsPort = ports[ISkillsPortName]; + + if (!skillsPort) { + throw new Error( + 'PlaybookChangeManagementAdapter: ISkillsPort not provided', + ); + } + + const standardsPort = ports[IStandardsPortName]; + + if (!standardsPort) { + throw new Error( + 'PlaybookChangeManagementAdapter: IStandardsPort not provided', + ); + } + + const deploymentPort = ports[IDeploymentPortName]; + + if (!deploymentPort) { + throw new Error( + 'PlaybookChangeManagementAdapter: IDeploymentPort not provided', + ); + } + + const { eventEmitterService } = ports; + + if (!eventEmitterService) { + throw new Error( + 'PlaybookChangeManagementAdapter: PackmindEventEmitterService not provided', + ); + } + + const changeProposalService = this.services.getChangeProposalService(); + const conflictDetectionService = + this.services.getConflictDetectionService(); + + const validators: IChangeProposalValidator[] = [ + new CommandChangeProposalValidator(recipesPort), + new SkillChangeProposalValidator(skillsPort), + new StandardChangeProposalValidator(standardsPort), + new RemovalChangeProposalValidator( + standardsPort, + recipesPort, + skillsPort, + ), + ]; + + this._applyChangeProposals = new ApplyChangeProposalsUseCase( + spacesPort, + accountsPort, + standardsPort, + recipesPort, + skillsPort, + deploymentPort, + changeProposalService, + eventEmitterService, + ); + + this._applyCreationChangeProposals = + new ApplyCreationChangeProposalsUseCase( + spacesPort, + accountsPort, + recipesPort, + standardsPort, + skillsPort, + changeProposalService, + eventEmitterService, + ); + + this._batchCreateChangeProposals = new BatchCreateChangeProposalsUseCase( + accountsPort, + this, + deploymentPort, + ); + + this._checkChangeProposals = new CheckChangeProposalsUseCase( + accountsPort, + changeProposalService, + validators, + ); + + this._createChangeProposal = new CreateChangeProposalUseCase( + spacesPort, + accountsPort, + changeProposalService, + validators, + eventEmitterService, + ); + + this._listChangeProposalsByArtefact = + new ListChangeProposalsByArtefactUseCase( + spacesPort, + accountsPort, + standardsPort, + recipesPort, + skillsPort, + deploymentPort, + changeProposalService, + conflictDetectionService, + ); + + this._listChangeProposalsBySpace = new ListChangeProposalsBySpaceUseCase( + spacesPort, + accountsPort, + standardsPort, + recipesPort, + skillsPort, + changeProposalService, + ); + + this._migrateChangeProposalsForMovedArtefact = + new MigrateChangeProposalsForMovedArtefactUseCase(changeProposalService); + + this._recomputeConflicts = new RecomputeConflictsUseCase( + spacesPort, + accountsPort, + changeProposalService, + conflictDetectionService, + ); + + this.logger.info( + 'PlaybookChangeManagementAdapter initialized successfully', + ); + } + + public isReady(): boolean { + return ( + this._applyChangeProposals !== undefined && + this._applyCreationChangeProposals !== undefined && + this._batchCreateChangeProposals !== undefined && + this._checkChangeProposals !== undefined && + this._createChangeProposal !== undefined && + this._listChangeProposalsByArtefact !== undefined && + this._listChangeProposalsBySpace !== undefined && + this._migrateChangeProposalsForMovedArtefact !== undefined && + this._recomputeConflicts !== undefined + ); + } + + public getPort(): IPlaybookChangeManagementPort { + return this as IPlaybookChangeManagementPort; + } +} diff --git a/packages/playbook-change-management/src/application/adapters/index.ts b/packages/playbook-change-management/src/application/adapters/index.ts new file mode 100644 index 000000000..c6721f6bf --- /dev/null +++ b/packages/playbook-change-management/src/application/adapters/index.ts @@ -0,0 +1 @@ +export * from './PlaybookChangeManagementAdapter'; diff --git a/packages/playbook-change-management/src/application/errors/ChangeProposalLimitExceededError.ts b/packages/playbook-change-management/src/application/errors/ChangeProposalLimitExceededError.ts new file mode 100644 index 000000000..aa3928104 --- /dev/null +++ b/packages/playbook-change-management/src/application/errors/ChangeProposalLimitExceededError.ts @@ -0,0 +1,36 @@ +import { ChangeProposalViolation } from '@packmind/types'; + +const VIOLATION_MESSAGES: Record< + ChangeProposalViolation, + (limit: number, actual: number) => string +> = { + [ChangeProposalViolation.STANDARD_NAME_TOO_LONG]: (limit, actual) => + `Standard name cannot exceed ${limit} characters (got ${actual})`, + [ChangeProposalViolation.TOO_MANY_RULES]: (limit, actual) => + `A standard cannot have more than ${limit} rules (got ${actual})`, + [ChangeProposalViolation.RULE_CONTENT_TOO_LONG]: (limit, actual) => + `Rule content cannot exceed ${limit} characters (got ${actual})`, + [ChangeProposalViolation.PAYLOAD_MISMATCH]: (limit, actual) => + `Payload mismatch (limit: ${limit}, actual: ${actual})`, + [ChangeProposalViolation.UNSUPPORTED_TYPE]: (limit, actual) => + `Unsupported change proposal type (limit: ${limit}, actual: ${actual})`, + [ChangeProposalViolation.SKILL_FILE_NOT_FOUND]: (limit, actual) => + `Skill file not found (limit: ${limit}, actual: ${actual})`, + [ChangeProposalViolation.SKILL_VERSION_NOT_FOUND]: (limit, actual) => + `Skill version not found (limit: ${limit}, actual: ${actual})`, +}; + +export class ChangeProposalLimitExceededError extends Error { + readonly changeProposal: ChangeProposalViolation; + readonly wasCreated = false; + + constructor( + violation: ChangeProposalViolation, + limit: number, + actual: number, + ) { + super(VIOLATION_MESSAGES[violation](limit, actual)); + this.name = 'ChangeProposalLimitExceededError'; + this.changeProposal = violation; + } +} diff --git a/packages/playbook-change-management/src/application/errors/ChangeProposalPayloadMismatchError.ts b/packages/playbook-change-management/src/application/errors/ChangeProposalPayloadMismatchError.ts new file mode 100644 index 000000000..3ba864550 --- /dev/null +++ b/packages/playbook-change-management/src/application/errors/ChangeProposalPayloadMismatchError.ts @@ -0,0 +1,14 @@ +import { ChangeProposalType } from '@packmind/types'; + +export class ChangeProposalPayloadMismatchError extends Error { + constructor( + type: ChangeProposalType, + oldValue: string, + currentValue: string, + ) { + super( + `Payload oldValue does not match current value for ${type}: expected "${currentValue}", got "${oldValue}"`, + ); + this.name = 'ChangeProposalPayloadMismatchError'; + } +} diff --git a/packages/playbook-change-management/src/application/errors/SkillFileNotFoundError.ts b/packages/playbook-change-management/src/application/errors/SkillFileNotFoundError.ts new file mode 100644 index 000000000..426916398 --- /dev/null +++ b/packages/playbook-change-management/src/application/errors/SkillFileNotFoundError.ts @@ -0,0 +1,8 @@ +import { SkillFileId } from '@packmind/types'; + +export class SkillFileNotFoundError extends Error { + constructor(skillFileId: SkillFileId) { + super(`Skill file ${skillFileId} not found`); + this.name = 'SkillFileNotFoundError'; + } +} diff --git a/packages/playbook-change-management/src/application/errors/SkillVersionNotFoundError.ts b/packages/playbook-change-management/src/application/errors/SkillVersionNotFoundError.ts new file mode 100644 index 000000000..8fbed5941 --- /dev/null +++ b/packages/playbook-change-management/src/application/errors/SkillVersionNotFoundError.ts @@ -0,0 +1,8 @@ +import { SkillId } from '@packmind/types'; + +export class SkillVersionNotFoundError extends Error { + constructor(skillId: SkillId) { + super(`No version found for skill ${skillId}`); + this.name = 'SkillVersionNotFoundError'; + } +} diff --git a/packages/playbook-change-management/src/application/errors/UnsupportedChangeProposalTypeError.ts b/packages/playbook-change-management/src/application/errors/UnsupportedChangeProposalTypeError.ts new file mode 100644 index 000000000..02ae49dfb --- /dev/null +++ b/packages/playbook-change-management/src/application/errors/UnsupportedChangeProposalTypeError.ts @@ -0,0 +1,8 @@ +import { ChangeProposalType } from '@packmind/types'; + +export class UnsupportedChangeProposalTypeError extends Error { + constructor(type: ChangeProposalType) { + super(`Unsupported change proposal type: ${type}`); + this.name = 'UnsupportedChangeProposalTypeError'; + } +} diff --git a/packages/playbook-change-management/src/application/errors/index.ts b/packages/playbook-change-management/src/application/errors/index.ts new file mode 100644 index 000000000..34692eb27 --- /dev/null +++ b/packages/playbook-change-management/src/application/errors/index.ts @@ -0,0 +1,5 @@ +export { ChangeProposalLimitExceededError } from './ChangeProposalLimitExceededError'; +export { ChangeProposalPayloadMismatchError } from './ChangeProposalPayloadMismatchError'; +export { SkillFileNotFoundError } from './SkillFileNotFoundError'; +export { SkillVersionNotFoundError } from './SkillVersionNotFoundError'; +export { UnsupportedChangeProposalTypeError } from './UnsupportedChangeProposalTypeError'; diff --git a/packages/playbook-change-management/src/application/listeners/ChangeManagementListener.spec.ts b/packages/playbook-change-management/src/application/listeners/ChangeManagementListener.spec.ts new file mode 100644 index 000000000..474c96418 --- /dev/null +++ b/packages/playbook-change-management/src/application/listeners/ChangeManagementListener.spec.ts @@ -0,0 +1,325 @@ +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { stubLogger } from '@packmind/test-utils'; +import { DataSource } from 'typeorm'; +import { ChangeManagementListener } from './ChangeManagementListener'; +import { ChangeProposalService } from '../services/ChangeProposalService'; +import { PlaybookChangeManagementAdapter } from '../adapters/PlaybookChangeManagementAdapter'; +import { + CommandDeletedEvent, + StandardDeletedEvent, + SkillDeletedEvent, + ArtefactRemovedFromPackageEvent, + PlaybookArtefactMovedEvent, + createSpaceId, + createOrganizationId, + createUserId, + createRecipeId, + createStandardId, + createSkillId, + createPackageId, +} from '@packmind/types'; + +describe('ChangeManagementListener', () => { + let eventService: PackmindEventEmitterService; + let mockChangeProposalService: jest.Mocked<ChangeProposalService>; + let mockAdapter: jest.Mocked<PlaybookChangeManagementAdapter>; + let listener: ChangeManagementListener; + let mockDataSource: DataSource; + + const spaceId = createSpaceId('space-456'); + const organizationId = createOrganizationId('org-789'); + const userId = createUserId('user-abc'); + + beforeEach(() => { + mockDataSource = { + isInitialized: true, + options: {}, + } as unknown as DataSource; + + eventService = new PackmindEventEmitterService(mockDataSource); + + mockChangeProposalService = { + cancelPendingByArtefactId: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked<ChangeProposalService>; + + mockAdapter = { + migrateChangeProposalsForMovedArtefact: jest.fn().mockResolvedValue({}), + } as unknown as jest.Mocked<PlaybookChangeManagementAdapter>; + + listener = new ChangeManagementListener( + mockChangeProposalService, + mockAdapter, + stubLogger(), + ); + listener.initialize(eventService); + }); + + afterEach(() => { + eventService.removeAllListeners(); + jest.clearAllMocks(); + }); + + describe('when CommandDeletedEvent is emitted', () => { + const recipeId = createRecipeId('recipe-123'); + + it('calls cancelPendingByArtefactId with the recipeId', async () => { + const event = new CommandDeletedEvent({ + id: recipeId, + spaceId, + organizationId, + userId, + source: 'api', + }); + + eventService.emit(event); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect( + mockChangeProposalService.cancelPendingByArtefactId, + ).toHaveBeenCalledWith(spaceId, recipeId, userId); + }); + + describe('when cancelPendingByArtefactId throws', () => { + it('does not propagate the error', async () => { + mockChangeProposalService.cancelPendingByArtefactId.mockRejectedValueOnce( + new Error('DB error'), + ); + + const event = new CommandDeletedEvent({ + id: recipeId, + spaceId, + organizationId, + userId, + source: 'api', + }); + + await expect(async () => { + eventService.emit(event); + await new Promise((resolve) => setTimeout(resolve, 10)); + }).not.toThrow(); + }); + }); + }); + + describe('when StandardDeletedEvent is emitted', () => { + const standardId = createStandardId('standard-123'); + + it('calls cancelPendingByArtefactId with the standardId', async () => { + const event = new StandardDeletedEvent({ + standardId, + spaceId, + organizationId, + userId, + source: 'api', + }); + + eventService.emit(event); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect( + mockChangeProposalService.cancelPendingByArtefactId, + ).toHaveBeenCalledWith(spaceId, standardId, userId); + }); + }); + + describe('when SkillDeletedEvent is emitted', () => { + const skillId = createSkillId('skill-123'); + + it('calls cancelPendingByArtefactId with the skillId', async () => { + const event = new SkillDeletedEvent({ + skillId, + spaceId, + organizationId, + userId, + source: 'api', + }); + + eventService.emit(event); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect( + mockChangeProposalService.cancelPendingByArtefactId, + ).toHaveBeenCalledWith(spaceId, skillId, userId); + }); + }); + + describe('when ArtefactRemovedFromPackageEvent is emitted', () => { + const packageId = createPackageId('pkg-1'); + const artefactId = 'some-artefact-id'; + + describe('when remainingPackagesCount is 0', () => { + it('calls cancelPendingByArtefactId', async () => { + const event = new ArtefactRemovedFromPackageEvent({ + artefactId, + spaceId, + packageId, + remainingPackagesCount: 0, + userId, + organizationId, + source: 'api', + }); + + eventService.emit(event); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect( + mockChangeProposalService.cancelPendingByArtefactId, + ).toHaveBeenCalledWith(spaceId, artefactId, userId); + }); + }); + + describe('when remainingPackagesCount is greater than 0', () => { + it('does NOT call cancelPendingByArtefactId', async () => { + const event = new ArtefactRemovedFromPackageEvent({ + artefactId, + spaceId, + packageId, + remainingPackagesCount: 2, + userId, + organizationId, + source: 'api', + }); + + eventService.emit(event); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect( + mockChangeProposalService.cancelPendingByArtefactId, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when cancelPendingByArtefactId throws', () => { + it('does not propagate the error', async () => { + mockChangeProposalService.cancelPendingByArtefactId.mockRejectedValueOnce( + new Error('DB error'), + ); + + const event = new ArtefactRemovedFromPackageEvent({ + artefactId, + spaceId, + packageId, + remainingPackagesCount: 0, + userId, + organizationId, + source: 'api', + }); + + await expect(async () => { + eventService.emit(event); + await new Promise((resolve) => setTimeout(resolve, 10)); + }).not.toThrow(); + }); + }); + }); + + describe('when PlaybookArtefactMovedEvent is emitted followed by a delete event for the same artifact', () => { + const destinationSpaceId = createSpaceId('dest-space'); + const standardId = createStandardId('standard-moved'); + + it('skips cancellation for the delete event while migration is in progress', async () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + let resolveMigration: () => void = () => {}; + const migrationPromise = new Promise<void>((resolve) => { + resolveMigration = resolve; + }); + mockAdapter.migrateChangeProposalsForMovedArtefact.mockReturnValue( + migrationPromise, + ); + + const movedEvent = new PlaybookArtefactMovedEvent({ + artifactType: 'standard', + oldArtifactId: standardId, + newArtifactId: 'new-standard-id', + sourceSpaceId: spaceId, + destinationSpaceId, + userId, + organizationId, + source: 'ui', + }); + + const deletedEvent = new StandardDeletedEvent({ + standardId, + spaceId, + organizationId, + userId, + source: 'ui', + }); + + // Both events emitted synchronously — move first, then delete + eventService.emit(movedEvent); + eventService.emit(deletedEvent); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect( + mockChangeProposalService.cancelPendingByArtefactId, + ).not.toHaveBeenCalled(); + + resolveMigration(); + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + }); + + describe('when PlaybookArtefactMovedEvent is emitted', () => { + const destinationSpaceId = createSpaceId('dest-space'); + + it('calls migrateChangeProposalsForMovedArtefact with correct command', async () => { + const event = new PlaybookArtefactMovedEvent({ + artifactType: 'standard', + oldArtifactId: 'old-standard-id', + newArtifactId: 'new-standard-id', + sourceSpaceId: spaceId, + destinationSpaceId, + userId, + organizationId, + source: 'ui', + }); + + eventService.emit(event); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect( + mockAdapter.migrateChangeProposalsForMovedArtefact, + ).toHaveBeenCalledWith({ + userId, + organizationId, + source: 'ui', + sourceSpaceId: spaceId, + destinationSpaceId, + oldArtefactId: 'old-standard-id', + newArtefactId: 'new-standard-id', + }); + }); + + describe('when migrateChangeProposalsForMovedArtefact throws', () => { + it('does not propagate the error', async () => { + mockAdapter.migrateChangeProposalsForMovedArtefact.mockRejectedValueOnce( + new Error('DB error'), + ); + + const event = new PlaybookArtefactMovedEvent({ + artifactType: 'standard', + oldArtifactId: 'old-standard-id', + newArtifactId: 'new-standard-id', + sourceSpaceId: spaceId, + destinationSpaceId, + userId, + organizationId, + source: 'ui', + }); + + await expect(async () => { + eventService.emit(event); + await new Promise((resolve) => setTimeout(resolve, 10)); + }).not.toThrow(); + }); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/listeners/ChangeManagementListener.ts b/packages/playbook-change-management/src/application/listeners/ChangeManagementListener.ts new file mode 100644 index 000000000..5c90f4016 --- /dev/null +++ b/packages/playbook-change-management/src/application/listeners/ChangeManagementListener.ts @@ -0,0 +1,223 @@ +import { PackmindLogger } from '@packmind/logger'; +import { PackmindListener } from '@packmind/node-utils'; +import { + ArtefactRemovedFromPackageEvent, + CommandDeletedEvent, + PlaybookArtefactMovedEvent, + SkillDeletedEvent, + StandardDeletedEvent, +} from '@packmind/types'; +import { PlaybookChangeManagementAdapter } from '../adapters/PlaybookChangeManagementAdapter'; +import { ChangeProposalService } from '../services/ChangeProposalService'; + +const origin = 'ChangeManagementListener'; + +export class ChangeManagementListener extends PackmindListener<ChangeProposalService> { + // Tracks artifacts currently being migrated (move in progress). + // Used to prevent delete handlers from cancelling proposals that migration will soft-delete. + private readonly migratingArtifacts = new Set<string>(); + + constructor( + adapter: ChangeProposalService, + private readonly changeManagementAdapter: PlaybookChangeManagementAdapter, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(adapter); + } + + protected registerHandlers(): void { + this.subscribe(CommandDeletedEvent, this.handleCommandDeleted); + this.subscribe(StandardDeletedEvent, this.handleStandardDeleted); + this.subscribe(SkillDeletedEvent, this.handleSkillDeleted); + this.subscribe( + ArtefactRemovedFromPackageEvent, + this.handleArtefactRemovedFromPackage, + ); + this.subscribe( + PlaybookArtefactMovedEvent, + this.handlePlaybookArtefactMoved, + ); + } + + private handleCommandDeleted = async ( + event: CommandDeletedEvent, + ): Promise<void> => { + const { id: recipeId, spaceId, userId } = event.payload; + + if (this.isBeingMigrated(spaceId, recipeId)) { + this.logger.info( + 'Skipping cancellation for deleted command — migration in progress', + { recipeId, spaceId }, + ); + return; + } + + this.logger.info( + 'Handling CommandDeletedEvent — cancelling pending proposals', + { recipeId, spaceId }, + ); + + try { + await this.adapter.cancelPendingByArtefactId(spaceId, recipeId, userId); + this.logger.info('Cancelled pending proposals for deleted command', { + recipeId, + spaceId, + }); + } catch (error) { + this.logger.error( + 'Failed to cancel pending proposals for deleted command', + { + recipeId, + spaceId, + error: error instanceof Error ? error.message : String(error), + }, + ); + } + }; + + private handleStandardDeleted = async ( + event: StandardDeletedEvent, + ): Promise<void> => { + const { standardId, spaceId, userId } = event.payload; + + if (this.isBeingMigrated(spaceId, standardId)) { + this.logger.info( + 'Skipping cancellation for deleted standard — migration in progress', + { standardId, spaceId }, + ); + return; + } + + this.logger.info( + 'Handling StandardDeletedEvent — cancelling pending proposals', + { standardId, spaceId }, + ); + + try { + await this.adapter.cancelPendingByArtefactId(spaceId, standardId, userId); + this.logger.info('Cancelled pending proposals for deleted standard', { + standardId, + spaceId, + }); + } catch (error) { + this.logger.error( + 'Failed to cancel pending proposals for deleted standard', + { + standardId, + spaceId, + error: error instanceof Error ? error.message : String(error), + }, + ); + } + }; + + private handleSkillDeleted = async ( + event: SkillDeletedEvent, + ): Promise<void> => { + const { skillId, spaceId, userId } = event.payload; + + if (this.isBeingMigrated(spaceId, skillId)) { + this.logger.info( + 'Skipping cancellation for deleted skill — migration in progress', + { skillId, spaceId }, + ); + return; + } + + this.logger.info( + 'Handling SkillDeletedEvent — cancelling pending proposals', + { skillId, spaceId }, + ); + + try { + await this.adapter.cancelPendingByArtefactId(spaceId, skillId, userId); + this.logger.info('Cancelled pending proposals for deleted skill', { + skillId, + spaceId, + }); + } catch (error) { + this.logger.error( + 'Failed to cancel pending proposals for deleted skill', + { + skillId, + spaceId, + error: error instanceof Error ? error.message : String(error), + }, + ); + } + }; + + private handleArtefactRemovedFromPackage = async ( + event: ArtefactRemovedFromPackageEvent, + ): Promise<void> => { + const { artefactId, spaceId, userId, remainingPackagesCount } = + event.payload; + + if (remainingPackagesCount > 0) { + return; + } + + try { + await this.adapter.cancelPendingByArtefactId(spaceId, artefactId, userId); + this.logger.info( + 'Cancelled pending proposals for artefact removed from last package', + { + artefactId, + spaceId, + }, + ); + } catch (error) { + this.logger.error( + 'Failed to cancel pending proposals for artefact removed from last package', + { + artefactId, + spaceId, + error: error instanceof Error ? error.message : String(error), + }, + ); + } + }; + + private migrationKey(spaceId: string, artefactId: string): string { + return `${spaceId}:${artefactId}`; + } + + private isBeingMigrated(spaceId: string, artefactId: string): boolean { + return this.migratingArtifacts.has(this.migrationKey(spaceId, artefactId)); + } + + private handlePlaybookArtefactMoved = async ( + event: PlaybookArtefactMovedEvent, + ): Promise<void> => { + const { oldArtifactId, newArtifactId, sourceSpaceId, destinationSpaceId } = + event.payload; + + const key = this.migrationKey(sourceSpaceId, oldArtifactId); + this.migratingArtifacts.add(key); + + try { + await this.changeManagementAdapter.migrateChangeProposalsForMovedArtefact( + { + userId: event.payload.userId, + organizationId: event.payload.organizationId, + source: event.payload.source, + sourceSpaceId, + destinationSpaceId, + oldArtefactId: oldArtifactId, + newArtefactId: newArtifactId, + ruleMappings: event.payload.ruleMappings, + }, + ); + } catch (error) { + this.logger.error('Failed to migrate proposals for moved artefact', { + oldArtifactId, + newArtifactId, + sourceSpaceId, + destinationSpaceId, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + this.migratingArtifacts.delete(key); + } + }; +} diff --git a/packages/playbook-change-management/src/application/services/ChangeProposalService.spec.ts b/packages/playbook-change-management/src/application/services/ChangeProposalService.spec.ts new file mode 100644 index 000000000..89800eb6a --- /dev/null +++ b/packages/playbook-change-management/src/application/services/ChangeProposalService.spec.ts @@ -0,0 +1,1182 @@ +import { + ChangeProposal, + ChangeProposalCaptureMode, + ChangeProposalStatus, + ChangeProposalType, + createChangeProposalId, + createRecipeId, + createRuleId, + createSkillId, + createSpaceId, + createStandardId, + createUserId, + CreateChangeProposalCommand, +} from '@packmind/types'; +import { stubLogger } from '@packmind/test-utils'; +import { DataSource } from 'typeorm'; +import { IChangeProposalRepository } from '../../domain/repositories/IChangeProposalRepository'; +import { ChangeProposalService } from './ChangeProposalService'; + +describe('ChangeProposalService', () => { + let service: ChangeProposalService; + let repository: jest.Mocked<IChangeProposalRepository>; + let dataSource: jest.Mocked<DataSource>; + + const spaceId = createSpaceId('space-id'); + + beforeEach(() => { + repository = { + save: jest.fn(), + findById: jest.fn(), + findByArtefactId: jest.fn(), + findBySpaceId: jest.fn(), + findExistingPending: jest.fn(), + update: jest.fn(), + cancelPendingByArtefactId: jest.fn(), + } as unknown as jest.Mocked<IChangeProposalRepository>; + + dataSource = { + manager: { + transaction: jest.fn(), + }, + } as unknown as jest.Mocked<DataSource>; + + service = new ChangeProposalService(repository, dataSource, stubLogger()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createChangeProposal', () => { + const recipeId = createRecipeId('recipe-id'); + const userId = createUserId('user-id'); + const artefactVersion = 3; + + const command: CreateChangeProposalCommand<ChangeProposalType.updateCommandName> = + { + userId: userId as unknown as string, + organizationId: 'org-1', + spaceId, + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + payload: { oldValue: 'old name', newValue: 'new name' }, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + }; + + it('creates a proposal with pending status', async () => { + const { changeProposal } = await service.createChangeProposal( + command, + artefactVersion, + ); + + expect(changeProposal.status).toBe(ChangeProposalStatus.pending); + }); + + it('uses the provided artefactVersion', async () => { + const { changeProposal } = await service.createChangeProposal( + command, + artefactVersion, + ); + + expect(changeProposal.artefactVersion).toBe(artefactVersion); + }); + + it('generates an id', async () => { + const { changeProposal } = await service.createChangeProposal( + command, + artefactVersion, + ); + + expect(changeProposal.id).toBeDefined(); + }); + + it('sets createdBy from userId', async () => { + const { changeProposal } = await service.createChangeProposal( + command, + artefactVersion, + ); + + expect(changeProposal.createdBy).toBe(userId); + }); + + it('saves the proposal to the repository', async () => { + await service.createChangeProposal(command, artefactVersion); + + expect(repository.save).toHaveBeenCalledWith( + expect.objectContaining({ + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + artefactVersion, + spaceId, + status: ChangeProposalStatus.pending, + }), + ); + }); + + it('sets message from command', async () => { + const commandWithMessage = { + ...command, + message: 'Refactored command names', + }; + const { changeProposal } = await service.createChangeProposal( + commandWithMessage, + artefactVersion, + ); + + expect(changeProposal.message).toBe('Refactored command names'); + }); + + describe('when message is not provided', () => { + it('defaults message to empty string', async () => { + const { changeProposal } = await service.createChangeProposal( + command, + artefactVersion, + ); + + expect(changeProposal.message).toBe(''); + }); + }); + + it('sets resolvedBy to null', async () => { + const { changeProposal } = await service.createChangeProposal( + command, + artefactVersion, + ); + + expect(changeProposal.resolvedBy).toBeNull(); + }); + + it('sets resolvedAt to null', async () => { + const { changeProposal } = await service.createChangeProposal( + command, + artefactVersion, + ); + + expect(changeProposal.resolvedAt).toBeNull(); + }); + }); + + describe('findExistingPending', () => { + const recipeId = createRecipeId('recipe-id'); + const userId = createUserId('user-id'); + + describe('when a pending duplicate exists', () => { + const existingProposal: ChangeProposal<ChangeProposalType> = { + id: createChangeProposalId('change-proposal-id'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + artefactVersion: 1, + spaceId, + payload: { oldValue: 'old name', newValue: 'new name' }, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + status: ChangeProposalStatus.pending, + createdBy: userId, + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(() => { + repository.findExistingPending.mockResolvedValue(existingProposal); + }); + + it('returns the existing proposal', async () => { + const result = await service.findExistingPending( + spaceId, + userId, + recipeId, + ChangeProposalType.updateCommandName, + { oldValue: 'old name', newValue: 'new name' }, + ); + + expect(result).toBe(existingProposal); + }); + }); + + describe('when no duplicate exists', () => { + beforeEach(() => { + repository.findExistingPending.mockResolvedValue(null); + }); + + it('returns null', async () => { + const result = await service.findExistingPending( + spaceId, + userId, + recipeId, + ChangeProposalType.updateCommandName, + { oldValue: 'old name', newValue: 'new name' }, + ); + + expect(result).toBeNull(); + }); + }); + }); + + describe('groupProposalsByArtefact', () => { + const standardId1 = createStandardId('standard-1'); + const standardId2 = createStandardId('standard-2'); + const recipeId1 = createRecipeId('recipe-1'); + const recipeId2 = createRecipeId('recipe-2'); + const skillId1 = createSkillId('skill-1'); + const skillId2 = createSkillId('skill-2'); + + const createProposal = <T extends ChangeProposalType>( + type: T, + artefactId: string | null, + createdAt: Date = new Date(), + ): ChangeProposal<T> => ({ + id: createChangeProposalId('change-proposal-id'), + type, + artefactId: artefactId as ChangeProposal<T>['artefactId'], + artefactVersion: 1, + spaceId, + payload: { + oldValue: 'old', + newValue: 'new', + } as ChangeProposal<T>['payload'], + captureMode: ChangeProposalCaptureMode.commit, + message: '', + status: ChangeProposalStatus.pending, + createdBy: createUserId('user-id'), + resolvedBy: null, + resolvedAt: null, + createdAt, + updatedAt: new Date(), + }); + + describe('when space has no proposals', () => { + beforeEach(() => { + repository.findBySpaceId.mockResolvedValue([]); + }); + + it('returns empty standards map', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.standards.size).toBe(0); + }); + + it('returns empty commands map', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.commands.size).toBe(0); + }); + + it('returns empty skills map', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.skills.size).toBe(0); + }); + + it('calls repository with correct spaceId', async () => { + await service.groupProposalsByArtefact(spaceId); + + expect(repository.findBySpaceId).toHaveBeenCalledWith(spaceId); + }); + }); + + describe('when space has proposals for different artefact types', () => { + const proposals = [ + createProposal(ChangeProposalType.updateStandardName, standardId1), + createProposal(ChangeProposalType.addRule, standardId1), + createProposal( + ChangeProposalType.updateStandardDescription, + standardId2, + ), + createProposal(ChangeProposalType.updateCommandName, recipeId1), + createProposal(ChangeProposalType.updateCommandDescription, recipeId1), + createProposal(ChangeProposalType.updateCommandName, recipeId2), + createProposal(ChangeProposalType.updateSkillName, skillId1), + createProposal(ChangeProposalType.updateSkillPrompt, skillId1), + createProposal(ChangeProposalType.addSkillFile, skillId2), + ]; + + beforeEach(() => { + repository.findBySpaceId.mockResolvedValue(proposals); + }); + + it('counts proposals for first standard', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.standards.get(standardId1)?.count).toBe(2); + }); + + it('counts proposals for second standard', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.standards.get(standardId2)?.count).toBe(1); + }); + + it('counts proposals for first command', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.commands.get(recipeId1)?.count).toBe(2); + }); + + it('counts proposals for second command', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.commands.get(recipeId2)?.count).toBe(1); + }); + + it('counts proposals for first skill', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.skills.get(skillId1)?.count).toBe(2); + }); + + it('counts proposals for second skill', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.skills.get(skillId2)?.count).toBe(1); + }); + + it('counts total standards', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.standards.size).toBe(2); + }); + + it('counts total commands', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.commands.size).toBe(2); + }); + + it('counts total skills', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.skills.size).toBe(2); + }); + }); + + describe('when tracking lastContributedAt', () => { + const olderDate = new Date('2024-01-01T00:00:00Z'); + const newerDate = new Date('2024-06-15T12:00:00Z'); + + const proposals = [ + createProposal( + ChangeProposalType.updateStandardName, + standardId1, + olderDate, + ), + createProposal(ChangeProposalType.addRule, standardId1, newerDate), + createProposal( + ChangeProposalType.updateCommandName, + recipeId1, + newerDate, + ), + createProposal( + ChangeProposalType.updateCommandDescription, + recipeId1, + olderDate, + ), + createProposal(ChangeProposalType.updateSkillName, skillId1, olderDate), + ]; + + beforeEach(() => { + repository.findBySpaceId.mockResolvedValue(proposals); + }); + + it('tracks max createdAt for standards', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.standards.get(standardId1)?.lastContributedAt).toEqual( + newerDate, + ); + }); + + it('tracks max createdAt for commands', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.commands.get(recipeId1)?.lastContributedAt).toEqual( + newerDate, + ); + }); + + it('tracks createdAt for skills with single proposal', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.skills.get(skillId1)?.lastContributedAt).toEqual( + olderDate, + ); + }); + }); + + describe('when space has only standard proposals', () => { + const proposals = [ + createProposal(ChangeProposalType.updateStandardName, standardId1), + createProposal(ChangeProposalType.updateRule, standardId1), + createProposal(ChangeProposalType.deleteRule, standardId1), + ]; + + beforeEach(() => { + repository.findBySpaceId.mockResolvedValue(proposals); + }); + + it('counts standard proposals', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.standards.get(standardId1)?.count).toBe(3); + }); + + it('has no command proposals', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.commands.size).toBe(0); + }); + + it('has no skill proposals', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.skills.size).toBe(0); + }); + }); + + describe('when space has only command proposals', () => { + const proposals = [ + createProposal(ChangeProposalType.updateCommandName, recipeId1), + createProposal(ChangeProposalType.updateCommandDescription, recipeId1), + ]; + + beforeEach(() => { + repository.findBySpaceId.mockResolvedValue(proposals); + }); + + it('has no standard proposals', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.standards.size).toBe(0); + }); + + it('counts command proposals', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.commands.get(recipeId1)?.count).toBe(2); + }); + + it('has no skill proposals', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.skills.size).toBe(0); + }); + }); + + describe('when space has only skill proposals', () => { + const proposals = [ + createProposal(ChangeProposalType.updateSkillName, skillId1), + createProposal(ChangeProposalType.updateSkillFileContent, skillId1), + createProposal(ChangeProposalType.deleteSkillFile, skillId1), + ]; + + beforeEach(() => { + repository.findBySpaceId.mockResolvedValue(proposals); + }); + + it('has no standard proposals', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.standards.size).toBe(0); + }); + + it('has no command proposals', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.commands.size).toBe(0); + }); + + it('counts skill proposals', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.skills.get(skillId1)?.count).toBe(3); + }); + }); + + describe('when space has createCommand proposals', () => { + const createCommandProposal = createProposal( + ChangeProposalType.createCommand, + null as unknown as ReturnType<typeof createStandardId>, + ); + + it('places createCommand proposals in creations array, not commands map', async () => { + repository.findBySpaceId.mockResolvedValue([createCommandProposal]); + + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.creations).toHaveLength(1); + }); + + it('does not add createCommand proposals to the commands map', async () => { + repository.findBySpaceId.mockResolvedValue([createCommandProposal]); + + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.commands.size).toBe(0); + }); + + it('accumulates multiple createCommand proposals in creations', async () => { + const second = createProposal( + ChangeProposalType.createCommand, + null as unknown as ReturnType<typeof createStandardId>, + ); + repository.findBySpaceId.mockResolvedValue([ + createCommandProposal, + second, + ]); + + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.creations).toHaveLength(2); + }); + }); + + describe('when space has createStandard proposals', () => { + const createStandardProposal = createProposal( + ChangeProposalType.createStandard, + null as unknown as ReturnType<typeof createStandardId>, + ); + + it('places createStandard proposals in creations array, not standards map', async () => { + repository.findBySpaceId.mockResolvedValue([createStandardProposal]); + + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.creations).toHaveLength(1); + }); + + it('does not add createStandard proposals to the standards map', async () => { + repository.findBySpaceId.mockResolvedValue([createStandardProposal]); + + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.standards.size).toBe(0); + }); + }); + + describe('when space has createSkill proposals', () => { + const createSkillProposal = createProposal( + ChangeProposalType.createSkill, + null as unknown as ReturnType<typeof createSkillId>, + ); + + it('places createSkill proposals in creations array, not skills map', async () => { + repository.findBySpaceId.mockResolvedValue([createSkillProposal]); + + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.creations).toHaveLength(1); + }); + + it('does not add createSkill proposals to the skills map', async () => { + repository.findBySpaceId.mockResolvedValue([createSkillProposal]); + + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.skills.size).toBe(0); + }); + }); + + describe('when proposals include removeCommand type', () => { + it('groups removeCommand proposals under commands', async () => { + const proposal = createProposal( + ChangeProposalType.removeCommand, + recipeId1, + ); + repository.findBySpaceId.mockResolvedValue([proposal]); + + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.commands.has(recipeId1)).toBe(true); + }); + }); + + describe('when proposals include removeStandard type', () => { + it('groups removeStandard proposals under standards', async () => { + const proposal = createProposal( + ChangeProposalType.removeStandard, + standardId1, + ); + repository.findBySpaceId.mockResolvedValue([proposal]); + + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.standards.has(standardId1)).toBe(true); + }); + }); + + describe('when space has a mix of pending and non-pending proposals', () => { + const proposals = [ + createProposal(ChangeProposalType.updateStandardName, standardId1), + { + ...createProposal(ChangeProposalType.addRule, standardId1), + status: ChangeProposalStatus.applied, + }, + { + ...createProposal(ChangeProposalType.updateCommandName, recipeId1), + status: ChangeProposalStatus.rejected, + }, + createProposal(ChangeProposalType.updateCommandDescription, recipeId1), + { + ...createProposal(ChangeProposalType.updateSkillName, skillId1), + status: ChangeProposalStatus.applied, + }, + ]; + + beforeEach(() => { + repository.findBySpaceId.mockResolvedValue(proposals); + }); + + it('counts only pending standard proposals', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.standards.get(standardId1)?.count).toBe(1); + }); + + it('counts only pending command proposals', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.commands.get(recipeId1)?.count).toBe(1); + }); + + it('excludes non-pending skill proposals entirely', async () => { + const result = await service.groupProposalsByArtefact(spaceId); + + expect(result.skills.size).toBe(0); + }); + }); + }); + + describe('cancelPendingByArtefactId', () => { + const userId = createUserId('user-id'); + const artefactId = createStandardId('standard-id'); + + it('delegates to repository', async () => { + repository.cancelPendingByArtefactId.mockResolvedValue(undefined); + + await service.cancelPendingByArtefactId(spaceId, artefactId, userId); + + expect(repository.cancelPendingByArtefactId).toHaveBeenCalledWith( + spaceId, + artefactId, + userId, + ); + }); + }); + + describe('migrateProposalsForMovedArtefact', () => { + const sourceSpaceId = createSpaceId('source-space'); + const destinationSpaceId = createSpaceId('dest-space'); + const oldArtefactId = createStandardId('old-artefact'); + const newArtefactId = 'new-artefact-id'; + + const createProposal = <T extends ChangeProposalType>( + type: T, + status: ChangeProposalStatus = ChangeProposalStatus.pending, + ): ChangeProposal<T> => ({ + id: createChangeProposalId(`cp-${Math.random()}`), + type, + artefactId: oldArtefactId as ChangeProposal<T>['artefactId'], + artefactVersion: 1, + spaceId: sourceSpaceId, + payload: { + oldValue: 'old', + newValue: 'new', + } as ChangeProposal<T>['payload'], + captureMode: ChangeProposalCaptureMode.commit, + message: 'test message', + status, + decision: null, + createdBy: createUserId('user-id'), + resolvedBy: null, + resolvedAt: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-02'), + }); + + let mockEntityManager: { + save: jest.Mock; + getRepository: jest.Mock; + }; + let mockQueryBuilder: { + softDelete: jest.Mock; + where: jest.Mock; + andWhere: jest.Mock; + execute: jest.Mock; + }; + + beforeEach(() => { + mockQueryBuilder = { + softDelete: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue(undefined), + }; + mockEntityManager = { + save: jest.fn().mockResolvedValue(undefined), + getRepository: jest.fn().mockReturnValue({ + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + }), + }; + (dataSource.manager.transaction as jest.Mock).mockImplementation( + async (cb: (em: typeof mockEntityManager) => Promise<void>) => { + await cb(mockEntityManager); + }, + ); + }); + + describe('when old artefact has proposals', () => { + const pendingProposal = createProposal( + ChangeProposalType.updateStandardName, + ChangeProposalStatus.pending, + ); + const appliedProposal = createProposal( + ChangeProposalType.addRule, + ChangeProposalStatus.applied, + ); + const rejectedProposal = createProposal( + ChangeProposalType.updateStandardDescription, + ChangeProposalStatus.rejected, + ); + + beforeEach(() => { + repository.findByArtefactId.mockResolvedValue([ + pendingProposal, + appliedProposal, + rejectedProposal, + ]); + }); + + it('fetches proposals from the source space', async () => { + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + }); + + expect(repository.findByArtefactId).toHaveBeenCalledWith( + sourceSpaceId, + oldArtefactId, + ); + }); + + it('saves a copy for each proposal', async () => { + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + }); + + expect(mockEntityManager.save).toHaveBeenCalledTimes(3); + }); + + it('saves copies with new artefactId and spaceId', async () => { + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + }); + + expect(mockEntityManager.save).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + artefactId: newArtefactId, + spaceId: destinationSpaceId, + }), + ); + }); + + it('generates distinct IDs for copies', async () => { + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + }); + + const savedIds = (mockEntityManager.save as jest.Mock).mock.calls.map( + ([, proposal]: [unknown, ChangeProposal<ChangeProposalType>]) => + proposal.id, + ); + const uniqueIds = new Set(savedIds); + expect(uniqueIds.size).toBe(3); + }); + + it('preserves original proposal fields in the copy', async () => { + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + }); + + expect(mockEntityManager.save).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + type: pendingProposal.type, + message: pendingProposal.message, + captureMode: pendingProposal.captureMode, + createdBy: pendingProposal.createdBy, + status: pendingProposal.status, + }), + ); + }); + + it('copies proposals regardless of status', async () => { + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + }); + + const savedStatuses = ( + mockEntityManager.save as jest.Mock + ).mock.calls.map( + ([, proposal]: [unknown, ChangeProposal<ChangeProposalType>]) => + proposal.status, + ); + expect(savedStatuses).toEqual([ + ChangeProposalStatus.pending, + ChangeProposalStatus.applied, + ChangeProposalStatus.rejected, + ]); + }); + + it('soft-deletes originals in the source space', async () => { + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + }); + + expect(mockQueryBuilder.softDelete).toHaveBeenCalled(); + }); + + it('filters soft-delete by source spaceId', async () => { + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + }); + + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + 'space_id = :spaceId', + { spaceId: sourceSpaceId }, + ); + }); + + it('filters soft-delete by old artefactId', async () => { + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'artefact_id = :artefactId', + { artefactId: oldArtefactId }, + ); + }); + + it('executes the soft-delete query', async () => { + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + }); + + expect(mockQueryBuilder.execute).toHaveBeenCalled(); + }); + }); + + describe('when ruleMappings are provided', () => { + const oldRuleId = createRuleId('old-rule-1'); + const newRuleId = createRuleId('new-rule-1'); + const ruleMappings = [ + { oldRuleId: oldRuleId as string, newRuleId: newRuleId as string }, + ]; + + const createRuleProposal = <T extends ChangeProposalType>( + type: T, + payload: ChangeProposal<T>['payload'], + ): ChangeProposal<T> => ({ + id: createChangeProposalId(`cp-${Math.random()}`), + type, + artefactId: oldArtefactId as ChangeProposal<T>['artefactId'], + artefactVersion: 1, + spaceId: sourceSpaceId, + payload, + captureMode: ChangeProposalCaptureMode.commit, + message: 'test message', + status: ChangeProposalStatus.pending, + decision: null, + createdBy: createUserId('user-id'), + resolvedBy: null, + resolvedAt: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-02'), + }); + + it('remaps targetId in updateRule proposals', async () => { + const updateRuleProposal = createRuleProposal( + ChangeProposalType.updateRule, + { + targetId: oldRuleId, + oldValue: 'old content', + newValue: 'new content', + }, + ); + repository.findByArtefactId.mockResolvedValue([updateRuleProposal]); + + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + ruleMappings, + }); + + expect(mockEntityManager.save).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + payload: expect.objectContaining({ + targetId: newRuleId, + }), + }), + ); + }); + + it('remaps targetId and item.id in deleteRule proposals', async () => { + const deleteRuleProposal = createRuleProposal( + ChangeProposalType.deleteRule, + { + targetId: oldRuleId, + item: { id: oldRuleId, content: 'rule content' }, + }, + ); + repository.findByArtefactId.mockResolvedValue([deleteRuleProposal]); + + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + ruleMappings, + }); + + expect(mockEntityManager.save).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + payload: expect.objectContaining({ + targetId: newRuleId, + item: expect.objectContaining({ id: newRuleId }), + }), + }), + ); + }); + + it('leaves addRule proposals unchanged', async () => { + const addRulePayload = { + item: { content: 'new rule content' }, + }; + const addRuleProposal = createRuleProposal( + ChangeProposalType.addRule, + addRulePayload, + ); + repository.findByArtefactId.mockResolvedValue([addRuleProposal]); + + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + ruleMappings, + }); + + expect(mockEntityManager.save).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + payload: addRulePayload, + }), + ); + }); + + describe('when no matching mapping exists', () => { + it('leaves updateRule proposals unchanged', async () => { + const unmappedRuleId = createRuleId('unmapped-rule'); + const payload = { + targetId: unmappedRuleId, + oldValue: 'old', + newValue: 'new', + }; + const proposal = createRuleProposal( + ChangeProposalType.updateRule, + payload, + ); + repository.findByArtefactId.mockResolvedValue([proposal]); + + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + ruleMappings, + }); + + expect(mockEntityManager.save).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + payload: expect.objectContaining({ + targetId: unmappedRuleId, + }), + }), + ); + }); + }); + }); + + describe('when old artefact has no proposals', () => { + beforeEach(() => { + repository.findByArtefactId.mockResolvedValue([]); + }); + + it('does not save any copies', async () => { + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + }); + + expect(mockEntityManager.save).not.toHaveBeenCalled(); + }); + + it('still executes soft-delete query', async () => { + await service.migrateProposalsForMovedArtefact({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + }); + + expect(mockQueryBuilder.execute).toHaveBeenCalled(); + }); + }); + }); + + describe('findProposalsByArtefact', () => { + const standardId = createStandardId('standard-1'); + const recipeId = createRecipeId('recipe-1'); + + const createProposal = <T extends ChangeProposalType>( + type: T, + artefactId: string | null, + ): ChangeProposal<T> => ({ + id: createChangeProposalId('change-proposal-id'), + type, + artefactId: artefactId as ChangeProposal<T>['artefactId'], + artefactVersion: 1, + spaceId, + payload: { + oldValue: 'old', + newValue: 'new', + } as ChangeProposal<T>['payload'], + captureMode: ChangeProposalCaptureMode.commit, + message: '', + status: ChangeProposalStatus.pending, + createdBy: createUserId('user-id'), + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + describe('when artefact has no proposals', () => { + beforeEach(() => { + repository.findByArtefactId.mockResolvedValue([]); + }); + + it('returns empty array', async () => { + const result = await service.findProposalsByArtefact( + spaceId, + standardId, + ); + + expect(result).toEqual([]); + }); + + it('calls repository with correct parameters', async () => { + await service.findProposalsByArtefact(spaceId, standardId); + + expect(repository.findByArtefactId).toHaveBeenCalledWith( + spaceId, + standardId, + ); + }); + }); + + describe('when artefact has proposals', () => { + const proposals = [ + createProposal(ChangeProposalType.updateStandardName, standardId), + createProposal(ChangeProposalType.addRule, standardId), + ]; + + beforeEach(() => { + repository.findByArtefactId.mockResolvedValue(proposals); + }); + + it('returns all proposals for the artefact', async () => { + const result = await service.findProposalsByArtefact( + spaceId, + standardId, + ); + + expect(result).toEqual(proposals); + }); + + it('returns correct number of proposals', async () => { + const result = await service.findProposalsByArtefact( + spaceId, + standardId, + ); + + expect(result.length).toBe(2); + }); + }); + + describe('when finding proposals for a recipe', () => { + const proposals = [ + createProposal(ChangeProposalType.updateCommandName, recipeId), + ]; + + beforeEach(() => { + repository.findByArtefactId.mockResolvedValue(proposals); + }); + + it('returns recipe proposals', async () => { + const result = await service.findProposalsByArtefact(spaceId, recipeId); + + expect(result).toEqual(proposals); + }); + + it('calls repository with recipe id', async () => { + await service.findProposalsByArtefact(spaceId, recipeId); + + expect(repository.findByArtefactId).toHaveBeenCalledWith( + spaceId, + recipeId, + ); + }); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/services/ChangeProposalService.ts b/packages/playbook-change-management/src/application/services/ChangeProposalService.ts new file mode 100644 index 000000000..9f04e1885 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/ChangeProposalService.ts @@ -0,0 +1,436 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + AcceptedChangeProposal, + ChangeProposal, + ChangeProposalArtefactId, + ChangeProposalId, + ChangeProposalPayload, + ChangeProposalStatus, + ChangeProposalType, + CollectionItemDeletePayload, + CollectionItemUpdatePayload, + CreateChangeProposalCommand, + createChangeProposalId, + createRuleId, + createUserId, + CreationChangeProposalTypes, + PendingChangeProposal, + RecipeId, + Rule, + RuleId, + RuleMapping, + SkillId, + SpaceId, + StandardId, + UserId, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; +import { DataSource } from 'typeorm'; +import { IChangeProposalRepository } from '../../domain/repositories/IChangeProposalRepository'; +import { ChangeProposalSchema } from '../../infra/schemas/ChangeProposalSchema'; +import { isExpectedChangeProposalType } from '../utils/isExpectedChangeProposalType'; + +const origin = 'ChangeProposalService'; + +export type ArtefactProposalStats = { + count: number; + lastContributedAt: Date; +}; + +export type GroupedProposalsByArtefact = { + standards: Map<StandardId, ArtefactProposalStats>; + commands: Map<RecipeId, ArtefactProposalStats>; + skills: Map<SkillId, ArtefactProposalStats>; + creations: PendingChangeProposal<CreationChangeProposalTypes>[]; +}; + +type ArtefactCategory = 'standards' | 'commands' | 'skills'; + +function getArtefactCategory(type: ChangeProposalType): ArtefactCategory { + if ( + type === ChangeProposalType.updateCommandName || + type === ChangeProposalType.updateCommandDescription || + type === ChangeProposalType.createCommand || + type === ChangeProposalType.removeCommand + ) { + return 'commands'; + } + + if ( + type === ChangeProposalType.updateStandardName || + type === ChangeProposalType.updateStandardDescription || + type === ChangeProposalType.updateStandardScope || + type === ChangeProposalType.addRule || + type === ChangeProposalType.updateRule || + type === ChangeProposalType.deleteRule || + type === ChangeProposalType.createStandard || + type === ChangeProposalType.removeStandard + ) { + return 'standards'; + } + + return 'skills'; +} + +export class ChangeProposalService { + constructor( + private readonly repository: IChangeProposalRepository, + private readonly dataSource: DataSource, + private readonly logger: PackmindLogger = new PackmindLogger(origin), + ) {} + + async createChangeProposal<T extends ChangeProposalType>( + command: CreateChangeProposalCommand<T>, + artefactVersion: number, + ): Promise<{ changeProposal: ChangeProposal<T> }> { + const createdBy = createUserId(command.userId); + + const proposal: ChangeProposal<T> = { + id: createChangeProposalId(uuidv4()), + type: command.type as T, + artefactId: command.artefactId, + artefactVersion, + spaceId: command.spaceId, + gitRepoId: command.gitRepoId, + targetId: command.targetId, + payload: command.payload, + captureMode: command.captureMode, + message: command.message ?? '', + status: ChangeProposalStatus.pending, + decision: null, + createdBy, + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + await this.repository.save(proposal); + + this.logger.info('Change proposal created', { + proposalId: proposal.id, + type: proposal.type, + artefactId: proposal.artefactId, + }); + + return { changeProposal: proposal }; + } + + async findExistingPending<T extends ChangeProposalType>( + spaceId: SpaceId, + createdBy: UserId, + artefactId: ChangeProposalArtefactId<T>, + type: T, + payload: ChangeProposalPayload<T>, + ): Promise<ChangeProposal<T> | null> { + const existing = await this.repository.findExistingPending({ + spaceId, + createdBy, + artefactId, + type, + payload, + }); + + if (existing) { + this.logger.info( + 'Duplicate pending change proposal found, skipping creation', + { + proposalId: existing.id, + type: existing.type, + artefactId: existing.artefactId, + }, + ); + } + + return existing; + } + + async findById( + changeProposalId: ChangeProposalId, + ): Promise<ChangeProposal<ChangeProposalType> | null> { + return this.repository.findById(changeProposalId); + } + + /** + * Batch update proposals (mark as applied or rejected) within a transaction. + * This ensures atomicity - if any operation fails, all changes are rolled back. + */ + async batchUpdateProposalsInTransaction(params: { + acceptedProposals: Array<{ + proposal: AcceptedChangeProposal<ChangeProposalType>; + userId: UserId; + }>; + rejectedProposals: Array<{ + proposal: ChangeProposal<ChangeProposalType>; + userId: UserId; + }>; + }): Promise<void> { + const { acceptedProposals, rejectedProposals } = params; + + await this.dataSource.manager.transaction(async (entityManager) => { + const now = new Date(); + + // Update accepted proposals + for (const { proposal, userId } of acceptedProposals) { + const appliedProposal: ChangeProposal<ChangeProposalType> = { + ...proposal, + resolvedBy: userId, + resolvedAt: now, + updatedAt: now, + }; + + await entityManager.save(ChangeProposalSchema, appliedProposal); + + this.logger.info('Change proposal marked as applied (in transaction)', { + proposalId: proposal.id, + artefactId: proposal.artefactId, + }); + } + + // Update rejected proposals + for (const { proposal, userId } of rejectedProposals) { + const rejectedProposal: ChangeProposal<ChangeProposalType> = { + ...proposal, + status: ChangeProposalStatus.rejected, + decision: null, + resolvedBy: userId, + resolvedAt: now, + updatedAt: now, + }; + + await entityManager.save(ChangeProposalSchema, rejectedProposal); + + this.logger.info( + 'Change proposal marked as rejected (in transaction)', + { + proposalId: proposal.id, + artefactId: proposal.artefactId, + }, + ); + } + }); + } + + async groupProposalsByArtefact( + spaceId: SpaceId, + ): Promise<GroupedProposalsByArtefact> { + const proposals = await this.repository.findBySpaceId(spaceId); + const pendingProposals = proposals.filter( + (p) => p.status === ChangeProposalStatus.pending, + ); + + const grouped: GroupedProposalsByArtefact = { + standards: new Map<StandardId, ArtefactProposalStats>(), + commands: new Map<RecipeId, ArtefactProposalStats>(), + skills: new Map<SkillId, ArtefactProposalStats>(), + creations: [], + }; + + for (const proposal of pendingProposals) { + const artefactCategory = getArtefactCategory(proposal.type); + switch (artefactCategory) { + case 'standards': { + if ( + isExpectedChangeProposalType( + proposal, + ChangeProposalType.createStandard, + ) + ) { + grouped.creations.push(proposal); + } else { + const key = proposal.artefactId as StandardId; + const existing = grouped.standards.get(key); + grouped.standards.set(key, { + count: (existing?.count ?? 0) + 1, + lastContributedAt: + existing && existing.lastContributedAt > proposal.createdAt + ? existing.lastContributedAt + : proposal.createdAt, + }); + } + break; + } + case 'commands': { + if ( + isExpectedChangeProposalType( + proposal, + ChangeProposalType.createCommand, + ) + ) { + grouped.creations.push(proposal); + } else { + const key = proposal.artefactId as RecipeId; + const existing = grouped.commands.get(key); + grouped.commands.set(key, { + count: (existing?.count ?? 0) + 1, + lastContributedAt: + existing && existing.lastContributedAt > proposal.createdAt + ? existing.lastContributedAt + : proposal.createdAt, + }); + } + break; + } + case 'skills': { + if ( + isExpectedChangeProposalType( + proposal, + ChangeProposalType.createSkill, + ) + ) { + grouped.creations.push(proposal); + } else { + const key = proposal.artefactId as SkillId; + const existing = grouped.skills.get(key); + grouped.skills.set(key, { + count: (existing?.count ?? 0) + 1, + lastContributedAt: + existing && existing.lastContributedAt > proposal.createdAt + ? existing.lastContributedAt + : proposal.createdAt, + }); + } + break; + } + } + } + + this.logger.info('Grouped change proposals by artefact', { + spaceId, + standardsCount: grouped.standards.size, + commandsCount: grouped.commands.size, + skillsCount: grouped.skills.size, + }); + + return grouped; + } + + async cancelPendingByArtefactId( + spaceId: SpaceId, + artefactId: string, + cancelledBy: UserId, + ): Promise<void> { + await this.repository.cancelPendingByArtefactId( + spaceId, + artefactId, + cancelledBy, + ); + this.logger.info('Cancelled pending proposals for deleted artefact', { + spaceId, + artefactId, + }); + } + + async migrateProposalsForMovedArtefact(params: { + sourceSpaceId: SpaceId; + destinationSpaceId: SpaceId; + oldArtefactId: string; + newArtefactId: string; + ruleMappings?: RuleMapping[]; + }): Promise<void> { + const { + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + ruleMappings, + } = params; + + const existingProposals = await this.repository.findByArtefactId( + sourceSpaceId, + oldArtefactId, + ); + + const ruleIdMap = new Map( + (ruleMappings ?? []).map((m) => [m.oldRuleId, m.newRuleId]), + ); + + const copies = existingProposals.map((proposal) => ({ + ...proposal, + id: createChangeProposalId(uuidv4()), + artefactId: newArtefactId as typeof proposal.artefactId, + spaceId: destinationSpaceId, + payload: this.remapRuleIdsInPayload(proposal, ruleIdMap), + })); + + await this.dataSource.manager.transaction(async (entityManager) => { + for (const copy of copies) { + await entityManager.save(ChangeProposalSchema, copy); + } + + await entityManager + .getRepository(ChangeProposalSchema) + .createQueryBuilder() + .softDelete() + .where('space_id = :spaceId', { spaceId: sourceSpaceId }) + .andWhere('artefact_id = :artefactId', { + artefactId: oldArtefactId, + }) + .execute(); + }); + + this.logger.info('Migrated change proposals for moved artefact', { + sourceSpaceId, + destinationSpaceId, + oldArtefactId, + newArtefactId, + copiedCount: copies.length, + softDeletedCount: existingProposals.length, + }); + } + + private remapRuleIdsInPayload( + proposal: ChangeProposal, + ruleIdMap: Map<string, string>, + ): ChangeProposalPayload { + if (ruleIdMap.size === 0) { + return proposal.payload; + } + + if (proposal.type === ChangeProposalType.updateRule) { + const payload = proposal.payload as CollectionItemUpdatePayload<RuleId>; + const newRuleId = ruleIdMap.get(payload.targetId); + if (newRuleId) { + return { + ...payload, + targetId: createRuleId(newRuleId), + } as ChangeProposalPayload; + } + } + + if (proposal.type === ChangeProposalType.deleteRule) { + const payload = proposal.payload as CollectionItemDeletePayload< + Omit<Rule, 'standardVersionId'> + >; + const newRuleId = ruleIdMap.get(payload.targetId); + if (newRuleId) { + const remappedId = createRuleId(newRuleId); + return { + ...payload, + targetId: remappedId, + item: { ...payload.item, id: remappedId }, + } as ChangeProposalPayload; + } + } + + return proposal.payload; + } + + async findProposalsByArtefact( + spaceId: SpaceId, + artefactId: StandardId | RecipeId | SkillId, + ): Promise<ChangeProposal<ChangeProposalType>[]> { + const proposals = await this.repository.findByArtefactId( + spaceId, + artefactId, + ); + + this.logger.info('Found change proposals for artefact', { + spaceId, + artefactId, + count: proposals.length, + }); + + return proposals; + } +} diff --git a/packages/playbook-change-management/src/application/services/ConflictDetectionService.spec.ts b/packages/playbook-change-management/src/application/services/ConflictDetectionService.spec.ts new file mode 100644 index 000000000..73f4ff213 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/ConflictDetectionService.spec.ts @@ -0,0 +1,426 @@ +import { + ChangeProposal, + ChangeProposalType, + createChangeProposalId, + createRecipeId, + createSkillFileId, + createSkillId, + createSpaceId, + createUserId, +} from '@packmind/types'; +import { changeProposalFactory } from '../../../test/changeProposalFactory'; +import { DiffService } from './DiffService'; +import { ConflictDetectionService } from './ConflictDetectionService'; + +describe('ConflictDetectionService', () => { + let service: ConflictDetectionService; + let diffService: jest.Mocked<DiffService>; + + const recipeId = createRecipeId('recipe-1'); + const spaceId = createSpaceId('space-1'); + const createdBy = createUserId('user-1'); + + beforeEach(() => { + diffService = { + hasConflict: jest.fn(), + applyLineDiff: jest.fn(), + }; + + service = new ConflictDetectionService(diffService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('with updateCommandName proposals', () => { + describe('when multiple proposals update command name', () => { + const proposal1 = changeProposalFactory({ + id: createChangeProposalId('1'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + payload: { oldValue: 'My command', newValue: 'My super command' }, + createdBy, + }); + + const proposal2 = changeProposalFactory({ + id: createChangeProposalId('2'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + payload: { oldValue: 'My command', newValue: 'My updated command' }, + createdBy, + }); + + it('marks both proposals as conflicting', () => { + const result = service.detectConflicts([proposal1, proposal2]); + + expect(result[0].conflictsWith).toEqual([proposal2.id]); + }); + + it('marks second proposal as conflicting with first', () => { + const result = service.detectConflicts([proposal1, proposal2]); + + expect(result[1].conflictsWith).toEqual([proposal1.id]); + }); + }); + + describe('when three proposals update command name', () => { + const proposal1 = changeProposalFactory({ + id: createChangeProposalId('1'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + payload: { oldValue: 'My command', newValue: 'Version 1' }, + createdBy, + }); + + const proposal2 = changeProposalFactory({ + id: createChangeProposalId('2'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + payload: { oldValue: 'My command', newValue: 'Version 2' }, + createdBy, + }); + + const proposal3 = changeProposalFactory({ + id: createChangeProposalId('3'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + payload: { oldValue: 'My command', newValue: 'Version 3' }, + createdBy, + }); + + it('marks first proposal as conflicting with second and third', () => { + const result = service.detectConflicts([ + proposal1, + proposal2, + proposal3, + ]); + + expect(result[0].conflictsWith).toEqual([proposal2.id, proposal3.id]); + }); + + it('marks second proposal as conflicting with first and third', () => { + const result = service.detectConflicts([ + proposal1, + proposal2, + proposal3, + ]); + + expect(result[1].conflictsWith).toEqual([proposal1.id, proposal3.id]); + }); + + it('marks third proposal as conflicting with first and second', () => { + const result = service.detectConflicts([ + proposal1, + proposal2, + proposal3, + ]); + + expect(result[2].conflictsWith).toEqual([proposal1.id, proposal2.id]); + }); + }); + }); + + describe('with updateCommandDescription proposals', () => { + const oldValue = `My command: + +It has a description. +`; + + describe('when proposals do not conflict', () => { + const proposal1 = changeProposalFactory({ + id: createChangeProposalId('1'), + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + spaceId, + payload: { + oldValue, + newValue: `My command: + +It has a description. + +And a new line at the end +`, + }, + createdBy, + }); + + beforeEach(() => { + diffService.hasConflict.mockReturnValue(false); + }); + + it('marks proposal as not conflicting', () => { + const result = service.detectConflicts([proposal1]); + + expect(result[0].conflictsWith).toEqual([]); + }); + }); + + describe('when proposals have different oldValues', () => { + const proposal1 = changeProposalFactory({ + id: createChangeProposalId('1'), + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + spaceId, + payload: { + oldValue: 'Old version 1', + newValue: 'New version 1', + }, + createdBy, + }); + + const proposal2 = changeProposalFactory({ + id: createChangeProposalId('2'), + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + spaceId, + payload: { + oldValue: 'Old version 2', + newValue: 'New version 2', + }, + createdBy, + }); + + it('marks proposals as not conflicting', () => { + const result = service.detectConflicts([proposal1, proposal2]); + + expect(result[0].conflictsWith).toEqual([]); + }); + + it('marks second proposal as not conflicting', () => { + const result = service.detectConflicts([proposal1, proposal2]); + + expect(result[1].conflictsWith).toEqual([]); + }); + + it('does not call diffService', () => { + service.detectConflicts([proposal1, proposal2]); + + expect(diffService.hasConflict).not.toHaveBeenCalled(); + }); + }); + + describe('when proposals conflict', () => { + const proposal1 = changeProposalFactory({ + id: createChangeProposalId('1'), + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + spaceId, + payload: { + oldValue, + newValue: `My updated command: + +It has a description. +`, + }, + createdBy, + }) as ChangeProposal<ChangeProposalType.updateCommandDescription>; + + const proposal2 = changeProposalFactory({ + id: createChangeProposalId('2'), + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + spaceId, + payload: { + oldValue, + newValue: `It has a description.`, + }, + createdBy, + }) as ChangeProposal<ChangeProposalType.updateCommandDescription>; + + beforeEach(() => { + diffService.hasConflict + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + }); + + it('marks first proposal as conflicting with second', () => { + const result = service.detectConflicts([proposal1, proposal2]); + + expect(result[0].conflictsWith).toEqual([proposal2.id]); + }); + + it('marks second proposal as conflicting with first', () => { + const result = service.detectConflicts([proposal1, proposal2]); + + expect(result[1].conflictsWith).toEqual([proposal1.id]); + }); + + it('calls diffService for first proposal', () => { + service.detectConflicts([proposal1, proposal2]); + + expect(diffService.hasConflict).toHaveBeenCalledWith( + oldValue, + proposal1.payload.newValue, + proposal2.payload.newValue, + ); + }); + + it('calls diffService for second proposal', () => { + service.detectConflicts([proposal1, proposal2]); + + expect(diffService.hasConflict).toHaveBeenCalledWith( + oldValue, + proposal2.payload.newValue, + proposal1.payload.newValue, + ); + }); + }); + }); + + describe('with mixed proposal types', () => { + const oldValue = `My command: + +It has a description. +`; + + const nameProposal1 = changeProposalFactory({ + id: createChangeProposalId('1'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + payload: { oldValue: 'My command', newValue: 'My super command' }, + createdBy, + }); + + const nameProposal2 = changeProposalFactory({ + id: createChangeProposalId('2'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + payload: { oldValue: 'My command', newValue: 'My updated command' }, + createdBy, + }); + + const descProposal = changeProposalFactory({ + id: createChangeProposalId('3'), + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + spaceId, + payload: { + oldValue, + newValue: `My command: + +It has a description. + +And a new line at the end +`, + }, + createdBy, + }); + + beforeEach(() => { + diffService.hasConflict.mockReturnValue(false); + }); + + it('marks name proposals as conflicting with each other', () => { + const result = service.detectConflicts([ + nameProposal1, + nameProposal2, + descProposal, + ]); + + expect(result[0].conflictsWith).toEqual([nameProposal2.id]); + }); + + it('marks second name proposal as conflicting with first', () => { + const result = service.detectConflicts([ + nameProposal1, + nameProposal2, + descProposal, + ]); + + expect(result[1].conflictsWith).toEqual([nameProposal1.id]); + }); + + it('marks description proposal as not conflicting', () => { + const result = service.detectConflicts([ + nameProposal1, + nameProposal2, + descProposal, + ]); + + expect(result[2].conflictsWith).toEqual([]); + }); + }); + + describe('with addSkillFile and updateSkillFilePermissions proposals', () => { + const skillId = createSkillId('skill-1'); + + const addSkillFileProposal = changeProposalFactory({ + id: createChangeProposalId('1'), + type: ChangeProposalType.addSkillFile, + artefactId: skillId, + spaceId, + payload: { + item: { + path: 'references/project-config.md', + content: '# Config', + permissions: 'rw-rw-r--', + isBase64: false, + }, + }, + createdBy, + }); + + const updatePermissionsProposal = changeProposalFactory({ + id: createChangeProposalId('2'), + type: ChangeProposalType.updateSkillFilePermissions, + artefactId: skillId, + spaceId, + payload: { + targetId: createSkillFileId('existing-file'), + oldValue: 'rw-r--r--', + newValue: 'rw-rw-r--', + }, + createdBy, + }); + + let result: ReturnType<typeof service.detectConflicts>; + + beforeEach(() => { + result = service.detectConflicts([ + addSkillFileProposal, + updatePermissionsProposal, + ]); + }); + + it('marks addSkillFile proposal as not conflicting', () => { + expect(result[0].conflictsWith).toEqual([]); + }); + + it('marks updateSkillFilePermissions proposal as not conflicting', () => { + expect(result[1].conflictsWith).toEqual([]); + }); + }); + + describe('with empty proposals array', () => { + it('returns empty array', () => { + const result = service.detectConflicts([]); + + expect(result).toEqual([]); + }); + }); + + describe('with single proposal', () => { + const proposal = changeProposalFactory({ + id: createChangeProposalId('1'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + payload: { oldValue: 'My command', newValue: 'My super command' }, + createdBy, + }); + + it('marks proposal as not conflicting', () => { + const result = service.detectConflicts([proposal]); + + expect(result[0].conflictsWith).toEqual([]); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/services/ConflictDetectionService.ts b/packages/playbook-change-management/src/application/services/ConflictDetectionService.ts new file mode 100644 index 000000000..600a6bdf1 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/ConflictDetectionService.ts @@ -0,0 +1,55 @@ +import { + ChangeProposal, + ChangeProposalId, + ChangeProposalType, +} from '@packmind/types'; +import { DiffService } from './DiffService'; +import { getConflictDetector } from './conflictDetection/getConflictDetector'; +import { SetMultimap } from '@teppeis/multimaps'; + +export class ConflictDetectionService { + constructor(private readonly diffService: DiffService) {} + + detectConflicts( + proposals: ChangeProposal<ChangeProposalType>[], + ): Array< + ChangeProposal<ChangeProposalType> & { conflictsWith: ChangeProposalId[] } + > { + const conflictsById = new SetMultimap<ChangeProposalId, ChangeProposalId>(); + + for (let i = 0; i < proposals.length - 1; i++) { + const proposal = proposals[i]; + const conflictIds = this.findConflictsFor( + proposal, + proposals.slice(i + 1), + ); + + for (const conflictId of conflictIds) { + conflictsById.put(proposal.id, conflictId); + conflictsById.put(conflictId, proposal.id); + } + } + + return proposals.map((proposal) => ({ + ...proposal, + conflictsWith: Array.from(conflictsById.get(proposal.id)), + })); + } + + private findConflictsFor( + proposal: ChangeProposal<ChangeProposalType>, + otherProposals: ChangeProposal<ChangeProposalType>[], + ): ChangeProposalId[] { + const conflictDetector = getConflictDetector(proposal); + return otherProposals.reduce((acc, otherProposal) => { + const secondCPDetector = getConflictDetector(otherProposal); + if ( + conflictDetector(proposal, otherProposal, this.diffService) || + secondCPDetector(otherProposal, proposal, this.diffService) + ) { + acc.push(otherProposal.id); + } + return acc; + }, [] as ChangeProposalId[]); + } +} diff --git a/packages/playbook-change-management/src/application/services/DiffService.spec.ts b/packages/playbook-change-management/src/application/services/DiffService.spec.ts new file mode 100644 index 000000000..13a1cadf4 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/DiffService.spec.ts @@ -0,0 +1,122 @@ +import { DiffService } from './DiffService'; + +describe('DiffService', () => { + let diffService: DiffService; + + beforeEach(() => { + diffService = new DiffService(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('applyLineDiff', () => { + describe('when the diff applies cleanly', () => { + it('returns success with the patched value', () => { + const oldValue = 'line 1\nline 2\nline 3'; + const newValue = 'line 1\nline 2 modified\nline 3'; + const currentValue = 'line 1\nline 2\nline 3'; + + const result = diffService.applyLineDiff( + oldValue, + newValue, + currentValue, + ); + + expect(result).toEqual({ + success: true, + value: 'line 1\nline 2 modified\nline 3', + }); + }); + }); + + describe('when the current value has non-conflicting changes', () => { + it('returns success with both changes merged', () => { + const oldValue = 'line 1\nline 2\nline 3'; + const newValue = 'line 1\nline 2 modified\nline 3'; + const currentValue = 'line 1\nline 2\nline 3\nline 4'; + + const result = diffService.applyLineDiff( + oldValue, + newValue, + currentValue, + ); + + expect(result).toEqual({ + success: true, + value: 'line 1\nline 2 modified\nline 3\nline 4', + }); + }); + }); + + describe('when the diff conflicts with current value', () => { + it('returns failure', () => { + const oldValue = 'line 1\nline 2\nline 3'; + const newValue = 'line 1\nline 2 modified\nline 3'; + const currentValue = 'line 1\nline 2 changed\nline 3'; + + const result = diffService.applyLineDiff( + oldValue, + newValue, + currentValue, + ); + + expect(result).toEqual({ success: false }); + }); + }); + + describe('when old and new values are identical', () => { + it('returns success with the current value unchanged', () => { + const oldValue = 'line 1\nline 2\nline 3'; + const newValue = 'line 1\nline 2\nline 3'; + const currentValue = 'line 1\nline 2\nline 3\nline 4'; + + const result = diffService.applyLineDiff( + oldValue, + newValue, + currentValue, + ); + + expect(result).toEqual({ + success: true, + value: 'line 1\nline 2\nline 3\nline 4', + }); + }); + }); + }); + + describe('hasConflict', () => { + describe('when the diff applies cleanly', () => { + it('returns false', () => { + const oldValue = 'line 1\nline 2\nline 3'; + const newValue = 'line 1\nline 2 modified\nline 3'; + const currentValue = 'line 1\nline 2\nline 3'; + + const result = diffService.hasConflict( + oldValue, + newValue, + currentValue, + ); + + expect(result).toBe(false); + }); + }); + + describe('when the diff conflicts', () => { + it('returns true', () => { + const oldValue = 'line 1\nline 2\nline 3'; + const newValue = 'line 1\nline 2 modified\nline 3'; + const currentValue = 'line 1\nline 2 changed\nline 3'; + + const result = diffService.hasConflict( + oldValue, + newValue, + currentValue, + ); + + expect(result).toBe(true); + }); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/services/DiffService.ts b/packages/playbook-change-management/src/application/services/DiffService.ts new file mode 100644 index 000000000..7fff41aa5 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/DiffService.ts @@ -0,0 +1 @@ +export { DiffService } from '@packmind/types'; diff --git a/packages/playbook-change-management/src/application/services/PlaybookChangeManagementServices.ts b/packages/playbook-change-management/src/application/services/PlaybookChangeManagementServices.ts new file mode 100644 index 000000000..7ab31b81e --- /dev/null +++ b/packages/playbook-change-management/src/application/services/PlaybookChangeManagementServices.ts @@ -0,0 +1,33 @@ +import { DataSource } from 'typeorm'; +import { IPlaybookChangeManagementRepositories } from '../../domain/repositories/IPlaybookChangeManagementRepositories'; +import { ChangeProposalService } from './ChangeProposalService'; +import { ConflictDetectionService } from './ConflictDetectionService'; +import { DiffService } from './DiffService'; + +export class PlaybookChangeManagementServices { + private readonly changeProposalService: ChangeProposalService; + private readonly diffService: DiffService; + private readonly conflictDetectionService: ConflictDetectionService; + + constructor( + private readonly repositories: IPlaybookChangeManagementRepositories, + private readonly dataSource: DataSource, + ) { + this.changeProposalService = new ChangeProposalService( + this.repositories.getChangeProposalRepository(), + this.dataSource, + ); + this.diffService = new DiffService(); + this.conflictDetectionService = new ConflictDetectionService( + this.diffService, + ); + } + + getChangeProposalService(): ChangeProposalService { + return this.changeProposalService; + } + + getConflictDetectionService(): ConflictDetectionService { + return this.conflictDetectionService; + } +} diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/ConflictDetector.ts b/packages/playbook-change-management/src/application/services/conflictDetection/ConflictDetector.ts new file mode 100644 index 000000000..7084c43ed --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/ConflictDetector.ts @@ -0,0 +1,11 @@ +import { ChangeProposal, ChangeProposalType } from '@packmind/types'; +import { DiffService } from '../DiffService'; + +export type ConflictDetector< + T1 extends ChangeProposalType, + T2 extends ChangeProposalType = ChangeProposalType, +> = ( + cp1: ChangeProposal<T1>, + cp2: ChangeProposal<T2>, + diffService: DiffService, +) => boolean; diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/detectAddSubItemConflict.spec.ts b/packages/playbook-change-management/src/application/services/conflictDetection/detectAddSubItemConflict.spec.ts new file mode 100644 index 000000000..cd9ee5c9a --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/detectAddSubItemConflict.spec.ts @@ -0,0 +1,226 @@ +import { changeProposalFactory } from '../../../../test'; +import { + ChangeProposal, + ChangeProposalPayload, + ChangeProposalType, + createRuleId, + createStandardId, +} from '@packmind/types'; +import { DiffService } from '../DiffService'; +import { + detectAddRuleConflict, + detectAddSkillFileConflict, + makeDetectAddSubItemConflict, +} from './detectAddSubItemConflict'; +import { ConflictDetector } from './ConflictDetector'; + +describe('makeDetectAddSubItemConflict', () => { + let detectAddSubItemConflict: ConflictDetector<ChangeProposalType.addRule>; + let changeProposal: ChangeProposal<ChangeProposalType.addRule>; + let diffService: jest.Mocked<DiffService>; + + beforeEach(() => { + changeProposal = changeProposalFactory({ + type: ChangeProposalType.addRule, + }) as ChangeProposal<ChangeProposalType.addRule>; + diffService = {} as jest.Mocked<DiffService>; + detectAddSubItemConflict = makeDetectAddSubItemConflict({}); + }); + + it('returns false if items have the same id', () => { + expect( + detectAddSubItemConflict( + changeProposal, + changeProposalFactory({ id: changeProposal.id }), + diffService, + ), + ).toEqual(false); + }); + + it("returns false if items don't have the same type", () => { + expect( + detectAddSubItemConflict( + changeProposal, + changeProposalFactory({ type: ChangeProposalType.updateCommandName }), + diffService, + ), + ).toEqual(false); + }); + + it("returns false if items don't target the same artefact", () => { + expect( + detectAddSubItemConflict( + changeProposal, + changeProposalFactory({ + artefactId: createStandardId(`${changeProposal.artefactId}-1`), + }), + diffService, + ), + ).toEqual(false); + }); +}); + +describe('detectAddRuleConflict', () => { + let changeProposal: ChangeProposal<ChangeProposalType.addRule>; + let diffService: jest.Mocked<DiffService>; + + beforeEach(() => { + changeProposal = changeProposalFactory({ + type: ChangeProposalType.addRule, + }) as ChangeProposal<ChangeProposalType.addRule>; + diffService = {} as jest.Mocked<DiffService>; + }); + + it('returns false if the two proposals have different content', () => { + expect( + detectAddRuleConflict( + { + ...changeProposal, + payload: { + item: { + content: 'My new rule', + }, + }, + }, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload: { + item: { + content: 'My other new rule', + }, + }, + }), + diffService, + ), + ).toEqual(false); + }); + + it('returns true if the two proposals have the same content', () => { + const payload = { + item: { + content: 'My new rule', + }, + }; + + expect( + detectAddRuleConflict( + { + ...changeProposal, + payload, + }, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload, + }), + diffService, + ), + ).toEqual(true); + }); + + describe('when the second proposal updates a rule', () => { + const payload = { + item: { + content: 'My new rule', + }, + }; + + it('returns false if the second proposal renames a rule with another value', () => { + expect( + detectAddRuleConflict( + { + ...changeProposal, + payload, + }, + changeProposalFactory({ + type: ChangeProposalType.updateRule, + artefactId: changeProposal.artefactId, + payload: { + targetId: createRuleId('second-rule'), + oldValue: 'whatever', + newValue: 'Something completely different', + }, + }), + diffService, + ), + ).toEqual(false); + }); + + it('returns true if the second proposal renames a rule with the same name', () => { + expect( + detectAddRuleConflict( + { + ...changeProposal, + payload, + }, + changeProposalFactory({ + type: ChangeProposalType.updateRule, + artefactId: changeProposal.artefactId, + payload: { + targetId: createRuleId('second-rule'), + oldValue: 'whatever', + newValue: payload.item.content, + }, + }), + diffService, + ), + ).toEqual(true); + }); + }); +}); + +describe('detectAddSkillFileConflicts', () => { + let changeProposal: ChangeProposal<ChangeProposalType.addSkillFile>; + let diffService: jest.Mocked<DiffService>; + + const payload: ChangeProposalPayload<ChangeProposalType.addSkillFile> = { + item: { + content: '', + path: 'path/to/the/file.pdf', + permissions: '', + isBase64: false, + }, + }; + + beforeEach(() => { + changeProposal = changeProposalFactory({ + type: ChangeProposalType.addSkillFile, + payload, + }) as ChangeProposal<ChangeProposalType.addSkillFile>; + diffService = {} as jest.Mocked<DiffService>; + }); + + it('returns false if the two proposals have different paths', () => { + expect( + detectAddSkillFileConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload: { + item: { + ...payload.item, + path: 'some/other/file.pdf', + }, + }, + }), + diffService, + ), + ).toEqual(false); + }); + + it('returns true if the two proposals have the same path', () => { + expect( + detectAddSkillFileConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload, + }), + diffService, + ), + ).toEqual(true); + }); +}); diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/detectAddSubItemConflict.ts b/packages/playbook-change-management/src/application/services/conflictDetection/detectAddSubItemConflict.ts new file mode 100644 index 000000000..73f2f41bc --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/detectAddSubItemConflict.ts @@ -0,0 +1,53 @@ +import { ConflictDetector } from './ConflictDetector'; +import { ChangeProposalType } from '@packmind/types'; +import { sameProposal } from './sameProposal'; +import { sameArtefact } from './sameArtefact'; +import { isExpectedChangeProposalType } from '../../utils/isExpectedChangeProposalType'; + +type AddSubItemChangeProposals = + | ChangeProposalType.addRule + | ChangeProposalType.addSkillFile; + +export function makeDetectAddSubItemConflict< + T extends AddSubItemChangeProposals, +>( + conflictDetectorByType: Partial<{ + [K in ChangeProposalType]: ConflictDetector<T, K>; + }>, +): ConflictDetector<T> { + return (cp1, cp2, diffService) => { + if (sameProposal(cp1, cp2) || !sameArtefact(cp1, cp2)) return false; + + for (const key of Object.keys(conflictDetectorByType)) { + const expectedType = key as ChangeProposalType; + const conflictDetector = conflictDetectorByType[ + expectedType + ] as ConflictDetector<T, typeof expectedType>; + + if ( + conflictDetector && + isExpectedChangeProposalType(cp2, expectedType) && + conflictDetector(cp1, cp2, diffService) + ) { + return true; + } + } + + return false; + }; +} + +export const detectAddRuleConflict = + makeDetectAddSubItemConflict<ChangeProposalType.addRule>({ + [ChangeProposalType.addRule]: (cp1, cp2) => + cp1.payload.item.content === cp2.payload.item.content, + [ChangeProposalType.updateRule]: (cp1, cp2) => { + return cp1.payload.item.content === cp2.payload.newValue; + }, + }); + +export const detectAddSkillFileConflict = + makeDetectAddSubItemConflict<ChangeProposalType.addSkillFile>({ + [ChangeProposalType.addSkillFile]: (cp1, cp2) => + cp1.payload.item.path === cp2.payload.item.path, + }); diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/detectMultiLineConflict.spec.ts b/packages/playbook-change-management/src/application/services/conflictDetection/detectMultiLineConflict.spec.ts new file mode 100644 index 000000000..9fced60f8 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/detectMultiLineConflict.spec.ts @@ -0,0 +1,191 @@ +import { changeProposalFactory } from '../../../../test'; +import { + ChangeProposal, + ChangeProposalStatus, + ChangeProposalType, + createStandardId, + ScalarUpdatePayload, +} from '@packmind/types'; +import { DiffService } from '../DiffService'; +import { detectMultiLineConflict } from './detectMultiLineConflict'; + +describe('detectMultiLineConflict', () => { + let changeProposal: ChangeProposal<ChangeProposalType.updateStandardDescription>; + let diffService: jest.Mocked<DiffService>; + const payload: ScalarUpdatePayload = { + oldValue: 'Some data', + newValue: 'Some new data', + }; + + beforeEach(() => { + changeProposal = changeProposalFactory({ + type: ChangeProposalType.updateStandardDescription, + payload, + }) as ChangeProposal<ChangeProposalType.updateStandardDescription>; + diffService = { + applyLineDiff: jest.fn(), + hasConflict: jest.fn(), + }; + }); + + it('returns false if items have the same id', () => { + expect( + detectMultiLineConflict( + changeProposal, + changeProposalFactory({ id: changeProposal.id }), + diffService, + ), + ).toEqual(false); + }); + + it("returns false if items don't have the same type", () => { + expect( + detectMultiLineConflict( + changeProposal, + changeProposalFactory({ type: ChangeProposalType.updateCommandName }), + diffService, + ), + ).toEqual(false); + }); + + it("returns false if items don't target the same artefact", () => { + expect( + detectMultiLineConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: createStandardId(`${changeProposal.artefactId}-1`), + }), + diffService, + ), + ).toEqual(false); + }); + + it('uses diffService.hasConflict to check if there is a conflict', () => { + detectMultiLineConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload: { + ...changeProposal.payload, + newValue: 'This is another change proposal', + }, + }), + + diffService, + ); + + expect(diffService.hasConflict).toHaveBeenCalledWith( + changeProposal.payload.oldValue, + changeProposal.payload.newValue, + 'This is another change proposal', + ); + }); + + it('returns the result of diffService.hasConflict', () => { + diffService.hasConflict.mockReturnValue(true); + + expect( + detectMultiLineConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload: { + ...changeProposal.payload, + newValue: 'This is another change proposal', + }, + }), + + diffService, + ), + ).toEqual(true); + }); + + describe('when decision is set', () => { + describe('when both proposals have decision', () => { + it('passes decision newValues to diffService.hasConflict instead of payload newValues', () => { + const cp1 = changeProposalFactory({ + type: ChangeProposalType.updateStandardDescription, + artefactId: changeProposal.artefactId, + payload: { oldValue: 'base text', newValue: 'payload-change-1' }, + status: ChangeProposalStatus.applied, + decision: { oldValue: 'base text', newValue: 'decision-change-1' }, + }) as ChangeProposal<ChangeProposalType.updateStandardDescription>; + + const cp2 = changeProposalFactory({ + type: ChangeProposalType.updateStandardDescription, + artefactId: changeProposal.artefactId, + payload: { oldValue: 'base text', newValue: 'payload-change-2' }, + status: ChangeProposalStatus.applied, + decision: { oldValue: 'base text', newValue: 'decision-change-2' }, + }) as ChangeProposal<ChangeProposalType.updateStandardDescription>; + + detectMultiLineConflict(cp1, cp2, diffService); + + expect(diffService.hasConflict).toHaveBeenCalledWith( + 'base text', + 'decision-change-1', + 'decision-change-2', + ); + }); + }); + + describe('when only one proposal has decision', () => { + it('passes decision newValue for one and payload newValue for the other', () => { + const cpWithDecision = changeProposalFactory({ + type: ChangeProposalType.updateStandardDescription, + artefactId: changeProposal.artefactId, + payload: { oldValue: 'base text', newValue: 'payload-change' }, + status: ChangeProposalStatus.applied, + decision: { oldValue: 'base text', newValue: 'decision-change' }, + }) as ChangeProposal<ChangeProposalType.updateStandardDescription>; + + const cpWithoutDecision = changeProposalFactory({ + type: ChangeProposalType.updateStandardDescription, + artefactId: changeProposal.artefactId, + payload: { oldValue: 'base text', newValue: 'other-change' }, + }) as ChangeProposal<ChangeProposalType.updateStandardDescription>; + + detectMultiLineConflict(cpWithDecision, cpWithoutDecision, diffService); + + expect(diffService.hasConflict).toHaveBeenCalledWith( + 'base text', + 'decision-change', + 'other-change', + ); + }); + }); + + describe('when proposals have different payload oldValues', () => { + let cp1: ChangeProposal<ChangeProposalType.updateStandardDescription>; + let cp2: ChangeProposal<ChangeProposalType.updateStandardDescription>; + + beforeEach(() => { + cp1 = changeProposalFactory({ + type: ChangeProposalType.updateStandardDescription, + artefactId: changeProposal.artefactId, + payload: { oldValue: 'base-1', newValue: 'change-1' }, + status: ChangeProposalStatus.applied, + decision: { oldValue: 'decision-base', newValue: 'decision-change' }, + }) as ChangeProposal<ChangeProposalType.updateStandardDescription>; + + cp2 = changeProposalFactory({ + type: ChangeProposalType.updateStandardDescription, + artefactId: changeProposal.artefactId, + payload: { oldValue: 'base-2', newValue: 'change-2' }, + }) as ChangeProposal<ChangeProposalType.updateStandardDescription>; + }); + + it('returns false', () => { + expect(detectMultiLineConflict(cp1, cp2, diffService)).toEqual(false); + }); + + it('does not call diffService.hasConflict', () => { + detectMultiLineConflict(cp1, cp2, diffService); + expect(diffService.hasConflict).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/detectMultiLineConflict.ts b/packages/playbook-change-management/src/application/services/conflictDetection/detectMultiLineConflict.ts new file mode 100644 index 000000000..95f8119d9 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/detectMultiLineConflict.ts @@ -0,0 +1,32 @@ +import { ChangeProposalType, ScalarUpdatePayload } from '@packmind/types'; +import { ConflictDetector } from './ConflictDetector'; +import { sameProposal } from './sameProposal'; +import { sameType } from './sameType'; +import { sameArtefact } from './sameArtefact'; + +type MultiLineChangeProposals = + | ChangeProposalType.updateCommandDescription + | ChangeProposalType.updateSkillDescription + | ChangeProposalType.updateStandardDescription + | ChangeProposalType.updateSkillPrompt + | ChangeProposalType.updateSkillFileContent; + +export const detectMultiLineConflict: ConflictDetector< + MultiLineChangeProposals +> = (cp1, cp2, diffService) => { + if (sameProposal(cp1, cp2) || !sameType(cp1, cp2) || !sameArtefact(cp1, cp2)) + return false; + + const { oldValue: oldValue1 } = cp1.payload; + const { oldValue: oldValue2 } = cp2.payload; + const newValue1 = ((cp1.decision ?? cp1.payload) as ScalarUpdatePayload) + .newValue; + const newValue2 = ((cp2.decision ?? cp2.payload) as ScalarUpdatePayload) + .newValue; + + if (oldValue1 !== oldValue2) { + return false; + } + + return diffService.hasConflict(oldValue1, newValue1, newValue2); +}; diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/detectRemoveConflict.spec.ts b/packages/playbook-change-management/src/application/services/conflictDetection/detectRemoveConflict.spec.ts new file mode 100644 index 000000000..0f107d9c3 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/detectRemoveConflict.spec.ts @@ -0,0 +1,79 @@ +import { + ChangeProposal, + ChangeProposalStatus, + ChangeProposalType, + createStandardId, +} from '@packmind/types'; +import { changeProposalFactory } from '../../../../test'; +import { DiffService } from '../DiffService'; +import { detectRemoveConflict } from './detectRemoveConflict'; + +describe('detectRemoveConflict', () => { + let changeProposal: ChangeProposal<ChangeProposalType.removeStandard>; + let diffService: jest.Mocked<DiffService>; + + beforeEach(() => { + changeProposal = changeProposalFactory({ + type: ChangeProposalType.removeStandard, + artefactId: createStandardId('some-standard-id'), + }) as ChangeProposal<ChangeProposalType.removeStandard>; + diffService = {} as jest.Mocked<DiffService>; + }); + + it('returns false if items have the same id', () => { + expect( + detectRemoveConflict( + changeProposal, + changeProposalFactory({ id: changeProposal.id }), + diffService, + ), + ).toEqual(false); + }); + + it('returns false if items target different artifacts', () => { + expect( + detectRemoveConflict( + changeProposal, + changeProposalFactory({ + artefactId: createStandardId('some-other-standard-id'), + }), + diffService, + ), + ).toEqual(false); + }); + + describe('when both item target the same artifact', () => { + it('returns false if the decision is to remove from package', () => { + expect( + detectRemoveConflict( + { + ...changeProposal, + status: ChangeProposalStatus.applied, + decision: { + delete: false, + removeFromPackages: [], + }, + }, + changeProposalFactory({ artefactId: changeProposal.artefactId }), + diffService, + ), + ).toEqual(false); + }); + + it('returns true if the decision is to delete the artefact', () => { + expect( + detectRemoveConflict( + { + ...changeProposal, + status: ChangeProposalStatus.applied, + decision: { + delete: true, + }, + }, + changeProposalFactory({ artefactId: changeProposal.artefactId }), + diffService, + ), + ).toEqual(true); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/detectRemoveConflict.ts b/packages/playbook-change-management/src/application/services/conflictDetection/detectRemoveConflict.ts new file mode 100644 index 000000000..df73958ce --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/detectRemoveConflict.ts @@ -0,0 +1,19 @@ +import { ConflictDetector } from './ConflictDetector'; +import { sameProposal } from './sameProposal'; +import { sameArtefact } from './sameArtefact'; +import { ChangeProposalType } from '@packmind/types'; + +type RemoveChangeProposals = + | ChangeProposalType.removeStandard + | ChangeProposalType.removeCommand + | ChangeProposalType.removeSkill; +export const detectRemoveConflict: ConflictDetector<RemoveChangeProposals> = ( + cp1, + cp2, +) => { + return ( + !sameProposal(cp1, cp2) && + sameArtefact(cp1, cp2) && + (cp1.decision?.delete ?? false) + ); +}; diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/detectSingleLineConflict.spec.ts b/packages/playbook-change-management/src/application/services/conflictDetection/detectSingleLineConflict.spec.ts new file mode 100644 index 000000000..0b606f98e --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/detectSingleLineConflict.spec.ts @@ -0,0 +1,191 @@ +import { changeProposalFactory } from '../../../../test'; +import { detectSingleLineConflict } from './detectSingleLineConflict'; +import { + ChangeProposal, + ChangeProposalPayload, + ChangeProposalStatus, + ChangeProposalType, + createStandardId, +} from '@packmind/types'; +import { DiffService } from '../DiffService'; + +describe('singleLineConflict', () => { + let changeProposal: ChangeProposal<ChangeProposalType.updateStandardName>; + let diffService: jest.Mocked<DiffService>; + const payload: ChangeProposalPayload<ChangeProposalType.updateStandardName> = + { + oldValue: 'The old value', + newValue: 'The new value', + }; + + beforeEach(() => { + changeProposal = changeProposalFactory({ + type: ChangeProposalType.updateStandardName, + payload, + }) as ChangeProposal<ChangeProposalType.updateStandardName>; + diffService = {} as jest.Mocked<DiffService>; + }); + + it('returns false if items have the same id', () => { + expect( + detectSingleLineConflict( + changeProposal, + changeProposalFactory({ id: changeProposal.id }), + diffService, + ), + ).toEqual(false); + }); + + it("returns false if items don't have the same type", () => { + expect( + detectSingleLineConflict( + changeProposal, + changeProposalFactory({ type: ChangeProposalType.updateCommandName }), + diffService, + ), + ).toEqual(false); + }); + + it("returns false if items don't target the same artefact", () => { + expect( + detectSingleLineConflict( + changeProposal, + changeProposalFactory({ + artefactId: createStandardId(`${changeProposal.artefactId}-1`), + }), + diffService, + ), + ).toEqual(false); + }); + + it('returns false if the two proposals target the same artefact with the same values', () => { + expect( + detectSingleLineConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload, + }), + diffService, + ), + ).toEqual(false); + }); + + it('returns true if the two proposals target the same artefact with different values', () => { + expect( + detectSingleLineConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload: { + ...changeProposal.payload, + newValue: 'Some different new value', + }, + }), + diffService, + ), + ).toEqual(true); + }); + + describe('when decision is set', () => { + describe('when both proposals have decision', () => { + describe('when decision newValues match', () => { + it('returns false even though payload newValues differ', () => { + const cp1 = changeProposalFactory({ + type: ChangeProposalType.updateStandardName, + artefactId: changeProposal.artefactId, + payload: { oldValue: 'old', newValue: 'payload-value-1' }, + status: ChangeProposalStatus.applied, + decision: { oldValue: 'old', newValue: 'agreed-value' }, + }) as ChangeProposal<ChangeProposalType.updateStandardName>; + + const cp2 = changeProposalFactory({ + type: ChangeProposalType.updateStandardName, + artefactId: changeProposal.artefactId, + payload: { oldValue: 'old', newValue: 'payload-value-2' }, + status: ChangeProposalStatus.applied, + decision: { oldValue: 'old', newValue: 'agreed-value' }, + }) as ChangeProposal<ChangeProposalType.updateStandardName>; + + expect(detectSingleLineConflict(cp1, cp2, diffService)).toEqual( + false, + ); + }); + }); + + describe('when decision newValues differ', () => { + it('returns true', () => { + const cp1 = changeProposalFactory({ + type: ChangeProposalType.updateStandardName, + artefactId: changeProposal.artefactId, + payload: { oldValue: 'old', newValue: 'same-payload' }, + status: ChangeProposalStatus.applied, + decision: { oldValue: 'old', newValue: 'decision-value-1' }, + }) as ChangeProposal<ChangeProposalType.updateStandardName>; + + const cp2 = changeProposalFactory({ + type: ChangeProposalType.updateStandardName, + artefactId: changeProposal.artefactId, + payload: { oldValue: 'old', newValue: 'same-payload' }, + status: ChangeProposalStatus.applied, + decision: { oldValue: 'old', newValue: 'decision-value-2' }, + }) as ChangeProposal<ChangeProposalType.updateStandardName>; + + expect(detectSingleLineConflict(cp1, cp2, diffService)).toEqual(true); + }); + }); + }); + + describe('when only one proposal has decision', () => { + it('returns false if decision newValue matches the other payload newValue', () => { + const cpWithDecision = changeProposalFactory({ + type: ChangeProposalType.updateStandardName, + artefactId: changeProposal.artefactId, + payload: { oldValue: 'old', newValue: 'original-value' }, + status: ChangeProposalStatus.applied, + decision: { oldValue: 'old', newValue: 'edited-value' }, + }) as ChangeProposal<ChangeProposalType.updateStandardName>; + + const cpWithoutDecision = changeProposalFactory({ + type: ChangeProposalType.updateStandardName, + artefactId: changeProposal.artefactId, + payload: { oldValue: 'old', newValue: 'edited-value' }, + }) as ChangeProposal<ChangeProposalType.updateStandardName>; + + expect( + detectSingleLineConflict( + cpWithDecision, + cpWithoutDecision, + diffService, + ), + ).toEqual(false); + }); + + it('returns true if decision newValue differs from the other payload newValue', () => { + const cpWithDecision = changeProposalFactory({ + type: ChangeProposalType.updateStandardName, + artefactId: changeProposal.artefactId, + payload: { oldValue: 'old', newValue: 'original-value' }, + status: ChangeProposalStatus.applied, + decision: { oldValue: 'old', newValue: 'edited-value' }, + }) as ChangeProposal<ChangeProposalType.updateStandardName>; + + const cpWithoutDecision = changeProposalFactory({ + type: ChangeProposalType.updateStandardName, + artefactId: changeProposal.artefactId, + payload: { oldValue: 'old', newValue: 'different-value' }, + }) as ChangeProposal<ChangeProposalType.updateStandardName>; + + expect( + detectSingleLineConflict( + cpWithDecision, + cpWithoutDecision, + diffService, + ), + ).toEqual(true); + }); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/detectSingleLineConflict.ts b/packages/playbook-change-management/src/application/services/conflictDetection/detectSingleLineConflict.ts new file mode 100644 index 000000000..cd6ef1b49 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/detectSingleLineConflict.ts @@ -0,0 +1,33 @@ +import { ChangeProposalType, ScalarUpdatePayload } from '@packmind/types'; +import { sameProposal } from './sameProposal'; +import { sameType } from './sameType'; +import { sameArtefact } from './sameArtefact'; +import { ConflictDetector } from './ConflictDetector'; + +type SingleLineChangeProposals = + | ChangeProposalType.updateCommandName + | ChangeProposalType.updateSkillName + | ChangeProposalType.updateStandardName + | ChangeProposalType.updateStandardScope + | ChangeProposalType.updateSkillCompatibility + | ChangeProposalType.updateSkillLicense + | ChangeProposalType.updateSkillAllowedTools + | ChangeProposalType.updateSkillMetadata + | ChangeProposalType.updateRule + | ChangeProposalType.updateSkillFilePermissions; + +export const detectSingleLineConflict: ConflictDetector< + SingleLineChangeProposals +> = (cp1, cp2) => { + const newValue1 = ((cp1.decision ?? cp1.payload) as ScalarUpdatePayload) + .newValue; + const newValue2 = ((cp2.decision ?? cp2.payload) as ScalarUpdatePayload) + .newValue; + + return ( + !sameProposal(cp1, cp2) && + sameType(cp1, cp2) && + sameArtefact(cp1, cp2) && + newValue1 !== newValue2 + ); +}; diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/detectSubItemDeleteConflict.spec.ts b/packages/playbook-change-management/src/application/services/conflictDetection/detectSubItemDeleteConflict.spec.ts new file mode 100644 index 000000000..736d51875 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/detectSubItemDeleteConflict.spec.ts @@ -0,0 +1,288 @@ +import { ConflictDetector } from './ConflictDetector'; +import { + ChangeProposal, + ChangeProposalPayload, + ChangeProposalType, + createRuleId, + createSkillFileId, + createStandardId, +} from '@packmind/types'; +import { DiffService } from '../DiffService'; +import { changeProposalFactory } from '../../../../test'; +import { ruleFactory } from '@packmind/standards/test'; +import { + detectDeleteRuleConflict, + detectDeleteSkillFileConflict, + makeDetectSubItemDeleteConflict, +} from './detectSubItemDeleteConflict'; +import { skillFileFactory } from '@packmind/skills/test'; + +describe('detectSubItemDeleteConflict', () => { + let detectDeleteSubItemConflict: ConflictDetector<ChangeProposalType.deleteRule>; + let changeProposal: ChangeProposal<ChangeProposalType.deleteRule>; + let diffService: jest.Mocked<DiffService>; + + const ruleId = createRuleId('rule-id'); + const rule = ruleFactory({ id: ruleId }); + const payload: ChangeProposalPayload<ChangeProposalType.deleteRule> = { + targetId: ruleId, + item: rule, + }; + + beforeEach(() => { + changeProposal = changeProposalFactory({ + type: ChangeProposalType.deleteRule, + payload, + }) as ChangeProposal<ChangeProposalType.deleteRule>; + diffService = {} as jest.Mocked<DiffService>; + detectDeleteSubItemConflict = makeDetectSubItemDeleteConflict({}); + }); + + it('returns false if both proposals have the same id', () => { + expect( + detectDeleteSubItemConflict( + changeProposal, + changeProposalFactory({ + id: changeProposal.id, + }), + diffService, + ), + ).toBe(false); + }); + + it('returns false if both proposals do not target the same artefact', () => { + expect( + detectDeleteSubItemConflict( + changeProposal, + changeProposalFactory({ + artefactId: createStandardId('another-id'), + }), + diffService, + ), + ).toBe(false); + }); +}); + +describe('detectDeleteRuleConflict', () => { + let changeProposal: ChangeProposal<ChangeProposalType.deleteRule>; + let diffService: jest.Mocked<DiffService>; + + const ruleId = createRuleId('rule-id'); + const rule = ruleFactory({ id: ruleId }); + const payload: ChangeProposalPayload<ChangeProposalType.deleteRule> = { + targetId: ruleId, + item: rule, + }; + + beforeEach(() => { + changeProposal = changeProposalFactory({ + type: ChangeProposalType.deleteRule, + payload, + }) as ChangeProposal<ChangeProposalType.deleteRule>; + diffService = {} as jest.Mocked<DiffService>; + }); + + describe('when second proposal deletes a rule', () => { + it('returns true if the second proposal deletes the same rule', () => { + expect( + detectDeleteRuleConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload, + }), + diffService, + ), + ).toBe(true); + }); + + it('returns false if the second proposal deletes another rule', () => { + const secondRule = ruleFactory(); + + expect( + detectDeleteRuleConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload: { + targetId: secondRule.id, + item: secondRule, + }, + }), + diffService, + ), + ).toBe(false); + }); + }); + + describe('when second proposal updates a rule', () => { + it('returns true if the update is on the same rule', () => { + expect( + detectDeleteRuleConflict( + changeProposal, + changeProposalFactory({ + type: ChangeProposalType.updateRule, + artefactId: changeProposal.artefactId, + payload: { + targetId: payload.targetId, + oldValue: '', + newValue: '', + }, + }), + diffService, + ), + ).toBe(true); + }); + + it('returns false if the update is on another rule', () => { + expect( + detectDeleteRuleConflict( + changeProposal, + changeProposalFactory({ + type: ChangeProposalType.updateRule, + artefactId: changeProposal.artefactId, + payload: { + targetId: createRuleId('another-id'), + oldValue: '', + newValue: '', + }, + }), + diffService, + ), + ).toBe(false); + }); + }); +}); + +describe('detectDeleteSkillFileConflict', () => { + let changeProposal: ChangeProposal<ChangeProposalType.deleteSkillFile>; + let diffService: jest.Mocked<DiffService>; + + const skillFileId = createSkillFileId('skill-file-id'); + const skillFile = skillFileFactory({ id: skillFileId }); + const payload: ChangeProposalPayload<ChangeProposalType.deleteSkillFile> = { + targetId: skillFileId, + item: skillFile, + }; + + beforeEach(() => { + changeProposal = changeProposalFactory({ + type: ChangeProposalType.deleteSkillFile, + payload, + }) as ChangeProposal<ChangeProposalType.deleteSkillFile>; + diffService = {} as jest.Mocked<DiffService>; + }); + + describe('when second proposal deletes a skill file', () => { + it('returns true if the second proposal deletes the same skill file', () => { + expect( + detectDeleteSkillFileConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload, + }), + diffService, + ), + ).toBe(true); + }); + + it('returns false if the second proposal deletes another rule', () => { + const secondSkillFile = skillFileFactory(); + + expect( + detectDeleteSkillFileConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload: { + targetId: secondSkillFile.id, + item: secondSkillFile, + }, + }), + diffService, + ), + ).toBe(false); + }); + }); + + describe('when second proposal updates a skill file content', () => { + it('returns true if the second proposal targets the same skill file', () => { + expect( + detectDeleteSkillFileConflict( + changeProposal, + changeProposalFactory({ + type: ChangeProposalType.updateSkillFileContent, + artefactId: changeProposal.artefactId, + payload: { + targetId: payload.targetId, + oldValue: '', + newValue: '', + }, + }), + diffService, + ), + ).toBe(true); + }); + + it('returns false if the second proposal targets another skill file', () => { + expect( + detectDeleteSkillFileConflict( + changeProposal, + changeProposalFactory({ + type: ChangeProposalType.updateSkillFileContent, + artefactId: changeProposal.artefactId, + payload: { + targetId: createSkillFileId('another-skill-file-id'), + oldValue: '', + newValue: '', + }, + }), + diffService, + ), + ).toBe(false); + }); + }); + + describe('when second proposal updates a skill file permissions', () => { + it('returns true if the second proposal targets the same skill file', () => { + expect( + detectDeleteSkillFileConflict( + changeProposal, + changeProposalFactory({ + type: ChangeProposalType.updateSkillFilePermissions, + artefactId: changeProposal.artefactId, + payload: { + targetId: payload.targetId, + oldValue: '', + newValue: '', + }, + }), + diffService, + ), + ).toBe(true); + }); + + it('returns false if the second proposal targets another skill file', () => { + expect( + detectDeleteSkillFileConflict( + changeProposal, + changeProposalFactory({ + type: ChangeProposalType.updateSkillFilePermissions, + artefactId: changeProposal.artefactId, + payload: { + targetId: createSkillFileId('another-skill-file-id'), + oldValue: '', + newValue: '', + }, + }), + diffService, + ), + ).toBe(false); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/detectSubItemDeleteConflict.ts b/packages/playbook-change-management/src/application/services/conflictDetection/detectSubItemDeleteConflict.ts new file mode 100644 index 000000000..57bcbc54d --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/detectSubItemDeleteConflict.ts @@ -0,0 +1,52 @@ +import { ChangeProposalType } from '@packmind/types'; +import { ConflictDetector } from './ConflictDetector'; +import { sameProposal } from './sameProposal'; +import { sameArtefact } from './sameArtefact'; +import { isExpectedChangeProposalType } from '../../utils/isExpectedChangeProposalType'; +import { sameSubTarget } from './sameSubTarget'; + +type DeleteSubItemChangeProposals = + | ChangeProposalType.deleteRule + | ChangeProposalType.deleteSkillFile; + +export function makeDetectSubItemDeleteConflict< + T extends DeleteSubItemChangeProposals, +>( + conflictDetectorByType: Partial<{ + [K in ChangeProposalType]: ConflictDetector<T, K>; + }>, +): ConflictDetector<T> { + return (cp1, cp2, diffService) => { + if (sameProposal(cp1, cp2) || !sameArtefact(cp1, cp2)) return false; + + for (const key of Object.keys(conflictDetectorByType)) { + const expectedType = key as ChangeProposalType; + const conflictDetector = conflictDetectorByType[ + expectedType + ] as ConflictDetector<T, typeof expectedType>; + + if ( + conflictDetector && + isExpectedChangeProposalType(cp2, expectedType) && + conflictDetector(cp1, cp2, diffService) + ) { + return true; + } + } + + return false; + }; +} + +export const detectDeleteRuleConflict = + makeDetectSubItemDeleteConflict<ChangeProposalType.deleteRule>({ + [ChangeProposalType.deleteRule]: sameSubTarget, + [ChangeProposalType.updateRule]: sameSubTarget, + }); + +export const detectDeleteSkillFileConflict = + makeDetectSubItemDeleteConflict<ChangeProposalType.deleteSkillFile>({ + [ChangeProposalType.deleteSkillFile]: sameSubTarget, + [ChangeProposalType.updateSkillFileContent]: sameSubTarget, + [ChangeProposalType.updateSkillFilePermissions]: sameSubTarget, + }); diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/detectUpdateSkillAdditionalPropertyConflict.spec.ts b/packages/playbook-change-management/src/application/services/conflictDetection/detectUpdateSkillAdditionalPropertyConflict.spec.ts new file mode 100644 index 000000000..fcf2b89c1 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/detectUpdateSkillAdditionalPropertyConflict.spec.ts @@ -0,0 +1,101 @@ +import { + ChangeProposal, + ChangeProposalPayload, + ChangeProposalType, + createSkillId, +} from '@packmind/types'; +import { DiffService } from '../DiffService'; +import { changeProposalFactory } from '../../../../test'; +import { detectUpdateSkillAdditionalPropertyConflict } from './detectUpdateSkillAdditionalPropertyConflict'; + +describe('detectUpdateSkillAdditionalPropertyConflict', () => { + let changeProposal: ChangeProposal<ChangeProposalType.updateSkillAdditionalProperty>; + let diffService: jest.Mocked<DiffService>; + + const payload: ChangeProposalPayload<ChangeProposalType.updateSkillAdditionalProperty> = + { + targetId: 'propertyKey', + newValue: 'new-value', + oldValue: 'old-value', + }; + + beforeEach(() => { + changeProposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillAdditionalProperty, + payload, + }) as ChangeProposal<ChangeProposalType.updateSkillAdditionalProperty>; + diffService = {} as jest.Mocked<DiffService>; + }); + + afterEach(() => jest.clearAllMocks()); + + it('returns false if both proposals have the same id', () => { + expect( + detectUpdateSkillAdditionalPropertyConflict( + changeProposal, + changeProposalFactory({ + id: changeProposal.id, + }), + diffService, + ), + ).toBe(false); + }); + + it('returns false if proposals do not target the same artefact', () => { + expect( + detectUpdateSkillAdditionalPropertyConflict( + changeProposal, + changeProposalFactory({ + artefactId: createSkillId('another-skill-id'), + }), + diffService, + ), + ).toBe(false); + }); + + it('returns false if second proposal has a different type', () => { + expect( + detectUpdateSkillAdditionalPropertyConflict( + changeProposal, + changeProposalFactory({ + type: ChangeProposalType.updateRule, + artefactId: changeProposal.artefactId, + }), + diffService, + ), + ).toBe(false); + }); + + describe('when second proposal is also an updateSkillAdditionalProperty', () => { + it('returns false if same targetId but same newValue', () => { + expect( + detectUpdateSkillAdditionalPropertyConflict( + changeProposal, + changeProposalFactory({ + type: ChangeProposalType.updateSkillAdditionalProperty, + artefactId: changeProposal.artefactId, + payload, + }), + diffService, + ), + ).toBe(false); + }); + + it('returns true if same targetId and different newValue', () => { + expect( + detectUpdateSkillAdditionalPropertyConflict( + changeProposal, + changeProposalFactory({ + type: ChangeProposalType.updateSkillAdditionalProperty, + artefactId: changeProposal.artefactId, + payload: { + ...payload, + newValue: 'a-different-value', + }, + }), + diffService, + ), + ).toBe(true); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/detectUpdateSkillAdditionalPropertyConflict.ts b/packages/playbook-change-management/src/application/services/conflictDetection/detectUpdateSkillAdditionalPropertyConflict.ts new file mode 100644 index 000000000..e25450da1 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/detectUpdateSkillAdditionalPropertyConflict.ts @@ -0,0 +1,28 @@ +import { ChangeProposal, ChangeProposalType } from '@packmind/types'; +import { ConflictDetector } from './ConflictDetector'; +import { sameProposal } from './sameProposal'; +import { sameArtefact } from './sameArtefact'; +import { sameSubTarget } from './sameSubTarget'; + +/** + * Detects conflicts for updateSkillAdditionalProperty change proposals. + * + * Two proposals conflict when they target the same artefact, the same + * property key (targetId), and propose different newValues. + */ +export const detectUpdateSkillAdditionalPropertyConflict: ConflictDetector< + ChangeProposalType.updateSkillAdditionalProperty +> = (cp1, cp2, diffService) => { + if (sameProposal(cp1, cp2) || !sameArtefact(cp1, cp2)) return false; + + if (cp2.type !== ChangeProposalType.updateSkillAdditionalProperty) { + return false; + } + + const narrowedCp2 = + cp2 as ChangeProposal<ChangeProposalType.updateSkillAdditionalProperty>; + + if (!sameSubTarget(cp1, narrowedCp2, diffService)) return false; + + return cp1.payload.newValue !== narrowedCp2.payload.newValue; +}; diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/detectUpdateSubItemConflict.spec.ts b/packages/playbook-change-management/src/application/services/conflictDetection/detectUpdateSubItemConflict.spec.ts new file mode 100644 index 000000000..402e119a6 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/detectUpdateSubItemConflict.spec.ts @@ -0,0 +1,422 @@ +import { ConflictDetector } from './ConflictDetector'; +import { + ChangeProposal, + ChangeProposalPayload, + ChangeProposalType, + createRuleId, + createSkillFileId, + createStandardId, +} from '@packmind/types'; +import { DiffService } from '../DiffService'; +import { changeProposalFactory } from '../../../../test'; +import { + detectUpdateRuleConflict, + detectUpdateSkillFileContentConflict, + detectUpdateSkillPermissionsContentConflict, + makeDetectUpdateSubItemConflict, +} from './detectUpdateSubItemConflict'; +import { ruleFactory } from '@packmind/standards/test'; +import { skillFileFactory } from '@packmind/skills/test'; + +describe('detectUpdateSubItemConflict', () => { + let detectAddSubItemConflict: ConflictDetector<ChangeProposalType.updateRule>; + let changeProposal: ChangeProposal<ChangeProposalType.updateRule>; + let diffService: jest.Mocked<DiffService>; + + const ruleId = createRuleId('rule-id'); + const payload: ChangeProposalPayload<ChangeProposalType.updateRule> = { + targetId: ruleId, + newValue: 'The old rule', + oldValue: 'The new rule', + }; + + beforeEach(() => { + changeProposal = changeProposalFactory({ + type: ChangeProposalType.updateRule, + payload, + }) as ChangeProposal<ChangeProposalType.updateRule>; + diffService = {} as jest.Mocked<DiffService>; + detectAddSubItemConflict = makeDetectUpdateSubItemConflict({}); + }); + + it('returns false if both proposals have the same id', () => { + expect( + detectAddSubItemConflict( + changeProposal, + changeProposalFactory({ + id: changeProposal.id, + }), + diffService, + ), + ).toBe(false); + }); + + it('returns false if both proposals do not target the same artefact', () => { + expect( + detectAddSubItemConflict( + changeProposal, + changeProposalFactory({ + artefactId: createStandardId('another-id'), + }), + diffService, + ), + ).toBe(false); + }); + + it('returns false if second proposal has the same type but targets another sub item', () => { + expect( + detectUpdateRuleConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload: { + targetId: createRuleId('another-rule-id'), + oldValue: 'whatever', + newValue: 'some new value', + }, + }), + diffService, + ), + ).toBe(false); + }); +}); + +describe('detectUpdateRuleConflict', () => { + let changeProposal: ChangeProposal<ChangeProposalType.updateRule>; + let diffService: jest.Mocked<DiffService>; + const ruleId = createRuleId('rule-id'); + const payload: ChangeProposalPayload<ChangeProposalType.updateRule> = { + targetId: ruleId, + newValue: 'The old rule', + oldValue: 'The new rule', + }; + + beforeEach(() => { + changeProposal = changeProposalFactory({ + type: ChangeProposalType.updateRule, + payload, + }) as ChangeProposal<ChangeProposalType.updateRule>; + diffService = {} as jest.Mocked<DiffService>; + }); + + describe('when the second proposal also update a rule', () => { + let changeProposal2: ChangeProposal<ChangeProposalType.updateRule>; + + beforeEach(() => { + changeProposal2 = changeProposalFactory({ + type: ChangeProposalType.updateRule, + artefactId: changeProposal.artefactId, + }) as ChangeProposal<ChangeProposalType.updateRule>; + }); + + it('returns false if second proposal targets the same rule with the same content', () => { + expect( + detectUpdateRuleConflict( + changeProposal, + { + ...changeProposal2, + payload, + }, + diffService, + ), + ).toBe(false); + }); + + it('returns true if second proposal targets the same rule with the another content', () => { + expect( + detectUpdateRuleConflict( + changeProposal, + { + ...changeProposal2, + payload: { + ...payload, + newValue: 'Some brand new content', + }, + }, + diffService, + ), + ).toBe(true); + }); + }); + + describe('when second proposal adds a rule', () => { + it('returns false if added rule has a different content', () => { + expect( + detectUpdateRuleConflict( + changeProposal, + changeProposalFactory({ + type: ChangeProposalType.addRule, + artefactId: changeProposal.artefactId, + payload: { + item: { + content: 'Some brand new value', + }, + }, + }), + diffService, + ), + ).toBe(false); + }); + + it('returns true if added rule has the same content', () => { + expect( + detectUpdateRuleConflict( + changeProposal, + changeProposalFactory({ + type: ChangeProposalType.addRule, + artefactId: changeProposal.artefactId, + payload: { + item: { + content: payload.newValue, + }, + }, + }), + diffService, + ), + ).toBe(true); + }); + }); + + describe('when second proposal removes a rule', () => { + it('returns false if another rule is deleted', () => { + const secondRule = ruleFactory(); + expect( + detectUpdateRuleConflict( + changeProposal, + changeProposalFactory({ + artefactId: changeProposal.artefactId, + type: ChangeProposalType.deleteRule, + payload: { + targetId: secondRule.id, + item: secondRule, + }, + }), + diffService, + ), + ).toBe(false); + }); + + it('returns true if the same rule is deleted', () => { + expect( + detectUpdateRuleConflict( + changeProposal, + changeProposalFactory({ + artefactId: changeProposal.artefactId, + type: ChangeProposalType.deleteRule, + payload: { + targetId: changeProposal.payload.targetId, + item: ruleFactory({ id: changeProposal.payload.targetId }), + }, + }), + diffService, + ), + ).toBe(true); + }); + }); +}); + +describe('detectUpdateSkillFileContentConflict', () => { + let changeProposal: ChangeProposal<ChangeProposalType.updateSkillFileContent>; + let diffService: jest.Mocked<DiffService>; + const skillFileId = createSkillFileId('skill-file-id'); + const payload: ChangeProposalPayload<ChangeProposalType.updateSkillFileContent> = + { + targetId: skillFileId, + newValue: 'The old content', + oldValue: 'ome new content', + }; + + beforeEach(() => { + changeProposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFileContent, + payload, + }) as ChangeProposal<ChangeProposalType.updateSkillFileContent>; + diffService = { + hasConflict: jest.fn(), + applyLineDiff: jest.fn(), + }; + }); + + describe('when second proposal is also a updateSkillFileContent', () => { + let result: boolean; + + describe('when change proposal is base64', () => { + it('returns true', () => { + expect( + detectUpdateSkillFileContentConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload: { + ...payload, + isBase64: true, + }, + }), + diffService, + ), + ).toBe(true); + }); + }); + + describe('when change proposal is not base64', () => { + beforeEach(() => { + diffService.hasConflict.mockReturnValue(true); + + result = detectUpdateSkillFileContentConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload: { + ...payload, + newValue: 'Some brand new value', + }, + }), + diffService, + ); + }); + + it('uses the diff service to check for conflicts', () => { + expect(diffService.hasConflict).toHaveBeenCalledWith( + payload.oldValue, + payload.newValue, + 'Some brand new value', + ); + }); + + it('returns the results from the diffService', () => { + expect(result).toBe(true); + }); + }); + }); + + describe('when the second proposal is a deleteSkillFile', () => { + it('returns false if the deleted file is not the same file', () => { + const secondSkillFile = skillFileFactory(); + + expect( + detectUpdateSkillFileContentConflict( + changeProposal, + changeProposalFactory({ + type: ChangeProposalType.deleteSkillFile, + artefactId: changeProposal.artefactId, + payload: { + targetId: secondSkillFile.id, + item: secondSkillFile, + }, + }), + diffService, + ), + ).toBe(false); + }); + + it('returns true if the deleted file is the same file', () => { + expect( + detectUpdateSkillFileContentConflict( + changeProposal, + changeProposalFactory({ + type: ChangeProposalType.deleteSkillFile, + artefactId: changeProposal.artefactId, + payload: { + targetId: skillFileId, + item: skillFileFactory({ id: skillFileId }), + }, + }), + diffService, + ), + ).toBe(true); + }); + }); +}); + +describe('detectUpdateSkillFilePermissionsConflict', () => { + let changeProposal: ChangeProposal<ChangeProposalType.updateSkillFilePermissions>; + let diffService: jest.Mocked<DiffService>; + const skillFileId = createSkillFileId('skill-file-id'); + const payload: ChangeProposalPayload<ChangeProposalType.updateSkillFilePermissions> = + { + targetId: skillFileId, + newValue: '0755', + oldValue: '0700', + }; + + beforeEach(() => { + changeProposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFilePermissions, + payload, + }) as ChangeProposal<ChangeProposalType.updateSkillFilePermissions>; + diffService = {} as jest.Mocked<DiffService>; + }); + + describe('when the second proposal is also a updateSkillFilePermissions', () => { + it('return false if the new value is the same', () => { + expect( + detectUpdateSkillPermissionsContentConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload, + }), + diffService, + ), + ).toBe(false); + }); + + it('return true if the new value is different', () => { + expect( + detectUpdateSkillPermissionsContentConflict( + changeProposal, + changeProposalFactory({ + type: changeProposal.type, + artefactId: changeProposal.artefactId, + payload: { + ...payload, + newValue: '12345', + }, + }), + diffService, + ), + ).toBe(true); + }); + }); + + describe('when second proposal is a deleteSkillFile', () => { + it('returns false if deleted file is another skill file', () => { + const seconFile = skillFileFactory(); + expect( + detectUpdateSkillPermissionsContentConflict( + changeProposal, + changeProposalFactory({ + type: ChangeProposalType.deleteSkillFile, + artefactId: changeProposal.artefactId, + payload: { + targetId: seconFile.id, + item: seconFile, + }, + }), + diffService, + ), + ).toBe(false); + }); + + it('returns true if deleted file is the same file', () => { + expect( + detectUpdateSkillPermissionsContentConflict( + changeProposal, + changeProposalFactory({ + type: ChangeProposalType.deleteSkillFile, + artefactId: changeProposal.artefactId, + payload: { + targetId: payload.targetId, + item: skillFileFactory({ id: payload.targetId }), + }, + }), + diffService, + ), + ).toBe(true); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/detectUpdateSubItemConflict.ts b/packages/playbook-change-management/src/application/services/conflictDetection/detectUpdateSubItemConflict.ts new file mode 100644 index 000000000..7ac46c775 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/detectUpdateSubItemConflict.ts @@ -0,0 +1,75 @@ +import { ChangeProposalType } from '@packmind/types'; +import { ConflictDetector } from './ConflictDetector'; +import { sameProposal } from './sameProposal'; +import { sameArtefact } from './sameArtefact'; +import { sameType } from './sameType'; +import { isExpectedChangeProposalType } from '../../utils/isExpectedChangeProposalType'; +import { detectSingleLineConflict } from './detectSingleLineConflict'; +import { detectMultiLineConflict } from './detectMultiLineConflict'; +import { sameSubTarget } from './sameSubTarget'; + +type UpdateSubItemChangeProposals = + | ChangeProposalType.updateRule + | ChangeProposalType.updateSkillFileContent + | ChangeProposalType.updateSkillFilePermissions; + +export function makeDetectUpdateSubItemConflict< + T extends UpdateSubItemChangeProposals, +>( + conflictDetectorByType: Partial<{ + [K in ChangeProposalType]: ConflictDetector<T, K>; + }>, +): ConflictDetector<T> { + return (cp1, cp2, diffService) => { + if (sameProposal(cp1, cp2) || !sameArtefact(cp1, cp2)) return false; + + for (const key of Object.keys(conflictDetectorByType)) { + const expectedType = key as ChangeProposalType; + const conflictDetector = conflictDetectorByType[ + expectedType + ] as ConflictDetector<T, typeof expectedType>; + + if (sameType(cp1, cp2) && !sameSubTarget(cp1, cp2, diffService)) { + return false; + } + + if ( + conflictDetector && + isExpectedChangeProposalType(cp2, expectedType) && + conflictDetector(cp1, cp2, diffService) + ) { + return true; + } + } + + return false; + }; +} + +export const detectUpdateRuleConflict = + makeDetectUpdateSubItemConflict<ChangeProposalType.updateRule>({ + [ChangeProposalType.updateRule]: detectSingleLineConflict, + [ChangeProposalType.addRule]: (cp1, cp2) => { + return cp1.payload.newValue === cp2.payload.item.content; + }, + [ChangeProposalType.deleteRule]: sameSubTarget, + }); + +export const detectUpdateSkillFileContentConflict = + makeDetectUpdateSubItemConflict<ChangeProposalType.updateSkillFileContent>({ + [ChangeProposalType.updateSkillFileContent]: (cp1, cp2, diffService) => { + if (cp1.payload.isBase64 || cp2.payload.isBase64) { + return true; + } + return detectMultiLineConflict(cp1, cp2, diffService); + }, + [ChangeProposalType.deleteSkillFile]: sameSubTarget, + }); + +export const detectUpdateSkillPermissionsContentConflict = + makeDetectUpdateSubItemConflict<ChangeProposalType.updateSkillFilePermissions>( + { + [ChangeProposalType.updateSkillFilePermissions]: detectSingleLineConflict, + [ChangeProposalType.deleteSkillFile]: sameSubTarget, + }, + ); diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/getConflictDetector.ts b/packages/playbook-change-management/src/application/services/conflictDetection/getConflictDetector.ts new file mode 100644 index 000000000..eaa12c0e7 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/getConflictDetector.ts @@ -0,0 +1,61 @@ +import { ChangeProposal, ChangeProposalType } from '@packmind/types'; +import { detectSingleLineConflict } from './detectSingleLineConflict'; +import { ConflictDetector } from './ConflictDetector'; +import { detectMultiLineConflict } from './detectMultiLineConflict'; +import { + detectAddRuleConflict, + detectAddSkillFileConflict, +} from './detectAddSubItemConflict'; +import { + detectUpdateRuleConflict, + detectUpdateSkillFileContentConflict, + detectUpdateSkillPermissionsContentConflict, +} from './detectUpdateSubItemConflict'; +import { + detectDeleteRuleConflict, + detectDeleteSkillFileConflict, +} from './detectSubItemDeleteConflict'; +import { detectRemoveConflict } from './detectRemoveConflict'; +import { detectUpdateSkillAdditionalPropertyConflict } from './detectUpdateSkillAdditionalPropertyConflict'; + +type ConflictDetectorMap = { + [K in ChangeProposalType]: ConflictDetector<K>; +}; + +const conflictDetectors: ConflictDetectorMap = { + [ChangeProposalType.updateCommandName]: detectSingleLineConflict, + [ChangeProposalType.updateCommandDescription]: detectMultiLineConflict, + [ChangeProposalType.updateStandardName]: detectSingleLineConflict, + [ChangeProposalType.updateStandardDescription]: detectMultiLineConflict, + [ChangeProposalType.updateStandardScope]: detectSingleLineConflict, + [ChangeProposalType.addRule]: detectAddRuleConflict, + [ChangeProposalType.updateRule]: detectUpdateRuleConflict, + [ChangeProposalType.deleteRule]: detectDeleteRuleConflict, + [ChangeProposalType.updateSkillName]: detectSingleLineConflict, + [ChangeProposalType.updateSkillDescription]: detectMultiLineConflict, + [ChangeProposalType.updateSkillPrompt]: detectMultiLineConflict, + [ChangeProposalType.updateSkillMetadata]: detectSingleLineConflict, + [ChangeProposalType.updateSkillLicense]: detectSingleLineConflict, + [ChangeProposalType.updateSkillCompatibility]: detectSingleLineConflict, + [ChangeProposalType.updateSkillAllowedTools]: detectSingleLineConflict, + [ChangeProposalType.addSkillFile]: detectAddSkillFileConflict, + [ChangeProposalType.updateSkillFileContent]: + detectUpdateSkillFileContentConflict, + [ChangeProposalType.updateSkillFilePermissions]: + detectUpdateSkillPermissionsContentConflict, + [ChangeProposalType.deleteSkillFile]: detectDeleteSkillFileConflict, + [ChangeProposalType.updateSkillAdditionalProperty]: + detectUpdateSkillAdditionalPropertyConflict, + [ChangeProposalType.createStandard]: () => false, + [ChangeProposalType.createCommand]: () => false, + [ChangeProposalType.createSkill]: () => false, + [ChangeProposalType.removeStandard]: detectRemoveConflict, + [ChangeProposalType.removeCommand]: detectRemoveConflict, + [ChangeProposalType.removeSkill]: detectRemoveConflict, +}; + +export function getConflictDetector<T extends ChangeProposalType>( + changeProposal: ChangeProposal<T>, +): ConflictDetector<T> { + return conflictDetectors[changeProposal.type]; +} diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/sameArtefact.ts b/packages/playbook-change-management/src/application/services/conflictDetection/sameArtefact.ts new file mode 100644 index 000000000..76bf2c4aa --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/sameArtefact.ts @@ -0,0 +1,8 @@ +import { ChangeProposal } from '@packmind/types'; + +export function sameArtefact( + cp1: ChangeProposal, + cp2: ChangeProposal, +): boolean { + return cp1.artefactId === cp2.artefactId; +} diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/sameProposal.ts b/packages/playbook-change-management/src/application/services/conflictDetection/sameProposal.ts new file mode 100644 index 000000000..642970159 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/sameProposal.ts @@ -0,0 +1,8 @@ +import { ChangeProposal } from '@packmind/types'; + +export function sameProposal( + cp1: ChangeProposal, + cp2: ChangeProposal, +): boolean { + return cp1.id === cp2.id; +} diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/sameSubTarget.ts b/packages/playbook-change-management/src/application/services/conflictDetection/sameSubTarget.ts new file mode 100644 index 000000000..303ce1d20 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/sameSubTarget.ts @@ -0,0 +1,17 @@ +import { ChangeProposalType } from '@packmind/types'; +import { ConflictDetector } from './ConflictDetector'; + +type WithSubTargetTypes = + | ChangeProposalType.updateRule + | ChangeProposalType.deleteRule + | ChangeProposalType.updateSkillFileContent + | ChangeProposalType.updateSkillFilePermissions + | ChangeProposalType.deleteSkillFile + | ChangeProposalType.updateSkillAdditionalProperty; + +export const sameSubTarget: ConflictDetector< + WithSubTargetTypes, + WithSubTargetTypes +> = (cp1, cp2) => { + return cp1.payload.targetId === cp2.payload.targetId; +}; diff --git a/packages/playbook-change-management/src/application/services/conflictDetection/sameType.ts b/packages/playbook-change-management/src/application/services/conflictDetection/sameType.ts new file mode 100644 index 000000000..1391005e7 --- /dev/null +++ b/packages/playbook-change-management/src/application/services/conflictDetection/sameType.ts @@ -0,0 +1,9 @@ +import { ChangeProposal, ChangeProposalType } from '@packmind/types'; +import { isExpectedChangeProposalType } from '../../utils/isExpectedChangeProposalType'; + +export function sameType<T extends ChangeProposalType>( + cp1: ChangeProposal<T>, + cp2: ChangeProposal, +): cp2 is ChangeProposal<T> { + return isExpectedChangeProposalType(cp2, cp1.type); +} diff --git a/packages/playbook-change-management/src/application/services/index.ts b/packages/playbook-change-management/src/application/services/index.ts new file mode 100644 index 000000000..a8e16e0ec --- /dev/null +++ b/packages/playbook-change-management/src/application/services/index.ts @@ -0,0 +1,3 @@ +export { ChangeProposalService } from './ChangeProposalService'; +export { DiffService } from './DiffService'; +export { PlaybookChangeManagementServices } from './PlaybookChangeManagementServices'; diff --git a/packages/playbook-change-management/src/application/services/validateArtefactInSpace.ts b/packages/playbook-change-management/src/application/services/validateArtefactInSpace.ts new file mode 100644 index 000000000..ce23c244b --- /dev/null +++ b/packages/playbook-change-management/src/application/services/validateArtefactInSpace.ts @@ -0,0 +1,49 @@ +import { + IRecipesPort, + ISkillsPort, + IStandardsPort, + RecipeId, + SkillId, + SpaceId, + StandardId, +} from '@packmind/types'; +import { ArtefactNotFoundError } from '../../domain/errors/ArtefactNotFoundError'; +import { ArtefactNotInSpaceError } from '../../domain/errors/ArtefactNotInSpaceError'; + +export type ArtefactType = 'standard' | 'recipe' | 'skill'; + +export async function validateArtefactInSpace( + artefactId: StandardId | RecipeId | SkillId, + spaceId: SpaceId, + standardsPort: IStandardsPort, + recipesPort: IRecipesPort, + skillsPort: ISkillsPort, +): Promise<ArtefactType> { + const standard = await standardsPort.getStandard(artefactId as StandardId); + if (standard) { + if (standard.spaceId !== spaceId) { + throw new ArtefactNotInSpaceError(artefactId, spaceId); + } + return 'standard'; + } + + const recipe = await recipesPort.getRecipeByIdInternal( + artefactId as RecipeId, + ); + if (recipe) { + if (recipe.spaceId !== spaceId) { + throw new ArtefactNotInSpaceError(artefactId, spaceId); + } + return 'recipe'; + } + + const skill = await skillsPort.getSkill(artefactId as SkillId); + if (skill) { + if (skill.spaceId !== spaceId) { + throw new ArtefactNotInSpaceError(artefactId, spaceId); + } + return 'skill'; + } + + throw new ArtefactNotFoundError(artefactId); +} diff --git a/packages/playbook-change-management/src/application/useCases/applyChangeProposals/ApplyChangeProposalsUseCase.spec.ts b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/ApplyChangeProposalsUseCase.spec.ts new file mode 100644 index 000000000..c00481d0a --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/ApplyChangeProposalsUseCase.spec.ts @@ -0,0 +1,1586 @@ +import { + PackmindEventEmitterService, + SpaceMembershipRequiredError, +} from '@packmind/node-utils'; +import { stubLogger } from '@packmind/test-utils'; +import { SkillValidationError } from '@packmind/skills'; +import { + AcceptedChangeProposal, + ChangeProposal, + ChangeProposalDecision, + createChangeProposalId, + createOrganizationId, + createPackageId, + createRecipeId, + createRecipeVersionId, + createSkillId, + createSpaceId, + createStandardId, + createUserId, + IAccountsPort, + IDeploymentPort, + IRecipesPort, + ISkillsPort, + ISpacesPort, + IStandardsPort, + RecipeId, + ChangeProposalType, + StandardId, + SkillId, + ChangeProposalStatus, + UserSpaceRole, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { recipeFactory } from '@packmind/recipes/test/recipeFactory'; +import { recipeVersionFactory } from '@packmind/recipes/test/recipeVersionFactory'; +import { changeProposalFactory } from '../../../../test/changeProposalFactory'; +import { ApplyChangeProposalsUseCase } from './ApplyChangeProposalsUseCase'; +import { ChangeProposalService } from '../../services/ChangeProposalService'; +import { skillVersionFactory } from '@packmind/skills/test'; +import { standardVersionFactory } from '@packmind/standards/test'; + +jest.mock('@packmind/node-utils', () => ({ + ...jest.requireActual('@packmind/node-utils'), + SSEEventPublisher: { + publishChangeProposalUpdateEvent: jest.fn().mockResolvedValue(undefined), + }, +})); + +describe('ApplyChangeProposalsUseCase', () => { + const organizationId = createOrganizationId('organization-id'); + const userId = createUserId('user-id'); + const spaceId = createSpaceId('space-id'); + const recipeId = createRecipeId('recipe-id'); + + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + const organization = organizationFactory({ id: organizationId }); + const space = spaceFactory({ id: spaceId, organizationId }); + const recipe = recipeFactory({ id: recipeId, spaceId }); + + function toAcceptedProposal<T extends ChangeProposalType>( + baseProposal: ChangeProposal<T>, + decision?: ChangeProposalDecision<T>, + ): AcceptedChangeProposal<T> { + return { + ...baseProposal, + status: ChangeProposalStatus.applied, + decision: decision ?? (baseProposal.payload as ChangeProposalDecision<T>), + } as AcceptedChangeProposal<T>; + } + + let useCase: ApplyChangeProposalsUseCase<StandardId | RecipeId | SkillId>; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked<ISpacesPort>; + let standardsPort: jest.Mocked<IStandardsPort>; + let recipesPort: jest.Mocked<IRecipesPort>; + let skillsPort: jest.Mocked<ISkillsPort>; + let deploymentPort: jest.Mocked<IDeploymentPort>; + let changeProposalService: jest.Mocked<ChangeProposalService>; + let eventEmitterService: jest.Mocked<PackmindEventEmitterService>; + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn(), + getOrganizationById: jest.fn(), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort = { + getSpaceById: jest.fn(), + findMembership: jest.fn().mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + createdBy: userId, + updatedBy: userId, + }), + } as unknown as jest.Mocked<ISpacesPort>; + + standardsPort = { + getStandard: jest.fn(), + getLatestStandardVersion: jest.fn(), + getRulesByStandardId: jest.fn(), + updateStandard: jest.fn(), + deleteStandard: jest.fn(), + } as unknown as jest.Mocked<IStandardsPort>; + + recipesPort = { + getRecipeByIdInternal: jest.fn(), + updateRecipeFromUI: jest.fn(), + getRecipeVersion: jest.fn(), + deleteRecipe: jest.fn(), + } as unknown as jest.Mocked<IRecipesPort>; + + skillsPort = { + getSkill: jest.fn(), + getLatestSkillVersion: jest.fn(), + getSkillFiles: jest.fn(), + saveSkillVersion: jest.fn(), + deleteSkill: jest.fn(), + } as unknown as jest.Mocked<ISkillsPort>; + + deploymentPort = { + getPackageById: jest.fn(), + updatePackage: jest.fn(), + } as unknown as jest.Mocked<IDeploymentPort>; + + changeProposalService = { + findById: jest.fn(), + batchUpdateProposalsInTransaction: jest.fn(), + cancelPendingByArtefactId: jest.fn(), + } as unknown as jest.Mocked<ChangeProposalService>; + + eventEmitterService = { + emit: jest.fn(), + } as unknown as jest.Mocked<PackmindEventEmitterService>; + + useCase = new ApplyChangeProposalsUseCase( + spacesPort, + accountsPort, + standardsPort, + recipesPort, + skillsPort, + deploymentPort, + changeProposalService, + eventEmitterService, + stubLogger(), + ); + + accountsPort.getUserById.mockResolvedValue(user); + accountsPort.getOrganizationById.mockResolvedValue(organization); + spacesPort.getSpaceById.mockResolvedValue(space); + standardsPort.getStandard.mockResolvedValue(null); + recipesPort.getRecipeByIdInternal.mockResolvedValue(recipe); + skillsPort.getSkill.mockResolvedValue(null); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when applying recipe change proposals successfully', () => { + const changeProposal1 = changeProposalFactory({ + id: createChangeProposalId('cp-1'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + status: ChangeProposalStatus.pending, + payload: { oldValue: 'Test Recipe', newValue: 'Updated Recipe Name' }, + }); + const changeProposal2 = changeProposalFactory({ + id: createChangeProposalId('cp-2'), + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + spaceId, + status: ChangeProposalStatus.pending, + payload: { oldValue: 'Test content', newValue: 'Updated content' }, + }); + + const recipeVersionId = createRecipeVersionId('recipe-version-1'); + const recipeVersion = recipeVersionFactory({ + id: recipeVersionId, + recipeId, + version: 2, + content: 'Test content', + }); + + beforeEach(() => { + // Mock findById for initial validation (2 calls) + fresh validation (2 calls) + changeProposalService.findById + .mockResolvedValueOnce(changeProposal1) // Initial validation - cp-1 + .mockResolvedValueOnce(changeProposal2) // Initial validation - cp-2 + .mockResolvedValueOnce(changeProposal1) // Fresh validation - cp-1 + .mockResolvedValueOnce(changeProposal2); // Fresh validation - cp-2 + + recipesPort.updateRecipeFromUI.mockResolvedValue({ + recipe: { ...recipe, version: 2 }, + }); + + recipesPort.getRecipeVersion.mockResolvedValue(recipeVersion); + + changeProposalService.batchUpdateProposalsInTransaction.mockResolvedValue( + undefined, + ); + }); + + it('returns new recipe version ID', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [ + toAcceptedProposal(changeProposal1), + toAcceptedProposal(changeProposal2), + ], + rejected: [], + }); + + expect(result.newArtefactVersion).toEqual(recipeVersionId); + }); + + it('calls updateRecipeFromUI once', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [ + toAcceptedProposal(changeProposal1), + toAcceptedProposal(changeProposal2), + ], + rejected: [], + }); + + expect(recipesPort.updateRecipeFromUI).toHaveBeenCalledTimes(1); + }); + + it('calls batchUpdateProposalsInTransaction once', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [ + toAcceptedProposal(changeProposal1), + toAcceptedProposal(changeProposal2), + ], + rejected: [], + }); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).toHaveBeenCalledTimes(1); + }); + + it('calls batchUpdateProposalsInTransaction with accepted proposals', async () => { + const acceptedProposal1 = toAcceptedProposal(changeProposal1); + const acceptedProposal2 = toAcceptedProposal(changeProposal2); + + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [acceptedProposal1, acceptedProposal2], + rejected: [], + }); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).toHaveBeenCalledWith({ + acceptedProposals: [ + { proposal: acceptedProposal1, userId }, + { proposal: acceptedProposal2, userId }, + ], + rejectedProposals: [], + }); + }); + + it('tracks one change_proposal_accepted event per accepted proposal', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [ + toAcceptedProposal(changeProposal1), + toAcceptedProposal(changeProposal2), + ], + rejected: [], + }); + + expect(eventEmitterService.emit).toHaveBeenCalledTimes(2); + }); + + it('tracks change_proposal_accepted event with correct payload', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [ + toAcceptedProposal(changeProposal1), + toAcceptedProposal(changeProposal2), + ], + rejected: [], + }); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + itemType: 'command', + changeType: ChangeProposalType.updateCommandName, + }), + }), + ); + }); + }); + + describe('when rejecting recipe change proposals successfully', () => { + const changeProposal1 = changeProposalFactory({ + id: createChangeProposalId('cp-1'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + status: ChangeProposalStatus.pending, + }); + const changeProposal2 = changeProposalFactory({ + id: createChangeProposalId('cp-2'), + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + spaceId, + status: ChangeProposalStatus.pending, + }); + + const recipeVersionId = createRecipeVersionId('recipe-version-1'); + const recipeVersion = recipeVersionFactory({ + id: recipeVersionId, + recipeId, + version: 1, // Current version (no update) + }); + + beforeEach(() => { + // Mock findById for initial validation (2 calls) + fresh validation (2 calls) + changeProposalService.findById + .mockResolvedValueOnce(changeProposal1) // Initial validation - cp-1 + .mockResolvedValueOnce(changeProposal2) // Initial validation - cp-2 + .mockResolvedValueOnce(changeProposal1) // Fresh validation - cp-1 + .mockResolvedValueOnce(changeProposal2); // Fresh validation - cp-2 + + recipesPort.getRecipeVersion.mockResolvedValue(recipeVersion); + + changeProposalService.batchUpdateProposalsInTransaction.mockResolvedValue( + undefined, + ); + }); + + it('returns current recipe version ID', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [], + rejected: [changeProposal1.id, changeProposal2.id], + }); + + expect(result.newArtefactVersion).toEqual(recipeVersionId); + }); + + it('does not call updateRecipeFromUI', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [], + rejected: [changeProposal1.id, changeProposal2.id], + }); + + expect(recipesPort.updateRecipeFromUI).not.toHaveBeenCalled(); + }); + + it('calls batchUpdateProposalsInTransaction once', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [], + rejected: [changeProposal1.id, changeProposal2.id], + }); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).toHaveBeenCalledTimes(1); + }); + + it('calls batchUpdateProposalsInTransaction with rejected proposals', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [], + rejected: [changeProposal1.id, changeProposal2.id], + }); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).toHaveBeenCalledWith({ + acceptedProposals: [], + rejectedProposals: [ + { proposal: changeProposal1, userId }, + { proposal: changeProposal2, userId }, + ], + }); + }); + + it('tracks one change_proposal_rejected event per rejected proposal', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [], + rejected: [changeProposal1.id, changeProposal2.id], + }); + + expect(eventEmitterService.emit).toHaveBeenCalledTimes(2); + }); + + it('tracks change_proposal_rejected event with correct payload', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [], + rejected: [changeProposal1.id, changeProposal2.id], + }); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + itemType: 'command', + changeType: ChangeProposalType.updateCommandName, + }), + }), + ); + }); + }); + + describe('when applying standard change proposals', () => { + const standardId = createStandardId('standard-id'); + const changeProposal = changeProposalFactory({ + id: createChangeProposalId('cp-1'), + type: ChangeProposalType.addRule, + artefactId: standardId, + spaceId, + status: ChangeProposalStatus.pending, + }); + + const standardVersion = { + id: 'standard-version-id', + standardId, + name: 'Test Standard', + description: 'Test description', + version: 1, + slug: 'test-standard', + scope: null, + rules: [], + }; + + const updatedStandard = { + id: standardId, + name: standardVersion.name, + version: 2, + spaceId, + }; + + beforeEach(() => { + changeProposalService.findById.mockResolvedValue(changeProposal); + standardsPort.getStandard.mockResolvedValue({ + id: standardId, + spaceId, + } as never); + standardsPort.getLatestStandardVersion.mockResolvedValue( + standardVersion as never, + ); + standardsPort.getRulesByStandardId.mockResolvedValue([]); + standardsPort.updateStandard.mockResolvedValue(updatedStandard as never); + recipesPort.getRecipeByIdInternal.mockResolvedValue(null); + changeProposalService.batchUpdateProposalsInTransaction.mockResolvedValue(); + }); + + it('returns the new artefact version', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: standardId, + accepted: [toAcceptedProposal(changeProposal)], + rejected: [], + }); + + expect(result.newArtefactVersion).toBe(standardVersion.id); + }); + + it('calls updateStandard', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: standardId, + accepted: [toAcceptedProposal(changeProposal)], + rejected: [], + }); + + expect(standardsPort.updateStandard).toHaveBeenCalled(); + }); + + it('does not call updateRecipeFromUI', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: standardId, + accepted: [toAcceptedProposal(changeProposal)], + rejected: [], + }); + + expect(recipesPort.updateRecipeFromUI).not.toHaveBeenCalled(); + }); + }); + + describe('when applying skill change proposals', () => { + const skillId = createSkillId('skill-id'); + const changeProposal = changeProposalFactory({ + id: createChangeProposalId('cp-1'), + type: ChangeProposalType.updateSkillName, + artefactId: skillId, + payload: { + oldValue: 'the old value', + newValue: 'the new value', + }, + spaceId, + status: ChangeProposalStatus.pending, + }); + + const skillVersion = skillVersionFactory({ + skillId, + version: 1, + }); + + beforeEach(() => { + changeProposalService.findById.mockResolvedValue(changeProposal); + skillsPort.getSkill.mockResolvedValue({ + id: skillId, + spaceId, + } as never); + skillsPort.getLatestSkillVersion.mockResolvedValue(skillVersion); + skillsPort.getSkillFiles.mockResolvedValue([]); + skillsPort.saveSkillVersion.mockResolvedValue(skillVersion); + }); + + it('calls skillsPort.save skill with the updated version', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: skillId, + accepted: [toAcceptedProposal(changeProposal)], + rejected: [], + }); + expect(skillsPort.saveSkillVersion).toHaveBeenCalledWith({ + userId, + spaceId, + organizationId, + skillVersion: { + ...skillVersion, + files: [], + name: 'the new value', + }, + }); + }); + }); + + describe('when change proposal is not found', () => { + const changeProposalId = createChangeProposalId('cp-not-found'); + const changeProposal = changeProposalFactory({ + id: changeProposalId, + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + status: ChangeProposalStatus.pending, + payload: { oldValue: 'Test', newValue: 'Updated' }, + }); + + beforeEach(() => { + changeProposalService.findById.mockResolvedValue(null); + }); + + it('throws error for missing change proposal', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [toAcceptedProposal(changeProposal)], + rejected: [], + }), + ).rejects.toThrow(`Change proposal ${changeProposalId} not found`); + }); + }); + + describe('when change proposal does not belong to artefact', () => { + const differentRecipeId = createRecipeId('different-recipe'); + const changeProposal = changeProposalFactory({ + id: createChangeProposalId('cp-1'), + type: ChangeProposalType.updateCommandName, + artefactId: differentRecipeId, + spaceId, + status: ChangeProposalStatus.pending, + }); + + beforeEach(() => { + changeProposalService.findById.mockResolvedValue(changeProposal); + }); + + it('throws an error', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [toAcceptedProposal(changeProposal)], + rejected: [], + }), + ).rejects.toThrow( + `Change proposal ${changeProposal.id} does not belong to artefact ${recipeId}`, + ); + }); + }); + + describe('when change proposal is not pending', () => { + const changeProposal = changeProposalFactory({ + id: createChangeProposalId('cp-1'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + status: ChangeProposalStatus.applied, + }); + + beforeEach(() => { + changeProposalService.findById.mockResolvedValue(changeProposal); + }); + + it('throws an error', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [toAcceptedProposal(changeProposal)], + rejected: [], + }), + ).rejects.toThrow( + `Change proposal ${changeProposal.id} is not pending (status: applied)`, + ); + }); + }); + + describe('when the user is not a member of the space', () => { + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue(null); + }); + + it('throws a SpaceMembershipRequiredError', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [], + rejected: [], + }), + ).rejects.toThrow(SpaceMembershipRequiredError); + }); + }); + + describe('when artefact does not belong to space', () => { + const differentSpaceId = createSpaceId('different-space'); + + beforeEach(() => { + recipesPort.getRecipeByIdInternal.mockResolvedValue({ + ...recipe, + spaceId: differentSpaceId, + }); + }); + + it('throws an error', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [], + rejected: [], + }), + ).rejects.toThrow(); + }); + }); + + describe('when applying change proposals with conflicts', () => { + const changeProposal1 = changeProposalFactory({ + id: createChangeProposalId('cp-1'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + status: ChangeProposalStatus.pending, + payload: { oldValue: 'Old Name', newValue: 'New Name' }, + }); + const changeProposal2 = changeProposalFactory({ + id: createChangeProposalId('cp-2'), + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + spaceId, + status: ChangeProposalStatus.pending, + payload: { oldValue: 'Old description', newValue: 'Conflicting change' }, + }); + + beforeEach(() => { + // Only mock for initial validation since conflict will be detected before fresh validation + changeProposalService.findById + .mockResolvedValueOnce(changeProposal1) + .mockResolvedValueOnce(changeProposal2); + }); + + describe('when conflict detected', () => { + it('throws error', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [ + toAcceptedProposal(changeProposal1), + toAcceptedProposal(changeProposal2), + ], + rejected: [], + }), + ).rejects.toThrow(); + }); + + it('does not update recipe', async () => { + await useCase + .execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [ + toAcceptedProposal(changeProposal1), + toAcceptedProposal(changeProposal2), + ], + rejected: [], + }) + .catch(() => { + /* expected error */ + }); + + expect(recipesPort.updateRecipeFromUI).not.toHaveBeenCalled(); + }); + + it('does not call batchUpdateProposalsInTransaction', async () => { + await useCase + .execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [ + toAcceptedProposal(changeProposal1), + toAcceptedProposal(changeProposal2), + ], + rejected: [], + }) + .catch(() => { + /* expected error */ + }); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when no change proposals are provided', () => { + const recipeVersionId = createRecipeVersionId('recipe-version-1'); + const recipeVersion = recipeVersionFactory({ + id: recipeVersionId, + recipeId, + version: 1, + }); + + beforeEach(() => { + recipesPort.getRecipeVersion.mockResolvedValue(recipeVersion); + changeProposalService.batchUpdateProposalsInTransaction.mockResolvedValue( + undefined, + ); + }); + + it('returns current recipe version ID', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [], + rejected: [], + }); + + expect(result.newArtefactVersion).toEqual(recipeVersionId); + }); + + it('does not call updateRecipeFromUI', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [], + rejected: [], + }); + + expect(recipesPort.updateRecipeFromUI).not.toHaveBeenCalled(); + }); + + it('calls batchUpdateProposalsInTransaction with empty arrays', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [], + rejected: [], + }); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).toHaveBeenCalledWith({ + acceptedProposals: [], + rejectedProposals: [], + }); + }); + }); + + describe('when accepting a removeCommand proposal with delete decision', () => { + const removeProposal = changeProposalFactory({ + id: createChangeProposalId('cp-remove'), + type: ChangeProposalType.removeCommand, + artefactId: recipeId, + spaceId, + status: ChangeProposalStatus.pending, + payload: {}, + }); + + const recipeVersionId = createRecipeVersionId('recipe-version-1'); + const recipeVersion = recipeVersionFactory({ + id: recipeVersionId, + recipeId, + version: 1, + }); + + beforeEach(() => { + changeProposalService.findById + .mockResolvedValueOnce(removeProposal) + .mockResolvedValueOnce(removeProposal); + recipesPort.getRecipeVersion.mockResolvedValue(recipeVersion); + recipesPort.deleteRecipe.mockResolvedValue({}); + changeProposalService.cancelPendingByArtefactId.mockResolvedValue( + undefined, + ); + changeProposalService.batchUpdateProposalsInTransaction.mockResolvedValue( + undefined, + ); + }); + + it('calls deleteRecipe on the recipes port', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [toAcceptedProposal(removeProposal, { delete: true })], + rejected: [], + }); + + expect(recipesPort.deleteRecipe).toHaveBeenCalledWith( + expect.objectContaining({ recipeId }), + ); + }); + + it('does not call updateRecipeFromUI', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [toAcceptedProposal(removeProposal, { delete: true })], + rejected: [], + }); + + expect(recipesPort.updateRecipeFromUI).not.toHaveBeenCalled(); + }); + + it('returns artefactDeleted true', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [toAcceptedProposal(removeProposal, { delete: true })], + rejected: [], + }); + + expect(result.artefactDeleted).toBe(true); + }); + + it('cancels all other pending proposals for the artefact after deleting', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [toAcceptedProposal(removeProposal, { delete: true })], + rejected: [], + }); + + expect( + changeProposalService.cancelPendingByArtefactId, + ).toHaveBeenCalledWith(spaceId, recipeId, userId); + }); + }); + + describe('when accepting a removeCommand proposal with removeFromPackages decision', () => { + const packageId = createPackageId('pkg-1'); + const removeProposal = changeProposalFactory({ + id: createChangeProposalId('cp-remove'), + type: ChangeProposalType.removeCommand, + artefactId: recipeId, + spaceId, + status: ChangeProposalStatus.pending, + payload: {}, + }); + + const recipeVersionId = createRecipeVersionId('recipe-version-1'); + const recipeVersion = recipeVersionFactory({ + id: recipeVersionId, + recipeId, + version: 1, + }); + + const pkg = { + id: packageId, + name: 'My Package', + slug: 'my-package', + description: 'desc', + spaceId, + createdBy: userId, + recipes: [recipeId], + standards: [], + skills: [], + }; + + beforeEach(() => { + changeProposalService.findById + .mockResolvedValueOnce(removeProposal) + .mockResolvedValueOnce(removeProposal); + recipesPort.getRecipeVersion.mockResolvedValue(recipeVersion); + deploymentPort.getPackageById.mockResolvedValue({ package: pkg }); + deploymentPort.updatePackage.mockResolvedValue({ package: pkg }); + changeProposalService.batchUpdateProposalsInTransaction.mockResolvedValue( + undefined, + ); + }); + + it('does not call updateRecipeFromUI for a removal-only proposal', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [ + toAcceptedProposal(removeProposal, { + delete: false, + removeFromPackages: [packageId], + }), + ], + rejected: [], + }); + + expect(recipesPort.updateRecipeFromUI).not.toHaveBeenCalled(); + }); + + it('calls getPackageById for each package to remove from', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [ + toAcceptedProposal(removeProposal, { + delete: false, + removeFromPackages: [packageId], + }), + ], + rejected: [], + }); + + expect(deploymentPort.getPackageById).toHaveBeenCalledWith( + expect.objectContaining({ packageId }), + ); + }); + + it('calls updatePackage without the recipe ID', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [ + toAcceptedProposal(removeProposal, { + delete: false, + removeFromPackages: [packageId], + }), + ], + rejected: [], + }); + + expect(deploymentPort.updatePackage).toHaveBeenCalledWith( + expect.objectContaining({ + packageId, + recipeIds: [], + }), + ); + }); + + it('returns the updated package IDs', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [ + toAcceptedProposal(removeProposal, { + delete: false, + removeFromPackages: [packageId], + }), + ], + rejected: [], + }); + + expect(result.updatedPackages).toEqual([packageId]); + }); + }); + + describe('when accepting a removeStandard proposal with delete decision', () => { + const standardId = createStandardId('standard-to-delete'); + const removeProposal = changeProposalFactory({ + id: createChangeProposalId('cp-remove-std'), + type: ChangeProposalType.removeStandard, + artefactId: standardId, + spaceId, + status: ChangeProposalStatus.pending, + payload: {}, + }); + + const standardVersion = standardVersionFactory({ + standardId, + version: 1, + }); + + beforeEach(() => { + changeProposalService.findById + .mockResolvedValueOnce(removeProposal) + .mockResolvedValueOnce(removeProposal); + standardsPort.getStandard.mockResolvedValue({ + id: standardId, + spaceId, + } as never); + standardsPort.getLatestStandardVersion.mockResolvedValue( + standardVersion as never, + ); + standardsPort.getRulesByStandardId.mockResolvedValue([]); + standardsPort.deleteStandard.mockResolvedValue(undefined); + recipesPort.getRecipeByIdInternal.mockResolvedValue(null); + changeProposalService.cancelPendingByArtefactId.mockResolvedValue( + undefined, + ); + changeProposalService.batchUpdateProposalsInTransaction.mockResolvedValue( + undefined, + ); + }); + + it('calls deleteStandard on the standards port', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: standardId, + accepted: [toAcceptedProposal(removeProposal, { delete: true })], + rejected: [], + }); + + expect(standardsPort.deleteStandard).toHaveBeenCalledWith( + expect.objectContaining({ standardId }), + ); + }); + + it('does not call updateStandard', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: standardId, + accepted: [toAcceptedProposal(removeProposal, { delete: true })], + rejected: [], + }); + + expect(standardsPort.updateStandard).not.toHaveBeenCalled(); + }); + + it('returns artefactDeleted true', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: standardId, + accepted: [toAcceptedProposal(removeProposal, { delete: true })], + rejected: [], + }); + + expect(result.artefactDeleted).toBe(true); + }); + }); + + describe('when accepting a removeSkill proposal with delete decision', () => { + const skillId = createSkillId('skill-to-delete'); + const removeProposal = changeProposalFactory({ + id: createChangeProposalId('cp-remove-skill'), + type: ChangeProposalType.removeSkill, + artefactId: skillId, + spaceId, + status: ChangeProposalStatus.pending, + payload: {}, + }); + + const skillVersion = skillVersionFactory({ + skillId, + version: 1, + }); + + beforeEach(() => { + changeProposalService.findById + .mockResolvedValueOnce(removeProposal) + .mockResolvedValueOnce(removeProposal); + skillsPort.getSkill.mockResolvedValue({ + id: skillId, + spaceId, + } as never); + skillsPort.getLatestSkillVersion.mockResolvedValue(skillVersion); + skillsPort.getSkillFiles.mockResolvedValue([]); + skillsPort.deleteSkill.mockResolvedValue(undefined); + recipesPort.getRecipeByIdInternal.mockResolvedValue(null); + changeProposalService.cancelPendingByArtefactId.mockResolvedValue( + undefined, + ); + changeProposalService.batchUpdateProposalsInTransaction.mockResolvedValue( + undefined, + ); + }); + + it('calls deleteSkill on the skills port', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: skillId, + accepted: [toAcceptedProposal(removeProposal, { delete: true })], + rejected: [], + }); + + expect(skillsPort.deleteSkill).toHaveBeenCalledWith( + expect.objectContaining({ skillId }), + ); + }); + + it('does not call saveSkillVersion', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: skillId, + accepted: [toAcceptedProposal(removeProposal, { delete: true })], + rejected: [], + }); + + expect(skillsPort.saveSkillVersion).not.toHaveBeenCalled(); + }); + + it('returns artefactDeleted true', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: skillId, + accepted: [toAcceptedProposal(removeProposal, { delete: true })], + rejected: [], + }); + + expect(result.artefactDeleted).toBe(true); + }); + }); + + describe('when batchUpdateProposalsInTransaction fails after deleteArtefact', () => { + const removeProposal = changeProposalFactory({ + id: createChangeProposalId('cp-remove-fail'), + type: ChangeProposalType.removeCommand, + artefactId: recipeId, + spaceId, + status: ChangeProposalStatus.pending, + payload: {}, + }); + + const recipeVersionId = createRecipeVersionId('recipe-version-fail'); + const recipeVersion = recipeVersionFactory({ + id: recipeVersionId, + recipeId, + version: 1, + }); + + beforeEach(() => { + changeProposalService.findById + .mockResolvedValueOnce(removeProposal) + .mockResolvedValueOnce(removeProposal); + recipesPort.getRecipeVersion.mockResolvedValue(recipeVersion); + recipesPort.deleteRecipe.mockResolvedValue({}); + changeProposalService.cancelPendingByArtefactId.mockResolvedValue( + undefined, + ); + changeProposalService.batchUpdateProposalsInTransaction.mockRejectedValue( + new Error('DB connection lost'), + ); + }); + + it('rejects with failed to mark change proposals', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [toAcceptedProposal(removeProposal, { delete: true })], + rejected: [], + }), + ).rejects.toThrow('Failed to mark change proposals'); + }); + + it('still calls deleteRecipe before the batch update fails', async () => { + await useCase + .execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [toAcceptedProposal(removeProposal, { delete: true })], + rejected: [], + }) + .catch(() => { + /* expected error */ + }); + + expect(recipesPort.deleteRecipe).toHaveBeenCalledTimes(1); + }); + }); + + describe('when accepting a removeCommand proposal with empty removeFromPackages', () => { + const removeProposal = changeProposalFactory({ + id: createChangeProposalId('cp-remove-empty-pkg'), + type: ChangeProposalType.removeCommand, + artefactId: recipeId, + spaceId, + status: ChangeProposalStatus.pending, + payload: {}, + }); + + const recipeVersionId = createRecipeVersionId('recipe-version-empty-pkg'); + const recipeVersion = recipeVersionFactory({ + id: recipeVersionId, + recipeId, + version: 1, + }); + + beforeEach(() => { + changeProposalService.findById + .mockResolvedValueOnce(removeProposal) + .mockResolvedValueOnce(removeProposal); + recipesPort.getRecipeVersion.mockResolvedValue(recipeVersion); + changeProposalService.batchUpdateProposalsInTransaction.mockResolvedValue( + undefined, + ); + }); + + it('does not call getPackageById', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [ + toAcceptedProposal(removeProposal, { + delete: false, + removeFromPackages: [], + }), + ], + rejected: [], + }); + + expect(deploymentPort.getPackageById).not.toHaveBeenCalled(); + }); + + it('returns updatedPackages as undefined', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: [ + toAcceptedProposal(removeProposal, { + delete: false, + removeFromPackages: [], + }), + ], + rejected: [], + }); + + expect(result.updatedPackages).toBeUndefined(); + }); + }); + + describe('when accepting a removeStandard proposal with removeFromPackages decision', () => { + const standardId = createStandardId('standard-remove-pkg'); + const stdPackageId = createPackageId('std-pkg-1'); + const removeProposal = changeProposalFactory({ + id: createChangeProposalId('cp-remove-std-pkg'), + type: ChangeProposalType.removeStandard, + artefactId: standardId, + spaceId, + status: ChangeProposalStatus.pending, + payload: {}, + }); + + const standardVersion = standardVersionFactory({ + standardId, + version: 1, + }); + + const pkg = { + id: stdPackageId, + name: 'Std Package', + slug: 'std-package', + description: 'desc', + spaceId, + createdBy: userId, + recipes: [], + standards: [standardId], + skills: [], + }; + + beforeEach(() => { + changeProposalService.findById + .mockResolvedValueOnce(removeProposal) + .mockResolvedValueOnce(removeProposal); + standardsPort.getStandard.mockResolvedValue({ + id: standardId, + spaceId, + } as never); + standardsPort.getLatestStandardVersion.mockResolvedValue( + standardVersion as never, + ); + standardsPort.getRulesByStandardId.mockResolvedValue([]); + recipesPort.getRecipeByIdInternal.mockResolvedValue(null); + deploymentPort.getPackageById.mockResolvedValue({ package: pkg }); + deploymentPort.updatePackage.mockResolvedValue({ package: pkg }); + changeProposalService.batchUpdateProposalsInTransaction.mockResolvedValue( + undefined, + ); + }); + + it('calls updatePackage with the standard removed', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: standardId, + accepted: [ + toAcceptedProposal(removeProposal, { + delete: false, + removeFromPackages: [stdPackageId], + }), + ], + rejected: [], + }); + + expect(deploymentPort.updatePackage).toHaveBeenCalledWith( + expect.objectContaining({ + packageId: stdPackageId, + standardIds: [], + }), + ); + }); + + it('returns the updated package IDs', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: standardId, + accepted: [ + toAcceptedProposal(removeProposal, { + delete: false, + removeFromPackages: [stdPackageId], + }), + ], + rejected: [], + }); + + expect(result.updatedPackages).toEqual([stdPackageId]); + }); + }); + + describe('when accepting an updateSkillDescription proposal with newValue > 1024 chars', () => { + const skillId = createSkillId('legacy-skill-id'); + const oversizedDescription = 'a'.repeat(1025); + const updateDescriptionProposal = changeProposalFactory({ + id: createChangeProposalId('cp-oversize-desc'), + type: ChangeProposalType.updateSkillDescription, + artefactId: skillId, + payload: { + oldValue: 'previous description', + newValue: oversizedDescription, + }, + spaceId, + status: ChangeProposalStatus.pending, + }); + + beforeEach(() => { + changeProposalService.findById.mockResolvedValue( + updateDescriptionProposal, + ); + skillsPort.getSkill.mockResolvedValue({ + id: skillId, + spaceId, + } as never); + skillsPort.getLatestSkillVersion.mockResolvedValue( + skillVersionFactory({ skillId, version: 1 }), + ); + skillsPort.getSkillFiles.mockResolvedValue([]); + }); + + it('rejects with an actionable error message before saving', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: skillId, + accepted: [toAcceptedProposal(updateDescriptionProposal)], + rejected: [], + }), + ).rejects.toThrow( + /description longer than 1024 characters\. Edit your skill and upload it again\./, + ); + }); + + it('does not save the skill version on validation failure', async () => { + await useCase + .execute({ + userId, + organizationId, + spaceId, + artefactId: skillId, + accepted: [toAcceptedProposal(updateDescriptionProposal)], + rejected: [], + }) + .catch(() => undefined); + + expect(skillsPort.saveSkillVersion).not.toHaveBeenCalled(); + }); + + it('rethrows a SkillValidationError so the controller can map it to 400', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: skillId, + accepted: [toAcceptedProposal(updateDescriptionProposal)], + rejected: [], + }), + ).rejects.toBeInstanceOf(SkillValidationError); + }); + }); + + describe('when accepting an updateSkillName on a legacy skill whose existing description exceeds 1024 chars', () => { + const skillId = createSkillId('legacy-skill-id-2'); + const legacyOversizedDescription = 'l'.repeat(1500); + const updateNameProposal = changeProposalFactory({ + id: createChangeProposalId('cp-update-name'), + type: ChangeProposalType.updateSkillName, + artefactId: skillId, + payload: { + oldValue: 'old name', + newValue: 'new name', + }, + spaceId, + status: ChangeProposalStatus.pending, + }); + + const legacySkillVersion = skillVersionFactory({ + skillId, + version: 1, + description: legacyOversizedDescription, + }); + + beforeEach(() => { + changeProposalService.findById.mockResolvedValue(updateNameProposal); + skillsPort.getSkill.mockResolvedValue({ + id: skillId, + spaceId, + } as never); + skillsPort.getLatestSkillVersion.mockResolvedValue(legacySkillVersion); + skillsPort.getSkillFiles.mockResolvedValue([]); + skillsPort.saveSkillVersion.mockResolvedValue(legacySkillVersion); + }); + + it('resolves without flagging the legacy description', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: skillId, + accepted: [toAcceptedProposal(updateNameProposal)], + rejected: [], + }), + ).resolves.toBeDefined(); + }); + + it('saves the skill version for the name update', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + artefactId: skillId, + accepted: [toAcceptedProposal(updateNameProposal)], + rejected: [], + }); + + expect(skillsPort.saveSkillVersion).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/useCases/applyChangeProposals/ApplyChangeProposalsUseCase.ts b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/ApplyChangeProposalsUseCase.ts new file mode 100644 index 000000000..dd181a0f9 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/ApplyChangeProposalsUseCase.ts @@ -0,0 +1,443 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + AbstractSpaceMemberUseCase, + SpaceMemberContext, + PackmindEventEmitterService, + SSEEventPublisher, +} from '@packmind/node-utils'; +import { + ApplyChangeProposalsCommand, + ApplyChangeProposalsResponse, + ArtefactVersionId, + ChangeProposal, + ChangeProposalAcceptedEvent, + ChangeProposalId, + ChangeProposalRejectedEvent, + isChangeProposalEdited, + ChangeProposalStatus, + ChangeProposalType, + createOrganizationId, + createUserId, + getItemTypeFromChangeProposalType, + IAccountsPort, + IApplyChangeProposalsUseCase, + IDeploymentPort, + IRecipesPort, + ISkillsPort, + ISpacesPort, + IStandardsPort, + PackageId, + RecipeId, + ScalarUpdatePayload, + SkillId, + StandardId, + UserId, +} from '@packmind/types'; +import { DESCRIPTION_MAX_LENGTH, SkillValidationError } from '@packmind/skills'; +import { ChangeProposalService } from '../../services/ChangeProposalService'; +import { validateArtefactInSpace } from '../../services/validateArtefactInSpace'; +import { DiffService } from '@packmind/types'; +import { CommandChangesApplier } from './shared/CommandChangesApplier'; +import { + IChangesProposalApplier, + ObjectVersions, +} from './IChangesProposalApplier'; +import { SkillChangesApplier } from './shared/SkillChangesApplier'; +import { StandardChangesApplier } from './shared/StandardChangesApplier'; + +const origin = 'ApplyChangeProposalsUseCase'; +export class ApplyChangeProposalsUseCase< + T extends StandardId | RecipeId | SkillId, +> + extends AbstractSpaceMemberUseCase< + ApplyChangeProposalsCommand<T>, + ApplyChangeProposalsResponse<T> + > + implements IApplyChangeProposalsUseCase<T> +{ + private readonly diffService: DiffService; + + constructor( + spacesPort: ISpacesPort, + accountsPort: IAccountsPort, + private readonly standardsPort: IStandardsPort, + private readonly recipesPort: IRecipesPort, + private readonly skillsPort: ISkillsPort, + private readonly deploymentPort: IDeploymentPort, + private readonly changeProposalService: ChangeProposalService, + private readonly eventEmitterService: PackmindEventEmitterService, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(spacesPort, accountsPort, logger); + this.diffService = new DiffService(); + } + + async executeForSpaceMembers( + command: ApplyChangeProposalsCommand<T> & SpaceMemberContext, + ): Promise<ApplyChangeProposalsResponse<T>> { + await validateArtefactInSpace( + command.artefactId, + command.spaceId, + this.standardsPort, + this.recipesPort, + this.skillsPort, + ); + + const acceptedIds = command.accepted.map((p) => p.id); + const allChangeProposalIds = [...acceptedIds, ...command.rejected]; + + // Fetch all proposals + const changeProposals = await Promise.all( + allChangeProposalIds.map((id) => this.changeProposalService.findById(id)), + ); + + this.assertAllProposalsValid( + changeProposals, + allChangeProposalIds, + command.artefactId, + ); + + const proposalMap = new Map( + changeProposals.map((p) => [p?.id, p] as const), + ); + + // Validate that proposals from command match those in DB + for (const acceptedProposal of command.accepted) { + const dbProposal = proposalMap.get(acceptedProposal.id); + if (!dbProposal) { + throw new Error( + `Change proposal ${acceptedProposal.id} not found in database`, + ); + } + this.assertProposalMatchesDatabase(acceptedProposal, dbProposal); + } + + // Reject legacy proposals (typically submitted via an older CLI) whose + // updateSkillDescription payload exceeds the current 1024-char cap. + // Scoped to updateSkillDescription.newValue: a skill with a legacy + // oversized description must still be editable on other fields. + try { + for (const acceptedProposal of command.accepted) { + if ( + acceptedProposal.type !== ChangeProposalType.updateSkillDescription + ) { + continue; + } + const payload = acceptedProposal.payload as ScalarUpdatePayload; + if (payload.newValue.length > DESCRIPTION_MAX_LENGTH) { + throw new SkillValidationError([ + { + field: 'description', + message: `description must not exceed ${DESCRIPTION_MAX_LENGTH} characters`, + }, + ]); + } + } + } catch (error) { + if (error instanceof SkillValidationError) { + const descriptionError = error.errors.find( + (e) => e.field === 'description', + ); + error.message = descriptionError + ? `A submitted skill has a description longer than ${DESCRIPTION_MAX_LENGTH} characters. Edit your skill and upload it again.` + : `A submitted skill is invalid: ${error.errors + .map((e) => e.message) + .join('; ')}. Edit your skill and upload it again.`; + } + throw error; + } + + const changesApplier = this.getApplier(changeProposals); + const currentVersion = await changesApplier.getVersion(command.artefactId); + + const changeProposalsToApply = command.accepted.map((acceptedProposal) => { + const proposal = proposalMap.get(acceptedProposal.id); + + if (!proposal) { + throw new Error( + `Change proposal ${acceptedProposal.id} not found in database`, + ); + } + return acceptedProposal; + }); + + // Re-validate that all proposals are still pending (fresh from DB) + // This prevents race conditions where proposals were already processed + const freshProposals = await Promise.all( + allChangeProposalIds.map((id) => this.changeProposalService.findById(id)), + ); + + for (let i = 0; i < freshProposals.length; i++) { + const proposal = freshProposals[i]; + const proposalId = allChangeProposalIds[i]; + + if (!proposal) { + throw new Error(`Change proposal ${proposalId} not found`); + } + + if (proposal.status !== ChangeProposalStatus.pending) { + throw new Error( + `Change proposal ${proposal.id} is not pending (status: ${proposal.status}). It may have been already processed in a previous attempt.`, + ); + } + } + + const appliedChangesProposalsResponse = changesApplier.applyChangeProposals( + currentVersion, + changeProposalsToApply, + ); + + const userId = createUserId(command.userId); + const organizationId = createOrganizationId(command.organizationId); + let newVersion = currentVersion; + let artefactDeleted = false; + const updatedPackages: PackageId[] = []; + + if (appliedChangesProposalsResponse.delete) { + await changesApplier.deleteArtefact( + currentVersion, + userId, + command.spaceId, + organizationId, + ); + try { + await this.changeProposalService.cancelPendingByArtefactId( + command.spaceId, + command.artefactId, + userId, + ); + } catch (error) { + this.logger.warn( + 'Failed to cancel pending proposals after artefact deletion — orphaned proposals may remain', + { + artefactId: command.artefactId, + spaceId: command.spaceId, + error: error instanceof Error ? error.message : String(error), + }, + ); + } + artefactDeleted = true; + } else { + const REMOVAL_PROPOSAL_TYPES = [ + ChangeProposalType.removeCommand, + ChangeProposalType.removeStandard, + ChangeProposalType.removeSkill, + ] as const; + + const hasContentChanges = changeProposalsToApply.some( + (p) => + !REMOVAL_PROPOSAL_TYPES.includes( + p.type as (typeof REMOVAL_PROPOSAL_TYPES)[number], + ), + ); + + if (hasContentChanges) { + newVersion = await changesApplier.saveNewVersion( + appliedChangesProposalsResponse.version, + userId, + command.spaceId, + organizationId, + ); + } + + for (const packageId of appliedChangesProposalsResponse.removeFromPackages) { + const { package: pkg } = await this.deploymentPort.getPackageById({ + packageId, + organizationId, + spaceId: command.spaceId, + userId, + }); + const artefactIds = + changesApplier.getUpdatePackageCommandWithoutArtefact( + currentVersion, + pkg, + ); + await this.deploymentPort.updatePackage({ + packageId, + spaceId: pkg.spaceId, + name: pkg.name, + description: pkg.description, + ...artefactIds, + userId, + organizationId, + }); + updatedPackages.push(packageId); + } + } + + // 3. Mark all proposals as applied/rejected in a single transaction + // This ensures atomicity - if any proposal update fails, all are rolled back + const acceptedProposals = command.accepted.map((proposal) => ({ + proposal, + userId: command.userId as UserId, + })); + + const rejectedProposals = command.rejected + .map((id) => { + const proposal = changeProposals.find((p) => p?.id === id); + return proposal ? { proposal, userId: command.userId as UserId } : null; + }) + .filter( + ( + item, + ): item is { + proposal: ChangeProposal<ChangeProposalType>; + userId: UserId; + } => item !== null, + ); + + try { + await this.changeProposalService.batchUpdateProposalsInTransaction({ + acceptedProposals, + rejectedProposals, + }); + } catch (error) { + this.logger.error( + 'Failed to mark change proposals - artefact was modified but proposals remain pending', + { + artefactDeleted, + ...(artefactDeleted + ? {} + : { newVersionId: newVersion.id, newVersion: newVersion.version }), + error: error instanceof Error ? error.message : String(error), + }, + ); + throw new Error( + artefactDeleted + ? 'Failed to mark change proposals. The artefact was deleted but proposal statuses could not be updated. Please contact support.' + : 'Failed to mark change proposals. The artefact was updated successfully, but the proposal statuses could not be changed. Please try again.', + ); + } + + this.logger.info('Applied change proposals', { + artefactId: command.artefactId, + spaceId: command.spaceId, + accepted: command.accepted.length, + rejected: command.rejected.length, + artefactDeleted, + newVersion: artefactDeleted ? undefined : newVersion.id, + }); + + SSEEventPublisher.publishChangeProposalUpdateEvent( + command.organization.id, + command.spaceId, + ).catch((error) => { + this.logger.error('Failed to publish change proposal update SSE event', { + organizationId: command.organization.id, + spaceId: command.spaceId, + error: error instanceof Error ? error.message : String(error), + }); + }); + + for (const { proposal } of acceptedProposals) { + this.eventEmitterService.emit( + new ChangeProposalAcceptedEvent({ + userId: createUserId(command.userId), + organizationId: command.organization.id, + source: command.source ?? 'ui', + changeProposalId: proposal.id, + itemType: getItemTypeFromChangeProposalType(proposal.type), + itemId: String(proposal.artefactId ?? ''), + changeType: proposal.type, + edited: isChangeProposalEdited( + proposal.type, + proposal.decision, + proposal.payload, + ), + }), + ); + } + + for (const { proposal } of rejectedProposals) { + this.eventEmitterService.emit( + new ChangeProposalRejectedEvent({ + userId: createUserId(command.userId), + organizationId: command.organization.id, + source: command.source ?? 'ui', + changeProposalId: proposal.id, + itemType: getItemTypeFromChangeProposalType(proposal.type), + itemId: String(proposal.artefactId ?? ''), + changeType: proposal.type, + }), + ); + } + + return { + newArtefactVersion: artefactDeleted + ? undefined + : (newVersion.id as ArtefactVersionId<T>), + updatedPackages: updatedPackages.length ? updatedPackages : undefined, + artefactDeleted: artefactDeleted || undefined, + }; + } + + private assertAllProposalsValid( + changeProposals: (ChangeProposal | null)[], + allChangeProposalIds: ChangeProposalId[], + artefactId: T, + ): asserts changeProposals is ChangeProposal[] { + for (let i = 0; i < changeProposals.length; i++) { + const proposal = changeProposals[i]; + const proposalId = allChangeProposalIds[i]; + + if (!proposal) { + throw new Error(`Change proposal ${proposalId} not found`); + } + + if (proposal.artefactId !== artefactId) { + throw new Error( + `Change proposal ${proposal.id} does not belong to artefact ${artefactId}`, + ); + } + + if (proposal.status !== ChangeProposalStatus.pending) { + throw new Error( + `Change proposal ${proposal.id} is not pending (status: ${proposal.status})`, + ); + } + } + } + + private getApplier<V extends ObjectVersions>( + changeProposals: ChangeProposal[], + ): IChangesProposalApplier<V> { + const appliers: IChangesProposalApplier<ObjectVersions>[] = [ + new CommandChangesApplier(this.diffService, this.recipesPort), + new SkillChangesApplier(this.diffService, this.skillsPort), + new StandardChangesApplier(this.diffService, this.standardsPort), + ]; + + for (const applier of appliers) { + if (applier.areChangesApplicable(changeProposals)) { + return applier as IChangesProposalApplier<V>; + } + } + + const changeProposalTypes = Array.from( + new Set(changeProposals.map((cp) => cp.type)), + ).join(', '); + throw new Error( + `Unable to find a valid applier for changes: ${changeProposalTypes}`, + ); + } + + private assertProposalMatchesDatabase( + acceptedProposal: ChangeProposal<ChangeProposalType>, + dbProposal: ChangeProposal<ChangeProposalType>, + ): void { + if (acceptedProposal.type !== dbProposal.type) { + throw new Error( + `Change proposal ${acceptedProposal.id} type mismatch: expected ${dbProposal.type}, got ${acceptedProposal.type}`, + ); + } + + if ( + JSON.stringify(acceptedProposal.payload) !== + JSON.stringify(dbProposal.payload) + ) { + throw new Error( + `Change proposal ${acceptedProposal.id} payload mismatch`, + ); + } + } +} diff --git a/packages/playbook-change-management/src/application/useCases/applyChangeProposals/IChangesProposalApplier.ts b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/IChangesProposalApplier.ts new file mode 100644 index 000000000..2250a6500 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/IChangesProposalApplier.ts @@ -0,0 +1,58 @@ +import { + ApplierObjectVersions, + ApplyChangeProposalsResult, + ChangeProposal, + OrganizationId, + Package, + Recipe, + RecipeVersion, + Skill, + SkillVersionWithFiles, + SpaceId, + Standard, + StandardVersion, + UpdatePackageCommand, + UserId, +} from '@packmind/types'; + +export type ObjectVersions = ApplierObjectVersions; + +export type ObjectByVersion<T extends ObjectVersions> = T extends RecipeVersion + ? Recipe + : T extends SkillVersionWithFiles + ? Skill + : T extends StandardVersion + ? Standard + : never; + +export interface IChangesProposalApplier< + Version extends ApplierObjectVersions, +> { + areChangesApplicable(changeProposals: ChangeProposal[]): boolean; + + getVersion(artefactId: ObjectByVersion<Version>['id']): Promise<Version>; + + applyChangeProposals( + source: Version, + changeProposals: ChangeProposal[], + ): ApplyChangeProposalsResult<Version>; + + deleteArtefact( + source: Version, + userId: UserId, + spaceId: SpaceId, + organizationId: OrganizationId, + ): Promise<void>; + + getUpdatePackageCommandWithoutArtefact( + source: Version, + pkg: Package, + ): Pick<UpdatePackageCommand, 'recipeIds' | 'standardIds' | 'skillsIds'>; + + saveNewVersion( + version: Version, + userId: UserId, + spaceId: SpaceId, + organizationId: OrganizationId, + ): Promise<Version>; +} diff --git a/packages/playbook-change-management/src/application/useCases/applyChangeProposals/index.ts b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/index.ts new file mode 100644 index 000000000..ed0de4c6e --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/index.ts @@ -0,0 +1 @@ +export * from './ApplyChangeProposalsUseCase'; diff --git a/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/CommandChangesApplier.spec.ts b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/CommandChangesApplier.spec.ts new file mode 100644 index 000000000..729293164 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/CommandChangesApplier.spec.ts @@ -0,0 +1,277 @@ +import { + ChangeProposalType, + createChangeProposalId, + createOrganizationId, + createPackageId, + createRecipeId, + createSkillId, + createSpaceId, + createStandardId, + createUserId, + IRecipesPort, + RecipeVersion, + DiffService, + ChangeProposalConflictError, +} from '@packmind/types'; +import { CommandChangesApplier } from './CommandChangesApplier'; +import { recipeFactory, recipeVersionFactory } from '@packmind/recipes/test'; +import { changeProposalFactory } from '../../../../../test'; + +describe('CommandChangesApplier', () => { + let recipeVersion: RecipeVersion; + let diffService: DiffService; + let recipePort: jest.Mocked<IRecipesPort>; + let applier: CommandChangesApplier; + + beforeEach(() => { + recipeVersion = recipeVersionFactory({ + name: 'Original name', + content: 'Original content', + }); + diffService = new DiffService(); + recipePort = { + updateRecipeFromUI: jest.fn(), + getRecipeVersion: jest.fn(), + deleteRecipe: jest.fn(), + } as unknown as jest.Mocked<IRecipesPort>; + + applier = new CommandChangesApplier(diffService, recipePort); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('applyChangeProposal', () => { + describe('when updating the name', () => { + it('overrides the name with each proposal', () => { + const newVersion = applier.applyChangeProposals(recipeVersion, [ + changeProposalFactory({ + type: ChangeProposalType.updateCommandName, + artefactId: recipeVersion.recipeId, + artefactVersion: recipeVersion.version, + payload: { + oldValue: recipeVersion.name, + newValue: `Before: ${recipeVersion.name}`, + }, + }), + changeProposalFactory({ + type: ChangeProposalType.updateCommandName, + artefactId: recipeVersion.recipeId, + artefactVersion: recipeVersion.version, + payload: { + oldValue: recipeVersion.name, + newValue: `${recipeVersion.name} - after`, + }, + }), + ]); + + expect(newVersion.version).toEqual( + expect.objectContaining({ + name: `${recipeVersion.name} - after`, + content: recipeVersion.content, + }), + ); + }); + }); + + describe('when updating the content', () => { + it('uses the diff service to apply all changes', () => { + const newVersion = applier.applyChangeProposals(recipeVersion, [ + changeProposalFactory({ + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeVersion.recipeId, + artefactVersion: recipeVersion.version, + payload: { + oldValue: recipeVersion.content, + newValue: `Some content before\n${recipeVersion.content}`, + }, + }), + changeProposalFactory({ + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeVersion.recipeId, + artefactVersion: recipeVersion.version, + payload: { + oldValue: recipeVersion.content, + newValue: `${recipeVersion.content}\nSome content after`, + }, + }), + ]); + + expect(newVersion.version).toEqual( + expect.objectContaining({ + name: recipeVersion.name, + content: `Some content before\n${recipeVersion.content}\nSome content after`, + }), + ); + }); + + it('throws a ChangeProposalConflictError if applying the diff fails', () => { + expect(() => + applier.applyChangeProposals(recipeVersion, [ + changeProposalFactory({ + id: createChangeProposalId('proposal-1'), + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeVersion.recipeId, + artefactVersion: recipeVersion.version, + payload: { + oldValue: recipeVersion.content, + newValue: `---${recipeVersion.content}`, + }, + }), + changeProposalFactory({ + id: createChangeProposalId('proposal-2'), + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeVersion.recipeId, + artefactVersion: recipeVersion.version, + payload: { + oldValue: recipeVersion.content, + newValue: `${recipeVersion.content}---`, + }, + }), + ]), + ).toThrow( + new ChangeProposalConflictError(createChangeProposalId('proposal-2')), + ); + }); + }); + + describe('when one proposal has delete: true and another has removeFromPackages', () => { + const packageId = createPackageId('pkg-1'); + let result: ReturnType<typeof applier.applyChangeProposals>; + + beforeEach(() => { + const removeProposal = changeProposalFactory({ + type: ChangeProposalType.removeCommand, + decision: { delete: false, removeFromPackages: [packageId] }, + }); + const deleteProposal = changeProposalFactory({ + type: ChangeProposalType.removeCommand, + decision: { delete: true }, + }); + + result = applier.applyChangeProposals(recipeVersion, [ + removeProposal, + deleteProposal, + ]); + }); + + it('marks as delete', () => { + expect(result.delete).toBe(true); + }); + + it('clears removeFromPackages', () => { + expect(result.removeFromPackages).toEqual([]); + }); + }); + }); + + describe('saveNewVersion', () => { + const userId = createUserId('user-id'); + const spaceId = createSpaceId('space-id'); + const organizationId = createOrganizationId('organization-id'); + + let newVersion: RecipeVersion; + + beforeEach(async () => { + newVersion = { + ...recipeVersion, + version: recipeVersion.version + 1, + }; + + recipePort.getRecipeVersion.mockResolvedValue(newVersion); + recipePort.updateRecipeFromUI.mockResolvedValue({ + recipe: recipeFactory({ + id: recipeVersion.recipeId, + version: newVersion.version, + }), + }); + + await applier.saveNewVersion( + { + ...recipeVersion, + name: 'New name', + content: 'New content', + }, + userId, + spaceId, + organizationId, + ); + }); + + it('calls recipePort.updateRecipeFromUI with the correct data', async () => { + expect(recipePort.updateRecipeFromUI).toHaveBeenCalledWith({ + recipeId: recipeVersion.recipeId, + name: 'New name', + content: 'New content', + userId, + spaceId, + organizationId, + }); + }); + + it('uses recipesPort.getRecipeVersion to get the newly created version', async () => { + expect(recipePort.getRecipeVersion).toHaveBeenCalledWith( + recipeVersion.recipeId, + newVersion.version, + expect.arrayContaining([expect.any(String)]), + ); + }); + }); + + describe('deleteArtefact', () => { + const userId = createUserId('user-id'); + const spaceId = createSpaceId('space-id'); + const organizationId = createOrganizationId('organization-id'); + + beforeEach(() => { + recipePort.deleteRecipe.mockResolvedValue({}); + }); + + it('calls deleteRecipe with the recipe ID and auth context from source version', async () => { + await applier.deleteArtefact( + recipeVersion, + userId, + spaceId, + organizationId, + ); + + expect(recipePort.deleteRecipe).toHaveBeenCalledWith({ + recipeId: recipeVersion.recipeId, + spaceId, + userId, + organizationId, + }); + }); + }); + + describe('getUpdatePackageCommandWithoutArtefact', () => { + it('removes the recipe from the package recipe list', () => { + const otherRecipeId = createRecipeId('other-recipe'); + const standardId = createStandardId('std-1'); + const skillId = createSkillId('skill-1'); + const pkg = { + id: createPackageId('pkg-1'), + name: 'My Package', + slug: 'my-package', + description: 'desc', + spaceId: createSpaceId('space-1'), + createdBy: createUserId('user-1'), + recipes: [recipeVersion.recipeId, otherRecipeId], + standards: [standardId], + skills: [skillId], + }; + + const result = applier.getUpdatePackageCommandWithoutArtefact( + recipeVersion, + pkg, + ); + + expect(result).toEqual({ + recipeIds: [otherRecipeId], + standardIds: [standardId], + skillsIds: [skillId], + }); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/CommandChangesApplier.ts b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/CommandChangesApplier.ts new file mode 100644 index 000000000..c70395cc3 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/CommandChangesApplier.ts @@ -0,0 +1,102 @@ +import { + CommandChangeProposalApplier, + DiffService, + Package, + UpdatePackageCommand, +} from '@packmind/types'; +import { + IRecipesPort, + OrganizationId, + RecipeId, + RecipeVersion, + SpaceId, + UserId, +} from '@packmind/types'; +import { IChangesProposalApplier } from '../IChangesProposalApplier'; + +export class CommandChangesApplier + extends CommandChangeProposalApplier + implements IChangesProposalApplier<RecipeVersion> +{ + constructor( + diffService: DiffService, + private readonly recipesPort: IRecipesPort, + ) { + super(diffService); + } + + async deleteArtefact( + source: RecipeVersion, + userId: UserId, + spaceId: SpaceId, + organizationId: OrganizationId, + ): Promise<void> { + await this.recipesPort.deleteRecipe({ + userId, + spaceId, + organizationId, + recipeId: source.recipeId, + }); + } + + getUpdatePackageCommandWithoutArtefact( + source: RecipeVersion, + pkg: Package, + ): Pick<UpdatePackageCommand, 'recipeIds' | 'standardIds' | 'skillsIds'> { + return { + recipeIds: pkg.recipes.filter((recipeId) => recipeId !== source.recipeId), + standardIds: pkg.standards, + skillsIds: pkg.skills, + }; + } + + async getVersion(artefactId: RecipeId): Promise<RecipeVersion> { + const recipe = await this.recipesPort.getRecipeByIdInternal(artefactId); + if (!recipe) { + throw new Error(`Unable to find recipe matching id ${artefactId}`); + } + + const recipeVersion = await this.recipesPort.getRecipeVersion( + recipe.id, + recipe.version, + [recipe.spaceId], + ); + if (!recipeVersion) { + throw new Error( + `Unable to find recipeVersion #${recipe.version} for recipe ${artefactId}`, + ); + } + + return recipeVersion; + } + + async saveNewVersion( + version: RecipeVersion, + userId: UserId, + spaceId: SpaceId, + organizationId: OrganizationId, + ): Promise<RecipeVersion> { + const updateResult = await this.recipesPort.updateRecipeFromUI({ + name: version.name, + content: version.content, + recipeId: version.recipeId, + userId, + spaceId, + organizationId, + }); + + const newVersion = await this.recipesPort.getRecipeVersion( + updateResult.recipe.id, + updateResult.recipe.version, + [updateResult.recipe.spaceId], + ); + + if (!newVersion) { + throw new Error( + `Failed to retrieve recipe version ${updateResult.recipe.version} for recipe ${updateResult.recipe.id}`, + ); + } + + return newVersion; + } +} diff --git a/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/SkillChangesApplier.spec.ts b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/SkillChangesApplier.spec.ts new file mode 100644 index 000000000..dea22df64 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/SkillChangesApplier.spec.ts @@ -0,0 +1,917 @@ +import { skillVersionFactory, skillFileFactory } from '@packmind/skills/test'; +import { + ChangeProposalType, + createChangeProposalId, + createOrganizationId, + createPackageId, + createRecipeId, + createSkillFileId, + createSkillId, + createSkillVersionId, + createSpaceId, + createStandardId, + createUserId, + ISkillsPort, + DiffService, + ChangeProposalConflictError, +} from '@packmind/types'; +import { changeProposalFactory } from '../../../../../test'; +import { SkillChangesApplier } from './SkillChangesApplier'; + +describe('SkillChangesApplier', () => { + let applier: SkillChangesApplier; + let skillsPort: jest.Mocked<ISkillsPort>; + let diffService: DiffService; + + beforeEach(() => { + diffService = new DiffService(); + skillsPort = { + deleteSkill: jest.fn(), + getLatestSkillVersion: jest.fn(), + getSkillFiles: jest.fn(), + saveSkillVersion: jest.fn(), + } as unknown as jest.Mocked<ISkillsPort>; + + applier = new SkillChangesApplier(diffService, skillsPort); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const skillVersion = skillVersionFactory({ + name: 'some-skill', + description: 'A description of the skill', + prompt: 'Do things and stuff', + }); + + describe('applyChangeProposal', () => { + describe('when updating the skill name', () => { + const changeProposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateSkillName, + payload: { + oldValue: skillVersion.name, + newValue: `before--${skillVersion.name}`, + }, + }), + changeProposalFactory({ + type: ChangeProposalType.updateSkillName, + payload: { + oldValue: skillVersion.name, + newValue: `${skillVersion.name}--after`, + }, + }), + ]; + + it('overrides the skill name with each proposal', () => { + const newSkillVersion = applier.applyChangeProposals( + skillVersion, + changeProposals, + ); + + expect(newSkillVersion.version).toEqual({ + ...skillVersion, + name: `${skillVersion.name}--after`, + }); + }); + }); + + describe('when updating the skill description', () => { + const changeProposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateSkillDescription, + payload: { + oldValue: skillVersion.description, + newValue: `A line before:\n${skillVersion.description}`, + }, + }), + changeProposalFactory({ + type: ChangeProposalType.updateSkillDescription, + payload: { + oldValue: skillVersion.description, + newValue: `${skillVersion.description}\nA line after`, + }, + }), + ]; + + it('uses the diff service to apply the changes', () => { + const newVersion = applier.applyChangeProposals( + skillVersion, + changeProposals, + ); + + expect(newVersion.version).toEqual({ + ...skillVersion, + description: `A line before:\n${skillVersion.description}\nA line after`, + }); + }); + + it('throws a ChangeProposalConflictError if applying the diff fails', () => { + expect(() => + applier.applyChangeProposals(skillVersion, [ + changeProposalFactory({ + id: createChangeProposalId('proposal-1'), + type: ChangeProposalType.updateSkillDescription, + payload: { + oldValue: skillVersion.description, + newValue: `---${skillVersion.description}`, + }, + }), + changeProposalFactory({ + id: createChangeProposalId('proposal-2'), + type: ChangeProposalType.updateSkillDescription, + payload: { + oldValue: skillVersion.description, + newValue: `${skillVersion.description}---`, + }, + }), + ]), + ).toThrow( + new ChangeProposalConflictError(createChangeProposalId('proposal-2')), + ); + }); + }); + + describe('when updating the skill prompt', () => { + const changeProposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateSkillPrompt, + payload: { + oldValue: skillVersion.prompt, + newValue: `Before:\n${skillVersion.prompt}`, + }, + }), + changeProposalFactory({ + type: ChangeProposalType.updateSkillPrompt, + payload: { + oldValue: skillVersion.prompt, + newValue: `${skillVersion.prompt}\nAfter`, + }, + }), + ]; + + it('uses the diff service to apply the changes', () => { + const newVersion = applier.applyChangeProposals( + skillVersion, + changeProposals, + ); + + expect(newVersion.version).toEqual({ + ...skillVersion, + prompt: `Before:\n${skillVersion.prompt}\nAfter`, + }); + }); + + it('throws a ChangeProposalConflictError if applying the diff fails', () => { + expect(() => + applier.applyChangeProposals(skillVersion, [ + changeProposalFactory({ + id: createChangeProposalId('proposal-1'), + type: ChangeProposalType.updateSkillPrompt, + payload: { + oldValue: skillVersion.prompt, + newValue: `---${skillVersion.prompt}`, + }, + }), + changeProposalFactory({ + id: createChangeProposalId('proposal-2'), + type: ChangeProposalType.updateSkillPrompt, + payload: { + oldValue: skillVersion.prompt, + newValue: `${skillVersion.prompt}---`, + }, + }), + ]), + ).toThrow( + new ChangeProposalConflictError(createChangeProposalId('proposal-2')), + ); + }); + }); + + describe('when updating the skill metadata', () => { + const changeProposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateSkillMetadata, + payload: { + oldValue: JSON.stringify({ key1: 'value1' }), + newValue: JSON.stringify({ key1: 'updated', key2: 'value2' }), + }, + }), + changeProposalFactory({ + type: ChangeProposalType.updateSkillMetadata, + payload: { + oldValue: JSON.stringify({ key1: 'updated', key2: 'value2' }), + newValue: JSON.stringify({ key3: 'value3' }), + }, + }), + ]; + + it('overrides the skill metadata with each proposal', () => { + const newSkillVersion = applier.applyChangeProposals( + skillVersion, + changeProposals, + ); + + expect(newSkillVersion.version).toEqual({ + ...skillVersion, + metadata: { key3: 'value3' }, + }); + }); + }); + + describe('when updating the skill license', () => { + const changeProposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateSkillLicense, + payload: { + oldValue: 'MIT', + newValue: 'Apache-2.0', + }, + }), + changeProposalFactory({ + type: ChangeProposalType.updateSkillLicense, + payload: { + oldValue: 'Apache-2.0', + newValue: 'GPL-3.0', + }, + }), + ]; + + it('overrides the skill license with each proposal', () => { + const newSkillVersion = applier.applyChangeProposals( + skillVersion, + changeProposals, + ); + + expect(newSkillVersion.version).toEqual({ + ...skillVersion, + license: 'GPL-3.0', + }); + }); + }); + + describe('when updating the skill compatibility', () => { + const changeProposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateSkillCompatibility, + payload: { + oldValue: '^1.0.0', + newValue: '^2.0.0', + }, + }), + changeProposalFactory({ + type: ChangeProposalType.updateSkillCompatibility, + payload: { + oldValue: '^2.0.0', + newValue: '^3.0.0', + }, + }), + ]; + + it('overrides the skill compatibility with each proposal', () => { + const newSkillVersion = applier.applyChangeProposals( + skillVersion, + changeProposals, + ); + + expect(newSkillVersion.version).toEqual({ + ...skillVersion, + compatibility: '^3.0.0', + }); + }); + }); + + describe('when updating the skill allowed tools', () => { + const changeProposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateSkillAllowedTools, + payload: { + oldValue: 'tool1,tool2', + newValue: 'tool2,tool3', + }, + }), + changeProposalFactory({ + type: ChangeProposalType.updateSkillAllowedTools, + payload: { + oldValue: 'tool2,tool3', + newValue: 'tool3,tool4,tool5', + }, + }), + ]; + + it('overrides the skill allowed tools with each proposal', () => { + const newSkillVersion = applier.applyChangeProposals( + skillVersion, + changeProposals, + ); + + expect(newSkillVersion.version).toEqual({ + ...skillVersion, + allowedTools: 'tool3,tool4,tool5', + }); + }); + }); + + describe('when adding skill files', () => { + describe('when skill version has no files', () => { + const skillVersionWithoutFiles = skillVersionFactory({ + files: undefined, + }); + + it('creates a new files array with one file with correct properties', () => { + const changeProposal = changeProposalFactory({ + type: ChangeProposalType.addSkillFile, + payload: { + item: { + path: 'new-file.md', + content: 'New file content', + permissions: 'rw-r--r--', + isBase64: false, + }, + }, + }); + + const newSkillVersion = applier.applyChangeProposals( + skillVersionWithoutFiles, + [changeProposal], + ); + + expect(newSkillVersion.version.files).toEqual([ + expect.objectContaining({ + id: expect.any(String), + path: 'new-file.md', + content: 'New file content', + permissions: 'rw-r--r--', + isBase64: false, + skillVersionId: skillVersionWithoutFiles.id, + }), + ]); + }); + }); + + describe('when skill version has existing files', () => { + const existingFile = skillFileFactory({ + path: 'existing.md', + content: 'Existing content', + }); + const skillVersionWithFiles = skillVersionFactory({ + files: [existingFile], + }); + + it('adds the new file while preserving existing files', () => { + const changeProposal = changeProposalFactory({ + type: ChangeProposalType.addSkillFile, + payload: { + item: { + path: 'new-file.md', + content: 'New file content', + permissions: 'rw-r--r--', + isBase64: false, + }, + }, + }); + + const newSkillVersion = applier.applyChangeProposals( + skillVersionWithFiles, + [changeProposal], + ); + + expect(newSkillVersion.version.files).toEqual([ + existingFile, + expect.objectContaining({ + id: expect.any(String), + path: 'new-file.md', + content: 'New file content', + permissions: 'rw-r--r--', + isBase64: false, + skillVersionId: skillVersionWithFiles.id, + }), + ]); + }); + }); + + describe('when adding multiple files', () => { + it('adds all files in sequence', () => { + const changeProposals = [ + changeProposalFactory({ + type: ChangeProposalType.addSkillFile, + payload: { + item: { + path: 'file1.md', + content: 'Content 1', + permissions: 'rw-r--r--', + isBase64: false, + }, + }, + }), + changeProposalFactory({ + type: ChangeProposalType.addSkillFile, + payload: { + item: { + path: 'file2.md', + content: 'Content 2', + permissions: 'rw-r--r--', + isBase64: false, + }, + }, + }), + ]; + + const newSkillVersion = applier.applyChangeProposals( + skillVersion, + changeProposals, + ); + + expect(newSkillVersion.version.files).toEqual([ + expect.objectContaining({ path: 'file1.md' }), + expect.objectContaining({ path: 'file2.md' }), + ]); + }); + }); + }); + + describe('when updating skill file content', () => { + const fileId = createSkillFileId('test-file-id'); + const existingFile = skillFileFactory({ + id: fileId, + path: 'test.md', + content: 'Original content', + isBase64: false, + }); + const skillVersionWithFile = skillVersionFactory({ + files: [existingFile], + }); + + it('applies diff to the file content', () => { + const changeProposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: fileId, + oldValue: 'Original content', + newValue: 'Original content\nAdded line', + }, + }); + + const newSkillVersion = applier.applyChangeProposals( + skillVersionWithFile, + [changeProposal], + ); + + expect(newSkillVersion.version.files?.[0]).toMatchObject({ + id: fileId, + content: 'Original content\nAdded line', + isBase64: false, + }); + }); + + describe('when isBase64 is provided', () => { + it('updates the isBase64 flag', () => { + const changeProposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: fileId, + oldValue: 'Original content', + newValue: 'Base64EncodedContent', + isBase64: true, + }, + }); + + const newSkillVersion = applier.applyChangeProposals( + skillVersionWithFile, + [changeProposal], + ); + + expect(newSkillVersion.version.files?.[0]).toMatchObject({ + id: fileId, + content: 'Base64EncodedContent', + isBase64: true, + }); + }); + }); + + describe('when multiple files exist', () => { + const otherFile = skillFileFactory({ + id: createSkillFileId('other-file-id'), + path: 'other.md', + content: 'Other content', + }); + const versionWithMultipleFiles = skillVersionFactory({ + files: [existingFile, otherFile], + }); + + it('updates only the targeted file content', () => { + const changeProposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: fileId, + oldValue: 'Original content', + newValue: 'Updated content', + }, + }); + + const newSkillVersion = applier.applyChangeProposals( + versionWithMultipleFiles, + [changeProposal], + ); + + expect(newSkillVersion.version.files).toEqual([ + expect.objectContaining({ + id: fileId, + content: 'Updated content', + }), + otherFile, + ]); + }); + }); + + it('throws a ChangeProposalConflictError if applying the diff fails', () => { + expect(() => + applier.applyChangeProposals(skillVersionWithFile, [ + changeProposalFactory({ + id: createChangeProposalId('proposal-1'), + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: fileId, + oldValue: 'Original content', + newValue: '---Original content', + }, + }), + changeProposalFactory({ + id: createChangeProposalId('proposal-2'), + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: fileId, + oldValue: 'Original content', + newValue: 'Original content---', + }, + }), + ]), + ).toThrow( + new ChangeProposalConflictError(createChangeProposalId('proposal-2')), + ); + }); + }); + + describe('when updating skill file permissions', () => { + const fileId = createSkillFileId('test-file-id'); + const existingFile = skillFileFactory({ + id: fileId, + path: 'test.md', + permissions: 'rw-r--r--', + }); + const skillVersionWithFile = skillVersionFactory({ + files: [existingFile], + }); + + it('updates the file permissions', () => { + const changeProposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFilePermissions, + payload: { + targetId: fileId, + oldValue: 'rw-r--r--', + newValue: 'rwxr-xr-x', + }, + }); + + const newSkillVersion = applier.applyChangeProposals( + skillVersionWithFile, + [changeProposal], + ); + + expect(newSkillVersion.version.files?.[0].permissions).toBe( + 'rwxr-xr-x', + ); + }); + + describe('when multiple files exist', () => { + const otherFile = skillFileFactory({ + id: createSkillFileId('other-file-id'), + path: 'other.md', + permissions: 'rw-------', + }); + const versionWithMultipleFiles = skillVersionFactory({ + files: [existingFile, otherFile], + }); + + it('updates only the targeted file permissions', () => { + const changeProposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillFilePermissions, + payload: { + targetId: fileId, + oldValue: 'rw-r--r--', + newValue: 'rwxr-xr-x', + }, + }); + + const newSkillVersion = applier.applyChangeProposals( + versionWithMultipleFiles, + [changeProposal], + ); + + expect(newSkillVersion.version.files).toEqual([ + expect.objectContaining({ + id: fileId, + permissions: 'rwxr-xr-x', + }), + otherFile, + ]); + }); + }); + }); + + describe('when deleting skill files', () => { + const fileId = createSkillFileId('test-file-id'); + const existingFile = skillFileFactory({ + id: fileId, + path: 'test.md', + }); + + describe('when skill version has a single file', () => { + const skillVersionWithFile = skillVersionFactory({ + files: [existingFile], + }); + + it('removes the file from the files array', () => { + const changeProposal = changeProposalFactory({ + type: ChangeProposalType.deleteSkillFile, + payload: { + targetId: fileId, + item: existingFile, + }, + }); + + const newSkillVersion = applier.applyChangeProposals( + skillVersionWithFile, + [changeProposal], + ); + + expect(newSkillVersion.version.files).toEqual([]); + }); + }); + + describe('when skill version has multiple files', () => { + const otherFile = skillFileFactory({ + id: createSkillFileId('other-file-id'), + path: 'other.md', + }); + const skillVersionWithFiles = skillVersionFactory({ + files: [existingFile, otherFile], + }); + + it('removes only the targeted file', () => { + const changeProposal = changeProposalFactory({ + type: ChangeProposalType.deleteSkillFile, + payload: { + targetId: fileId, + item: existingFile, + }, + }); + + const newSkillVersion = applier.applyChangeProposals( + skillVersionWithFiles, + [changeProposal], + ); + + expect(newSkillVersion.version.files).toEqual([otherFile]); + }); + }); + + describe('when deleting multiple files', () => { + const file1Id = createSkillFileId('file-1-id'); + const file2Id = createSkillFileId('file-2-id'); + const file1 = skillFileFactory({ id: file1Id, path: 'file1.md' }); + const file2 = skillFileFactory({ id: file2Id, path: 'file2.md' }); + const file3 = skillFileFactory({ + id: createSkillFileId('file-3-id'), + path: 'file3.md', + }); + const skillVersionWithFiles = skillVersionFactory({ + files: [file1, file2, file3], + }); + + it('removes all targeted files', () => { + const changeProposals = [ + changeProposalFactory({ + type: ChangeProposalType.deleteSkillFile, + payload: { + targetId: file1Id, + item: file1, + }, + }), + changeProposalFactory({ + type: ChangeProposalType.deleteSkillFile, + payload: { + targetId: file2Id, + item: file2, + }, + }), + ]; + + const newSkillVersion = applier.applyChangeProposals( + skillVersionWithFiles, + changeProposals, + ); + + expect(newSkillVersion.version.files).toEqual([file3]); + }); + }); + + describe('when skill version has no files', () => { + const skillVersionWithoutFiles = skillVersionFactory({ + files: undefined, + }); + + it('returns an empty array', () => { + const changeProposal = changeProposalFactory({ + type: ChangeProposalType.deleteSkillFile, + payload: { + targetId: fileId, + item: existingFile, + }, + }); + + const newSkillVersion = applier.applyChangeProposals( + skillVersionWithoutFiles, + [changeProposal], + ); + + expect(newSkillVersion.version.files).toEqual([]); + }); + }); + }); + }); + + describe('areChangesApplicable', () => { + it('returns true for skill change types', () => { + const changeProposals = [ + changeProposalFactory({ type: ChangeProposalType.updateSkillName }), + changeProposalFactory({ + type: ChangeProposalType.updateSkillDescription, + }), + ]; + + expect(applier.areChangesApplicable(changeProposals)).toBe(true); + }); + + it('returns false for non-skill change types', () => { + const changeProposals = [ + changeProposalFactory({ type: ChangeProposalType.updateStandardName }), + ]; + + expect(applier.areChangesApplicable(changeProposals)).toBe(false); + }); + }); + + describe('getVersion', () => { + const skillId = skillVersion.skillId; + + beforeEach(() => { + skillsPort.getLatestSkillVersion.mockResolvedValue(skillVersion as never); + skillsPort.getSkillFiles.mockResolvedValue([]); + }); + + it('returns the skill version with files', async () => { + const result = await applier.getVersion(skillId); + + expect(result).toEqual({ ...skillVersion, files: [] }); + }); + + it('calls getLatestSkillVersion with the skill ID', async () => { + await applier.getVersion(skillId); + + expect(skillsPort.getLatestSkillVersion).toHaveBeenCalledWith(skillId); + }); + + it('calls getSkillFiles with the skill version ID', async () => { + await applier.getVersion(skillId); + + expect(skillsPort.getSkillFiles).toHaveBeenCalledWith(skillVersion.id); + }); + + describe('when skill version is not found', () => { + it('throws an error', async () => { + skillsPort.getLatestSkillVersion.mockResolvedValue(null as never); + + await expect(applier.getVersion(skillId)).rejects.toThrow( + `Unable to find skillVersion with id ${skillId}.`, + ); + }); + }); + }); + + describe('saveNewVersion', () => { + const userId = createUserId('test-user-id'); + const organizationId = createOrganizationId('test-org-id'); + const spaceId = createSpaceId('test-space-id'); + + beforeEach(() => { + const savedVersion = { + ...skillVersion, + id: createSkillVersionId('new-version-id'), + }; + skillsPort.saveSkillVersion.mockResolvedValue(savedVersion as never); + skillsPort.getSkillFiles.mockResolvedValue([]); + }); + + it('calls saveSkillVersion with correct parameters', async () => { + await applier.saveNewVersion( + skillVersion, + userId, + spaceId, + organizationId, + ); + + expect(skillsPort.saveSkillVersion).toHaveBeenCalledWith({ + skillVersion, + userId, + spaceId, + organizationId, + }); + }); + + it('returns the new version with files', async () => { + const result = await applier.saveNewVersion( + skillVersion, + userId, + spaceId, + organizationId, + ); + + expect(result).toEqual(expect.objectContaining({ files: [] })); + }); + + it('fetches files for the new version ID', async () => { + const savedVersion = { + ...skillVersion, + id: createSkillVersionId('new-version-id'), + }; + skillsPort.saveSkillVersion.mockResolvedValue(savedVersion as never); + + await applier.saveNewVersion( + skillVersion, + userId, + spaceId, + organizationId, + ); + + expect(skillsPort.getSkillFiles).toHaveBeenCalledWith(savedVersion.id); + }); + }); + + describe('deleteArtefact', () => { + const userId = createUserId('user-id'); + const spaceId = createSpaceId('space-id'); + const organizationId = createOrganizationId('organization-id'); + + beforeEach(() => { + skillsPort.deleteSkill.mockResolvedValue({ success: true } as never); + }); + + it('calls deleteSkill with the skill ID and auth context', async () => { + await applier.deleteArtefact( + skillVersion, + userId, + spaceId, + organizationId, + ); + + expect(skillsPort.deleteSkill).toHaveBeenCalledWith({ + skillId: skillVersion.skillId, + userId, + organizationId, + spaceId, + }); + }); + }); + + describe('getUpdatePackageCommandWithoutArtefact', () => { + it('removes the skill from the package skill list', () => { + const otherSkillId = createSkillId('other-skill'); + const recipeId = createRecipeId('recipe-1'); + const standardId = createStandardId('std-1'); + const pkg = { + id: createPackageId('pkg-1'), + name: 'My Package', + slug: 'my-package', + description: 'desc', + spaceId: createSpaceId('space-1'), + createdBy: createUserId('user-1'), + recipes: [recipeId], + standards: [standardId], + skills: [skillVersion.skillId, otherSkillId], + }; + + const result = applier.getUpdatePackageCommandWithoutArtefact( + skillVersion, + pkg, + ); + + expect(result).toEqual({ + recipeIds: [recipeId], + standardIds: [standardId], + skillsIds: [otherSkillId], + }); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/SkillChangesApplier.ts b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/SkillChangesApplier.ts new file mode 100644 index 000000000..350903fd5 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/SkillChangesApplier.ts @@ -0,0 +1,89 @@ +import { + SkillChangeProposalApplier, + SkillVersionWithFiles, + DiffService, + Package, + UpdatePackageCommand, +} from '@packmind/types'; +import { + ISkillsPort, + OrganizationId, + SkillId, + SpaceId, + UserId, +} from '@packmind/types'; +import { IChangesProposalApplier } from '../IChangesProposalApplier'; + +export class SkillChangesApplier + extends SkillChangeProposalApplier + implements IChangesProposalApplier<SkillVersionWithFiles> +{ + constructor( + diffService: DiffService, + private readonly skillsPort: ISkillsPort, + ) { + super(diffService); + } + + async getVersion(artefactId: SkillId): Promise<SkillVersionWithFiles> { + const skillVersion = + await this.skillsPort.getLatestSkillVersion(artefactId); + + if (!skillVersion) { + throw new Error(`Unable to find skillVersion with id ${artefactId}.`); + } + + const skillVersionsFiles = await this.skillsPort.getSkillFiles( + skillVersion.id, + ); + + return { + ...skillVersion, + files: skillVersionsFiles, + }; + } + + async deleteArtefact( + source: SkillVersionWithFiles, + userId: UserId, + spaceId: SpaceId, + organizationId: OrganizationId, + ): Promise<void> { + await this.skillsPort.deleteSkill({ + userId, + organizationId, + spaceId, + skillId: source.skillId, + }); + } + + getUpdatePackageCommandWithoutArtefact( + source: SkillVersionWithFiles, + pkg: Package, + ): Pick<UpdatePackageCommand, 'recipeIds' | 'standardIds' | 'skillsIds'> { + return { + recipeIds: pkg.recipes, + standardIds: pkg.standards, + skillsIds: pkg.skills.filter((skillId) => skillId !== source.skillId), + }; + } + + async saveNewVersion( + skillVersion: SkillVersionWithFiles, + userId: UserId, + spaceId: SpaceId, + organizationId: OrganizationId, + ): Promise<SkillVersionWithFiles> { + const newVersion = await this.skillsPort.saveSkillVersion({ + skillVersion, + userId, + spaceId, + organizationId, + }); + + return { + ...newVersion, + files: await this.skillsPort.getSkillFiles(newVersion.id), + }; + } +} diff --git a/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/StandardChangesApplier.spec.ts b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/StandardChangesApplier.spec.ts new file mode 100644 index 000000000..4f7096df0 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/StandardChangesApplier.spec.ts @@ -0,0 +1,578 @@ +import { standardVersionFactory, ruleFactory } from '@packmind/standards/test'; +import { + ChangeProposalType, + createChangeProposalId, + createOrganizationId, + createPackageId, + createRecipeId, + createSkillId, + createSpaceId, + createStandardId, + createUserId, + IStandardsPort, + ChangeProposal, + DiffService, + ChangeProposalConflictError, +} from '@packmind/types'; +import { changeProposalFactory } from '../../../../../test'; +import { StandardChangesApplier } from './StandardChangesApplier'; + +describe('StandardChangesApplier', () => { + let applier: StandardChangesApplier; + let standardsPort: jest.Mocked<IStandardsPort>; + let diffService: DiffService; + + beforeEach(() => { + diffService = new DiffService(); + standardsPort = { + getLatestStandardVersion: jest.fn(), + getRulesByStandardId: jest.fn(), + updateStandard: jest.fn(), + deleteStandard: jest.fn(), + } as unknown as jest.Mocked<IStandardsPort>; + + applier = new StandardChangesApplier(diffService, standardsPort); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const rule1 = ruleFactory({ + content: 'Always use TypeScript for new files', + }); + + const rule2 = ruleFactory({ + content: 'Use const for variables that do not change', + }); + + const standardVersion = standardVersionFactory({ + name: 'TypeScript Best Practices', + scope: '**/*.ts', + description: 'A collection of TypeScript best practices', + rules: [rule1, rule2], + }); + + describe('areChangesApplicable', () => { + it('returns true for standard change types', () => { + const changeProposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateStandardName, + }), + changeProposalFactory({ + type: ChangeProposalType.updateStandardScope, + }), + changeProposalFactory({ + type: ChangeProposalType.updateStandardDescription, + }), + changeProposalFactory({ + type: ChangeProposalType.addRule, + }), + ]; + + expect(applier.areChangesApplicable(changeProposals)).toBe(true); + }); + + it('returns false for non-standard change types', () => { + const changeProposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateSkillName, + }), + ]; + + expect(applier.areChangesApplicable(changeProposals)).toBe(false); + }); + }); + + describe('applyChangeProposal', () => { + describe('when updating the standard name', () => { + const changeProposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateStandardName, + payload: { + oldValue: standardVersion.name, + newValue: `before--${standardVersion.name}`, + }, + }), + changeProposalFactory({ + type: ChangeProposalType.updateStandardName, + payload: { + oldValue: standardVersion.name, + newValue: `${standardVersion.name}--after`, + }, + }), + ]; + + it('overrides the standard name with each proposal', () => { + const newStandardVersion = applier.applyChangeProposals( + standardVersion, + changeProposals, + ); + + expect(newStandardVersion.version).toEqual({ + ...standardVersion, + name: `${standardVersion.name}--after`, + }); + }); + }); + + describe('when updating the standard scope', () => { + const changeProposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateStandardScope, + payload: { + oldValue: standardVersion.name, + newValue: `**/*.spec.ts`, + }, + }), + changeProposalFactory({ + type: ChangeProposalType.updateStandardScope, + payload: { + oldValue: standardVersion.name, + newValue: `**/*.test.ts`, + }, + }), + ]; + + it('overrides the standard scope with each proposal', () => { + const newStandardVersion = applier.applyChangeProposals( + standardVersion, + changeProposals, + ); + + expect(newStandardVersion.version).toEqual({ + ...standardVersion, + scope: `**/*.test.ts`, + }); + }); + }); + + describe('when updating the standard description', () => { + const changeProposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateStandardDescription, + payload: { + oldValue: standardVersion.description, + newValue: `A line before:\n${standardVersion.description}`, + }, + }), + changeProposalFactory({ + type: ChangeProposalType.updateStandardDescription, + payload: { + oldValue: standardVersion.description, + newValue: `${standardVersion.description}\nA line after`, + }, + }), + ]; + + it('uses the diff service to apply the changes', () => { + const newVersion = applier.applyChangeProposals( + standardVersion, + changeProposals, + ); + + expect(newVersion.version).toEqual({ + ...standardVersion, + description: `A line before:\n${standardVersion.description}\nA line after`, + }); + }); + + it('throws a ChangeProposalConflictError if applying the diff fails', () => { + expect(() => + applier.applyChangeProposals(standardVersion, [ + changeProposalFactory({ + id: createChangeProposalId('proposal-1'), + type: ChangeProposalType.updateStandardDescription, + payload: { + oldValue: standardVersion.description, + newValue: `---${standardVersion.description}`, + }, + }), + changeProposalFactory({ + id: createChangeProposalId('proposal-2'), + type: ChangeProposalType.updateStandardDescription, + payload: { + oldValue: standardVersion.description, + newValue: `${standardVersion.description}---`, + }, + }), + ]), + ).toThrow( + new ChangeProposalConflictError(createChangeProposalId('proposal-2')), + ); + }); + }); + + describe('when adding a rule', () => { + const newRuleContent = 'Use async/await instead of promises'; + const changeProposal = changeProposalFactory({ + type: ChangeProposalType.addRule, + payload: { + item: { + content: newRuleContent, + }, + }, + }); + + it('adds the new rule to the rules array', () => { + const newVersion = applier.applyChangeProposals(standardVersion, [ + changeProposal, + ]); + + expect(newVersion.version.rules).toEqual([ + expect.objectContaining({ content: rule1.content }), + expect.objectContaining({ content: rule2.content }), + expect.objectContaining({ + content: newRuleContent, + standardVersionId: standardVersion.id, + }), + ]); + }); + + it('generates a new rule ID for the added rule', () => { + const newVersion = applier.applyChangeProposals(standardVersion, [ + changeProposal, + ]); + + expect(newVersion.version.rules).toEqual([ + expect.objectContaining({ id: rule1.id }), + expect.objectContaining({ id: rule2.id }), + expect.objectContaining({ + id: expect.not.stringContaining(rule1.id), + }), + ]); + }); + }); + + describe('when updating a rule', () => { + const updatedContent = 'Always use TypeScript strict mode'; + const changeProposal = changeProposalFactory({ + type: ChangeProposalType.updateRule, + payload: { + targetId: rule1.id, + oldValue: rule1.content, + newValue: updatedContent, + }, + }); + + it('overrides the rule content with the new value', () => { + const newVersion = applier.applyChangeProposals(standardVersion, [ + changeProposal, + ]); + + expect(newVersion.version.rules).toEqual([ + expect.objectContaining({ content: updatedContent }), + expect.objectContaining({ content: rule2.content }), + ]); + }); + + it('preserves the rule ID', () => { + const newVersion = applier.applyChangeProposals(standardVersion, [ + changeProposal, + ]); + + expect(newVersion.version.rules).toEqual([ + expect.objectContaining({ id: rule1.id }), + expect.objectContaining({ id: rule2.id }), + ]); + }); + }); + + describe('when deleting a rule', () => { + const changeProposal = changeProposalFactory({ + type: ChangeProposalType.deleteRule, + payload: { + targetId: rule1.id, + item: rule1, + }, + }); + + it('removes the rule from the rules array', () => { + const newVersion = applier.applyChangeProposals(standardVersion, [ + changeProposal, + ]); + + expect(newVersion.version.rules).toEqual([ + expect.objectContaining({ + id: rule2.id, + content: rule2.content, + }), + ]); + }); + }); + + describe('when applying multiple changes', () => { + let changeProposals: ChangeProposal[]; + + beforeEach(() => { + changeProposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateStandardName, + payload: { + oldValue: standardVersion.name, + newValue: 'Updated TypeScript Best Practices', + }, + }), + changeProposalFactory({ + type: ChangeProposalType.addRule, + payload: { + item: { + content: 'Use interfaces for object types', + }, + }, + }), + changeProposalFactory({ + type: ChangeProposalType.deleteRule, + payload: { + targetId: rule1.id, + item: rule1, + }, + }), + ]; + }); + + it('updates the standard name', () => { + const newVersion = applier.applyChangeProposals( + standardVersion, + changeProposals, + ); + + expect(newVersion.version.name).toBe( + 'Updated TypeScript Best Practices', + ); + }); + + it('applies all rule changes correctly', () => { + const newVersion = applier.applyChangeProposals( + standardVersion, + changeProposals, + ); + + expect(newVersion.version.rules).toEqual([ + expect.objectContaining({ + id: rule2.id, + content: rule2.content, + }), + expect.objectContaining({ + content: 'Use interfaces for object types', + standardVersionId: standardVersion.id, + }), + ]); + }); + }); + + describe('when encountering an unsupported change type', () => { + it('returns source unchanged', () => { + const changeProposal = changeProposalFactory({ + type: ChangeProposalType.updateSkillName, + payload: { + oldValue: 'old', + newValue: 'new', + }, + }); + + const result = applier.applyChangeProposals(standardVersion, [ + changeProposal, + ]); + + expect(result.version).toEqual(standardVersion); + }); + }); + }); + + describe('getVersion', () => { + const standardId = standardVersion.standardId; + + beforeEach(() => { + standardsPort.getLatestStandardVersion.mockResolvedValue( + standardVersion as never, + ); + standardsPort.getRulesByStandardId.mockResolvedValue([rule1, rule2]); + }); + + it('returns the standard version with rules', async () => { + const result = await applier.getVersion(standardId); + + expect(result).toEqual({ + ...standardVersion, + rules: [rule1, rule2], + }); + }); + + it('calls getLatestStandardVersion with standardId', async () => { + await applier.getVersion(standardId); + + expect(standardsPort.getLatestStandardVersion).toHaveBeenCalledWith( + standardId, + ); + }); + + it('calls getRulesByStandardId with standardId', async () => { + await applier.getVersion(standardId); + + expect(standardsPort.getRulesByStandardId).toHaveBeenCalledWith( + standardId, + ); + }); + + describe('when standard version is not found', () => { + it('throws an error', async () => { + const standardId = standardVersion.standardId; + standardsPort.getLatestStandardVersion.mockResolvedValue(null as never); + + await expect(applier.getVersion(standardId)).rejects.toThrow( + `Unable to find standard version with id ${standardId}`, + ); + }); + }); + }); + + describe('saveNewVersion', () => { + const userId = createUserId('test-user-id'); + const organizationId = createOrganizationId('test-org-id'); + const spaceId = createSpaceId('test-space-id'); + + beforeEach(() => { + const updatedStandard = { + id: standardVersion.standardId, + name: standardVersion.name, + version: 2, + }; + + const newVersion = { + ...standardVersion, + version: 2, + }; + + standardsPort.updateStandard.mockResolvedValue(updatedStandard as never); + standardsPort.getLatestStandardVersion.mockResolvedValue( + newVersion as never, + ); + standardsPort.getRulesByStandardId.mockResolvedValue([rule1, rule2]); + }); + + it('calls updateStandard with correct parameters', async () => { + await applier.saveNewVersion( + standardVersion, + userId, + spaceId, + organizationId, + ); + + expect(standardsPort.updateStandard).toHaveBeenCalledWith({ + userId, + organizationId, + spaceId, + standardId: standardVersion.standardId, + name: standardVersion.name, + description: standardVersion.description, + rules: [ + { id: rule1.id, content: rule1.content }, + { id: rule2.id, content: rule2.content }, + ], + scope: standardVersion.scope, + }); + }); + + it('returns the new version with rules', async () => { + const result = await applier.saveNewVersion( + standardVersion, + userId, + spaceId, + organizationId, + ); + + const newVersion = { + ...standardVersion, + version: 2, + }; + + expect(result).toEqual({ + ...newVersion, + rules: [rule1, rule2], + }); + }); + + describe('when failed to retrieve the new version', () => { + it('throws an error', async () => { + const updatedStandard = { + id: standardVersion.standardId, + name: standardVersion.name, + version: 2, + }; + + standardsPort.updateStandard.mockResolvedValue( + updatedStandard as never, + ); + standardsPort.getLatestStandardVersion.mockResolvedValue(null as never); + + await expect( + applier.saveNewVersion( + standardVersion, + userId, + spaceId, + organizationId, + ), + ).rejects.toThrow( + `Failed to retrieve latest version for standard ${updatedStandard.id}`, + ); + }); + }); + }); + + describe('deleteArtefact', () => { + const userId = createUserId('user-id'); + const spaceId = createSpaceId('space-id'); + const organizationId = createOrganizationId('organization-id'); + + beforeEach(() => { + standardsPort.deleteStandard.mockResolvedValue(undefined as never); + }); + + it('calls deleteStandard with the standard ID and auth context', async () => { + await applier.deleteArtefact( + standardVersion, + userId, + spaceId, + organizationId, + ); + + expect(standardsPort.deleteStandard).toHaveBeenCalledWith({ + standardId: standardVersion.standardId, + userId, + organizationId, + spaceId, + }); + }); + }); + + describe('getUpdatePackageCommandWithoutArtefact', () => { + it('removes the standard from the package standard list', () => { + const otherStandardId = createStandardId('other-standard'); + const recipeId = createRecipeId('recipe-1'); + const skillId = createSkillId('skill-1'); + const pkg = { + id: createPackageId('pkg-1'), + name: 'My Package', + slug: 'my-package', + description: 'desc', + spaceId: createSpaceId('space-1'), + createdBy: createUserId('user-1'), + recipes: [recipeId], + standards: [standardVersion.standardId, otherStandardId], + skills: [skillId], + }; + + const result = applier.getUpdatePackageCommandWithoutArtefact( + standardVersion, + pkg, + ); + + expect(result).toEqual({ + recipeIds: [recipeId], + standardIds: [otherStandardId], + skillsIds: [skillId], + }); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/StandardChangesApplier.ts b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/StandardChangesApplier.ts new file mode 100644 index 000000000..fce7e33d6 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyChangeProposals/shared/StandardChangesApplier.ts @@ -0,0 +1,110 @@ +import { + StandardChangeProposalApplier, + DiffService, + Package, + UpdatePackageCommand, +} from '@packmind/types'; +import { + IStandardsPort, + OrganizationId, + SpaceId, + StandardId, + StandardVersion, + UserId, +} from '@packmind/types'; +import { IChangesProposalApplier } from '../IChangesProposalApplier'; + +export class StandardChangesApplier + extends StandardChangeProposalApplier + implements IChangesProposalApplier<StandardVersion> +{ + constructor( + diffService: DiffService, + private readonly standardsPort: IStandardsPort, + ) { + super(diffService); + } + + async getVersion(artefactId: StandardId): Promise<StandardVersion> { + const standardVersion = + await this.standardsPort.getLatestStandardVersion(artefactId); + + if (!standardVersion) { + throw new Error(`Unable to find standard version with id ${artefactId}.`); + } + + const rules = await this.standardsPort.getRulesByStandardId(artefactId); + + return { + ...standardVersion, + rules, + }; + } + + async deleteArtefact( + source: StandardVersion, + userId: UserId, + spaceId: SpaceId, + organizationId: OrganizationId, + ): Promise<void> { + await this.standardsPort.deleteStandard({ + userId, + organizationId, + spaceId, + standardId: source.standardId, + }); + } + + getUpdatePackageCommandWithoutArtefact( + source: StandardVersion, + pkg: Package, + ): Pick<UpdatePackageCommand, 'recipeIds' | 'standardIds' | 'skillsIds'> { + return { + recipeIds: pkg.recipes, + standardIds: pkg.standards.filter( + (standardId) => standardId !== source.standardId, + ), + skillsIds: pkg.skills, + }; + } + + async saveNewVersion( + version: StandardVersion, + userId: UserId, + spaceId: SpaceId, + organizationId: OrganizationId, + ): Promise<StandardVersion> { + const updatedStandard = await this.standardsPort.updateStandard({ + userId, + organizationId, + spaceId, + standardId: version.standardId, + name: version.name, + description: version.description, + rules: (version.rules || []).map((rule) => ({ + id: rule.id, + content: rule.content, + })), + scope: version.scope, + }); + + const newVersion = await this.standardsPort.getLatestStandardVersion( + updatedStandard.id, + ); + + if (!newVersion) { + throw new Error( + `Failed to retrieve latest version for standard ${updatedStandard.id}`, + ); + } + + const rules = await this.standardsPort.getRulesByStandardId( + updatedStandard.id, + ); + + return { + ...newVersion, + rules, + }; + } +} diff --git a/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/ApplyCreationChangeProposalsUseCase.spec.ts b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/ApplyCreationChangeProposalsUseCase.spec.ts new file mode 100644 index 000000000..8fdd9a71c --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/ApplyCreationChangeProposalsUseCase.spec.ts @@ -0,0 +1,1422 @@ +import { + PackmindEventEmitterService, + SpaceMembershipRequiredError, +} from '@packmind/node-utils'; +import { stubLogger } from '@packmind/test-utils'; +import { SkillValidationError } from '@packmind/skills'; +import { + ChangeProposal, + ChangeProposalDecision, + ChangeProposalStatus, + ChangeProposalType, + createChangeProposalId, + createOrganizationId, + createRecipeId, + createSkillId, + createSpaceId, + createStandardId, + createUserId, + IAccountsPort, + IRecipesPort, + ISkillsPort, + ISpacesPort, + IStandardsPort, + NewCommandPayload, + NewSkillPayload, + NewStandardPayload, + UserSpaceRole, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { recipeFactory } from '@packmind/recipes/test/recipeFactory'; +import { standardFactory } from '@packmind/standards/test/standardFactory'; +import { skillFactory } from '@packmind/skills/test/skillFactory'; +import { changeProposalFactory } from '../../../../test/changeProposalFactory'; +import { ChangeProposalService } from '../../services/ChangeProposalService'; +import { ApplyCreationChangeProposalsUseCase } from './ApplyCreationChangeProposalsUseCase'; + +jest.mock('@packmind/node-utils', () => ({ + ...jest.requireActual('@packmind/node-utils'), + SSEEventPublisher: { + publishChangeProposalUpdateEvent: jest.fn().mockResolvedValue(undefined), + }, +})); + +describe('ApplyCreationChangeProposalsUseCase', () => { + const organizationId = createOrganizationId('org-id'); + const userId = createUserId('user-id'); + const spaceId = createSpaceId('space-id'); + const proposalId = createChangeProposalId('proposal-1'); + const recipeId = createRecipeId('new-recipe'); + + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + const organization = organizationFactory({ id: organizationId }); + const space = spaceFactory({ id: spaceId, organizationId }); + const recipe = recipeFactory({ id: recipeId, spaceId }); + + const payload: NewCommandPayload = { + name: 'My Command', + content: 'Do something', + }; + const proposal: ChangeProposal<ChangeProposalType.createCommand> = + changeProposalFactory({ + id: proposalId, + type: ChangeProposalType.createCommand, + artefactId: null, + payload, + status: ChangeProposalStatus.pending, + spaceId, + }) as ChangeProposal<ChangeProposalType.createCommand>; + + function toAcceptedProposal<T extends ChangeProposalType>( + baseProposal: ChangeProposal<T>, + decision?: ChangeProposalDecision<T>, + ): AcceptedChangeProposal<T> { + return { + ...baseProposal, + status: ChangeProposalStatus.applied, + decision: decision ?? (baseProposal.payload as ChangeProposalDecision<T>), + } as AcceptedChangeProposal<T>; + } + + let useCase: ApplyCreationChangeProposalsUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked<ISpacesPort>; + let recipesPort: jest.Mocked<IRecipesPort>; + let standardsPort: jest.Mocked<IStandardsPort>; + let skillsPort: jest.Mocked<ISkillsPort>; + let changeProposalService: jest.Mocked<ChangeProposalService>; + let eventEmitterService: jest.Mocked<PackmindEventEmitterService>; + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(user), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort = { + getSpaceById: jest.fn().mockResolvedValue(space), + findMembership: jest.fn().mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + createdBy: userId, + updatedBy: userId, + }), + } as unknown as jest.Mocked<ISpacesPort>; + + recipesPort = { + captureRecipe: jest.fn().mockResolvedValue(recipe), + } as unknown as jest.Mocked<IRecipesPort>; + + standardsPort = { + createStandardWithExamples: jest.fn(), + } as unknown as jest.Mocked<IStandardsPort>; + + skillsPort = { + uploadSkill: jest.fn(), + } as unknown as jest.Mocked<ISkillsPort>; + + changeProposalService = { + findById: jest.fn().mockResolvedValue(proposal), + batchUpdateProposalsInTransaction: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked<ChangeProposalService>; + + eventEmitterService = { + emit: jest.fn(), + } as unknown as jest.Mocked<PackmindEventEmitterService>; + + useCase = new ApplyCreationChangeProposalsUseCase( + spacesPort, + accountsPort, + recipesPort, + standardsPort, + skillsPort, + changeProposalService, + eventEmitterService, + stubLogger(), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when accepting a createCommand proposal', () => { + it('creates a recipe via recipesPort', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposal)], + rejected: [], + }); + + expect(recipesPort.captureRecipe).toHaveBeenCalledWith({ + userId: proposal.createdBy, + organizationId, + spaceId, + name: payload.name, + content: payload.content, + }); + }); + + it('returns the created recipe id', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposal)], + rejected: [], + }); + + expect(result.created).toEqual({ + commands: [recipeId], + standards: [], + skills: [], + }); + }); + + it('returns empty rejected list', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposal)], + rejected: [], + }); + + expect(result.rejected).toEqual([]); + }); + + it('calls batchUpdateProposalsInTransaction with accepted proposals', async () => { + const acceptedProposal = toAcceptedProposal(proposal); + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [acceptedProposal], + rejected: [], + }); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).toHaveBeenCalledWith({ + acceptedProposals: [{ proposal: acceptedProposal, userId }], + rejectedProposals: [], + }); + }); + + it('emits accepted event with itemType, changeType, and created artefact id', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposal)], + rejected: [], + }); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + userId: userId, + itemType: 'command', + changeType: ChangeProposalType.createCommand, + itemId: recipeId, + }), + }), + ); + }); + }); + + describe('when rejecting a createCommand proposal', () => { + it('does not create any recipe', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [proposalId], + }); + + expect(recipesPort.captureRecipe).not.toHaveBeenCalled(); + }); + + it('returns empty created list', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [proposalId], + }); + + expect(result.created).toEqual({ + commands: [], + standards: [], + skills: [], + }); + }); + + it('returns the rejected proposal id', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [proposalId], + }); + + expect(result.rejected).toEqual([proposalId]); + }); + + it('calls batchUpdateProposalsInTransaction with rejected proposals', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [proposalId], + }); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).toHaveBeenCalledWith({ + acceptedProposals: [], + rejectedProposals: [{ proposal, userId }], + }); + }); + + it('emits rejected event with itemType and changeType', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [proposalId], + }); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + userId: userId, + itemType: 'command', + changeType: ChangeProposalType.createCommand, + itemId: '', + }), + }), + ); + }); + }); + + describe('when proposal is not pending', () => { + it('throws an error', async () => { + changeProposalService.findById.mockResolvedValue({ + ...proposal, + status: ChangeProposalStatus.applied, + decision: proposal.payload, + }); + + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposal)], + rejected: [], + }), + ).rejects.toThrow( + `Change proposal ${proposalId} is not pending (status: applied)`, + ); + }); + }); + + describe('when proposal is not a supported creation type', () => { + it('throws an error', async () => { + changeProposalService.findById.mockResolvedValue({ + ...proposal, + type: ChangeProposalType.updateCommandName, + }); + + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposal)], + rejected: [], + }), + ).rejects.toThrow( + `Change proposal ${proposalId} has unsupported type for creation (type: updateCommandName)`, + ); + }); + }); + + describe('when proposal is not found', () => { + it('throws an error', async () => { + changeProposalService.findById.mockResolvedValue(null); + + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposal)], + rejected: [], + }), + ).rejects.toThrow(`Change proposal ${proposalId} not found`); + }); + }); + + describe('when accepted proposal type does not match database', () => { + it('throws an error', async () => { + const acceptedProposal = toAcceptedProposal({ + ...proposal, + type: ChangeProposalType.createStandard, + decision: proposal.payload, + } as unknown as ChangeProposal<ChangeProposalType.createStandard>); + + changeProposalService.findById.mockResolvedValue(proposal); + + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [acceptedProposal], + rejected: [], + }), + ).rejects.toThrow( + `Change proposal ${proposalId} type mismatch: expected createCommand, got createStandard`, + ); + }); + }); + + describe('when accepted proposal payload does not match database', () => { + it('throws an error', async () => { + const acceptedProposal = toAcceptedProposal({ + ...proposal, + payload: { name: 'Different Name', content: 'Different content' }, + }); + + changeProposalService.findById.mockResolvedValue(proposal); + + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [acceptedProposal], + rejected: [], + }), + ).rejects.toThrow(`Change proposal ${proposalId} payload mismatch`); + }); + }); + + describe('when the user is not a member of the space', () => { + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue(null); + }); + + it('throws a SpaceMembershipRequiredError', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposal)], + rejected: [], + }), + ).rejects.toThrow(SpaceMembershipRequiredError); + }); + }); + + describe('when both accepted and rejected lists are empty', () => { + it('returns empty created and rejected lists', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [], + }); + + expect(result.created).toEqual({ + commands: [], + standards: [], + skills: [], + }); + }); + + it('returns empty rejected list', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [], + }); + + expect(result.rejected).toEqual([]); + }); + + it('calls batchUpdateProposalsInTransaction with empty proposals', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [], + }); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).toHaveBeenCalledWith({ + acceptedProposals: [], + rejectedProposals: [], + }); + }); + }); + + describe('when accepting multiple createCommand proposals', () => { + it('creates all recipes and returns all created IDs', async () => { + const proposalId2 = createChangeProposalId('proposal-2'); + const recipeId2 = createRecipeId('recipe-2'); + const proposal2 = changeProposalFactory({ + id: proposalId2, + type: ChangeProposalType.createCommand, + artefactId: null, + payload, + status: ChangeProposalStatus.pending, + spaceId, + }); + const recipe2 = recipeFactory({ id: recipeId2, spaceId }); + + changeProposalService.findById + .mockResolvedValueOnce(proposal) + .mockResolvedValueOnce(proposal2); + recipesPort.captureRecipe + .mockResolvedValueOnce(recipe) + .mockResolvedValueOnce(recipe2); + + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposal), toAcceptedProposal(proposal2)], + rejected: [], + }); + + expect(result.created).toEqual({ + commands: [recipeId, recipeId2], + standards: [], + skills: [], + }); + }); + + it('calls captureRecipe for each accepted proposal', async () => { + const proposalId2 = createChangeProposalId('proposal-2'); + const proposal2 = changeProposalFactory({ + id: proposalId2, + type: ChangeProposalType.createCommand, + artefactId: null, + payload, + status: ChangeProposalStatus.pending, + spaceId, + }); + + changeProposalService.findById + .mockResolvedValueOnce(proposal) + .mockResolvedValueOnce(proposal2); + recipesPort.captureRecipe.mockResolvedValue(recipe); + + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposal), toAcceptedProposal(proposal2)], + rejected: [], + }); + + expect(recipesPort.captureRecipe).toHaveBeenCalledTimes(2); + }); + }); + + describe('when some proposals are accepted and others rejected', () => { + it('creates recipes only for accepted proposals', async () => { + const proposalId2 = createChangeProposalId('proposal-2'); + const proposal2 = changeProposalFactory({ + id: proposalId2, + type: ChangeProposalType.createCommand, + artefactId: null, + payload, + status: ChangeProposalStatus.pending, + spaceId, + }); + + changeProposalService.findById + .mockResolvedValueOnce(proposal) + .mockResolvedValueOnce(proposal2); + recipesPort.captureRecipe.mockResolvedValue(recipe); + + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposal)], + rejected: [proposalId2], + }); + + expect(recipesPort.captureRecipe).toHaveBeenCalledTimes(1); + }); + + it('returns correct split of created and rejected', async () => { + const proposalId2 = createChangeProposalId('proposal-2'); + const proposal2 = changeProposalFactory({ + id: proposalId2, + type: ChangeProposalType.createCommand, + artefactId: null, + payload, + status: ChangeProposalStatus.pending, + spaceId, + }); + + changeProposalService.findById + .mockResolvedValueOnce(proposal) + .mockResolvedValueOnce(proposal2); + recipesPort.captureRecipe.mockResolvedValue(recipe); + + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposal)], + rejected: [proposalId2], + }); + + expect(result.rejected).toEqual([proposalId2]); + }); + }); + + describe('when captureRecipe fails', () => { + beforeEach(() => { + recipesPort.captureRecipe.mockRejectedValue( + new Error('Recipe creation failed'), + ); + }); + + it('throws the captureRecipe error', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposal)], + rejected: [], + }), + ).rejects.toThrow('Recipe creation failed'); + }); + + it('does not call batchUpdateProposalsInTransaction', async () => { + await useCase + .execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposal)], + rejected: [], + }) + .catch(() => undefined); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when accepting a createStandard proposal', () => { + const standardId = createStandardId('new-standard'); + const standardPayload: NewStandardPayload = { + name: 'My Standard', + description: 'A coding standard', + scope: '*.ts', + rules: [{ content: 'Use const for immutable variables' }], + }; + const standardProposal = changeProposalFactory({ + id: createChangeProposalId('standard-proposal-1'), + type: ChangeProposalType.createStandard, + artefactId: null, + payload: standardPayload, + status: ChangeProposalStatus.pending, + spaceId, + }) as ChangeProposal<ChangeProposalType.createStandard>; + const standard = standardFactory({ id: standardId, spaceId }); + + beforeEach(() => { + changeProposalService.findById.mockResolvedValue(standardProposal); + standardsPort.createStandardWithExamples.mockResolvedValue(standard); + }); + + describe('when accepted', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + let acceptedStandardProposal: AcceptedChangeProposal<ChangeProposalType.createStandard>; + + beforeEach(async () => { + acceptedStandardProposal = toAcceptedProposal(standardProposal); + result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [acceptedStandardProposal], + rejected: [], + }); + }); + + it('creates a standard via standardsPort', () => { + expect(standardsPort.createStandardWithExamples).toHaveBeenCalledWith({ + userId: standardProposal.createdBy, + organizationId, + spaceId, + name: standardPayload.name, + description: standardPayload.description, + summary: null, + scope: standardPayload.scope, + rules: [{ content: 'Use const for immutable variables' }], + }); + }); + + it('returns the created standard id', () => { + expect(result.created).toEqual({ + commands: [], + standards: [standardId], + skills: [], + }); + }); + + it('returns empty rejected list', () => { + expect(result.rejected).toEqual([]); + }); + + it('calls batchUpdateProposalsInTransaction with accepted proposals', () => { + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).toHaveBeenCalledWith({ + acceptedProposals: [{ proposal: acceptedStandardProposal, userId }], + rejectedProposals: [], + }); + }); + + it('emits accepted event with itemType, changeType, and created standard id', () => { + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + userId: userId, + itemType: 'standard', + changeType: ChangeProposalType.createStandard, + itemId: standardId, + }), + }), + ); + }); + }); + + describe('when scope is an array', () => { + it('joins scope array with comma', async () => { + const proposalWithArrayScope = changeProposalFactory({ + id: createChangeProposalId('standard-proposal-2'), + type: ChangeProposalType.createStandard, + artefactId: null, + payload: { + ...standardPayload, + scope: ['*.ts', '*.tsx'], + }, + status: ChangeProposalStatus.pending, + spaceId, + }); + + changeProposalService.findById.mockResolvedValue( + proposalWithArrayScope, + ); + + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposalWithArrayScope)], + rejected: [], + }); + + expect(standardsPort.createStandardWithExamples).toHaveBeenCalledWith( + expect.objectContaining({ + scope: '*.ts, *.tsx', + }), + ); + }); + }); + + describe('when scope is null', () => { + it('passes null scope', async () => { + const proposalWithNullScope = changeProposalFactory({ + id: createChangeProposalId('standard-proposal-3'), + type: ChangeProposalType.createStandard, + artefactId: null, + payload: { + ...standardPayload, + scope: null, + }, + status: ChangeProposalStatus.pending, + spaceId, + }); + + changeProposalService.findById.mockResolvedValue(proposalWithNullScope); + + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposalWithNullScope)], + rejected: [], + }); + + expect(standardsPort.createStandardWithExamples).toHaveBeenCalledWith( + expect.objectContaining({ + scope: null, + }), + ); + }); + }); + }); + + describe('when rejecting a createStandard proposal', () => { + const standardPayload: NewStandardPayload = { + name: 'My Standard', + description: 'A coding standard', + scope: '*.ts', + rules: [{ content: 'Use const for immutable variables' }], + }; + const standardProposal = changeProposalFactory({ + id: createChangeProposalId('standard-proposal-1'), + type: ChangeProposalType.createStandard, + artefactId: null, + payload: standardPayload, + status: ChangeProposalStatus.pending, + spaceId, + }); + + beforeEach(() => { + changeProposalService.findById.mockResolvedValue(standardProposal); + }); + + it('does not create any standard', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [standardProposal.id], + }); + + expect(standardsPort.createStandardWithExamples).not.toHaveBeenCalled(); + }); + + it('returns empty created list', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [standardProposal.id], + }); + + expect(result.created).toEqual({ + commands: [], + standards: [], + skills: [], + }); + }); + + it('returns the rejected proposal id', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [standardProposal.id], + }); + + expect(result.rejected).toEqual([standardProposal.id]); + }); + + it('calls batchUpdateProposalsInTransaction with rejected proposals', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [standardProposal.id], + }); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).toHaveBeenCalledWith({ + acceptedProposals: [], + rejectedProposals: [{ proposal: standardProposal, userId }], + }); + }); + }); + + describe('when accepting mixed createCommand and createStandard proposals', () => { + const standardId = createStandardId('new-standard'); + const standardPayload: NewStandardPayload = { + name: 'My Standard', + description: 'A coding standard', + scope: '*.ts', + rules: [{ content: 'Use const for immutable variables' }], + }; + const standardProposal = changeProposalFactory({ + id: createChangeProposalId('standard-proposal-1'), + type: ChangeProposalType.createStandard, + artefactId: null, + payload: standardPayload, + status: ChangeProposalStatus.pending, + spaceId, + }); + const standard = standardFactory({ id: standardId, spaceId }); + + beforeEach(() => { + changeProposalService.findById + .mockResolvedValueOnce(proposal) + .mockResolvedValueOnce(standardProposal); + recipesPort.captureRecipe.mockResolvedValue(recipe); + standardsPort.createStandardWithExamples.mockResolvedValue(standard); + }); + + it('returns both created command and standard IDs', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [ + toAcceptedProposal(proposal), + toAcceptedProposal(standardProposal), + ], + rejected: [], + }); + + expect(result.created).toEqual({ + commands: [recipeId], + standards: [standardId], + skills: [], + }); + }); + + it('calls captureRecipe for command proposal', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [ + toAcceptedProposal(proposal), + toAcceptedProposal(standardProposal), + ], + rejected: [], + }); + + expect(recipesPort.captureRecipe).toHaveBeenCalledTimes(1); + }); + + it('calls createStandardWithExamples for standard proposal', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [ + toAcceptedProposal(proposal), + toAcceptedProposal(standardProposal), + ], + rejected: [], + }); + + expect(standardsPort.createStandardWithExamples).toHaveBeenCalledTimes(1); + }); + }); + + describe('when createStandardWithExamples fails', () => { + const standardPayload: NewStandardPayload = { + name: 'My Standard', + description: 'A coding standard', + scope: '*.ts', + rules: [{ content: 'Use const for immutable variables' }], + }; + const standardProposal = changeProposalFactory({ + id: createChangeProposalId('standard-proposal-1'), + type: ChangeProposalType.createStandard, + artefactId: null, + payload: standardPayload, + status: ChangeProposalStatus.pending, + spaceId, + }); + + beforeEach(() => { + changeProposalService.findById.mockResolvedValue(standardProposal); + standardsPort.createStandardWithExamples.mockRejectedValue( + new Error('Standard creation failed'), + ); + }); + + it('throws the createStandardWithExamples error', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(standardProposal)], + rejected: [], + }), + ).rejects.toThrow('Standard creation failed'); + }); + + it('does not call batchUpdateProposalsInTransaction', async () => { + await useCase + .execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(standardProposal)], + rejected: [], + }) + .catch(() => undefined); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when accepting a createSkill proposal', () => { + const skillId = createSkillId('new-skill'); + const skillPayload: NewSkillPayload = { + name: 'My Skill', + description: 'A coding skill', + prompt: 'This is the skill prompt with instructions', + skillMdPermissions: 'rw-r--r--', + license: 'MIT', + compatibility: '>=1.0.0', + }; + const skillProposal = changeProposalFactory({ + id: createChangeProposalId('skill-proposal-1'), + type: ChangeProposalType.createSkill, + artefactId: null, + payload: skillPayload, + status: ChangeProposalStatus.pending, + spaceId, + }); + const skill = skillFactory({ id: skillId, spaceId }); + + beforeEach(() => { + changeProposalService.findById.mockResolvedValue(skillProposal); + skillsPort.uploadSkill.mockResolvedValue({ + skill, + versionCreated: true, + }); + }); + + it('creates a skill via skillsPort', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(skillProposal)], + rejected: [], + }); + + expect(skillsPort.uploadSkill).toHaveBeenCalledWith( + expect.objectContaining({ + userId: skillProposal.createdBy, + organizationId, + spaceId, + files: expect.arrayContaining([ + expect.objectContaining({ + path: 'SKILL.md', + isBase64: false, + }), + ]), + }), + ); + }); + + describe('generated SKILL.md', () => { + let skillMdFile: { path: string; content: string } | undefined; + + beforeEach(async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(skillProposal)], + rejected: [], + }); + + const uploadCall = skillsPort.uploadSkill.mock.calls[0][0]; + skillMdFile = uploadCall.files.find((f) => f.path === 'SKILL.md'); + }); + + it('includes skill name', () => { + expect(skillMdFile?.content).toContain('name: "My Skill"'); + }); + + it('includes skill description', () => { + expect(skillMdFile?.content).toContain('description: "A coding skill"'); + }); + + it('includes license', () => { + expect(skillMdFile?.content).toContain('license: "MIT"'); + }); + + it('includes compatibility', () => { + expect(skillMdFile?.content).toContain('compatibility: ">=1.0.0"'); + }); + + it('includes prompt in body', () => { + expect(skillMdFile?.content).toContain( + 'This is the skill prompt with instructions', + ); + }); + }); + + it('returns the created skill id', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(skillProposal)], + rejected: [], + }); + + expect(result.created).toEqual({ + commands: [], + standards: [], + skills: [skillId], + }); + }); + + it('returns empty rejected list', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(skillProposal)], + rejected: [], + }); + + expect(result.rejected).toEqual([]); + }); + + it('calls batchUpdateProposalsInTransaction with accepted proposals', async () => { + const acceptedSkillProposal = toAcceptedProposal(skillProposal); + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [acceptedSkillProposal], + rejected: [], + }); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).toHaveBeenCalledWith({ + acceptedProposals: [{ proposal: acceptedSkillProposal, userId }], + rejectedProposals: [], + }); + }); + + it('emits accepted event with itemType, changeType, and created skill id', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(skillProposal)], + rejected: [], + }); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + userId: userId, + itemType: 'skill', + changeType: ChangeProposalType.createSkill, + itemId: skillId, + }), + }), + ); + }); + + describe('when payload includes additional files', () => { + it('includes all files in upload', async () => { + const proposalWithFiles = changeProposalFactory({ + id: createChangeProposalId('skill-proposal-2'), + type: ChangeProposalType.createSkill, + artefactId: null, + payload: { + ...skillPayload, + files: [ + { + path: 'helper.js', + content: 'export function helper() {}', + permissions: 'rw-r--r--', + isBase64: false, + }, + ], + }, + status: ChangeProposalStatus.pending, + spaceId, + }); + + changeProposalService.findById.mockResolvedValue(proposalWithFiles); + + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(proposalWithFiles)], + rejected: [], + }); + + expect(skillsPort.uploadSkill).toHaveBeenCalledWith( + expect.objectContaining({ + files: expect.arrayContaining([ + expect.objectContaining({ path: 'SKILL.md' }), + expect.objectContaining({ path: 'helper.js' }), + ]), + }), + ); + }); + }); + }); + + describe('when rejecting a createSkill proposal', () => { + const skillPayload: NewSkillPayload = { + name: 'My Skill', + description: 'A coding skill', + prompt: 'This is the skill prompt', + skillMdPermissions: 'rw-r--r--', + }; + const skillProposal = changeProposalFactory({ + id: createChangeProposalId('skill-proposal-1'), + type: ChangeProposalType.createSkill, + artefactId: null, + payload: skillPayload, + status: ChangeProposalStatus.pending, + spaceId, + }); + + beforeEach(() => { + changeProposalService.findById.mockResolvedValue(skillProposal); + }); + + it('does not create any skill', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [skillProposal.id], + }); + + expect(skillsPort.uploadSkill).not.toHaveBeenCalled(); + }); + + it('returns empty created list', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [skillProposal.id], + }); + + expect(result.created).toEqual({ + commands: [], + standards: [], + skills: [], + }); + }); + + it('returns the rejected proposal id', async () => { + const result = await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [skillProposal.id], + }); + + expect(result.rejected).toEqual([skillProposal.id]); + }); + + it('calls batchUpdateProposalsInTransaction with rejected proposals', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [skillProposal.id], + }); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).toHaveBeenCalledWith({ + acceptedProposals: [], + rejectedProposals: [{ proposal: skillProposal, userId }], + }); + }); + + it('emits rejected event with itemType and changeType', async () => { + await useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [], + rejected: [skillProposal.id], + }); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + userId: userId, + itemType: 'skill', + changeType: ChangeProposalType.createSkill, + itemId: '', + }), + }), + ); + }); + }); + + describe('when uploadSkill fails', () => { + const skillPayload: NewSkillPayload = { + name: 'My Skill', + description: 'A coding skill', + prompt: 'This is the skill prompt', + skillMdPermissions: 'rw-r--r--', + }; + const skillProposal = changeProposalFactory({ + id: createChangeProposalId('skill-proposal-1'), + type: ChangeProposalType.createSkill, + artefactId: null, + payload: skillPayload, + status: ChangeProposalStatus.pending, + spaceId, + }); + + beforeEach(() => { + changeProposalService.findById.mockResolvedValue(skillProposal); + skillsPort.uploadSkill.mockRejectedValue( + new Error('Skill upload failed'), + ); + }); + + it('throws the uploadSkill error', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(skillProposal)], + rejected: [], + }), + ).rejects.toThrow('Skill upload failed'); + }); + + it('does not call batchUpdateProposalsInTransaction', async () => { + await useCase + .execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(skillProposal)], + rejected: [], + }) + .catch(() => undefined); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).not.toHaveBeenCalled(); + }); + }); + + describe('when uploadSkill throws SkillValidationError on a legacy createSkill proposal', () => { + const skillPayload: NewSkillPayload = { + name: 'My Skill', + description: 'a'.repeat(1025), + prompt: 'This is the skill prompt', + skillMdPermissions: 'rw-r--r--', + }; + const skillProposal = changeProposalFactory({ + id: createChangeProposalId('skill-proposal-1'), + type: ChangeProposalType.createSkill, + artefactId: null, + payload: skillPayload, + status: ChangeProposalStatus.pending, + spaceId, + }); + + beforeEach(() => { + changeProposalService.findById.mockResolvedValue(skillProposal); + skillsPort.uploadSkill.mockRejectedValue( + new SkillValidationError([ + { + field: 'description', + message: 'description must not exceed 1024 characters', + }, + ]), + ); + }); + + it('rethrows with an actionable user-facing message', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(skillProposal)], + rejected: [], + }), + ).rejects.toThrow( + /description longer than 1024 characters\. Edit your skill and upload it again\./, + ); + }); + + it('rethrows a SkillValidationError so the controller can map it to 400', async () => { + await expect( + useCase.execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(skillProposal)], + rejected: [], + }), + ).rejects.toBeInstanceOf(SkillValidationError); + }); + + it('does not call batchUpdateProposalsInTransaction so the proposal stays pending', async () => { + await useCase + .execute({ + userId, + organizationId, + spaceId, + accepted: [toAcceptedProposal(skillProposal)], + rejected: [], + }) + .catch(() => undefined); + + expect( + changeProposalService.batchUpdateProposalsInTransaction, + ).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/ApplyCreationChangeProposalsUseCase.ts b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/ApplyCreationChangeProposalsUseCase.ts new file mode 100644 index 000000000..4a8efadb8 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/ApplyCreationChangeProposalsUseCase.ts @@ -0,0 +1,299 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + AbstractSpaceMemberUseCase, + SpaceMemberContext, + PackmindEventEmitterService, + SSEEventPublisher, +} from '@packmind/node-utils'; +import { + ApplyCreationChangeProposalsCommand, + ApplyCreationChangeProposalsResponse, + ChangeProposal, + ChangeProposalAcceptedEvent, + ChangeProposalId, + ChangeProposalRejectedEvent, + isChangeProposalEdited, + ChangeProposalStatus, + ChangeProposalType, + createOrganizationId, + createUserId, + CreationChangeProposalTypes, + getItemTypeFromChangeProposalType, + IAccountsPort, + IApplyCreationChangeProposalsUseCase, + IRecipesPort, + ISkillsPort, + ISpacesPort, + IStandardsPort, + UserId, +} from '@packmind/types'; +import { DESCRIPTION_MAX_LENGTH, SkillValidationError } from '@packmind/skills'; +import { ChangeProposalService } from '../../services/ChangeProposalService'; +import { + CreatedIds, + ICreateChangeProposalApplier, +} from './ICreateChangeProposalApplier'; +import { CommandCreateChangeProposalApplier } from './shared/CommandCreateChangeProposalApplier'; +import { StandardCreateChangeProposalApplier } from './shared/StandardCreateChangeProposalApplier'; +import { SkillCreateChangeProposalApplier } from './shared/SkillCreateChangeProposalApplier'; +import { isExpectedChangeProposalType } from '../../utils/isExpectedChangeProposalType'; + +export type { + ApplyCreationChangeProposalsCommand, + ApplyCreationChangeProposalsResponse, +}; + +const origin = 'ApplyCreationChangeProposalsUseCase'; + +export class ApplyCreationChangeProposalsUseCase + extends AbstractSpaceMemberUseCase< + ApplyCreationChangeProposalsCommand, + ApplyCreationChangeProposalsResponse + > + implements IApplyCreationChangeProposalsUseCase +{ + private readonly appliers: Record< + CreationChangeProposalTypes, + ICreateChangeProposalApplier<CreationChangeProposalTypes> + >; + + constructor( + spacesPort: ISpacesPort, + accountsPort: IAccountsPort, + recipesPort: IRecipesPort, + standardsPort: IStandardsPort, + skillsPort: ISkillsPort, + private readonly changeProposalService: ChangeProposalService, + private readonly eventEmitterService: PackmindEventEmitterService, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(spacesPort, accountsPort, logger); + + this.appliers = { + [ChangeProposalType.createCommand]: + new CommandCreateChangeProposalApplier(recipesPort), + [ChangeProposalType.createStandard]: + new StandardCreateChangeProposalApplier(standardsPort), + [ChangeProposalType.createSkill]: new SkillCreateChangeProposalApplier( + skillsPort, + ), + }; + } + + async executeForSpaceMembers( + command: ApplyCreationChangeProposalsCommand & SpaceMemberContext, + ): Promise<ApplyCreationChangeProposalsResponse> { + const acceptedIds = command.accepted.map((p) => p.id); + const allIds = [...acceptedIds, ...command.rejected]; + const proposals = await Promise.all( + allIds.map((id) => this.changeProposalService.findById(id)), + ); + + this.assertProposalsAreCreationProposal(command.accepted); + this.assertAllProposalsValid(proposals, allIds); + + const proposalMap = new Map(proposals.map((p) => [p.id, p])); + let createdIds: CreatedIds = { + commands: [], + standards: [], + skills: [], + }; + const createdArtefactIdByProposalId = new Map<ChangeProposalId, string>(); + + try { + for (const acceptedProposal of command.accepted) { + const dbProposal = proposalMap.get(acceptedProposal.id); + if (!dbProposal) { + throw new Error( + `Change proposal ${acceptedProposal.id} not found in database`, + ); + } + + // Validate that the proposal from command matches the one in DB + this.assertProposalMatchesDatabase(acceptedProposal, dbProposal); + + const applier = this.appliers[acceptedProposal.type]; + + const artefact = await applier.apply( + acceptedProposal, + command.spaceId, + createOrganizationId(command.organizationId), + ); + createdArtefactIdByProposalId.set(acceptedProposal.id, artefact.id); + createdIds = applier.updateCreatedIds(createdIds, artefact); + } + } catch (error) { + if (error instanceof SkillValidationError) { + const descriptionError = error.errors.find( + (e) => e.field === 'description', + ); + error.message = descriptionError + ? `A submitted skill has a description longer than ${DESCRIPTION_MAX_LENGTH} characters. Edit your skill and upload it again.` + : `A submitted skill is invalid: ${error.errors + .map((e) => e.message) + .join('; ')}. Edit your skill and upload it again.`; + } + throw error; + } + + const acceptedProposals = command.accepted.map((proposal) => ({ + proposal, + userId: command.userId as UserId, + })); + + const rejectedProposals = command.rejected + .map((id) => { + const proposal = proposalMap.get(id); + return proposal ? { proposal, userId: command.userId as UserId } : null; + }) + .filter( + ( + item, + ): item is { + proposal: ChangeProposal<CreationChangeProposalTypes>; + userId: UserId; + } => item !== null, + ); + + await this.changeProposalService.batchUpdateProposalsInTransaction({ + acceptedProposals, + rejectedProposals, + }); + + this.logger.info('Applied creation change proposals', { + spaceId: command.spaceId, + accepted: command.accepted.length, + rejected: command.rejected.length, + createdCommands: createdIds.commands.length, + createdStandards: createdIds.standards.length, + createdSkills: createdIds.skills.length, + }); + + SSEEventPublisher.publishChangeProposalUpdateEvent( + command.organization.id, + command.spaceId, + ).catch((error) => { + this.logger.error('Failed to publish change proposal update SSE event', { + organizationId: command.organization.id, + spaceId: command.spaceId, + error: error instanceof Error ? error.message : String(error), + }); + }); + + for (const { proposal } of acceptedProposals) { + const createdArtefactId = createdArtefactIdByProposalId.get(proposal.id); + if (!createdArtefactId) { + throw new Error( + `Created artefact ID not found for proposal ${proposal.id}`, + ); + } + + this.eventEmitterService.emit( + new ChangeProposalAcceptedEvent({ + userId: createUserId(command.userId), + organizationId: command.organization.id, + source: command.source ?? 'ui', + changeProposalId: proposal.id, + itemType: getItemTypeFromChangeProposalType(proposal.type), + itemId: createdArtefactId, + changeType: proposal.type, + edited: isChangeProposalEdited( + proposal.type, + proposal.decision, + proposal.payload, + ), + }), + ); + } + + for (const { proposal } of rejectedProposals) { + this.eventEmitterService.emit( + new ChangeProposalRejectedEvent({ + userId: createUserId(command.userId), + organizationId: command.organization.id, + source: command.source ?? 'ui', + changeProposalId: proposal.id, + itemType: getItemTypeFromChangeProposalType(proposal.type), + // creation proposals have no artefactId before they are applied — empty string is correct for rejections + itemId: String(proposal.artefactId ?? ''), + changeType: proposal.type, + }), + ); + } + + return { + created: createdIds, + rejected: command.rejected, + }; + } + + private assertProposalsAreCreationProposal( + proposals: ChangeProposal[], + ): asserts proposals is ChangeProposal<CreationChangeProposalTypes>[] { + for (const proposal of proposals) { + this.assertProposalIsCreationProposal(proposal); + } + } + + private assertAllProposalsValid( + proposals: (ChangeProposal | null)[], + allIds: ChangeProposalId[], + ): asserts proposals is ChangeProposal<CreationChangeProposalTypes>[] { + for (let i = 0; i < proposals.length; i++) { + const proposal = proposals[i]; + const id = allIds[i]; + + if (!proposal) { + throw new Error(`Change proposal ${id} not found`); + } + + if (proposal.status !== ChangeProposalStatus.pending) { + throw new Error( + `Change proposal ${id} is not pending (status: ${proposal.status})`, + ); + } + + this.assertProposalIsCreationProposal(proposal); + } + } + + private assertProposalIsCreationProposal( + proposal: ChangeProposal, + ): asserts proposal is ChangeProposal<CreationChangeProposalTypes> { + if ( + !isExpectedChangeProposalType( + proposal, + ChangeProposalType.createCommand, + ) && + !isExpectedChangeProposalType( + proposal, + ChangeProposalType.createStandard, + ) && + !isExpectedChangeProposalType(proposal, ChangeProposalType.createSkill) + ) { + throw new Error( + `Change proposal ${proposal.id} has unsupported type for creation (type: ${proposal.type})`, + ); + } + } + + private assertProposalMatchesDatabase( + acceptedProposal: ChangeProposal<CreationChangeProposalTypes>, + dbProposal: ChangeProposal<CreationChangeProposalTypes>, + ): void { + if (acceptedProposal.type !== dbProposal.type) { + throw new Error( + `Change proposal ${acceptedProposal.id} type mismatch: expected ${dbProposal.type}, got ${acceptedProposal.type}`, + ); + } + + if ( + JSON.stringify(acceptedProposal.payload) !== + JSON.stringify(dbProposal.payload) + ) { + throw new Error( + `Change proposal ${acceptedProposal.id} payload mismatch`, + ); + } + } +} diff --git a/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/ICreateChangeProposalApplier.ts b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/ICreateChangeProposalApplier.ts new file mode 100644 index 000000000..994e2cd9b --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/ICreateChangeProposalApplier.ts @@ -0,0 +1,34 @@ +import { + ApplyCreationChangeProposalsResponse, + ChangeProposal, + ChangeProposalType, + CreationChangeProposalTypes, + OrganizationId, + Recipe, + Skill, + SpaceId, + Standard, +} from '@packmind/types'; + +export type CreatedIds = ApplyCreationChangeProposalsResponse['created']; + +type Artefact<CP extends CreationChangeProposalTypes> = + CP extends ChangeProposalType.createCommand + ? Recipe + : CP extends ChangeProposalType.createStandard + ? Standard + : CP extends ChangeProposalType.createSkill + ? Skill + : never; + +export interface ICreateChangeProposalApplier< + CP extends CreationChangeProposalTypes, +> { + apply( + changeProposal: ChangeProposal<CP>, + spaceId: SpaceId, + organizationId: OrganizationId, + ): Promise<Artefact<CP>>; + + updateCreatedIds(createdIds: CreatedIds, artefact: Artefact<CP>): CreatedIds; +} diff --git a/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/index.ts b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/index.ts new file mode 100644 index 000000000..d9c69c6c0 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/index.ts @@ -0,0 +1,5 @@ +export { ApplyCreationChangeProposalsUseCase } from './ApplyCreationChangeProposalsUseCase'; +export type { + ApplyCreationChangeProposalsCommand, + ApplyCreationChangeProposalsResponse, +} from './ApplyCreationChangeProposalsUseCase'; diff --git a/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/shared/CommandCreateChangeProposalApplier.ts b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/shared/CommandCreateChangeProposalApplier.ts new file mode 100644 index 000000000..2230bab47 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/shared/CommandCreateChangeProposalApplier.ts @@ -0,0 +1,37 @@ +import { + CreatedIds, + ICreateChangeProposalApplier, +} from '../ICreateChangeProposalApplier'; +import { + ChangeProposal, + ChangeProposalType, + IRecipesPort, + OrganizationId, + Recipe, + SpaceId, +} from '@packmind/types'; + +export class CommandCreateChangeProposalApplier implements ICreateChangeProposalApplier<ChangeProposalType.createCommand> { + constructor(private readonly recipesPort: IRecipesPort) {} + + apply( + changeProposal: ChangeProposal<ChangeProposalType.createCommand>, + spaceId: SpaceId, + organizationId: OrganizationId, + ): Promise<Recipe> { + return this.recipesPort.captureRecipe({ + userId: changeProposal.createdBy, + organizationId, + spaceId, + name: changeProposal.payload.name, + content: changeProposal.payload.content, + }); + } + + updateCreatedIds(createdIds: CreatedIds, artefact: Recipe): CreatedIds { + return { + ...createdIds, + commands: [...createdIds.commands, artefact.id], + }; + } +} diff --git a/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/shared/SkillCreateChangeProposalApplier.spec.ts b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/shared/SkillCreateChangeProposalApplier.spec.ts new file mode 100644 index 000000000..3ec0f3add --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/shared/SkillCreateChangeProposalApplier.spec.ts @@ -0,0 +1,264 @@ +import { + parseSkillMdContent, + serializeSkillMetadata, +} from '@packmind/node-utils'; +import { + ChangeProposalType, + createOrganizationId, + createSpaceId, + createUserId, + ISkillsPort, +} from '@packmind/types'; +import { changeProposalFactory } from '../../../../../test/changeProposalFactory'; +import { skillFactory } from '@packmind/skills/test/skillFactory'; +import { SkillCreateChangeProposalApplier } from './SkillCreateChangeProposalApplier'; + +describe('SkillCreateChangeProposalApplier', () => { + const organizationId = createOrganizationId('org-id'); + const spaceId = createSpaceId('space-id'); + const userId = createUserId('user-id'); + + let skillsPort: jest.Mocked<Pick<ISkillsPort, 'uploadSkill'>>; + let applier: SkillCreateChangeProposalApplier; + let capturedSkillMdContent: string; + + beforeEach(() => { + capturedSkillMdContent = ''; + skillsPort = { + uploadSkill: jest.fn().mockImplementation((cmd) => { + const skillMdFile = cmd.files.find( + (f: { path: string }) => f.path === 'SKILL.md', + ); + capturedSkillMdContent = skillMdFile?.content ?? ''; + return Promise.resolve({ skill: skillFactory() }); + }), + }; + applier = new SkillCreateChangeProposalApplier( + skillsPort as unknown as ISkillsPort, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('generateSkillMd — YAML injection safety', () => { + describe('when name contains YAML special characters', () => { + beforeEach(async () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.createSkill, + createdBy: userId, + payload: { + name: 'my: skill', + description: 'A description', + prompt: 'Do something', + }, + }); + await applier.apply(proposal, spaceId, organizationId); + }); + + it('produces parseable YAML frontmatter', () => { + const parsed = parseSkillMdContent(capturedSkillMdContent); + + expect(parsed?.properties.name).toBe('my: skill'); + }); + }); + + describe('when description starts with a YAML special character', () => { + beforeEach(async () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.createSkill, + createdBy: userId, + payload: { + name: 'test-skill', + description: '{curly braces are special}', + prompt: 'Do something', + }, + }); + await applier.apply(proposal, spaceId, organizationId); + }); + + it('produces parseable YAML frontmatter', () => { + const parsed = parseSkillMdContent(capturedSkillMdContent); + + expect(parsed?.properties.description).toBe( + '{curly braces are special}', + ); + }); + }); + + describe('when license contains YAML special characters', () => { + let parsed: ReturnType<typeof parseSkillMdContent>; + + beforeEach(async () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.createSkill, + createdBy: userId, + payload: { + name: 'test-skill', + description: 'A description', + prompt: 'Do something', + license: 'MIT: v2', + compatibility: '[cursor, claude]', + }, + }); + await applier.apply(proposal, spaceId, organizationId); + parsed = parseSkillMdContent(capturedSkillMdContent); + }); + + it('preserves the license value', () => { + expect(parsed?.properties.license).toBe('MIT: v2'); + }); + + it('preserves the compatibility value', () => { + expect(parsed?.properties.compatibility).toBe('[cursor, claude]'); + }); + }); + + describe('when allowedTools contains special characters', () => { + beforeEach(async () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.createSkill, + createdBy: userId, + payload: { + name: 'test-skill', + description: 'A description', + prompt: 'Do something', + allowedTools: 'tool: #1, tool: #2', + }, + }); + await applier.apply(proposal, spaceId, organizationId); + }); + + it('produces parseable YAML frontmatter', () => { + const parsed = parseSkillMdContent(capturedSkillMdContent); + + expect(parsed?.properties.allowedTools).toBe('tool: #1, tool: #2'); + }); + }); + }); + + describe('generateSkillMd — metadata serialization', () => { + describe('when metadata has unordered keys', () => { + const metadata = { z: 'last', a: 'first', m: 'middle' }; + + beforeEach(async () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.createSkill, + createdBy: userId, + payload: { + name: 'test-skill', + description: 'A description', + prompt: 'Do something', + metadata, + }, + }); + await applier.apply(proposal, spaceId, organizationId); + }); + + it('serializes metadata with keys sorted alphabetically', () => { + expect(capturedSkillMdContent).toContain( + `metadata: ${serializeSkillMetadata(metadata)}`, + ); + }); + }); + }); + + describe('generateSkillMd — additionalProperties', () => { + describe('when payload has additionalProperties', () => { + let parsed: ReturnType<typeof parseSkillMdContent>; + + beforeEach(async () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.createSkill, + createdBy: userId, + payload: { + name: 'test-skill', + description: 'A description', + prompt: 'Do something', + additionalProperties: { model: 'opus', userInvocable: true }, + }, + }); + await applier.apply(proposal, spaceId, organizationId); + parsed = parseSkillMdContent(capturedSkillMdContent); + }); + + it('includes model in frontmatter as kebab-case', () => { + expect(parsed?.properties['model']).toBe('opus'); + }); + + it('includes user-invocable in frontmatter as kebab-case', () => { + expect(parsed?.properties['user-invocable']).toBe(true); + }); + }); + + describe('when payload has no additionalProperties', () => { + let parsed: ReturnType<typeof parseSkillMdContent>; + + beforeEach(async () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.createSkill, + createdBy: userId, + payload: { + name: 'test-skill', + description: 'A description', + prompt: 'Do something', + }, + }); + await applier.apply(proposal, spaceId, organizationId); + parsed = parseSkillMdContent(capturedSkillMdContent); + }); + + it('does not include extra fields in frontmatter', () => { + const keys = Object.keys(parsed?.properties ?? {}); + expect(keys).toEqual(['name', 'description']); + }); + }); + }); + + describe('generateSkillMd — happy path', () => { + let parsed: ReturnType<typeof parseSkillMdContent>; + + beforeEach(async () => { + const proposal = changeProposalFactory({ + type: ChangeProposalType.createSkill, + createdBy: userId, + payload: { + name: 'My Skill', + description: 'A useful skill', + prompt: 'Do the thing', + license: 'MIT', + compatibility: 'cursor', + metadata: { author: 'test' }, + allowedTools: 'Read,Write', + }, + }); + await applier.apply(proposal, spaceId, organizationId); + parsed = parseSkillMdContent(capturedSkillMdContent); + }); + + it('parses the name correctly', () => { + expect(parsed?.properties.name).toBe('My Skill'); + }); + + it('parses the description correctly', () => { + expect(parsed?.properties.description).toBe('A useful skill'); + }); + + it('parses the license correctly', () => { + expect(parsed?.properties.license).toBe('MIT'); + }); + + it('parses the compatibility correctly', () => { + expect(parsed?.properties.compatibility).toBe('cursor'); + }); + + it('parses the allowedTools correctly', () => { + expect(parsed?.properties.allowedTools).toBe('Read,Write'); + }); + + it('parses the prompt body correctly', () => { + expect(parsed?.body).toBe('Do the thing'); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/shared/SkillCreateChangeProposalApplier.ts b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/shared/SkillCreateChangeProposalApplier.ts new file mode 100644 index 000000000..757ed5967 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/shared/SkillCreateChangeProposalApplier.ts @@ -0,0 +1,135 @@ +import { + CreatedIds, + ICreateChangeProposalApplier, +} from '../ICreateChangeProposalApplier'; +import { + CAMEL_TO_YAML_KEY, + ChangeProposal, + ChangeProposalType, + ISkillsPort, + OrganizationId, + Skill, + SpaceId, + camelToKebab, + toYamlLike, +} from '@packmind/types'; +import { + serializeSkillMetadata, + sortAdditionalPropertiesKeys, +} from '@packmind/node-utils'; + +export class SkillCreateChangeProposalApplier implements ICreateChangeProposalApplier<ChangeProposalType.createSkill> { + constructor(private readonly skillsPort: ISkillsPort) {} + + async apply( + changeProposal: ChangeProposal<ChangeProposalType.createSkill>, + spaceId: SpaceId, + organizationId: OrganizationId, + ): Promise<Skill> { + const { + name, + description, + prompt, + skillMdPermissions, + license, + compatibility, + metadata, + allowedTools, + additionalProperties, + files, + } = changeProposal.payload; + + // Generate SKILL.md content from payload + const skillMdContent = this.generateSkillMd({ + name, + description, + prompt, + license, + compatibility, + metadata, + allowedTools, + additionalProperties, + }); + + // Prepare files array with SKILL.md + const uploadFiles = [ + { + path: 'SKILL.md', + content: skillMdContent, + permissions: skillMdPermissions, + isBase64: false, + }, + ...(files || []).map((file) => ({ + path: file.path, + content: file.content, + permissions: file.permissions, + isBase64: file.isBase64, + })), + ]; + + const result = await this.skillsPort.uploadSkill({ + userId: changeProposal.createdBy, + organizationId, + spaceId, + files: uploadFiles, + }); + + return result.skill; + } + + private generateSkillMd(metadata: { + name: string; + description: string; + prompt: string; + license?: string; + compatibility?: string; + metadata?: Record<string, string>; + allowedTools?: string; + additionalProperties?: Record<string, unknown>; + }): string { + const frontmatter = [ + `name: ${JSON.stringify(metadata.name)}`, + `description: ${JSON.stringify(metadata.description)}`, + ]; + + if (metadata.license) { + frontmatter.push(`license: ${JSON.stringify(metadata.license)}`); + } + if (metadata.compatibility) { + frontmatter.push( + `compatibility: ${JSON.stringify(metadata.compatibility)}`, + ); + } + if (metadata.metadata) { + frontmatter.push( + `metadata: ${serializeSkillMetadata(metadata.metadata)}`, + ); + } + if (metadata.allowedTools) { + frontmatter.push( + `allowed-tools: ${JSON.stringify(metadata.allowedTools)}`, + ); + } + if (metadata.additionalProperties) { + for (const [camelKey, value] of sortAdditionalPropertiesKeys( + metadata.additionalProperties, + )) { + const kebabKey = CAMEL_TO_YAML_KEY[camelKey] ?? camelToKebab(camelKey); + if (typeof value === 'object' && value !== null) { + frontmatter.push(`${kebabKey}:\n${toYamlLike(value, 1)}`); + } else { + frontmatter.push(`${kebabKey}: ${toYamlLike(value, 0)}`); + } + } + } + + return `---\n${frontmatter.join('\n')}\n---\n\n${metadata.prompt}`; + } + + updateCreatedIds(createdIds: CreatedIds, skill: Skill): CreatedIds { + return { + ...createdIds, + skills: [...createdIds.skills, skill.id], + }; + } +} diff --git a/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/shared/StandardCreateChangeProposalApplier.ts b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/shared/StandardCreateChangeProposalApplier.ts new file mode 100644 index 000000000..0abb81307 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/applyCreationChangeProposals/shared/StandardCreateChangeProposalApplier.ts @@ -0,0 +1,45 @@ +import { + CreatedIds, + ICreateChangeProposalApplier, +} from '../ICreateChangeProposalApplier'; +import { + ChangeProposal, + ChangeProposalType, + IStandardsPort, + OrganizationId, + SpaceId, + Standard, +} from '@packmind/types'; + +export class StandardCreateChangeProposalApplier implements ICreateChangeProposalApplier<ChangeProposalType.createStandard> { + constructor(private readonly standardsPort: IStandardsPort) {} + + async apply( + changeProposal: ChangeProposal<ChangeProposalType.createStandard>, + spaceId: SpaceId, + organizationId: OrganizationId, + ): Promise<Standard> { + const { name, description, scope, rules } = changeProposal.payload; + + // Normalize scope: convert array to string if needed + const normalizedScope = Array.isArray(scope) ? scope.join(', ') : scope; + + return this.standardsPort.createStandardWithExamples({ + name, + description, + summary: null, + rules: rules.map((rule) => ({ content: rule.content })), + organizationId, + userId: changeProposal.createdBy, + scope: normalizedScope, + spaceId, + }); + } + + updateCreatedIds(createdIds: CreatedIds, standard: Standard): CreatedIds { + return { + ...createdIds, + standards: [...createdIds.standards, standard.id], + }; + } +} diff --git a/packages/playbook-change-management/src/application/useCases/batchCreateChangeProposals/BatchCreateChangeProposalsUseCase.spec.ts b/packages/playbook-change-management/src/application/useCases/batchCreateChangeProposals/BatchCreateChangeProposalsUseCase.spec.ts new file mode 100644 index 000000000..a115d5e2f --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/batchCreateChangeProposals/BatchCreateChangeProposalsUseCase.spec.ts @@ -0,0 +1,333 @@ +import { PackmindLogger } from '@packmind/logger'; +import { stubLogger } from '@packmind/test-utils'; +import { + BatchCreateChangeProposalItem, + BatchCreateChangeProposalsCommand, + ChangeProposalCaptureMode, + ChangeProposalType, + ChangeProposalViolation, + createOrganizationId, + createSpaceId, + createUserId, + IAccountsPort, + IPlaybookChangeManagementPort, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { ChangeProposalPayloadMismatchError } from '../../errors/ChangeProposalPayloadMismatchError'; +import { UnsupportedChangeProposalTypeError } from '../../errors/UnsupportedChangeProposalTypeError'; +import { BatchCreateChangeProposalsUseCase } from './BatchCreateChangeProposalsUseCase'; + +describe('BatchCreateChangeProposalsUseCase', () => { + const organizationId = createOrganizationId('organization-id'); + const userId = createUserId('user-id'); + const spaceId = createSpaceId('space-id'); + + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + const organization = organizationFactory({ id: organizationId }); + + let useCase: BatchCreateChangeProposalsUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let playbookChangeManagementPort: jest.Mocked<IPlaybookChangeManagementPort>; + let logger: jest.Mocked<PackmindLogger>; + + const buildProposalItem = ( + overrides?: Partial<BatchCreateChangeProposalItem>, + ): BatchCreateChangeProposalItem => ({ + type: ChangeProposalType.updateCommandName, + artefactId: 'artefact-1', + payload: { oldValue: 'Old Name', newValue: 'New Name' }, + captureMode: ChangeProposalCaptureMode.commit, + message: 'test message', + ...overrides, + }); + + const buildCommand = ( + proposals: BatchCreateChangeProposalItem[] = [], + ): BatchCreateChangeProposalsCommand => ({ + userId, + organizationId, + spaceId, + proposals, + }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn(), + getOrganizationById: jest.fn(), + } as unknown as jest.Mocked<IAccountsPort>; + + playbookChangeManagementPort = { + createChangeProposal: jest.fn(), + } as unknown as jest.Mocked<IPlaybookChangeManagementPort>; + + logger = stubLogger(); + useCase = new BatchCreateChangeProposalsUseCase( + accountsPort, + playbookChangeManagementPort, + logger, + ); + + accountsPort.getUserById.mockResolvedValue(user); + accountsPort.getOrganizationById.mockResolvedValue(organization); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when all proposals are created successfully', () => { + const proposals = [ + buildProposalItem({ artefactId: 'artefact-1' }), + buildProposalItem({ artefactId: 'artefact-2' }), + buildProposalItem({ artefactId: 'artefact-3' }), + ]; + const command = buildCommand(proposals); + + beforeEach(() => { + playbookChangeManagementPort.createChangeProposal.mockResolvedValue({ + changeProposal: {} as never, + wasCreated: true, + }); + }); + + it('returns created count matching the number of proposals', async () => { + const result = await useCase.execute(command); + + expect(result.created).toBe(3); + }); + + it('returns zero skipped', async () => { + const result = await useCase.execute(command); + + expect(result.skipped).toBe(0); + }); + + it('returns an empty errors array', async () => { + const result = await useCase.execute(command); + + expect(result.errors).toEqual([]); + }); + + it('calls createChangeProposal for each proposal', async () => { + await useCase.execute(command); + + expect( + playbookChangeManagementPort.createChangeProposal, + ).toHaveBeenCalledTimes(3); + }); + }); + + describe('when one proposal fails with UnsupportedChangeProposalTypeError', () => { + const proposals = [ + buildProposalItem({ artefactId: 'artefact-1' }), + buildProposalItem({ + artefactId: 'artefact-2', + type: ChangeProposalType.updateStandardName, + }), + buildProposalItem({ artefactId: 'artefact-3' }), + ]; + const command = buildCommand(proposals); + + beforeEach(() => { + playbookChangeManagementPort.createChangeProposal + .mockResolvedValueOnce({ + changeProposal: {} as never, + wasCreated: true, + }) + .mockRejectedValueOnce( + new UnsupportedChangeProposalTypeError( + ChangeProposalType.updateStandardName, + ), + ) + .mockResolvedValueOnce({ + changeProposal: {} as never, + wasCreated: true, + }); + }); + + it('adds the error with the correct item index', async () => { + const result = await useCase.execute(command); + + expect(result.errors).toEqual([ + { + index: 1, + message: `Unsupported change proposal type: ${ChangeProposalType.updateStandardName}`, + code: 'UnsupportedChangeProposalTypeError', + }, + ]); + }); + + it('still creates the other proposals successfully', async () => { + const result = await useCase.execute(command); + + expect(result.created).toBe(2); + }); + }); + + describe('when one proposal fails with ChangeProposalPayloadMismatchError', () => { + const proposals = [ + buildProposalItem({ artefactId: 'artefact-1' }), + buildProposalItem({ artefactId: 'artefact-2' }), + ]; + const command = buildCommand(proposals); + + beforeEach(() => { + playbookChangeManagementPort.createChangeProposal + .mockRejectedValueOnce( + new ChangeProposalPayloadMismatchError( + ChangeProposalType.updateCommandName, + 'Wrong Name', + 'Actual Name', + ), + ) + .mockResolvedValueOnce({ + changeProposal: {} as never, + wasCreated: true, + }); + }); + + it('adds the error with the correct item index', async () => { + const result = await useCase.execute(command); + + expect(result.errors).toEqual([ + { + index: 0, + message: `Payload oldValue does not match current value for ${ChangeProposalType.updateCommandName}: expected "Actual Name", got "Wrong Name"`, + code: 'ChangeProposalPayloadMismatchError', + }, + ]); + }); + + it('still creates the remaining proposals successfully', async () => { + const result = await useCase.execute(command); + + expect(result.created).toBe(1); + }); + }); + + describe('when one proposal returns a violation branch', () => { + const proposals = [ + buildProposalItem({ artefactId: 'artefact-1' }), + buildProposalItem({ artefactId: 'artefact-2' }), + ]; + const command = buildCommand(proposals); + + beforeEach(() => { + playbookChangeManagementPort.createChangeProposal + .mockResolvedValueOnce({ + changeProposal: {} as never, + wasCreated: true, + }) + .mockResolvedValueOnce({ + changeProposal: null, + wasCreated: false, + violation: ChangeProposalViolation.TOO_MANY_RULES, + violationMessage: + 'A standard cannot have more than 500 rules (got 502)', + }); + }); + + it('adds an error with human-readable message', async () => { + const result = await useCase.execute(command); + + expect(result.errors).toEqual([ + { + index: 1, + message: 'A standard cannot have more than 500 rules (got 502)', + code: ChangeProposalViolation.TOO_MANY_RULES, + }, + ]); + }); + + it('still creates the other proposals', async () => { + const result = await useCase.execute(command); + + expect(result.created).toBe(1); + }); + }); + + describe('when a duplicate exists', () => { + const proposals = [ + buildProposalItem({ artefactId: 'artefact-1' }), + buildProposalItem({ artefactId: 'artefact-2' }), + buildProposalItem({ artefactId: 'artefact-3' }), + ]; + const command = buildCommand(proposals); + + beforeEach(() => { + playbookChangeManagementPort.createChangeProposal + .mockResolvedValueOnce({ + changeProposal: {} as never, + wasCreated: true, + }) + .mockResolvedValueOnce({ + changeProposal: {} as never, + wasCreated: false, + }) + .mockResolvedValueOnce({ + changeProposal: {} as never, + wasCreated: true, + }); + }); + + it('counts only newly created proposals', async () => { + const result = await useCase.execute(command); + + expect(result.created).toBe(2); + }); + + it('counts the duplicate as skipped', async () => { + const result = await useCase.execute(command); + + expect(result.skipped).toBe(1); + }); + + it('returns an empty errors array', async () => { + const result = await useCase.execute(command); + + expect(result.errors).toEqual([]); + }); + + it('calls createChangeProposal for all proposals', async () => { + await useCase.execute(command); + + expect( + playbookChangeManagementPort.createChangeProposal, + ).toHaveBeenCalledTimes(3); + }); + }); + + describe('when proposals array is empty', () => { + const command = buildCommand([]); + + it('returns zero created', async () => { + const result = await useCase.execute(command); + + expect(result.created).toBe(0); + }); + + it('returns zero skipped', async () => { + const result = await useCase.execute(command); + + expect(result.skipped).toBe(0); + }); + + it('returns an empty errors array', async () => { + const result = await useCase.execute(command); + + expect(result.errors).toEqual([]); + }); + + it('does not call createChangeProposal', async () => { + await useCase.execute(command); + + expect( + playbookChangeManagementPort.createChangeProposal, + ).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/useCases/batchCreateChangeProposals/BatchCreateChangeProposalsUseCase.ts b/packages/playbook-change-management/src/application/useCases/batchCreateChangeProposals/BatchCreateChangeProposalsUseCase.ts new file mode 100644 index 000000000..9fb0c8d32 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/batchCreateChangeProposals/BatchCreateChangeProposalsUseCase.ts @@ -0,0 +1,112 @@ +import { PackmindLogger } from '@packmind/logger'; +import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; +import { + BatchCreateChangeProposalsCommand, + BatchCreateChangeProposalsResponse, + ChangeProposalType, + CreateChangeProposalCommand, + GitRepoId, + IAccountsPort, + IDeploymentPort, + IPlaybookChangeManagementPort, + TargetId, +} from '@packmind/types'; + +const origin = 'BatchCreateChangeProposalsUseCase'; + +export class BatchCreateChangeProposalsUseCase extends AbstractMemberUseCase< + BatchCreateChangeProposalsCommand, + BatchCreateChangeProposalsResponse +> { + constructor( + accountsPort: IAccountsPort, + private readonly playbookChangeManagementPort: IPlaybookChangeManagementPort, + private readonly deploymentPort: IDeploymentPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + async executeForMembers( + command: BatchCreateChangeProposalsCommand & MemberContext, + ): Promise<BatchCreateChangeProposalsResponse> { + this.logger.info('Processing batch change proposals', { + spaceId: command.spaceId, + count: command.proposals.length, + }); + + let created = 0; + let skipped = 0; + const errors: Array<{ index: number; message: string; code?: string }> = []; + + for (let i = 0; i < command.proposals.length; i++) { + const proposal = command.proposals[i]; + + let targetId: TargetId | undefined; + let gitRepoId: GitRepoId | undefined; + + if (proposal.targetId) { + const { target } = await this.deploymentPort.getTargetById({ + userId: command.userId, + organizationId: command.organizationId, + targetId: proposal.targetId, + }); + + if (target) { + targetId = proposal.targetId; + gitRepoId = target.gitRepoId; + } + } + + const itemCommand = { + userId: command.userId, + organizationId: command.organizationId, + spaceId: command.spaceId, + type: proposal.type, + artefactId: proposal.artefactId, + payload: proposal.payload, + captureMode: proposal.captureMode, + message: proposal.message, + targetId, + gitRepoId, + } as CreateChangeProposalCommand<ChangeProposalType>; + + try { + const result = + await this.playbookChangeManagementPort.createChangeProposal( + itemCommand, + ); + if ('violation' in result) { + errors.push({ + index: i, + message: result.violationMessage, + code: result.violation, + }); + } else if (result.wasCreated) { + created++; + } else { + skipped++; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const code = error instanceof Error ? error.name : undefined; + this.logger.error('Failed to create change proposal', { + index: i, + type: proposal.type, + artefactId: proposal.artefactId, + error: message, + }); + errors.push({ index: i, message, code }); + } + } + + this.logger.info('Batch change proposals processed', { + spaceId: command.spaceId, + created, + skipped, + errors: errors.length, + }); + + return { created, skipped, errors }; + } +} diff --git a/packages/playbook-change-management/src/application/useCases/batchCreateChangeProposals/index.ts b/packages/playbook-change-management/src/application/useCases/batchCreateChangeProposals/index.ts new file mode 100644 index 000000000..d1dcd6ae0 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/batchCreateChangeProposals/index.ts @@ -0,0 +1 @@ +export { BatchCreateChangeProposalsUseCase } from './BatchCreateChangeProposalsUseCase'; diff --git a/packages/playbook-change-management/src/application/useCases/checkChangeProposals/CheckChangeProposalsUseCase.spec.ts b/packages/playbook-change-management/src/application/useCases/checkChangeProposals/CheckChangeProposalsUseCase.spec.ts new file mode 100644 index 000000000..deadac6c2 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/checkChangeProposals/CheckChangeProposalsUseCase.spec.ts @@ -0,0 +1,331 @@ +import { PackmindLogger } from '@packmind/logger'; +import { stubLogger } from '@packmind/test-utils'; +import { + BatchCreateChangeProposalItem, + CheckChangeProposalsCommand, + ChangeProposal, + ChangeProposalCaptureMode, + ChangeProposalType, + createOrganizationId, + createSpaceId, + createUserId, + IAccountsPort, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { ChangeProposalService } from '../../services/ChangeProposalService'; +import { IChangeProposalValidator } from '../../validators/IChangeProposalValidator'; +import { CheckChangeProposalsUseCase } from './CheckChangeProposalsUseCase'; + +describe('CheckChangeProposalsUseCase', () => { + const organizationId = createOrganizationId('organization-id'); + const userId = createUserId('user-id'); + const spaceId = createSpaceId('space-id'); + + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + const organization = organizationFactory({ id: organizationId }); + + let useCase: CheckChangeProposalsUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let changeProposalService: jest.Mocked<ChangeProposalService>; + let validators: jest.Mocked<IChangeProposalValidator>[]; + let logger: jest.Mocked<PackmindLogger>; + + const buildProposalItem = ( + overrides?: Partial<BatchCreateChangeProposalItem>, + ): BatchCreateChangeProposalItem => ({ + type: ChangeProposalType.updateCommandName, + artefactId: 'artefact-1', + payload: { oldValue: 'Old Name', newValue: 'New Name' }, + captureMode: ChangeProposalCaptureMode.commit, + message: 'test message', + ...overrides, + }); + + const buildCommand = ( + proposals: BatchCreateChangeProposalItem[] = [], + ): CheckChangeProposalsCommand => ({ + userId, + organizationId, + spaceId, + proposals, + }); + + const buildExistingProposal = ( + createdAt: Date, + message = 'test message', + ): ChangeProposal<ChangeProposalType> => + ({ + createdAt, + message, + }) as ChangeProposal<ChangeProposalType>; + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn(), + getOrganizationById: jest.fn(), + } as unknown as jest.Mocked<IAccountsPort>; + + changeProposalService = { + findExistingPending: jest.fn(), + } as unknown as jest.Mocked<ChangeProposalService>; + + validators = []; + + logger = stubLogger(); + useCase = new CheckChangeProposalsUseCase( + accountsPort, + changeProposalService, + validators, + logger, + ); + + accountsPort.getUserById.mockResolvedValue(user); + accountsPort.getOrganizationById.mockResolvedValue(organization); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when proposals array is empty', () => { + const command = buildCommand([]); + + it('returns an empty results array', async () => { + const result = await useCase.execute(command); + + expect(result.results).toEqual([]); + }); + + it('does not call findExistingPending', async () => { + await useCase.execute(command); + + expect(changeProposalService.findExistingPending).not.toHaveBeenCalled(); + }); + }); + + describe('when all proposals are new', () => { + const proposals = [ + buildProposalItem({ artefactId: 'artefact-1' }), + buildProposalItem({ artefactId: 'artefact-2' }), + ]; + const command = buildCommand(proposals); + + beforeEach(() => { + changeProposalService.findExistingPending.mockResolvedValue(null); + }); + + it('returns exists false for all proposals', async () => { + const result = await useCase.execute(command); + + expect(result.results).toEqual([ + { index: 0, exists: false, createdAt: null, message: null }, + { index: 1, exists: false, createdAt: null, message: null }, + ]); + }); + + it('calls findExistingPending for each proposal', async () => { + await useCase.execute(command); + + expect(changeProposalService.findExistingPending).toHaveBeenCalledTimes( + 2, + ); + }); + }); + + describe('when all proposals already exist', () => { + const createdAt = new Date('2025-06-15T10:30:00.000Z'); + const proposals = [ + buildProposalItem({ artefactId: 'artefact-1' }), + buildProposalItem({ artefactId: 'artefact-2' }), + ]; + const command = buildCommand(proposals); + + beforeEach(() => { + changeProposalService.findExistingPending.mockResolvedValue( + buildExistingProposal(createdAt), + ); + }); + + it('returns exists true with createdAt for all proposals', async () => { + const result = await useCase.execute(command); + + expect(result.results).toEqual([ + { + index: 0, + exists: true, + createdAt: '2025-06-15T10:30:00.000Z', + message: 'test message', + }, + { + index: 1, + exists: true, + createdAt: '2025-06-15T10:30:00.000Z', + message: 'test message', + }, + ]); + }); + }); + + describe('when proposals have mixed results', () => { + const createdAt = new Date('2025-06-15T10:30:00.000Z'); + const proposals = [ + buildProposalItem({ artefactId: 'artefact-1' }), + buildProposalItem({ artefactId: 'artefact-2' }), + buildProposalItem({ artefactId: 'artefact-3' }), + ]; + const command = buildCommand(proposals); + + beforeEach(() => { + changeProposalService.findExistingPending + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(buildExistingProposal(createdAt)) + .mockResolvedValueOnce(null); + }); + + it('returns correct exists status for each proposal', async () => { + const result = await useCase.execute(command); + + expect(result.results).toEqual([ + { index: 0, exists: false, createdAt: null, message: null }, + { + index: 1, + exists: true, + createdAt: '2025-06-15T10:30:00.000Z', + message: 'test message', + }, + { index: 2, exists: false, createdAt: null, message: null }, + ]); + }); + + it('calls findExistingPending with correct parameters', async () => { + await useCase.execute(command); + + expect(changeProposalService.findExistingPending).toHaveBeenCalledWith( + spaceId, + userId, + 'artefact-2', + ChangeProposalType.updateCommandName, + { oldValue: 'Old Name', newValue: 'New Name' }, + ); + }); + }); + + describe('when a validator resolves the payload', () => { + const resolvedPayload = { + targetId: 'resolved-rule-id', + item: { id: 'resolved-rule-id', content: 'rule content' }, + }; + const createdAt = new Date('2025-06-15T10:30:00.000Z'); + + const deleteRuleCommand = buildCommand([ + buildProposalItem({ + type: ChangeProposalType.deleteRule, + artefactId: 'standard-1', + payload: { + targetId: 'unresolved-xxx', + item: { content: 'rule content' }, + }, + }), + ]); + + beforeEach(() => { + const validator: jest.Mocked<IChangeProposalValidator> = { + supports: jest.fn((type) => type === ChangeProposalType.deleteRule), + validate: jest.fn().mockResolvedValue({ + artefactVersion: 1, + resolvedPayload, + }), + }; + validators.push(validator); + changeProposalService.findExistingPending.mockResolvedValue( + buildExistingProposal(createdAt), + ); + }); + + it('calls findExistingPending with the resolved payload', async () => { + await useCase.execute(deleteRuleCommand); + + expect(changeProposalService.findExistingPending).toHaveBeenCalledWith( + spaceId, + userId, + 'standard-1', + ChangeProposalType.deleteRule, + resolvedPayload, + ); + }); + + it('returns exists true', async () => { + const result = await useCase.execute(deleteRuleCommand); + + expect(result.results[0].exists).toBe(true); + }); + }); + + describe('when validation throws an error', () => { + const deleteRuleCommand = buildCommand([ + buildProposalItem({ + type: ChangeProposalType.deleteRule, + artefactId: 'standard-1', + payload: { + targetId: 'unresolved-xxx', + item: { content: 'deleted rule' }, + }, + }), + ]); + + beforeEach(() => { + const validator: jest.Mocked<IChangeProposalValidator> = { + supports: jest.fn((type) => type === ChangeProposalType.deleteRule), + validate: jest.fn().mockRejectedValue(new Error('rule not found')), + }; + validators.push(validator); + }); + + it('returns exists false', async () => { + const result = await useCase.execute(deleteRuleCommand); + + expect(result.results[0].exists).toBe(false); + }); + + it('does not call findExistingPending', async () => { + await useCase.execute(deleteRuleCommand); + + expect(changeProposalService.findExistingPending).not.toHaveBeenCalled(); + }); + }); + + describe('when no validator matches the proposal type', () => { + const addRuleCommand = buildCommand([ + buildProposalItem({ + type: ChangeProposalType.addRule, + payload: { item: { content: 'new rule' } }, + }), + ]); + + beforeEach(() => { + changeProposalService.findExistingPending.mockResolvedValue(null); + }); + + it('calls findExistingPending with the original payload', async () => { + await useCase.execute(addRuleCommand); + + expect(changeProposalService.findExistingPending).toHaveBeenCalledWith( + spaceId, + userId, + 'artefact-1', + ChangeProposalType.addRule, + { item: { content: 'new rule' } }, + ); + }); + + it('returns exists false', async () => { + const result = await useCase.execute(addRuleCommand); + + expect(result.results[0].exists).toBe(false); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/useCases/checkChangeProposals/CheckChangeProposalsUseCase.ts b/packages/playbook-change-management/src/application/useCases/checkChangeProposals/CheckChangeProposalsUseCase.ts new file mode 100644 index 000000000..04bf73d59 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/checkChangeProposals/CheckChangeProposalsUseCase.ts @@ -0,0 +1,100 @@ +import { PackmindLogger } from '@packmind/logger'; +import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; +import { + CheckChangeProposalsCommand, + CheckChangeProposalsResponse, + ChangeProposalType, + IAccountsPort, + createUserId, + CreateChangeProposalCommand, +} from '@packmind/types'; +import { ChangeProposalService } from '../../services/ChangeProposalService'; +import { IChangeProposalValidator } from '../../validators/IChangeProposalValidator'; + +const origin = 'CheckChangeProposalsUseCase'; + +export class CheckChangeProposalsUseCase extends AbstractMemberUseCase< + CheckChangeProposalsCommand, + CheckChangeProposalsResponse +> { + constructor( + accountsPort: IAccountsPort, + private readonly changeProposalService: ChangeProposalService, + private readonly validators: IChangeProposalValidator[], + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + async executeForMembers( + command: CheckChangeProposalsCommand & MemberContext, + ): Promise<CheckChangeProposalsResponse> { + this.logger.info('Checking existing change proposals', { + spaceId: command.spaceId, + count: command.proposals.length, + }); + + const results = await Promise.all( + command.proposals.map(async (proposal, index) => { + let payload = proposal.payload; + + const validator = this.validators.find((v) => + v.supports(proposal.type as ChangeProposalType), + ); + + if (validator) { + try { + const validationCommand = { + userId: command.userId, + organizationId: command.organizationId, + spaceId: command.spaceId, + type: proposal.type as ChangeProposalType, + artefactId: proposal.artefactId, + payload: proposal.payload, + captureMode: proposal.captureMode, + message: proposal.message, + user: command.user, + organization: command.organization, + membership: command.membership, + } as CreateChangeProposalCommand<ChangeProposalType> & + MemberContext; + + const { resolvedPayload } = + await validator.validate(validationCommand); + payload = resolvedPayload ?? payload; + } catch { + return { + index, + exists: false, + createdAt: null, + message: null, + }; + } + } + + const existing = await this.changeProposalService.findExistingPending( + command.spaceId, + createUserId(command.userId), + proposal.artefactId, + proposal.type as ChangeProposalType, + payload, + ); + + return { + index, + exists: existing !== null, + createdAt: existing?.createdAt.toISOString() ?? null, + message: existing?.message ?? null, + }; + }), + ); + + this.logger.info('Change proposals check completed', { + spaceId: command.spaceId, + total: command.proposals.length, + existing: results.filter((r) => r.exists).length, + }); + + return { results }; + } +} diff --git a/packages/playbook-change-management/src/application/useCases/checkChangeProposals/index.ts b/packages/playbook-change-management/src/application/useCases/checkChangeProposals/index.ts new file mode 100644 index 000000000..fab7d7241 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/checkChangeProposals/index.ts @@ -0,0 +1 @@ +export * from './CheckChangeProposalsUseCase'; diff --git a/packages/playbook-change-management/src/application/useCases/createChangeProposal/CreateChangeProposalUseCase.spec.ts b/packages/playbook-change-management/src/application/useCases/createChangeProposal/CreateChangeProposalUseCase.spec.ts new file mode 100644 index 000000000..cf0f15e10 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/createChangeProposal/CreateChangeProposalUseCase.spec.ts @@ -0,0 +1,1000 @@ +import { + PackmindEventEmitterService, + SpaceMembershipRequiredError, +} from '@packmind/node-utils'; +import { stubLogger } from '@packmind/test-utils'; +import { + ChangeProposal, + ChangeProposalCaptureMode, + ChangeProposalStatus, + ChangeProposalType, + ChangeProposalViolation, + createChangeProposalId, + CreateChangeProposalCommand, + CreateChangeProposalResponse, + createOrganizationId, + createRecipeId, + createSkillFileId, + createSkillId, + createSkillVersionId, + createSpaceId, + createUserId, + IAccountsPort, + IRecipesPort, + ISkillsPort, + ISpacesPort, + UserSpaceRole, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { recipeFactory } from '@packmind/recipes/test/recipeFactory'; +import { skillFactory } from '@packmind/skills/test/skillFactory'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { ChangeProposalService } from '../../services/ChangeProposalService'; +import { ChangeProposalPayloadMismatchError } from '../../errors/ChangeProposalPayloadMismatchError'; +import { SkillFileNotFoundError } from '../../errors/SkillFileNotFoundError'; +import { SkillVersionNotFoundError } from '../../errors/SkillVersionNotFoundError'; +import { ChangeProposalLimitExceededError } from '../../errors/ChangeProposalLimitExceededError'; +import { UnsupportedChangeProposalTypeError } from '../../errors/UnsupportedChangeProposalTypeError'; +import { CreateChangeProposalUseCase } from './CreateChangeProposalUseCase'; +import { CommandChangeProposalValidator } from '../../validators/CommandChangeProposalValidator'; +import { IChangeProposalValidator } from '../../validators/IChangeProposalValidator'; +import { SkillChangeProposalValidator } from '../../validators/SkillChangeProposalValidator'; + +jest.mock('@packmind/node-utils', () => ({ + ...jest.requireActual('@packmind/node-utils'), + SSEEventPublisher: { + publishChangeProposalUpdateEvent: jest.fn().mockResolvedValue(undefined), + }, +})); + +describe('CreateChangeProposalUseCase', () => { + const organizationId = createOrganizationId('organization-id'); + const userId = createUserId('user-id'); + const spaceId = createSpaceId('space-id'); + const recipeId = createRecipeId('recipe-id'); + const skillId = createSkillId('skill-id'); + + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + const organization = organizationFactory({ id: organizationId }); + const space = spaceFactory({ id: spaceId, organizationId }); + const recipe = recipeFactory({ id: recipeId, spaceId, version: 5 }); + const skill = skillFactory({ + id: skillId, + spaceId, + version: 3, + name: 'My Skill', + description: 'Skill description', + prompt: 'Skill prompt', + }); + + let useCase: CreateChangeProposalUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let recipesPort: jest.Mocked<IRecipesPort>; + let skillsPort: jest.Mocked<ISkillsPort>; + let spacesPort: jest.Mocked<ISpacesPort>; + let service: jest.Mocked<ChangeProposalService>; + let eventEmitterService: jest.Mocked<PackmindEventEmitterService>; + + const buildCommand = ( + overrides?: Partial<CreateChangeProposalCommand<ChangeProposalType>>, + ): CreateChangeProposalCommand<ChangeProposalType> => + ({ + userId, + organizationId, + spaceId, + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + payload: { oldValue: recipe.name, newValue: 'New Recipe Name' }, + captureMode: ChangeProposalCaptureMode.commit, + message: 'test message', + ...overrides, + }) as CreateChangeProposalCommand<ChangeProposalType>; + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn(), + getOrganizationById: jest.fn(), + } as unknown as jest.Mocked<IAccountsPort>; + + recipesPort = { + getRecipeById: jest.fn(), + } as unknown as jest.Mocked<IRecipesPort>; + + skillsPort = { + getSkill: jest.fn(), + getLatestSkillVersion: jest.fn(), + getSkillFiles: jest.fn(), + listSkillVersions: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked<ISkillsPort>; + + spacesPort = { + getSpaceById: jest.fn(), + findMembership: jest.fn().mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + createdBy: userId, + updatedBy: userId, + }), + } as unknown as jest.Mocked<ISpacesPort>; + + service = { + createChangeProposal: jest.fn(), + findExistingPending: jest.fn().mockResolvedValue(null), + } as unknown as jest.Mocked<ChangeProposalService>; + + eventEmitterService = { + emit: jest.fn(), + } as unknown as jest.Mocked<PackmindEventEmitterService>; + + useCase = new CreateChangeProposalUseCase( + spacesPort, + accountsPort, + service, + [ + new CommandChangeProposalValidator(recipesPort), + new SkillChangeProposalValidator(skillsPort), + ], + eventEmitterService, + stubLogger(), + ); + + accountsPort.getUserById.mockResolvedValue(user); + accountsPort.getOrganizationById.mockResolvedValue(organization); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when type is updateCommandName', () => { + const command = buildCommand(); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + recipesPort.getRecipeById.mockResolvedValue(recipe); + service.createChangeProposal.mockResolvedValue({ + changeProposal: + {} as CreateChangeProposalResponse<ChangeProposalType.updateCommandName>['changeProposal'], + }); + }); + + it('delegates to service with recipe version', async () => { + await useCase.execute(command); + + expect(service.createChangeProposal).toHaveBeenCalledWith( + expect.objectContaining({ + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + }), + 5, + ); + }); + + it('calls getRecipeById with access control', async () => { + await useCase.execute(command); + + expect(recipesPort.getRecipeById).toHaveBeenCalledWith({ + userId: userId as unknown as string, + organizationId, + spaceId, + recipeId, + }); + }); + + it('returns wasCreated true', async () => { + const result = await useCase.execute(command); + + expect(result.wasCreated).toBe(true); + }); + + it('tracks change_proposal_submitted event', async () => { + await useCase.execute(command); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + itemType: 'command', + changeType: ChangeProposalType.updateCommandName, + captureMode: ChangeProposalCaptureMode.commit, + }), + }), + ); + }); + }); + + describe('when type is updateCommandDescription', () => { + const command = buildCommand({ + type: ChangeProposalType.updateCommandDescription, + payload: { oldValue: recipe.content, newValue: 'new content' }, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + recipesPort.getRecipeById.mockResolvedValue(recipe); + service.createChangeProposal.mockResolvedValue({ + changeProposal: + {} as CreateChangeProposalResponse<ChangeProposalType.updateCommandDescription>['changeProposal'], + }); + }); + + it('delegates to service with recipe version', async () => { + await useCase.execute(command); + + expect(service.createChangeProposal).toHaveBeenCalledWith( + expect.objectContaining({ + type: ChangeProposalType.updateCommandDescription, + }), + 5, + ); + }); + }); + + describe('when type is updateSkillName', () => { + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + skillsPort.getSkill.mockResolvedValue(skill); + service.createChangeProposal.mockResolvedValue({ + changeProposal: + {} as CreateChangeProposalResponse<ChangeProposalType.updateSkillName>['changeProposal'], + }); + }); + + it('delegates to service with skill version', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillName, + artefactId: skillId, + payload: { oldValue: skill.name, newValue: 'New Skill Name' }, + }); + + await useCase.execute(command); + + expect(service.createChangeProposal).toHaveBeenCalledWith( + expect.objectContaining({ + type: ChangeProposalType.updateSkillName, + artefactId: skillId, + }), + 3, + ); + }); + + it('calls getSkill with the artefactId', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillName, + artefactId: skillId, + payload: { oldValue: skill.name, newValue: 'New Skill Name' }, + }); + + await useCase.execute(command); + + expect(skillsPort.getSkill).toHaveBeenCalledWith(skillId); + }); + }); + + describe('when skill is not found', () => { + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + skillsPort.getSkill.mockResolvedValue(null); + }); + + it('throws an error', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillDescription, + artefactId: skillId, + payload: { oldValue: 'old', newValue: 'new' }, + }); + + await expect(useCase.execute(command)).rejects.toThrow( + `Skill ${skillId} not found`, + ); + }); + }); + + describe('when skill payload oldValue does not match', () => { + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + skillsPort.getSkill.mockResolvedValue(skill); + }); + + it('throws ChangeProposalPayloadMismatchError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillName, + artefactId: skillId, + payload: { oldValue: 'Wrong Name', newValue: 'New Name' }, + }); + + await expect(useCase.execute(command)).rejects.toBeInstanceOf( + ChangeProposalPayloadMismatchError, + ); + }); + }); + + describe('when type is addSkillFile', () => { + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + skillsPort.getSkill.mockResolvedValue(skill); + service.createChangeProposal.mockResolvedValue({ + changeProposal: + {} as CreateChangeProposalResponse<ChangeProposalType.addSkillFile>['changeProposal'], + }); + }); + + it('delegates to service with skill version', async () => { + const command = buildCommand({ + type: ChangeProposalType.addSkillFile, + artefactId: skillId, + payload: { + item: { + path: 'helper.ts', + content: 'console.log("hello")', + permissions: 'read', + isBase64: false, + }, + }, + }); + + await useCase.execute(command); + + expect(service.createChangeProposal).toHaveBeenCalledWith( + expect.objectContaining({ + type: ChangeProposalType.addSkillFile, + }), + 3, + ); + }); + }); + + describe('when type is updateSkillFilePermissions', () => { + const skillFileId = createSkillFileId('file-1'); + const skillVersionId = createSkillVersionId('version-1'); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + skillsPort.getSkill.mockResolvedValue(skill); + skillsPort.getLatestSkillVersion.mockResolvedValue({ + id: skillVersionId, + skillId, + version: 3, + userId, + name: 'My Skill', + slug: 'my-skill', + description: 'Skill description', + prompt: 'Skill prompt', + }); + skillsPort.getSkillFiles.mockResolvedValue([ + { + id: skillFileId, + skillVersionId, + path: 'helper.ts', + content: 'content', + permissions: 'rw-r--r--', + isBase64: false, + }, + ]); + service.createChangeProposal.mockResolvedValue({ + changeProposal: + {} as CreateChangeProposalResponse<ChangeProposalType.updateSkillFilePermissions>['changeProposal'], + }); + }); + + it('delegates to service with skill version', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillFilePermissions, + artefactId: skillId, + payload: { + targetId: skillFileId, + oldValue: 'rw-r--r--', + newValue: 'rwxr-xr-x', + }, + }); + + await useCase.execute(command); + + expect(service.createChangeProposal).toHaveBeenCalledWith( + expect.objectContaining({ + type: ChangeProposalType.updateSkillFilePermissions, + }), + 3, + ); + }); + + describe('when oldValue does not match', () => { + it('throws ChangeProposalPayloadMismatchError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillFilePermissions, + artefactId: skillId, + payload: { + targetId: skillFileId, + oldValue: 'r--r--r--', + newValue: 'rwxr-xr-x', + }, + }); + + await expect(useCase.execute(command)).rejects.toBeInstanceOf( + ChangeProposalPayloadMismatchError, + ); + }); + }); + }); + + describe('when type is updateSkillFileContent', () => { + const skillFileId = createSkillFileId('file-1'); + const skillVersionId = createSkillVersionId('version-1'); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + skillsPort.getSkill.mockResolvedValue(skill); + skillsPort.getLatestSkillVersion.mockResolvedValue({ + id: skillVersionId, + skillId, + version: 3, + userId, + name: 'My Skill', + slug: 'my-skill', + description: 'Skill description', + prompt: 'Skill prompt', + }); + skillsPort.getSkillFiles.mockResolvedValue([ + { + id: skillFileId, + skillVersionId, + path: 'helper.ts', + content: 'original content', + permissions: 'rw-r--r--', + isBase64: false, + }, + ]); + service.createChangeProposal.mockResolvedValue({ + changeProposal: + {} as CreateChangeProposalResponse<ChangeProposalType.updateSkillFileContent>['changeProposal'], + }); + }); + + it('delegates to service with skill version', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillFileContent, + artefactId: skillId, + payload: { + targetId: skillFileId, + oldValue: 'original content', + newValue: 'updated content', + }, + }); + + await useCase.execute(command); + + expect(service.createChangeProposal).toHaveBeenCalledWith( + expect.objectContaining({ + type: ChangeProposalType.updateSkillFileContent, + }), + 3, + ); + }); + + describe('when oldValue does not match', () => { + it('throws ChangeProposalPayloadMismatchError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillFileContent, + artefactId: skillId, + payload: { + targetId: skillFileId, + oldValue: 'wrong content', + newValue: 'updated content', + }, + }); + + await expect(useCase.execute(command)).rejects.toBeInstanceOf( + ChangeProposalPayloadMismatchError, + ); + }); + }); + }); + + describe('when updateSkillFileContent has no skill version', () => { + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + skillsPort.getSkill.mockResolvedValue(skill); + skillsPort.getLatestSkillVersion.mockResolvedValue(null); + }); + + it('throws SkillVersionNotFoundError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillFileContent, + artefactId: skillId, + payload: { + targetId: createSkillFileId('file-1'), + oldValue: 'content', + newValue: 'updated', + }, + }); + + await expect(useCase.execute(command)).rejects.toBeInstanceOf( + SkillVersionNotFoundError, + ); + }); + }); + + describe('when updateSkillFileContent targets nonexistent file', () => { + const skillVersionId = createSkillVersionId('version-1'); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + skillsPort.getSkill.mockResolvedValue(skill); + skillsPort.getLatestSkillVersion.mockResolvedValue({ + id: skillVersionId, + skillId, + version: 3, + userId, + name: 'My Skill', + slug: 'my-skill', + description: 'desc', + prompt: 'prompt', + }); + skillsPort.getSkillFiles.mockResolvedValue([]); + }); + + it('throws SkillFileNotFoundError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillFileContent, + artefactId: skillId, + payload: { + targetId: createSkillFileId('nonexistent'), + oldValue: 'content', + newValue: 'updated', + }, + }); + + await expect(useCase.execute(command)).rejects.toBeInstanceOf( + SkillFileNotFoundError, + ); + }); + }); + + describe('when updateSkillFilePermissions has no skill version', () => { + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + skillsPort.getSkill.mockResolvedValue(skill); + skillsPort.getLatestSkillVersion.mockResolvedValue(null); + }); + + it('throws SkillVersionNotFoundError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillFilePermissions, + artefactId: skillId, + payload: { + targetId: createSkillFileId('file-1'), + oldValue: 'rw-r--r--', + newValue: 'rwxr-xr-x', + }, + }); + + await expect(useCase.execute(command)).rejects.toBeInstanceOf( + SkillVersionNotFoundError, + ); + }); + }); + + describe('when updateSkillFilePermissions targets nonexistent file', () => { + const skillVersionId = createSkillVersionId('version-1'); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + skillsPort.getSkill.mockResolvedValue(skill); + skillsPort.getLatestSkillVersion.mockResolvedValue({ + id: skillVersionId, + skillId, + version: 3, + userId, + name: 'My Skill', + slug: 'my-skill', + description: 'desc', + prompt: 'prompt', + }); + skillsPort.getSkillFiles.mockResolvedValue([]); + }); + + it('throws SkillFileNotFoundError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillFilePermissions, + artefactId: skillId, + payload: { + targetId: createSkillFileId('nonexistent'), + oldValue: 'rw-r--r--', + newValue: 'rwxr-xr-x', + }, + }); + + await expect(useCase.execute(command)).rejects.toBeInstanceOf( + SkillFileNotFoundError, + ); + }); + }); + + describe('when deleteSkillFile targets nonexistent file', () => { + const skillVersionId = createSkillVersionId('version-1'); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + skillsPort.getSkill.mockResolvedValue(skill); + skillsPort.getLatestSkillVersion.mockResolvedValue({ + id: skillVersionId, + skillId, + version: 3, + userId, + name: 'My Skill', + slug: 'my-skill', + description: 'desc', + prompt: 'prompt', + }); + skillsPort.getSkillFiles.mockResolvedValue([]); + }); + + it('throws SkillFileNotFoundError', async () => { + const command = buildCommand({ + type: ChangeProposalType.deleteSkillFile, + artefactId: skillId, + payload: { + targetId: createSkillFileId('nonexistent'), + item: { + id: createSkillFileId('nonexistent'), + path: 'helper.ts', + content: 'content', + permissions: 'read', + isBase64: false, + }, + }, + }); + + await expect(useCase.execute(command)).rejects.toBeInstanceOf( + SkillFileNotFoundError, + ); + }); + }); + + describe('when deleteSkillFile targets existing file', () => { + const skillFileId = createSkillFileId('file-1'); + const skillVersionId = createSkillVersionId('version-1'); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + skillsPort.getSkill.mockResolvedValue(skill); + skillsPort.getLatestSkillVersion.mockResolvedValue({ + id: skillVersionId, + skillId, + version: 3, + userId, + name: 'My Skill', + slug: 'my-skill', + description: 'desc', + prompt: 'prompt', + }); + skillsPort.getSkillFiles.mockResolvedValue([ + { + id: skillFileId, + skillVersionId, + path: 'helper.ts', + content: 'content', + permissions: 'read', + isBase64: false, + }, + ]); + service.createChangeProposal.mockResolvedValue({ + changeProposal: + {} as CreateChangeProposalResponse<ChangeProposalType.deleteSkillFile>['changeProposal'], + }); + }); + + it('delegates to service with skill version', async () => { + const command = buildCommand({ + type: ChangeProposalType.deleteSkillFile, + artefactId: skillId, + payload: { + targetId: skillFileId, + item: { + id: skillFileId, + path: 'helper.ts', + content: 'content', + permissions: 'read', + isBase64: false, + }, + }, + }); + + await useCase.execute(command); + + expect(service.createChangeProposal).toHaveBeenCalledWith( + expect.objectContaining({ + type: ChangeProposalType.deleteSkillFile, + }), + 3, + ); + }); + }); + + describe('when a pending duplicate exists', () => { + const command = buildCommand(); + + const existingProposal: ChangeProposal<ChangeProposalType> = { + id: createChangeProposalId('change-proposal-id'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + artefactVersion: 5, + spaceId, + payload: { oldValue: recipe.name, newValue: 'New Recipe Name' }, + captureMode: ChangeProposalCaptureMode.commit, + status: ChangeProposalStatus.pending, + createdBy: createUserId('user-id'), + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + recipesPort.getRecipeById.mockResolvedValue(recipe); + service.findExistingPending.mockResolvedValue(existingProposal); + }); + + it('returns the existing proposal', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposal).toBe(existingProposal); + }); + + it('returns wasCreated false', async () => { + const result = await useCase.execute(command); + + expect(result.wasCreated).toBe(false); + }); + + it('does not call createChangeProposal', async () => { + await useCase.execute(command); + + expect(service.createChangeProposal).not.toHaveBeenCalled(); + }); + + it('does not track change_proposal_submitted event', async () => { + await useCase.execute(command); + + expect(eventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + + describe('when validator returns resolvedPayload', () => { + const resolvedPayload = { + oldValue: 'resolved-old', + newValue: 'resolved-new', + }; + + let mockValidator: jest.Mocked<IChangeProposalValidator>; + + beforeEach(() => { + mockValidator = { + supports: jest.fn().mockReturnValue(true), + validate: jest.fn().mockResolvedValue({ + artefactVersion: 5, + resolvedPayload, + }), + }; + + useCase = new CreateChangeProposalUseCase( + spacesPort, + accountsPort, + service, + [mockValidator], + eventEmitterService, + stubLogger(), + ); + + spacesPort.getSpaceById.mockResolvedValue(space); + service.createChangeProposal.mockResolvedValue({ + changeProposal: + {} as CreateChangeProposalResponse<ChangeProposalType.updateCommandName>['changeProposal'], + }); + }); + + it('uses resolvedPayload for dedup check', async () => { + const command = buildCommand(); + + await useCase.execute(command); + + expect(service.findExistingPending).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + resolvedPayload, + ); + }); + + it('uses resolvedPayload for change proposal creation', async () => { + const command = buildCommand(); + + await useCase.execute(command); + + expect(service.createChangeProposal).toHaveBeenCalledWith( + expect.objectContaining({ payload: resolvedPayload }), + 5, + ); + }); + }); + + describe('when validator throws ChangeProposalLimitExceededError', () => { + const command = buildCommand(); + let mockValidator: jest.Mocked<IChangeProposalValidator>; + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + + mockValidator = { + supports: jest.fn().mockReturnValue(true), + validate: jest + .fn() + .mockRejectedValue( + new ChangeProposalLimitExceededError( + ChangeProposalViolation.TOO_MANY_RULES, + 500, + 502, + ), + ), + }; + + useCase = new CreateChangeProposalUseCase( + spacesPort, + accountsPort, + service, + [mockValidator], + eventEmitterService, + stubLogger(), + ); + }); + + it('returns violation enum', async () => { + const result = await useCase.execute(command); + expect(result).toMatchObject({ + violation: ChangeProposalViolation.TOO_MANY_RULES, + }); + }); + + it('returns human-readable violationMessage', async () => { + const result = await useCase.execute(command); + expect(result).toMatchObject({ + violationMessage: + 'A standard cannot have more than 500 rules (got 502)', + }); + }); + + it('returns wasCreated false', async () => { + const result = await useCase.execute(command); + expect(result).toMatchObject({ wasCreated: false }); + }); + }); + + describe('when type is unsupported', () => { + const command = buildCommand({ + type: ChangeProposalType.updateStandardName, + }); + + it('throws UnsupportedChangeProposalTypeError', async () => { + await expect(useCase.execute(command)).rejects.toBeInstanceOf( + UnsupportedChangeProposalTypeError, + ); + }); + + it('does not call the service', async () => { + await useCase.execute(command).catch(() => { + /* expected rejection */ + }); + + expect(service.createChangeProposal).not.toHaveBeenCalled(); + }); + }); + + describe('when recipe is not found', () => { + const command = buildCommand(); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + recipesPort.getRecipeById.mockResolvedValue(null); + }); + + it('throws an error', async () => { + await expect(useCase.execute(command)).rejects.toThrow( + `Recipe ${recipeId} not found`, + ); + }); + + it('does not call the service', async () => { + await useCase.execute(command).catch(() => { + /* expected rejection */ + }); + + expect(service.createChangeProposal).not.toHaveBeenCalled(); + }); + }); + + describe('when payload oldValue does not match current recipe name', () => { + const command = buildCommand({ + type: ChangeProposalType.updateCommandName, + payload: { oldValue: 'Wrong Name', newValue: 'New Name' }, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + recipesPort.getRecipeById.mockResolvedValue(recipe); + }); + + it('throws ChangeProposalPayloadMismatchError', async () => { + await expect(useCase.execute(command)).rejects.toBeInstanceOf( + ChangeProposalPayloadMismatchError, + ); + }); + + it('does not call the service', async () => { + await useCase.execute(command).catch(() => { + /* expected rejection */ + }); + + expect(service.createChangeProposal).not.toHaveBeenCalled(); + }); + }); + + describe('when payload oldValue does not match current recipe content', () => { + const command = buildCommand({ + type: ChangeProposalType.updateCommandDescription, + payload: { oldValue: 'wrong content', newValue: 'new content' }, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + recipesPort.getRecipeById.mockResolvedValue(recipe); + }); + + it('throws ChangeProposalPayloadMismatchError', async () => { + await expect(useCase.execute(command)).rejects.toBeInstanceOf( + ChangeProposalPayloadMismatchError, + ); + }); + + it('does not call the service', async () => { + await useCase.execute(command).catch(() => { + /* expected rejection */ + }); + + expect(service.createChangeProposal).not.toHaveBeenCalled(); + }); + }); + + describe('when the user is not a member of the space', () => { + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue(null); + }); + + it('throws a SpaceMembershipRequiredError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceMembershipRequiredError, + ); + }); + + it('does not call the service', async () => { + await useCase.execute(buildCommand()).catch(() => { + /* expected rejection */ + }); + + expect(service.createChangeProposal).not.toHaveBeenCalled(); + }); + + it('does not call getRecipeById', async () => { + await useCase.execute(buildCommand()).catch(() => { + /* expected rejection */ + }); + + expect(recipesPort.getRecipeById).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/useCases/createChangeProposal/CreateChangeProposalUseCase.ts b/packages/playbook-change-management/src/application/useCases/createChangeProposal/CreateChangeProposalUseCase.ts new file mode 100644 index 000000000..3011877be --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/createChangeProposal/CreateChangeProposalUseCase.ts @@ -0,0 +1,120 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + AbstractSpaceMemberUseCase, + SpaceMemberContext, + PackmindEventEmitterService, + SSEEventPublisher, +} from '@packmind/node-utils'; +import { + ChangeProposalType, + ChangeProposalSubmittedEvent, + CreateChangeProposalCommand, + CreateChangeProposalResponse, + createUserId, + getItemTypeFromChangeProposalType, + IAccountsPort, + ICreateChangeProposalUseCase, + ISpacesPort, +} from '@packmind/types'; +import { ChangeProposalService } from '../../services/ChangeProposalService'; +import { ChangeProposalLimitExceededError } from '../../errors/ChangeProposalLimitExceededError'; +import { UnsupportedChangeProposalTypeError } from '../../errors/UnsupportedChangeProposalTypeError'; +import { IChangeProposalValidator } from '../../validators/IChangeProposalValidator'; + +const origin = 'CreateChangeProposalUseCase'; + +export class CreateChangeProposalUseCase + extends AbstractSpaceMemberUseCase< + CreateChangeProposalCommand<ChangeProposalType>, + CreateChangeProposalResponse<ChangeProposalType> + > + implements ICreateChangeProposalUseCase<ChangeProposalType> +{ + constructor( + spacesPort: ISpacesPort, + accountsPort: IAccountsPort, + private readonly service: ChangeProposalService, + private readonly validators: IChangeProposalValidator[], + private readonly eventEmitterService: PackmindEventEmitterService, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(spacesPort, accountsPort, logger); + } + + async executeForSpaceMembers( + command: CreateChangeProposalCommand<ChangeProposalType> & + SpaceMemberContext, + ): Promise<CreateChangeProposalResponse<ChangeProposalType>> { + const validator = this.validators.find((v) => v.supports(command.type)); + if (!validator) { + throw new UnsupportedChangeProposalTypeError(command.type); + } + + let artefactVersion: number; + let resolvedPayload: Awaited< + ReturnType<typeof validator.validate> + >['resolvedPayload']; + + try { + ({ artefactVersion, resolvedPayload } = + await validator.validate(command)); + } catch (error) { + if (error instanceof ChangeProposalLimitExceededError) { + return { + changeProposal: null, + wasCreated: false, + violation: error.changeProposal, + violationMessage: error.message, + }; + } + throw error; + } + + const resolvedCommand = resolvedPayload + ? { ...command, payload: resolvedPayload } + : command; + + const existing = await this.service.findExistingPending( + resolvedCommand.spaceId, + createUserId(resolvedCommand.userId), + resolvedCommand.artefactId, + resolvedCommand.type, + resolvedCommand.payload, + ); + + if (existing) { + return { changeProposal: existing, wasCreated: false }; + } + + const { changeProposal } = await this.service.createChangeProposal( + resolvedCommand, + artefactVersion, + ); + + SSEEventPublisher.publishChangeProposalUpdateEvent( + command.organization.id, + command.spaceId, + ).catch((error) => { + this.logger.error('Failed to publish change proposal update SSE event', { + organizationId: command.organization.id, + spaceId: command.spaceId, + error: error instanceof Error ? error.message : String(error), + }); + }); + + this.eventEmitterService.emit( + new ChangeProposalSubmittedEvent({ + userId: createUserId(command.userId), + organizationId: command.organization.id, + source: command.source ?? 'ui', + changeProposalId: changeProposal.id, + itemType: getItemTypeFromChangeProposalType(command.type), + itemId: String(changeProposal.artefactId ?? ''), + changeType: command.type, + captureMode: command.captureMode, + }), + ); + + return { changeProposal, wasCreated: true }; + } +} diff --git a/packages/playbook-change-management/src/application/useCases/createChangeProposal/index.ts b/packages/playbook-change-management/src/application/useCases/createChangeProposal/index.ts new file mode 100644 index 000000000..75f895bde --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/createChangeProposal/index.ts @@ -0,0 +1 @@ +export { CreateChangeProposalUseCase } from './CreateChangeProposalUseCase'; diff --git a/packages/playbook-change-management/src/application/useCases/index.ts b/packages/playbook-change-management/src/application/useCases/index.ts new file mode 100644 index 000000000..283244df1 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/index.ts @@ -0,0 +1,4 @@ +export * from './applyChangeProposals'; +export * from './batchCreateChangeProposals'; +export * from './checkChangeProposals'; +export * from './createChangeProposal'; diff --git a/packages/playbook-change-management/src/application/useCases/listChangeProposalsByArtefact/ListChangeProposalsByArtefactUseCase.spec.ts b/packages/playbook-change-management/src/application/useCases/listChangeProposalsByArtefact/ListChangeProposalsByArtefactUseCase.spec.ts new file mode 100644 index 000000000..5acf0610a --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/listChangeProposalsByArtefact/ListChangeProposalsByArtefactUseCase.spec.ts @@ -0,0 +1,893 @@ +import { SpaceMembershipRequiredError } from '@packmind/node-utils'; +import { stubLogger } from '@packmind/test-utils'; +import { + ChangeProposalStatus, + ChangeProposalType, + createChangeProposalId, + createOrganizationId, + createPackageId, + createRecipeId, + createSkillId, + createSpaceId, + createStandardId, + createUserId, + IAccountsPort, + IDeploymentPort, + IRecipesPort, + ISkillsPort, + ISpacesPort, + IStandardsPort, + ListChangeProposalsByArtefactCommand, + RecipeId, + SkillId, + StandardId, + UserSpaceRole, +} from '@packmind/types'; +import { packageFactory } from '@packmind/deployments/test/packageFactory'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { recipeFactory } from '@packmind/recipes/test/recipeFactory'; +import { standardFactory } from '@packmind/standards/test/standardFactory'; +import { skillFactory } from '@packmind/skills/test/skillFactory'; +import { changeProposalFactory } from '../../../../test/changeProposalFactory'; +import { ChangeProposalService } from '../../services/ChangeProposalService'; +import { ConflictDetectionService } from '../../services/ConflictDetectionService'; +import { ListChangeProposalsByArtefactUseCase } from './ListChangeProposalsByArtefactUseCase'; + +describe('ListChangeProposalsByArtefactUseCase', () => { + const userId = createUserId('user-id'); + const organizationId = createOrganizationId('organization-id'); + const spaceId = createSpaceId('space-id'); + const standardId = createStandardId('standard-1'); + const recipeId = createRecipeId('recipe-1'); + const skillId = createSkillId('skill-1'); + + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + const organization = organizationFactory({ id: organizationId }); + const space = spaceFactory({ id: spaceId, organizationId }); + const standard = standardFactory({ id: standardId, spaceId }); + const recipe = recipeFactory({ id: recipeId, spaceId }); + const skill = skillFactory({ id: skillId, spaceId }); + + let useCase: ListChangeProposalsByArtefactUseCase< + StandardId | RecipeId | SkillId + >; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked<ISpacesPort>; + let standardsPort: jest.Mocked<IStandardsPort>; + let recipesPort: jest.Mocked<IRecipesPort>; + let skillsPort: jest.Mocked<ISkillsPort>; + let deploymentPort: jest.Mocked<IDeploymentPort>; + let service: jest.Mocked<ChangeProposalService>; + let conflictDetectionService: jest.Mocked<ConflictDetectionService>; + + const buildCommand = ( + overrides?: Partial< + ListChangeProposalsByArtefactCommand<StandardId | RecipeId | SkillId> + >, + ): ListChangeProposalsByArtefactCommand<StandardId | RecipeId | SkillId> => ({ + userId, + organizationId, + spaceId, + artefactId: standardId, + ...overrides, + }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn(), + getOrganizationById: jest.fn(), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort = { + getSpaceById: jest.fn(), + findMembership: jest.fn().mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + createdBy: userId, + updatedBy: userId, + }), + } as unknown as jest.Mocked<ISpacesPort>; + + standardsPort = { + getStandard: jest.fn(), + } as unknown as jest.Mocked<IStandardsPort>; + + recipesPort = { + getRecipeByIdInternal: jest.fn(), + } as unknown as jest.Mocked<IRecipesPort>; + + skillsPort = { + getSkill: jest.fn(), + } as unknown as jest.Mocked<ISkillsPort>; + + service = { + findProposalsByArtefact: jest.fn(), + } as unknown as jest.Mocked<ChangeProposalService>; + + conflictDetectionService = { + detectConflicts: jest.fn(), + } as unknown as jest.Mocked<ConflictDetectionService>; + + deploymentPort = { + listPackagesBySpace: jest.fn().mockResolvedValue({ packages: [] }), + } as unknown as jest.Mocked<IDeploymentPort>; + + useCase = new ListChangeProposalsByArtefactUseCase( + spacesPort, + accountsPort, + standardsPort, + recipesPort, + skillsPort, + deploymentPort, + service, + conflictDetectionService, + stubLogger(), + ); + + accountsPort.getUserById.mockResolvedValue(user); + accountsPort.getOrganizationById.mockResolvedValue(organization); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('with valid standard artefact', () => { + const proposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + spaceId, + }), + changeProposalFactory({ + type: ChangeProposalType.addRule, + artefactId: standardId, + spaceId, + }), + ]; + + const command = buildCommand(); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + standardsPort.getStandard.mockResolvedValue(standard); + recipesPort.getRecipeByIdInternal.mockResolvedValue(null); + skillsPort.getSkill.mockResolvedValue(null); + service.findProposalsByArtefact.mockResolvedValue(proposals); + conflictDetectionService.detectConflicts.mockReturnValue( + proposals.map((p) => ({ ...p, conflictsWith: [] })), + ); + }); + + it('returns all proposals', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals.length).toBe(2); + }); + + it('includes first proposal', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals[0].id).toBe(proposals[0].id); + }); + + it('includes second proposal', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals[1].id).toBe(proposals[1].id); + }); + + it('adds empty conflictsWith array to first proposal', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals[0].conflictsWith).toEqual([]); + }); + + it('adds empty conflictsWith array to second proposal', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals[1].conflictsWith).toEqual([]); + }); + + it('validates space membership', async () => { + await useCase.execute(command); + + expect(spacesPort.findMembership).toHaveBeenCalledWith(userId, spaceId); + }); + + it('validates artefact exists in space', async () => { + await useCase.execute(command); + + expect(standardsPort.getStandard).toHaveBeenCalledWith(standardId); + }); + + it('calls service with correct parameters', async () => { + await useCase.execute(command); + + expect(service.findProposalsByArtefact).toHaveBeenCalledWith( + spaceId, + standardId, + ); + }); + + it('calls conflict detection service', async () => { + await useCase.execute(command); + + expect(conflictDetectionService.detectConflicts).toHaveBeenCalledWith( + proposals, + ); + }); + }); + + describe('with valid recipe artefact', () => { + const proposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + }), + ]; + + const command = buildCommand({ artefactId: recipeId }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + standardsPort.getStandard.mockResolvedValue(null); + recipesPort.getRecipeByIdInternal.mockResolvedValue(recipe); + skillsPort.getSkill.mockResolvedValue(null); + service.findProposalsByArtefact.mockResolvedValue(proposals); + conflictDetectionService.detectConflicts.mockReturnValue( + proposals.map((p) => ({ ...p, conflictsWith: [] })), + ); + }); + + it('returns recipe proposals', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals.length).toBe(1); + }); + + it('validates recipe exists in space', async () => { + await useCase.execute(command); + + expect(recipesPort.getRecipeByIdInternal).toHaveBeenCalledWith(recipeId); + }); + }); + + describe('with valid skill artefact', () => { + const proposals = [ + changeProposalFactory({ + type: ChangeProposalType.updateSkillName, + artefactId: skillId, + spaceId, + }), + ]; + + const command = buildCommand({ artefactId: skillId }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + standardsPort.getStandard.mockResolvedValue(null); + recipesPort.getRecipeByIdInternal.mockResolvedValue(null); + skillsPort.getSkill.mockResolvedValue(skill); + service.findProposalsByArtefact.mockResolvedValue(proposals); + conflictDetectionService.detectConflicts.mockReturnValue( + proposals.map((p) => ({ ...p, conflictsWith: [] })), + ); + }); + + it('returns skill proposals', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals.length).toBe(1); + }); + + it('validates skill exists in space', async () => { + await useCase.execute(command); + + expect(skillsPort.getSkill).toHaveBeenCalledWith(skillId); + }); + }); + + describe('when space has no proposals', () => { + const command = buildCommand(); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + standardsPort.getStandard.mockResolvedValue(standard); + recipesPort.getRecipeByIdInternal.mockResolvedValue(null); + skillsPort.getSkill.mockResolvedValue(null); + service.findProposalsByArtefact.mockResolvedValue([]); + conflictDetectionService.detectConflicts.mockReturnValue([]); + }); + + it('returns empty proposals array', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals).toEqual([]); + }); + }); + + describe('pendingOnly parameter', () => { + const pendingProposal = changeProposalFactory({ + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + spaceId, + status: ChangeProposalStatus.pending, + }); + const appliedProposal = changeProposalFactory({ + type: ChangeProposalType.addRule, + artefactId: standardId, + spaceId, + status: ChangeProposalStatus.applied, + }); + const rejectedProposal = changeProposalFactory({ + type: ChangeProposalType.updateStandardDescription, + artefactId: standardId, + spaceId, + status: ChangeProposalStatus.rejected, + }); + + const allProposals = [pendingProposal, appliedProposal, rejectedProposal]; + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + standardsPort.getStandard.mockResolvedValue(standard); + recipesPort.getRecipeByIdInternal.mockResolvedValue(null); + skillsPort.getSkill.mockResolvedValue(null); + service.findProposalsByArtefact.mockResolvedValue(allProposals); + conflictDetectionService.detectConflicts.mockImplementation((ps) => + ps.map((p) => ({ ...p, conflictsWith: [] })), + ); + }); + + describe('when pendingOnly is not specified', () => { + const command = buildCommand(); + + it('returns only pending proposals', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals.length).toBe(1); + }); + + it('includes pending proposal', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals).toContainEqual( + expect.objectContaining({ + id: pendingProposal.id, + }), + ); + }); + + it('excludes applied proposal', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals).not.toContainEqual( + expect.objectContaining({ + id: appliedProposal.id, + }), + ); + }); + + it('excludes rejected proposal', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals).not.toContainEqual( + expect.objectContaining({ + id: rejectedProposal.id, + }), + ); + }); + }); + + describe('when pendingOnly is true', () => { + const command = buildCommand({ pendingOnly: true }); + + it('returns only pending proposals', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals.length).toBe(1); + }); + + it('includes pending proposal', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals).toContainEqual( + expect.objectContaining({ + id: pendingProposal.id, + }), + ); + }); + + it('excludes applied proposal', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals).not.toContainEqual( + expect.objectContaining({ + id: appliedProposal.id, + }), + ); + }); + + it('excludes rejected proposal', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals).not.toContainEqual( + expect.objectContaining({ + id: rejectedProposal.id, + }), + ); + }); + }); + + describe('when pendingOnly is false', () => { + const command = buildCommand({ pendingOnly: false }); + + it('returns all proposals', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals.length).toBe(3); + }); + + it('includes pending proposal', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals).toContainEqual( + expect.objectContaining({ + id: pendingProposal.id, + }), + ); + }); + + it('includes applied proposal', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals).toContainEqual( + expect.objectContaining({ + id: appliedProposal.id, + }), + ); + }); + + it('includes rejected proposal', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals).toContainEqual( + expect.objectContaining({ + id: rejectedProposal.id, + }), + ); + }); + }); + }); + + describe('when the user is not a member of the space', () => { + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue(null); + }); + + it('throws a SpaceMembershipRequiredError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceMembershipRequiredError, + ); + }); + + it('does not validate artefact', async () => { + await useCase.execute(buildCommand()).catch(() => { + /* expected rejection */ + }); + + expect(standardsPort.getStandard).not.toHaveBeenCalled(); + }); + + it('does not call service', async () => { + await useCase.execute(buildCommand()).catch(() => { + /* expected rejection */ + }); + + expect(service.findProposalsByArtefact).not.toHaveBeenCalled(); + }); + }); + + describe('when artefact does not exist', () => { + const command = buildCommand(); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + standardsPort.getStandard.mockResolvedValue(null); + recipesPort.getRecipeByIdInternal.mockResolvedValue(null); + skillsPort.getSkill.mockResolvedValue(null); + }); + + it('returns an empty changeProposals array', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals).toEqual([]); + }); + + it('returns an empty currentPackageIds array', async () => { + const result = await useCase.execute(command); + + expect(result.currentPackageIds).toEqual([]); + }); + + it('does not call service', async () => { + await useCase.execute(command); + + expect(service.findProposalsByArtefact).not.toHaveBeenCalled(); + }); + }); + + describe('when artefact belongs to different space', () => { + const differentSpaceId = createSpaceId('different-space'); + const differentStandard = standardFactory({ + id: standardId, + spaceId: differentSpaceId, + }); + const command = buildCommand(); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + standardsPort.getStandard.mockResolvedValue(differentStandard); + }); + + it('returns an empty changeProposals array', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals).toEqual([]); + }); + + it('returns an empty currentPackageIds array', async () => { + const result = await useCase.execute(command); + + expect(result.currentPackageIds).toEqual([]); + }); + + it('does not call service', async () => { + await useCase.execute(command); + + expect(service.findProposalsByArtefact).not.toHaveBeenCalled(); + }); + }); + + describe('conflict detection', () => { + const oldValue = `My command: + +It has a description. +`; + + describe('when multiple updateCommandName proposals exist', () => { + const proposals = [ + changeProposalFactory({ + id: createChangeProposalId('1'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + payload: { oldValue: 'My command', newValue: 'My super command' }, + }), + changeProposalFactory({ + id: createChangeProposalId('2'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + payload: { oldValue: 'My command', newValue: 'My updated command' }, + }), + ]; + + const command = buildCommand({ artefactId: recipeId }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + standardsPort.getStandard.mockResolvedValue(null); + recipesPort.getRecipeByIdInternal.mockResolvedValue(recipe); + skillsPort.getSkill.mockResolvedValue(null); + service.findProposalsByArtefact.mockResolvedValue(proposals); + conflictDetectionService.detectConflicts.mockImplementation((ps) => + ps.map((p) => ({ + ...p, + conflictsWith: ps + .filter((other) => other.id !== p.id) + .map((other) => other.id), + })), + ); + }); + + it('marks first proposal as conflicting with second', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals[0].conflictsWith).toEqual([ + proposals[1].id, + ]); + }); + + it('marks second proposal as conflicting with first', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals[1].conflictsWith).toEqual([ + proposals[0].id, + ]); + }); + }); + + describe('when updateCommandDescription proposals conflict', () => { + const proposals = [ + changeProposalFactory({ + id: createChangeProposalId('1'), + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + spaceId, + payload: { + oldValue, + newValue: `My updated command: + +It has a description. +`, + }, + }), + changeProposalFactory({ + id: createChangeProposalId('2'), + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + spaceId, + payload: { + oldValue, + newValue: `It has a description.`, + }, + }), + ]; + + const command = buildCommand({ artefactId: recipeId }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + standardsPort.getStandard.mockResolvedValue(null); + recipesPort.getRecipeByIdInternal.mockResolvedValue(recipe); + skillsPort.getSkill.mockResolvedValue(null); + service.findProposalsByArtefact.mockResolvedValue(proposals); + conflictDetectionService.detectConflicts.mockImplementation((ps) => + ps.map((p) => ({ + ...p, + conflictsWith: ps + .filter((other) => other.id !== p.id) + .map((other) => other.id), + })), + ); + }); + + it('marks proposals as conflicting', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals[0].conflictsWith).toEqual([ + proposals[1].id, + ]); + }); + }); + + describe('when updateCommandDescription proposals do not conflict', () => { + const proposal = changeProposalFactory({ + id: createChangeProposalId('1'), + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + spaceId, + payload: { + oldValue, + newValue: `My command: + +It has a description. + +And a new line at the end +`, + }, + }); + + const command = buildCommand({ artefactId: recipeId }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + standardsPort.getStandard.mockResolvedValue(null); + recipesPort.getRecipeByIdInternal.mockResolvedValue(recipe); + skillsPort.getSkill.mockResolvedValue(null); + service.findProposalsByArtefact.mockResolvedValue([proposal]); + conflictDetectionService.detectConflicts.mockImplementation((ps) => + ps.map((p) => ({ ...p, conflictsWith: [] })), + ); + }); + + it('marks proposal as not conflicting', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals[0].conflictsWith).toEqual([]); + }); + }); + + describe('when mixed proposal types exist', () => { + const proposals = [ + changeProposalFactory({ + id: createChangeProposalId('1'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + payload: { oldValue: 'My command', newValue: 'My super command' }, + }), + changeProposalFactory({ + id: createChangeProposalId('2'), + type: ChangeProposalType.updateCommandName, + artefactId: recipeId, + spaceId, + payload: { oldValue: 'My command', newValue: 'My updated command' }, + }), + changeProposalFactory({ + id: createChangeProposalId('3'), + type: ChangeProposalType.updateCommandDescription, + artefactId: recipeId, + spaceId, + payload: { + oldValue, + newValue: `My command: + +It has a description. + +And a new line at the end +`, + }, + }), + ]; + + const command = buildCommand({ artefactId: recipeId }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + standardsPort.getStandard.mockResolvedValue(null); + recipesPort.getRecipeByIdInternal.mockResolvedValue(recipe); + skillsPort.getSkill.mockResolvedValue(null); + service.findProposalsByArtefact.mockResolvedValue(proposals); + conflictDetectionService.detectConflicts.mockImplementation((ps) => + ps.map((p, index) => ({ + ...p, + conflictsWith: index < 2 ? [ps[index === 0 ? 1 : 0].id] : [], + })), + ); + }); + + it('marks name proposals as conflicting with each other', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals[0].conflictsWith).toEqual([ + proposals[1].id, + ]); + }); + + it('marks description proposal as not conflicting', async () => { + const result = await useCase.execute(command); + + expect(result.changeProposals[2].conflictsWith).toEqual([]); + }); + }); + }); + + describe('currentPackageIds', () => { + const packageId1 = createPackageId('pkg-1'); + const packageId2 = createPackageId('pkg-2'); + const command = buildCommand(); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + standardsPort.getStandard.mockResolvedValue(standard); + recipesPort.getRecipeByIdInternal.mockResolvedValue(null); + skillsPort.getSkill.mockResolvedValue(null); + service.findProposalsByArtefact.mockResolvedValue([]); + conflictDetectionService.detectConflicts.mockReturnValue([]); + }); + + describe('when artefact is in some packages', () => { + beforeEach(() => { + deploymentPort.listPackagesBySpace.mockResolvedValue({ + packages: [ + packageFactory({ + id: packageId1, + spaceId, + standards: [standardId], + }), + packageFactory({ + id: packageId2, + spaceId, + standards: [], + }), + ], + }); + }); + + it('returns only packages containing the artefact', async () => { + const result = await useCase.execute(command); + + expect(result.currentPackageIds).toEqual([packageId1]); + }); + }); + + describe('when artefact is in no packages', () => { + beforeEach(() => { + deploymentPort.listPackagesBySpace.mockResolvedValue({ + packages: [ + packageFactory({ id: packageId1, spaceId, standards: [] }), + ], + }); + }); + + it('returns empty array', async () => { + const result = await useCase.execute(command); + + expect(result.currentPackageIds).toEqual([]); + }); + }); + + describe('when artefact is a recipe', () => { + const recipeCommand = buildCommand({ artefactId: recipeId }); + + beforeEach(() => { + standardsPort.getStandard.mockResolvedValue(null); + recipesPort.getRecipeByIdInternal.mockResolvedValue(recipe); + deploymentPort.listPackagesBySpace.mockResolvedValue({ + packages: [ + packageFactory({ + id: packageId1, + spaceId, + recipes: [recipeId], + }), + ], + }); + }); + + it('checks the recipes field on packages', async () => { + const result = await useCase.execute(recipeCommand); + + expect(result.currentPackageIds).toEqual([packageId1]); + }); + }); + + describe('when artefact is a skill', () => { + const skillCommand = buildCommand({ artefactId: skillId }); + + beforeEach(() => { + standardsPort.getStandard.mockResolvedValue(null); + recipesPort.getRecipeByIdInternal.mockResolvedValue(null); + skillsPort.getSkill.mockResolvedValue(skill); + deploymentPort.listPackagesBySpace.mockResolvedValue({ + packages: [ + packageFactory({ + id: packageId1, + spaceId, + skills: [skillId], + }), + ], + }); + }); + + it('checks the skills field on packages', async () => { + const result = await useCase.execute(skillCommand); + + expect(result.currentPackageIds).toEqual([packageId1]); + }); + }); + + describe('when there are no packages in the space', () => { + beforeEach(() => { + deploymentPort.listPackagesBySpace.mockResolvedValue({ + packages: [], + }); + }); + + it('returns empty array', async () => { + const result = await useCase.execute(command); + + expect(result.currentPackageIds).toEqual([]); + }); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/useCases/listChangeProposalsByArtefact/ListChangeProposalsByArtefactUseCase.ts b/packages/playbook-change-management/src/application/useCases/listChangeProposalsByArtefact/ListChangeProposalsByArtefactUseCase.ts new file mode 100644 index 000000000..2374b61e9 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/listChangeProposalsByArtefact/ListChangeProposalsByArtefactUseCase.ts @@ -0,0 +1,107 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + AbstractSpaceMemberUseCase, + SpaceMemberContext, +} from '@packmind/node-utils'; +import { + IAccountsPort, + IDeploymentPort, + IRecipesPort, + ISkillsPort, + ISpacesPort, + IStandardsPort, + IListChangeProposalsByArtefact, + ListChangeProposalsByArtefactCommand, + ListChangeProposalsByArtefactResponse, + RecipeId, + SkillId, + StandardId, + ChangeProposalStatus, +} from '@packmind/types'; +import { ArtefactNotFoundError } from '../../../domain/errors/ArtefactNotFoundError'; +import { ArtefactNotInSpaceError } from '../../../domain/errors/ArtefactNotInSpaceError'; +import { ChangeProposalService } from '../../services/ChangeProposalService'; +import { ConflictDetectionService } from '../../services/ConflictDetectionService'; +import { + ArtefactType, + validateArtefactInSpace, +} from '../../services/validateArtefactInSpace'; + +const origin = 'ListChangeProposalsByArtefactUseCase'; + +export class ListChangeProposalsByArtefactUseCase< + T extends StandardId | RecipeId | SkillId, +> + extends AbstractSpaceMemberUseCase< + ListChangeProposalsByArtefactCommand<T>, + ListChangeProposalsByArtefactResponse + > + implements IListChangeProposalsByArtefact<T> +{ + constructor( + spacesPort: ISpacesPort, + accountsPort: IAccountsPort, + private readonly standardsPort: IStandardsPort, + private readonly recipesPort: IRecipesPort, + private readonly skillsPort: ISkillsPort, + private readonly deploymentPort: IDeploymentPort, + private readonly service: ChangeProposalService, + private readonly conflictDetectionService: ConflictDetectionService, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(spacesPort, accountsPort, logger); + } + + async executeForSpaceMembers( + command: ListChangeProposalsByArtefactCommand<T> & SpaceMemberContext, + ): Promise<ListChangeProposalsByArtefactResponse> { + let artefactType: ArtefactType; + try { + artefactType = await validateArtefactInSpace( + command.artefactId, + command.spaceId, + this.standardsPort, + this.recipesPort, + this.skillsPort, + ); + } catch (error) { + if ( + error instanceof ArtefactNotFoundError || + error instanceof ArtefactNotInSpaceError + ) { + this.logger.warn(error.message); + return { changeProposals: [], currentPackageIds: [] }; + } + throw error; + } + + const [allProposals, { packages }] = await Promise.all([ + this.service.findProposalsByArtefact(command.spaceId, command.artefactId), + this.deploymentPort.listPackagesBySpace({ + spaceId: command.spaceId, + organizationId: command.organization.id, + userId: command.userId, + }), + ]); + + const currentPackageIds = packages + .filter((pkg) => { + if (artefactType === 'standard') + return pkg.standards.includes(command.artefactId as StandardId); + if (artefactType === 'recipe') + return pkg.recipes.includes(command.artefactId as RecipeId); + return pkg.skills.includes(command.artefactId as SkillId); + }) + .map((pkg) => pkg.id); + + const proposalsToReturn = + command.pendingOnly === false + ? allProposals + : allProposals.filter((p) => p.status === ChangeProposalStatus.pending); + + const changeProposals = + this.conflictDetectionService.detectConflicts(proposalsToReturn); + + return { changeProposals, currentPackageIds }; + } +} diff --git a/packages/playbook-change-management/src/application/useCases/listChangeProposalsBySpace/ListChangeProposalsBySpaceUseCase.spec.ts b/packages/playbook-change-management/src/application/useCases/listChangeProposalsBySpace/ListChangeProposalsBySpaceUseCase.spec.ts new file mode 100644 index 000000000..d298ed772 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/listChangeProposalsBySpace/ListChangeProposalsBySpaceUseCase.spec.ts @@ -0,0 +1,826 @@ +import { SpaceMembershipRequiredError } from '@packmind/node-utils'; +import { stubLogger } from '@packmind/test-utils'; +import { + ChangeProposalCaptureMode, + ChangeProposalStatus, + ChangeProposalType, + createChangeProposalId, + createOrganizationId, + createRecipeId, + createSkillId, + createSpaceId, + createStandardId, + createUserId, + IAccountsPort, + IRecipesPort, + ISkillsPort, + ISpacesPort, + IStandardsPort, + ListChangeProposalsBySpaceCommand, + RecipeId, + SkillId, + StandardId, + UserSpaceRole, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { recipeFactory } from '@packmind/recipes/test/recipeFactory'; +import { standardFactory } from '@packmind/standards/test/standardFactory'; +import { skillFactory } from '@packmind/skills/test/skillFactory'; +import { + ArtefactProposalStats, + ChangeProposalService, +} from '../../services/ChangeProposalService'; +import { ListChangeProposalsBySpaceUseCase } from './ListChangeProposalsBySpaceUseCase'; + +describe('ListChangeProposalsBySpaceUseCase', () => { + const organizationId = createOrganizationId('organization-id'); + const userId = createUserId('user-id'); + const spaceId = createSpaceId('space-id'); + + const standardId1 = createStandardId('standard-1'); + const standardId2 = createStandardId('standard-2'); + const recipeId1 = createRecipeId('recipe-1'); + const recipeId2 = createRecipeId('recipe-2'); + const skillId1 = createSkillId('skill-1'); + + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + const organization = organizationFactory({ id: organizationId }); + const space = spaceFactory({ id: spaceId, organizationId }); + + const standard1 = standardFactory({ id: standardId1, name: 'Standard 1' }); + const standard2 = standardFactory({ id: standardId2, name: 'Standard 2' }); + const recipe1 = recipeFactory({ id: recipeId1, name: 'Recipe 1' }); + const recipe2 = recipeFactory({ id: recipeId2, name: 'Recipe 2' }); + const skill1 = skillFactory({ id: skillId1, name: 'Skill 1' }); + + let useCase: ListChangeProposalsBySpaceUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked<ISpacesPort>; + let standardsPort: jest.Mocked<IStandardsPort>; + let recipesPort: jest.Mocked<IRecipesPort>; + let skillsPort: jest.Mocked<ISkillsPort>; + let service: jest.Mocked<ChangeProposalService>; + + const buildCommand = ( + overrides?: Partial<ListChangeProposalsBySpaceCommand>, + ): ListChangeProposalsBySpaceCommand => ({ + userId: userId, + organizationId: organizationId, + spaceId: spaceId, + ...overrides, + }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn(), + getOrganizationById: jest.fn(), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort = { + getSpaceById: jest.fn(), + findMembership: jest.fn().mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + createdBy: userId, + updatedBy: userId, + }), + } as unknown as jest.Mocked<ISpacesPort>; + + standardsPort = { + getStandard: jest.fn(), + } as unknown as jest.Mocked<IStandardsPort>; + + recipesPort = { + getRecipeByIdInternal: jest.fn(), + } as unknown as jest.Mocked<IRecipesPort>; + + skillsPort = { + getSkill: jest.fn(), + } as unknown as jest.Mocked<ISkillsPort>; + + service = { + groupProposalsByArtefact: jest.fn(), + } as unknown as jest.Mocked<ChangeProposalService>; + + useCase = new ListChangeProposalsBySpaceUseCase( + spacesPort, + accountsPort, + standardsPort, + recipesPort, + skillsPort, + service, + stubLogger(), + ); + + accountsPort.getUserById.mockResolvedValue(user); + accountsPort.getOrganizationById.mockResolvedValue(organization); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when listing proposals successfully', () => { + const command = buildCommand(); + const standardDate1 = new Date('2024-03-15T10:00:00Z'); + const standardDate2 = new Date('2024-03-14T08:00:00Z'); + const recipeDate1 = new Date('2024-03-13T12:00:00Z'); + const recipeDate2 = new Date('2024-03-12T14:00:00Z'); + const skillDate1 = new Date('2024-03-16T09:00:00Z'); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + + service.groupProposalsByArtefact.mockResolvedValue({ + standards: new Map<StandardId, ArtefactProposalStats>([ + [standardId1, { count: 3, lastContributedAt: standardDate1 }], + [standardId2, { count: 1, lastContributedAt: standardDate2 }], + ]), + commands: new Map<RecipeId, ArtefactProposalStats>([ + [recipeId1, { count: 2, lastContributedAt: recipeDate1 }], + [recipeId2, { count: 1, lastContributedAt: recipeDate2 }], + ]), + skills: new Map<SkillId, ArtefactProposalStats>([ + [skillId1, { count: 5, lastContributedAt: skillDate1 }], + ]), + creations: [], + }); + + standardsPort.getStandard.mockImplementation(async (id) => { + if (id === standardId1) return standard1; + if (id === standardId2) return standard2; + return null; + }); + + recipesPort.getRecipeByIdInternal.mockImplementation(async (id) => { + if (id === recipeId1) return recipe1; + if (id === recipeId2) return recipe2; + return null; + }); + + skillsPort.getSkill.mockImplementation(async (id) => { + if (id === skillId1) return skill1; + return null; + }); + }); + + it('calls service to group proposals by artefact', async () => { + await useCase.execute(command); + + expect(service.groupProposalsByArtefact).toHaveBeenCalledWith(spaceId); + }); + + it('enriches standards with names and counts', async () => { + const result = await useCase.execute(command); + + expect(result.standards).toEqual([ + { + artefactId: standardId1, + name: 'Standard 1', + changeProposalCount: 3, + lastContributedAt: standardDate1.toISOString(), + }, + { + artefactId: standardId2, + name: 'Standard 2', + changeProposalCount: 1, + lastContributedAt: standardDate2.toISOString(), + }, + ]); + }); + + it('enriches commands with names and counts', async () => { + const result = await useCase.execute(command); + + expect(result.commands).toEqual([ + { + artefactId: recipeId1, + name: 'Recipe 1', + changeProposalCount: 2, + lastContributedAt: recipeDate1.toISOString(), + }, + { + artefactId: recipeId2, + name: 'Recipe 2', + changeProposalCount: 1, + lastContributedAt: recipeDate2.toISOString(), + }, + ]); + }); + + it('enriches skills with names and counts', async () => { + const result = await useCase.execute(command); + + expect(result.skills).toEqual([ + { + artefactId: skillId1, + name: 'Skill 1', + changeProposalCount: 5, + lastContributedAt: skillDate1.toISOString(), + }, + ]); + }); + + it('fetches first standard details', async () => { + await useCase.execute(command); + + expect(standardsPort.getStandard).toHaveBeenCalledWith(standardId1); + }); + + it('fetches second standard details', async () => { + await useCase.execute(command); + + expect(standardsPort.getStandard).toHaveBeenCalledWith(standardId2); + }); + + it('fetches first recipe details', async () => { + await useCase.execute(command); + + expect(recipesPort.getRecipeByIdInternal).toHaveBeenCalledWith(recipeId1); + }); + + it('fetches second recipe details', async () => { + await useCase.execute(command); + + expect(recipesPort.getRecipeByIdInternal).toHaveBeenCalledWith(recipeId2); + }); + + it('fetches skill details', async () => { + await useCase.execute(command); + + expect(skillsPort.getSkill).toHaveBeenCalledWith(skillId1); + }); + }); + + describe('when space has no proposals', () => { + const command = buildCommand(); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + service.groupProposalsByArtefact.mockResolvedValue({ + standards: new Map(), + commands: new Map(), + skills: new Map(), + creations: [], + }); + }); + + it('returns empty standards array', async () => { + const result = await useCase.execute(command); + + expect(result.standards).toEqual([]); + }); + + it('returns empty commands array', async () => { + const result = await useCase.execute(command); + + expect(result.commands).toEqual([]); + }); + + it('returns empty skills array', async () => { + const result = await useCase.execute(command); + + expect(result.skills).toEqual([]); + }); + + it('does not call standards port', async () => { + await useCase.execute(command); + + expect(standardsPort.getStandard).not.toHaveBeenCalled(); + }); + + it('does not call recipes port', async () => { + await useCase.execute(command); + + expect(recipesPort.getRecipeByIdInternal).not.toHaveBeenCalled(); + }); + + it('does not call skills port', async () => { + await useCase.execute(command); + + expect(skillsPort.getSkill).not.toHaveBeenCalled(); + }); + }); + + describe('when artefact is not found', () => { + const command = buildCommand(); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + service.groupProposalsByArtefact.mockResolvedValue({ + standards: new Map([ + [ + standardId1, + { count: 2, lastContributedAt: new Date('2024-01-01') }, + ], + ]), + commands: new Map([ + [recipeId1, { count: 1, lastContributedAt: new Date('2024-01-01') }], + ]), + skills: new Map([ + [skillId1, { count: 3, lastContributedAt: new Date('2024-01-01') }], + ]), + creations: [], + }); + + standardsPort.getStandard.mockResolvedValue(null); + recipesPort.getRecipeByIdInternal.mockResolvedValue(null); + skillsPort.getSkill.mockResolvedValue(null); + }); + + it('excludes standards that are not found', async () => { + const result = await useCase.execute(command); + + expect(result.standards).toEqual([]); + }); + + it('excludes commands that are not found', async () => { + const result = await useCase.execute(command); + + expect(result.commands).toEqual([]); + }); + + it('excludes skills that are not found', async () => { + const result = await useCase.execute(command); + + expect(result.skills).toEqual([]); + }); + }); + + describe('when the user is not a member of the space', () => { + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue(null); + }); + + it('throws a SpaceMembershipRequiredError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceMembershipRequiredError, + ); + }); + + it('does not call the service', async () => { + await useCase.execute(buildCommand()).catch(() => { + /* expected rejection */ + }); + + expect(service.groupProposalsByArtefact).not.toHaveBeenCalled(); + }); + }); + + describe('when createCommand proposals exist', () => { + const proposalId = createChangeProposalId('proposal-id'); + const command = buildCommand(); + const commandCreatedAt = new Date('2024-05-10T15:00:00Z'); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + service.groupProposalsByArtefact.mockResolvedValue({ + standards: new Map(), + commands: new Map(), + skills: new Map(), + creations: [ + { + id: proposalId, + type: ChangeProposalType.createCommand, + artefactId: null, + artefactVersion: 0, + spaceId, + payload: { name: 'My Command', content: 'Do something' }, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + status: ChangeProposalStatus.pending, + decision: null, + createdBy: userId, + resolvedBy: null, + resolvedAt: null, + createdAt: commandCreatedAt, + updatedAt: new Date(), + }, + ], + }); + }); + + it('returns creation overview with proposalId and payload fields', async () => { + const result = await useCase.execute(command); + + expect(result.creations).toEqual([ + expect.objectContaining({ + id: proposalId, + artefactType: 'commands', + name: 'My Command', + payload: { + name: 'My Command', + content: 'Do something', + }, + message: '', + createdBy: userId, + lastContributedAt: commandCreatedAt.toISOString(), + }), + ]); + }); + + it('does not call recipesPort for creation proposals', async () => { + await useCase.execute(command); + + expect(recipesPort.getRecipeByIdInternal).not.toHaveBeenCalled(); + }); + + it('returns empty commands array alongside creations', async () => { + const result = await useCase.execute(command); + + expect(result.commands).toEqual([]); + }); + }); + + describe('when createStandard proposals exist', () => { + const proposalId = createChangeProposalId('std-proposal-id'); + const command = buildCommand(); + const standardCreatedAt = new Date('2024-04-20T11:00:00Z'); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + service.groupProposalsByArtefact.mockResolvedValue({ + standards: new Map(), + commands: new Map(), + skills: new Map(), + creations: [ + { + id: proposalId, + type: ChangeProposalType.createStandard, + artefactId: null, + artefactVersion: 0, + spaceId, + payload: { + name: 'My Standard', + description: 'A description', + scope: ['TypeScript'], + rules: [{ content: 'Rule one' }], + }, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + status: ChangeProposalStatus.pending, + decision: null, + createdBy: userId, + resolvedBy: null, + resolvedAt: null, + createdAt: standardCreatedAt, + updatedAt: new Date(), + }, + ], + }); + }); + + it('returns creation overview with artefactType standards and standard fields', async () => { + const result = await useCase.execute(command); + + expect(result.creations).toEqual([ + expect.objectContaining({ + id: proposalId, + artefactType: 'standards', + name: 'My Standard', + payload: { + name: 'My Standard', + description: 'A description', + scope: 'TypeScript', + rules: [{ content: 'Rule one' }], + }, + message: '', + createdBy: userId, + lastContributedAt: standardCreatedAt.toISOString(), + }), + ]); + }); + + it('normalizes array scope to comma-separated string', async () => { + service.groupProposalsByArtefact.mockResolvedValue({ + standards: new Map(), + commands: new Map(), + skills: new Map(), + creations: [ + { + id: proposalId, + type: ChangeProposalType.createStandard, + artefactId: null, + artefactVersion: 0, + spaceId, + payload: { + name: 'My Standard', + description: 'A description', + scope: ['TypeScript', 'JavaScript'], + rules: [], + }, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + status: ChangeProposalStatus.pending, + decision: null, + createdBy: userId, + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + const result = await useCase.execute(command); + + expect(result.creations[0].payload).toMatchObject({ + scope: 'TypeScript, JavaScript', + }); + }); + + it('passes string scope through unchanged', async () => { + service.groupProposalsByArtefact.mockResolvedValue({ + standards: new Map(), + commands: new Map(), + skills: new Map(), + creations: [ + { + id: proposalId, + type: ChangeProposalType.createStandard, + artefactId: null, + artefactVersion: 0, + spaceId, + payload: { + name: 'My Standard', + description: 'A description', + scope: 'TypeScript', + rules: [], + }, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + status: ChangeProposalStatus.pending, + decision: null, + createdBy: userId, + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + const result = await useCase.execute(command); + + expect(result.creations[0].payload).toMatchObject({ + scope: 'TypeScript', + }); + }); + + it('passes empty scope through as null', async () => { + service.groupProposalsByArtefact.mockResolvedValue({ + standards: new Map(), + commands: new Map(), + skills: new Map(), + creations: [ + { + id: proposalId, + type: ChangeProposalType.createStandard, + artefactId: null, + artefactVersion: 0, + spaceId, + payload: { + name: 'My Standard', + description: 'A description', + scope: null, + rules: [], + }, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + status: ChangeProposalStatus.pending, + decision: null, + createdBy: userId, + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + const result = await useCase.execute(command); + + expect(result.creations[0].payload).toMatchObject({ scope: null }); + }); + + it('preserves empty rules array', async () => { + service.groupProposalsByArtefact.mockResolvedValue({ + standards: new Map(), + commands: new Map(), + skills: new Map(), + creations: [ + { + id: proposalId, + type: ChangeProposalType.createStandard, + artefactId: null, + artefactVersion: 0, + spaceId, + payload: { + name: 'My Standard', + description: 'A description', + scope: null, + rules: [], + }, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + status: ChangeProposalStatus.pending, + decision: null, + createdBy: userId, + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + const result = await useCase.execute(command); + + expect(result.creations[0].payload).toMatchObject({ rules: [] }); + }); + + describe('when createSkill proposals exist', () => { + const skillProposalId = createChangeProposalId('skill-proposal-id'); + const command = buildCommand(); + const skillCreatedAt = new Date('2024-06-01T16:00:00Z'); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + service.groupProposalsByArtefact.mockResolvedValue({ + standards: new Map(), + commands: new Map(), + skills: new Map(), + creations: [ + { + id: skillProposalId, + type: ChangeProposalType.createSkill, + artefactId: null, + artefactVersion: 0, + spaceId, + payload: { + name: 'My Skill', + description: 'A skill description', + prompt: 'You are a helpful assistant.', + skillMdPermissions: '0755', + }, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + status: ChangeProposalStatus.pending, + decision: null, + createdBy: userId, + resolvedBy: null, + resolvedAt: null, + createdAt: skillCreatedAt, + updatedAt: new Date(), + }, + ], + }); + }); + + it('returns creation overview with proposalId and payload fields', async () => { + const result = await useCase.execute(command); + + expect(result.creations).toEqual([ + expect.objectContaining({ + id: skillProposalId, + artefactType: 'skills', + name: 'My Skill', + payload: { + name: 'My Skill', + description: 'A skill description', + prompt: 'You are a helpful assistant.', + skillMdPermissions: '0755', + }, + message: '', + createdBy: userId, + lastContributedAt: skillCreatedAt.toISOString(), + }), + ]); + }); + + it('returns empty skills array alongside creations', async () => { + const result = await useCase.execute(command); + + expect(result.skills).toEqual([]); + }); + }); + + describe('when createSkill proposals have optional fields', () => { + const skillProposalId = createChangeProposalId('skill-full-proposal-id'); + const command = buildCommand(); + const fullSkillCreatedAt = new Date('2024-07-01T10:00:00Z'); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + service.groupProposalsByArtefact.mockResolvedValue({ + standards: new Map(), + commands: new Map(), + skills: new Map(), + creations: [ + { + id: skillProposalId, + type: ChangeProposalType.createSkill, + artefactId: null, + artefactVersion: 0, + spaceId, + payload: { + name: 'Full Skill', + description: 'A complete skill', + prompt: 'Do the thing.', + license: 'MIT', + compatibility: '>=2.0', + allowedTools: 'Read, Write', + files: [ + { + path: 'scripts/init.sh', + content: '#!/bin/bash\necho hello', + permissions: '755', + isBase64: false, + }, + ], + }, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + status: ChangeProposalStatus.pending, + createdBy: userId, + resolvedBy: null, + resolvedAt: null, + createdAt: fullSkillCreatedAt, + updatedAt: new Date(), + }, + ], + }); + }); + + it('includes license, compatibility, allowedTools, and files in overview', async () => { + const result = await useCase.execute(command); + + expect(result.creations).toEqual([ + expect.objectContaining({ + id: skillProposalId, + artefactType: 'skills', + name: 'Full Skill', + payload: { + name: 'Full Skill', + description: 'A complete skill', + prompt: 'Do the thing.', + license: 'MIT', + compatibility: '>=2.0', + allowedTools: 'Read, Write', + files: [ + { + path: 'scripts/init.sh', + content: '#!/bin/bash\necho hello', + permissions: '755', + isBase64: false, + }, + ], + }, + message: '', + createdBy: userId, + lastContributedAt: fullSkillCreatedAt.toISOString(), + }), + ]); + }); + }); + + it('throw an error unknown proposal types', async () => { + service.groupProposalsByArtefact.mockResolvedValue({ + standards: new Map(), + commands: new Map(), + skills: new Map(), + creations: [ + { + id: proposalId, + type: 'unknownType' as ChangeProposalType, + artefactId: null, + artefactVersion: 0, + spaceId, + payload: { + name: 'My Command', + content: 'Do something', + }, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + status: ChangeProposalStatus.pending, + createdBy: userId, + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + await expect(() => useCase.execute(command)).rejects.toThrow( + new Error('Unsupported creation ChangeProposalType: unknownType'), + ); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/useCases/listChangeProposalsBySpace/ListChangeProposalsBySpaceUseCase.ts b/packages/playbook-change-management/src/application/useCases/listChangeProposalsBySpace/ListChangeProposalsBySpaceUseCase.ts new file mode 100644 index 000000000..b3c291714 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/listChangeProposalsBySpace/ListChangeProposalsBySpaceUseCase.ts @@ -0,0 +1,182 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + AbstractSpaceMemberUseCase, + SpaceMemberContext, +} from '@packmind/node-utils'; +import { + ChangeProposalType, + CreationChangeProposalTypes, + CreationProposalOverview, + IAccountsPort, + IListChangeProposalsBySpace, + IRecipesPort, + ISkillsPort, + ISpacesPort, + IStandardsPort, + ListChangeProposalsBySpaceCommand, + ListChangeProposalsBySpaceResponse, + ListProposalsOverview, + PendingChangeProposal, + RecipeId, + SkillId, + StandardId, +} from '@packmind/types'; +import { + ArtefactProposalStats, + ChangeProposalService, +} from '../../services/ChangeProposalService'; +import { isExpectedChangeProposalType } from '../../utils/isExpectedChangeProposalType'; + +const origin = 'ListChangeProposalsBySpaceUseCase'; + +export class ListChangeProposalsBySpaceUseCase + extends AbstractSpaceMemberUseCase< + ListChangeProposalsBySpaceCommand, + ListChangeProposalsBySpaceResponse + > + implements IListChangeProposalsBySpace +{ + constructor( + spacesPort: ISpacesPort, + accountsPort: IAccountsPort, + private readonly standardsPort: IStandardsPort, + private readonly recipesPort: IRecipesPort, + private readonly skillsPort: ISkillsPort, + private readonly service: ChangeProposalService, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(spacesPort, accountsPort, logger); + } + + async executeForSpaceMembers( + command: ListChangeProposalsBySpaceCommand & SpaceMemberContext, + ): Promise<ListChangeProposalsBySpaceResponse> { + const grouped = await this.service.groupProposalsByArtefact( + command.spaceId, + ); + + const standards = await this.enrichStandardsWithNames(grouped.standards); + const commands = await this.enrichRecipesWithNames(grouped.commands); + const skills = await this.enrichSkillsWithNames(grouped.skills); + const creations = this.enrichCreations(grouped.creations); + + return { + standards, + commands, + skills, + creations, + }; + } + + private async enrichStandardsWithNames( + standardsMap: Map<StandardId, ArtefactProposalStats>, + ): Promise<ListProposalsOverview<StandardId>[]> { + const result: ListProposalsOverview<StandardId>[] = []; + + for (const [artefactId, stats] of standardsMap.entries()) { + const standard = await this.standardsPort.getStandard(artefactId); + if (standard) { + result.push({ + artefactId, + name: standard.name, + changeProposalCount: stats.count, + lastContributedAt: stats.lastContributedAt.toISOString(), + }); + } + } + + return result; + } + + private async enrichRecipesWithNames( + recipesMap: Map<RecipeId, ArtefactProposalStats>, + ): Promise<ListProposalsOverview<RecipeId>[]> { + const result: ListProposalsOverview<RecipeId>[] = []; + + for (const [artefactId, stats] of recipesMap.entries()) { + const recipe = await this.recipesPort.getRecipeByIdInternal(artefactId); + if (recipe) { + result.push({ + artefactId, + name: recipe.name, + changeProposalCount: stats.count, + lastContributedAt: stats.lastContributedAt.toISOString(), + }); + } + } + + return result; + } + + private enrichCreations( + proposals: PendingChangeProposal<CreationChangeProposalTypes>[], + ): CreationProposalOverview[] { + return proposals.map((proposal) => { + const lastContributedAt = proposal.createdAt.toISOString(); + if ( + isExpectedChangeProposalType(proposal, ChangeProposalType.createCommand) + ) { + return { + ...proposal, + artefactType: 'commands' as const, + name: proposal.payload.name, + lastContributedAt, + }; + } + + if ( + isExpectedChangeProposalType( + proposal, + ChangeProposalType.createStandard, + ) + ) { + return { + ...proposal, + artefactType: 'standards' as const, + name: proposal.payload.name, + payload: { + ...proposal.payload, + scope: Array.isArray(proposal.payload.scope) + ? proposal.payload.scope.join(', ') + : proposal.payload.scope, + }, + lastContributedAt, + }; + } + if ( + isExpectedChangeProposalType(proposal, ChangeProposalType.createSkill) + ) { + return { + ...proposal, + artefactType: 'skills' as const, + name: proposal.payload.name, + lastContributedAt, + }; + } + + throw new Error( + `Unsupported creation ChangeProposalType: ${proposal.type}`, + ); + }); + } + + private async enrichSkillsWithNames( + skillsMap: Map<SkillId, ArtefactProposalStats>, + ): Promise<ListProposalsOverview<SkillId>[]> { + const result: ListProposalsOverview<SkillId>[] = []; + + for (const [artefactId, stats] of skillsMap.entries()) { + const skill = await this.skillsPort.getSkill(artefactId); + if (skill) { + result.push({ + artefactId, + name: skill.name, + changeProposalCount: stats.count, + lastContributedAt: stats.lastContributedAt.toISOString(), + }); + } + } + + return result; + } +} diff --git a/packages/playbook-change-management/src/application/useCases/listChangeProposalsBySpace/index.ts b/packages/playbook-change-management/src/application/useCases/listChangeProposalsBySpace/index.ts new file mode 100644 index 000000000..3886c69f2 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/listChangeProposalsBySpace/index.ts @@ -0,0 +1 @@ +export * from './ListChangeProposalsBySpaceUseCase'; diff --git a/packages/playbook-change-management/src/application/useCases/migrateChangeProposalsForMovedArtefact/MigrateChangeProposalsForMovedArtefactUseCase.spec.ts b/packages/playbook-change-management/src/application/useCases/migrateChangeProposalsForMovedArtefact/MigrateChangeProposalsForMovedArtefactUseCase.spec.ts new file mode 100644 index 000000000..1b2f03ceb --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/migrateChangeProposalsForMovedArtefact/MigrateChangeProposalsForMovedArtefactUseCase.spec.ts @@ -0,0 +1,72 @@ +import { + createOrganizationId, + createSpaceId, + createUserId, + MigrateChangeProposalsForMovedArtefactCommand, +} from '@packmind/types'; +import { ChangeProposalService } from '../../services/ChangeProposalService'; +import { MigrateChangeProposalsForMovedArtefactUseCase } from './MigrateChangeProposalsForMovedArtefactUseCase'; + +describe('MigrateChangeProposalsForMovedArtefactUseCase', () => { + let useCase: MigrateChangeProposalsForMovedArtefactUseCase; + let changeProposalService: jest.Mocked<ChangeProposalService>; + + const userId = createUserId('user-1'); + const organizationId = createOrganizationId('org-1'); + const sourceSpaceId = createSpaceId('source-space'); + const destinationSpaceId = createSpaceId('dest-space'); + + const buildCommand = (): MigrateChangeProposalsForMovedArtefactCommand => ({ + userId: userId as unknown as string, + organizationId: organizationId as unknown as string, + sourceSpaceId, + destinationSpaceId, + oldArtefactId: 'old-artefact-id', + newArtefactId: 'new-artefact-id', + }); + + beforeEach(() => { + changeProposalService = { + migrateProposalsForMovedArtefact: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked<ChangeProposalService>; + + useCase = new MigrateChangeProposalsForMovedArtefactUseCase( + changeProposalService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('delegates to changeProposalService with correct parameters', async () => { + const command = buildCommand(); + + await useCase.execute(command); + + expect( + changeProposalService.migrateProposalsForMovedArtefact, + ).toHaveBeenCalledWith({ + sourceSpaceId, + destinationSpaceId, + oldArtefactId: 'old-artefact-id', + newArtefactId: 'new-artefact-id', + }); + }); + + it('returns an empty response', async () => { + const result = await useCase.execute(buildCommand()); + + expect(result).toEqual({}); + }); + + describe('when the service throws', () => { + it('propagates the error', async () => { + changeProposalService.migrateProposalsForMovedArtefact.mockRejectedValueOnce( + new Error('DB error'), + ); + + await expect(useCase.execute(buildCommand())).rejects.toThrow('DB error'); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/useCases/migrateChangeProposalsForMovedArtefact/MigrateChangeProposalsForMovedArtefactUseCase.ts b/packages/playbook-change-management/src/application/useCases/migrateChangeProposalsForMovedArtefact/MigrateChangeProposalsForMovedArtefactUseCase.ts new file mode 100644 index 000000000..695dcfbfa --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/migrateChangeProposalsForMovedArtefact/MigrateChangeProposalsForMovedArtefactUseCase.ts @@ -0,0 +1,29 @@ +import { + IMigrateChangeProposalsForMovedArtefactUseCase, + MigrateChangeProposalsForMovedArtefactCommand, + MigrateChangeProposalsForMovedArtefactResponse, +} from '@packmind/types'; +import { ChangeProposalService } from '../../services/ChangeProposalService'; + +/** + * Internal use case triggered by PlaybookArtefactMovedEvent — not user-initiated. + * Implements IUseCase directly (instead of AbstractMemberUseCase) because no + * membership validation is needed; the operation is a system-level data migration. + */ +export class MigrateChangeProposalsForMovedArtefactUseCase implements IMigrateChangeProposalsForMovedArtefactUseCase { + constructor(private readonly changeProposalService: ChangeProposalService) {} + + async execute( + command: MigrateChangeProposalsForMovedArtefactCommand, + ): Promise<MigrateChangeProposalsForMovedArtefactResponse> { + await this.changeProposalService.migrateProposalsForMovedArtefact({ + sourceSpaceId: command.sourceSpaceId, + destinationSpaceId: command.destinationSpaceId, + oldArtefactId: command.oldArtefactId, + newArtefactId: command.newArtefactId, + ruleMappings: command.ruleMappings, + }); + + return {}; + } +} diff --git a/packages/playbook-change-management/src/application/useCases/recomputeConflicts/RecomputeConflictsUseCase.spec.ts b/packages/playbook-change-management/src/application/useCases/recomputeConflicts/RecomputeConflictsUseCase.spec.ts new file mode 100644 index 000000000..bbf441802 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/recomputeConflicts/RecomputeConflictsUseCase.spec.ts @@ -0,0 +1,260 @@ +import { SpaceMembershipRequiredError } from '@packmind/node-utils'; +import { stubLogger } from '@packmind/test-utils'; +import { + ChangeProposalStatus, + ChangeProposalType, + createChangeProposalId, + createOrganizationId, + createSpaceId, + createStandardId, + createUserId, + IAccountsPort, + ISpacesPort, + RecomputeConflictsCommand, + UserSpaceRole, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { changeProposalFactory } from '../../../../test/changeProposalFactory'; +import { ChangeProposalService } from '../../services/ChangeProposalService'; +import { ConflictDetectionService } from '../../services/ConflictDetectionService'; +import { RecomputeConflictsUseCase } from './RecomputeConflictsUseCase'; + +describe('RecomputeConflictsUseCase', () => { + const userId = createUserId('user-id'); + const organizationId = createOrganizationId('organization-id'); + const spaceId = createSpaceId('space-id'); + const standardId = createStandardId('standard-1'); + + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + const organization = organizationFactory({ id: organizationId }); + const space = spaceFactory({ id: spaceId, organizationId }); + + let useCase: RecomputeConflictsUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked<ISpacesPort>; + let service: jest.Mocked<ChangeProposalService>; + let conflictDetectionService: jest.Mocked<ConflictDetectionService>; + + const buildCommand = ( + overrides?: Partial<RecomputeConflictsCommand>, + ): RecomputeConflictsCommand => ({ + userId, + organizationId, + spaceId, + artefactId: standardId, + decisions: {}, + ...overrides, + }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn(), + getOrganizationById: jest.fn(), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort = { + getSpaceById: jest.fn(), + findMembership: jest.fn().mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + createdBy: userId, + updatedBy: userId, + }), + } as unknown as jest.Mocked<ISpacesPort>; + + service = { + findProposalsByArtefact: jest.fn(), + } as unknown as jest.Mocked<ChangeProposalService>; + + conflictDetectionService = { + detectConflicts: jest.fn(), + } as unknown as jest.Mocked<ConflictDetectionService>; + + useCase = new RecomputeConflictsUseCase( + spacesPort, + accountsPort, + service, + conflictDetectionService, + stubLogger(), + ); + + accountsPort.getUserById.mockResolvedValue(user); + accountsPort.getOrganizationById.mockResolvedValue(organization); + spacesPort.getSpaceById.mockResolvedValue(space); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when proposals have no decisions', () => { + const proposalId1 = createChangeProposalId('proposal-1'); + const proposalId2 = createChangeProposalId('proposal-2'); + + const proposals = [ + changeProposalFactory({ + id: proposalId1, + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + spaceId, + }), + changeProposalFactory({ + id: proposalId2, + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + spaceId, + }), + ]; + + beforeEach(() => { + service.findProposalsByArtefact.mockResolvedValue(proposals); + conflictDetectionService.detectConflicts.mockReturnValue( + proposals.map((p) => ({ + ...p, + conflictsWith: [proposals.find((other) => other.id !== p.id)!.id], + })), + ); + }); + + it('returns conflicts from detection service', async () => { + const result = await useCase.execute(buildCommand()); + + expect(result.conflicts[proposalId1]).toEqual([proposalId2]); + }); + + it('returns bidirectional conflicts', async () => { + const result = await useCase.execute(buildCommand()); + + expect(result.conflicts[proposalId2]).toEqual([proposalId1]); + }); + + it('passes unmodified proposals to detection service', async () => { + await useCase.execute(buildCommand()); + + expect(conflictDetectionService.detectConflicts).toHaveBeenCalledWith( + proposals, + ); + }); + }); + + describe('when a proposal has a decision', () => { + const proposalId1 = createChangeProposalId('proposal-1'); + const proposalId2 = createChangeProposalId('proposal-2'); + + const proposals = [ + changeProposalFactory({ + id: proposalId1, + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + spaceId, + payload: { oldValue: 'Original', newValue: 'Change A' }, + }), + changeProposalFactory({ + id: proposalId2, + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + spaceId, + payload: { oldValue: 'Original', newValue: 'Change B' }, + }), + ]; + + const decision = { oldValue: 'Original', newValue: 'Edited A' }; + + beforeEach(() => { + service.findProposalsByArtefact.mockResolvedValue(proposals); + conflictDetectionService.detectConflicts.mockReturnValue( + proposals.map((p) => ({ ...p, conflictsWith: [] })), + ); + }); + + it('injects decision into the matching proposal', async () => { + await useCase.execute( + buildCommand({ decisions: { [proposalId1]: decision } }), + ); + + const passedProposals = + conflictDetectionService.detectConflicts.mock.calls[0][0]; + + expect(passedProposals[0].decision).toEqual(decision); + }); + + it('leaves other proposals unchanged', async () => { + await useCase.execute( + buildCommand({ decisions: { [proposalId1]: decision } }), + ); + + const passedProposals = + conflictDetectionService.detectConflicts.mock.calls[0][0]; + + expect(passedProposals[1]).toBe(proposals[1]); + }); + }); + + describe('when proposals include non-pending statuses', () => { + const pendingProposalId = createChangeProposalId('pending-proposal'); + + const pendingProposal = changeProposalFactory({ + id: pendingProposalId, + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + spaceId, + status: ChangeProposalStatus.pending, + }); + + const appliedProposal = changeProposalFactory({ + artefactId: standardId, + spaceId, + status: ChangeProposalStatus.applied, + decision: { oldValue: 'Old', newValue: 'Applied' }, + }); + + beforeEach(() => { + service.findProposalsByArtefact.mockResolvedValue([ + pendingProposal, + appliedProposal, + ]); + conflictDetectionService.detectConflicts.mockReturnValue([ + { ...pendingProposal, conflictsWith: [] }, + ]); + }); + + it('filters out non-pending proposals', async () => { + await useCase.execute(buildCommand()); + + expect(conflictDetectionService.detectConflicts).toHaveBeenCalledWith([ + pendingProposal, + ]); + }); + }); + + describe('when no proposals exist', () => { + beforeEach(() => { + service.findProposalsByArtefact.mockResolvedValue([]); + conflictDetectionService.detectConflicts.mockReturnValue([]); + }); + + it('returns empty conflicts', async () => { + const result = await useCase.execute(buildCommand()); + + expect(result.conflicts).toEqual({}); + }); + }); + + describe('when the user is not a member of the space', () => { + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue(null); + }); + + it('throws a SpaceMembershipRequiredError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceMembershipRequiredError, + ); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/useCases/recomputeConflicts/RecomputeConflictsUseCase.ts b/packages/playbook-change-management/src/application/useCases/recomputeConflicts/RecomputeConflictsUseCase.ts new file mode 100644 index 000000000..c4785e912 --- /dev/null +++ b/packages/playbook-change-management/src/application/useCases/recomputeConflicts/RecomputeConflictsUseCase.ts @@ -0,0 +1,76 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + AbstractSpaceMemberUseCase, + SpaceMemberContext, +} from '@packmind/node-utils'; +import { + ChangeProposal, + ChangeProposalId, + ChangeProposalStatus, + ChangeProposalType, + IAccountsPort, + ISpacesPort, + IRecomputeConflictsUseCase, + RecomputeConflictsCommand, + RecomputeConflictsResponse, +} from '@packmind/types'; +import { ChangeProposalService } from '../../services/ChangeProposalService'; +import { ConflictDetectionService } from '../../services/ConflictDetectionService'; + +const origin = 'RecomputeConflictsUseCase'; + +export class RecomputeConflictsUseCase + extends AbstractSpaceMemberUseCase< + RecomputeConflictsCommand, + RecomputeConflictsResponse + > + implements IRecomputeConflictsUseCase +{ + constructor( + spacesPort: ISpacesPort, + accountsPort: IAccountsPort, + private readonly service: ChangeProposalService, + private readonly conflictDetectionService: ConflictDetectionService, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(spacesPort, accountsPort, logger); + } + + async executeForSpaceMembers( + command: RecomputeConflictsCommand & SpaceMemberContext, + ): Promise<RecomputeConflictsResponse> { + const allProposals = await this.service.findProposalsByArtefact( + command.spaceId, + command.artefactId, + ); + + const pendingProposals = allProposals.filter( + (p) => p.status === ChangeProposalStatus.pending, + ); + + const proposalsWithDecisions = pendingProposals.map((p) => { + const decision = command.decisions[p.id]; + if (decision) { + // The ChangeProposal discriminated union constrains pending proposals to `decision: null`. + // We temporarily set decision for conflict computation only (not persisted). + // Safe because conflict detectors read `decision ?? payload` without inspecting `status`. + return { + ...p, + decision, + } as unknown as ChangeProposal<ChangeProposalType>; + } + return p; + }); + + const enrichedProposals = this.conflictDetectionService.detectConflicts( + proposalsWithDecisions, + ); + + const conflicts: Record<ChangeProposalId, ChangeProposalId[]> = {}; + for (const proposal of enrichedProposals) { + conflicts[proposal.id] = proposal.conflictsWith; + } + + return { conflicts }; + } +} diff --git a/packages/playbook-change-management/src/application/utils/isExpectedChangeProposalType.ts b/packages/playbook-change-management/src/application/utils/isExpectedChangeProposalType.ts new file mode 100644 index 000000000..87c74436d --- /dev/null +++ b/packages/playbook-change-management/src/application/utils/isExpectedChangeProposalType.ts @@ -0,0 +1 @@ +export { isExpectedChangeProposalType } from '@packmind/types'; diff --git a/packages/playbook-change-management/src/application/validators/CommandChangeProposalValidator.spec.ts b/packages/playbook-change-management/src/application/validators/CommandChangeProposalValidator.spec.ts new file mode 100644 index 000000000..d9918c3e6 --- /dev/null +++ b/packages/playbook-change-management/src/application/validators/CommandChangeProposalValidator.spec.ts @@ -0,0 +1,97 @@ +import { + ChangeProposalCaptureMode, + ChangeProposalType, + createOrganizationId, + createSpaceId, + createUserId, + IRecipesPort, + NewCommandPayload, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { CommandChangeProposalValidator } from './CommandChangeProposalValidator'; + +describe('CommandChangeProposalValidator', () => { + const userId = createUserId('user-id'); + const organizationId = createOrganizationId('org-id'); + const spaceId = createSpaceId('space-id'); + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + const organization = organizationFactory({ id: organizationId }); + + let recipesPort: jest.Mocked<IRecipesPort>; + let validator: CommandChangeProposalValidator; + + beforeEach(() => { + recipesPort = { + getRecipeById: jest.fn(), + } as unknown as jest.Mocked<IRecipesPort>; + validator = new CommandChangeProposalValidator(recipesPort); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('supports()', () => { + it('returns true for createCommand', () => { + expect(validator.supports(ChangeProposalType.createCommand)).toBe(true); + }); + + it('returns false for createStandard', () => { + expect(validator.supports(ChangeProposalType.createStandard)).toBe(false); + }); + }); + + describe('validate() for createCommand', () => { + it('returns artefactVersion 0 without calling recipesPort', async () => { + const payload: NewCommandPayload = { + name: 'My Command', + content: 'Do something', + }; + const command = { + userId, + organizationId, + spaceId, + type: ChangeProposalType.createCommand, + artefactId: null, + payload, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + user, + organization, + membership: { userId, organizationId, role: 'member' as const }, + }; + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 0 }); + }); + + it('does not call recipesPort for createCommand', async () => { + const payload: NewCommandPayload = { + name: 'My Command', + content: 'Do something', + }; + const command = { + userId, + organizationId, + spaceId, + type: ChangeProposalType.createCommand, + artefactId: null, + payload, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + user, + organization, + membership: { userId, organizationId, role: 'member' as const }, + }; + + await validator.validate(command); + + expect(recipesPort.getRecipeById).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/validators/CommandChangeProposalValidator.ts b/packages/playbook-change-management/src/application/validators/CommandChangeProposalValidator.ts new file mode 100644 index 000000000..02ec73942 --- /dev/null +++ b/packages/playbook-change-management/src/application/validators/CommandChangeProposalValidator.ts @@ -0,0 +1,79 @@ +import { + ChangeProposalType, + CreateChangeProposalCommand, + IRecipesPort, + Recipe, + RecipeId, + ScalarUpdatePayload, +} from '@packmind/types'; +import { MemberContext } from '@packmind/node-utils'; +import { IChangeProposalValidator } from './IChangeProposalValidator'; +import { ChangeProposalPayloadMismatchError } from '../errors/ChangeProposalPayloadMismatchError'; + +type SupportedType = + | ChangeProposalType.updateCommandName + | ChangeProposalType.updateCommandDescription + | ChangeProposalType.createCommand; + +const SUPPORTED_TYPES: ReadonlySet<ChangeProposalType> = new Set<SupportedType>( + [ + ChangeProposalType.updateCommandName, + ChangeProposalType.updateCommandDescription, + ChangeProposalType.createCommand, + ], +); + +type UpdateSupportedType = Exclude< + SupportedType, + ChangeProposalType.createCommand +>; + +const RECIPE_FIELD_BY_TYPE: Record< + UpdateSupportedType, + (recipe: Recipe) => string +> = { + [ChangeProposalType.updateCommandName]: (recipe) => recipe.name, + [ChangeProposalType.updateCommandDescription]: (recipe) => recipe.content, +}; + +export class CommandChangeProposalValidator implements IChangeProposalValidator { + constructor(private readonly recipesPort: IRecipesPort) {} + + supports(type: ChangeProposalType): boolean { + return SUPPORTED_TYPES.has(type); + } + + async validate( + command: CreateChangeProposalCommand<ChangeProposalType> & MemberContext, + ): Promise<{ artefactVersion: number }> { + // Creation proposals have no existing artefact — no validation needed + if (command.type === ChangeProposalType.createCommand) { + return { artefactVersion: 0 }; + } + + const recipeId = command.artefactId as RecipeId; + + const recipe = await this.recipesPort.getRecipeById({ + userId: command.userId, + organizationId: command.organization.id, + spaceId: command.spaceId, + recipeId, + }); + if (!recipe) { + throw new Error(`Recipe ${recipeId} not found`); + } + + const payload = command.payload as ScalarUpdatePayload; + const currentValue = + RECIPE_FIELD_BY_TYPE[command.type as UpdateSupportedType](recipe); + if (payload.oldValue !== currentValue) { + throw new ChangeProposalPayloadMismatchError( + command.type, + payload.oldValue, + currentValue, + ); + } + + return { artefactVersion: recipe.version }; + } +} diff --git a/packages/playbook-change-management/src/application/validators/IChangeProposalValidator.ts b/packages/playbook-change-management/src/application/validators/IChangeProposalValidator.ts new file mode 100644 index 000000000..f71698a13 --- /dev/null +++ b/packages/playbook-change-management/src/application/validators/IChangeProposalValidator.ts @@ -0,0 +1,19 @@ +import { + ChangeProposalPayload, + ChangeProposalType, + CreateChangeProposalCommand, +} from '@packmind/types'; +import { MemberContext } from '@packmind/node-utils'; + +export type ChangeProposalValidationResult = { + artefactVersion: number; + resolvedPayload?: ChangeProposalPayload<ChangeProposalType>; +}; + +export interface IChangeProposalValidator { + supports(type: ChangeProposalType): boolean; + + validate( + command: CreateChangeProposalCommand<ChangeProposalType> & MemberContext, + ): Promise<ChangeProposalValidationResult>; +} diff --git a/packages/playbook-change-management/src/application/validators/RemovalChangeProposalValidator.spec.ts b/packages/playbook-change-management/src/application/validators/RemovalChangeProposalValidator.spec.ts new file mode 100644 index 000000000..8ebc1ecbc --- /dev/null +++ b/packages/playbook-change-management/src/application/validators/RemovalChangeProposalValidator.spec.ts @@ -0,0 +1,330 @@ +import { + ChangeProposalCaptureMode, + ChangeProposalType, + createOrganizationId, + createPackageId, + createRecipeId, + createSkillId, + createSpaceId, + createStandardId, + createUserId, + IRecipesPort, + ISkillsPort, + IStandardsPort, + RemoveArtefactPayload, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { recipeFactory } from '@packmind/recipes/test/recipeFactory'; +import { standardFactory } from '@packmind/standards/test/standardFactory'; +import { skillFactory } from '@packmind/skills/test/skillFactory'; +import { RemovalChangeProposalValidator } from './RemovalChangeProposalValidator'; + +describe('RemovalChangeProposalValidator', () => { + const userId = createUserId('user-id'); + const organizationId = createOrganizationId('org-id'); + const spaceId = createSpaceId('space-id'); + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + const organization = organizationFactory({ id: organizationId }); + + let standardsPort: jest.Mocked<IStandardsPort>; + let recipesPort: jest.Mocked<IRecipesPort>; + let skillsPort: jest.Mocked<ISkillsPort>; + let validator: RemovalChangeProposalValidator; + + beforeEach(() => { + standardsPort = { + getStandard: jest.fn(), + } as unknown as jest.Mocked<IStandardsPort>; + recipesPort = { + getRecipeById: jest.fn(), + } as unknown as jest.Mocked<IRecipesPort>; + skillsPort = { + getSkill: jest.fn(), + } as unknown as jest.Mocked<ISkillsPort>; + validator = new RemovalChangeProposalValidator( + standardsPort, + recipesPort, + skillsPort, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('supports()', () => { + it('returns true for removeStandard', () => { + expect(validator.supports(ChangeProposalType.removeStandard)).toBe(true); + }); + + it('returns true for removeCommand', () => { + expect(validator.supports(ChangeProposalType.removeCommand)).toBe(true); + }); + + it('returns true for removeSkill', () => { + expect(validator.supports(ChangeProposalType.removeSkill)).toBe(true); + }); + + it('returns false for createStandard', () => { + expect(validator.supports(ChangeProposalType.createStandard)).toBe(false); + }); + }); + + describe('validate() for removeStandard', () => { + const standardId = createStandardId('standard-id'); + const payload: RemoveArtefactPayload = { + packageIds: [createPackageId('package-id')], + }; + + describe('when standard exists', () => { + beforeEach(() => { + const standard = standardFactory({ + id: standardId, + version: 3, + }); + standardsPort.getStandard.mockResolvedValue(standard); + }); + + it('returns standard version', async () => { + const command = { + userId, + organizationId, + spaceId, + type: ChangeProposalType.removeStandard, + artefactId: standardId, + payload, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + user, + organization, + membership: { userId, organizationId, role: 'member' as const }, + }; + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 3 }); + }); + + it('calls getStandard with correct ID', async () => { + const command = { + userId, + organizationId, + spaceId, + type: ChangeProposalType.removeStandard, + artefactId: standardId, + payload, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + user, + organization, + membership: { userId, organizationId, role: 'member' as const }, + }; + + await validator.validate(command); + + expect(standardsPort.getStandard).toHaveBeenCalledWith(standardId); + }); + }); + + describe('when standard does not exist', () => { + beforeEach(() => { + standardsPort.getStandard.mockResolvedValue(null); + }); + + it('throws error', async () => { + const command = { + userId, + organizationId, + spaceId, + type: ChangeProposalType.removeStandard, + artefactId: standardId, + payload, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + user, + organization, + membership: { userId, organizationId, role: 'member' as const }, + }; + + await expect(validator.validate(command)).rejects.toThrow( + `Standard ${standardId} not found`, + ); + }); + }); + }); + + describe('validate() for removeCommand', () => { + const recipeId = createRecipeId('recipe-id'); + const payload: RemoveArtefactPayload = { + packageIds: [createPackageId('package-id')], + }; + + describe('when recipe exists', () => { + beforeEach(() => { + const recipe = recipeFactory({ + id: recipeId, + version: 2, + }); + recipesPort.getRecipeById.mockResolvedValue(recipe); + }); + + it('returns recipe version', async () => { + const command = { + userId, + organizationId, + spaceId, + type: ChangeProposalType.removeCommand, + artefactId: recipeId, + payload, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + user, + organization, + membership: { userId, organizationId, role: 'member' as const }, + }; + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + + it('calls getRecipeById with correct parameters', async () => { + const command = { + userId, + organizationId, + spaceId, + type: ChangeProposalType.removeCommand, + artefactId: recipeId, + payload, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + user, + organization, + membership: { userId, organizationId, role: 'member' as const }, + }; + + await validator.validate(command); + + expect(recipesPort.getRecipeById).toHaveBeenCalledWith({ + userId, + organizationId: organization.id, + spaceId, + recipeId, + }); + }); + }); + + describe('when recipe does not exist', () => { + beforeEach(() => { + recipesPort.getRecipeById.mockResolvedValue(null); + }); + + it('throws error', async () => { + const command = { + userId, + organizationId, + spaceId, + type: ChangeProposalType.removeCommand, + artefactId: recipeId, + payload, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + user, + organization, + membership: { userId, organizationId, role: 'member' as const }, + }; + + await expect(validator.validate(command)).rejects.toThrow( + `Recipe ${recipeId} not found`, + ); + }); + }); + }); + + describe('validate() for removeSkill', () => { + const skillId = createSkillId('skill-id'); + const payload: RemoveArtefactPayload = { + packageIds: [createPackageId('package-id')], + }; + + describe('when skill exists', () => { + beforeEach(() => { + const skill = skillFactory({ + id: skillId, + version: 1, + }); + skillsPort.getSkill.mockResolvedValue(skill); + }); + + it('returns skill version', async () => { + const command = { + userId, + organizationId, + spaceId, + type: ChangeProposalType.removeSkill, + artefactId: skillId, + payload, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + user, + organization, + membership: { userId, organizationId, role: 'member' as const }, + }; + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 1 }); + }); + + it('calls getSkill with correct ID', async () => { + const command = { + userId, + organizationId, + spaceId, + type: ChangeProposalType.removeSkill, + artefactId: skillId, + payload, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + user, + organization, + membership: { userId, organizationId, role: 'member' as const }, + }; + + await validator.validate(command); + + expect(skillsPort.getSkill).toHaveBeenCalledWith(skillId); + }); + }); + + describe('when skill does not exist', () => { + beforeEach(() => { + skillsPort.getSkill.mockResolvedValue(null); + }); + + it('throws error', async () => { + const command = { + userId, + organizationId, + spaceId, + type: ChangeProposalType.removeSkill, + artefactId: skillId, + payload, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + user, + organization, + membership: { userId, organizationId, role: 'member' as const }, + }; + + await expect(validator.validate(command)).rejects.toThrow( + `Skill ${skillId} not found`, + ); + }); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/validators/RemovalChangeProposalValidator.ts b/packages/playbook-change-management/src/application/validators/RemovalChangeProposalValidator.ts new file mode 100644 index 000000000..2cccef6a1 --- /dev/null +++ b/packages/playbook-change-management/src/application/validators/RemovalChangeProposalValidator.ts @@ -0,0 +1,75 @@ +import { + ChangeProposalType, + CreateChangeProposalCommand, + IRecipesPort, + ISkillsPort, + IStandardsPort, + RecipeId, + SkillId, + StandardId, +} from '@packmind/types'; +import { MemberContext } from '@packmind/node-utils'; +import { IChangeProposalValidator } from './IChangeProposalValidator'; + +type SupportedType = + | ChangeProposalType.removeStandard + | ChangeProposalType.removeCommand + | ChangeProposalType.removeSkill; + +const SUPPORTED_TYPES: ReadonlySet<ChangeProposalType> = new Set<SupportedType>( + [ + ChangeProposalType.removeStandard, + ChangeProposalType.removeCommand, + ChangeProposalType.removeSkill, + ], +); + +export class RemovalChangeProposalValidator implements IChangeProposalValidator { + constructor( + private readonly standardsPort: IStandardsPort, + private readonly recipesPort: IRecipesPort, + private readonly skillsPort: ISkillsPort, + ) {} + + supports(type: ChangeProposalType): boolean { + return SUPPORTED_TYPES.has(type); + } + + async validate( + command: CreateChangeProposalCommand<ChangeProposalType> & MemberContext, + ): Promise<{ artefactVersion: number }> { + if (command.type === ChangeProposalType.removeStandard) { + const standardId = command.artefactId as StandardId; + const standard = await this.standardsPort.getStandard(standardId); + if (!standard) { + throw new Error(`Standard ${standardId} not found`); + } + return { artefactVersion: standard.version }; + } + + if (command.type === ChangeProposalType.removeCommand) { + const recipeId = command.artefactId as RecipeId; + const recipe = await this.recipesPort.getRecipeById({ + userId: command.userId, + organizationId: command.organization.id, + spaceId: command.spaceId, + recipeId, + }); + if (!recipe) { + throw new Error(`Recipe ${recipeId} not found`); + } + return { artefactVersion: recipe.version }; + } + + if (command.type === ChangeProposalType.removeSkill) { + const skillId = command.artefactId as SkillId; + const skill = await this.skillsPort.getSkill(skillId); + if (!skill) { + throw new Error(`Skill ${skillId} not found`); + } + return { artefactVersion: skill.version }; + } + + throw new Error(`Unsupported removal type: ${command.type}`); + } +} diff --git a/packages/playbook-change-management/src/application/validators/SkillChangeProposalValidator.spec.ts b/packages/playbook-change-management/src/application/validators/SkillChangeProposalValidator.spec.ts new file mode 100644 index 000000000..69ab8abd8 --- /dev/null +++ b/packages/playbook-change-management/src/application/validators/SkillChangeProposalValidator.spec.ts @@ -0,0 +1,724 @@ +import { + ChangeProposalCaptureMode, + ChangeProposalType, + CollectionItemUpdatePayload, + CreateChangeProposalCommand, + createSkillFileId, + createSkillId, + createSkillVersionId, + createOrganizationId, + createUserId, + ISkillsPort, + Skill, + SkillFile, + SkillFileId, + SkillVersion, +} from '@packmind/types'; +import { MemberContext } from '@packmind/node-utils'; +import { DESCRIPTION_MAX_LENGTH, SkillValidationError } from '@packmind/skills'; +import { SkillChangeProposalValidator } from './SkillChangeProposalValidator'; +import { ChangeProposalPayloadMismatchError } from '../errors/ChangeProposalPayloadMismatchError'; +import { SkillFileNotFoundError } from '../errors/SkillFileNotFoundError'; + +describe('SkillChangeProposalValidator', () => { + const skillId = createSkillId('skill-1'); + const organizationId = createOrganizationId('org-1'); + const userId = createUserId('user-1'); + const latestVersionId = createSkillVersionId('version-2'); + const oldVersionId = createSkillVersionId('version-1'); + const oldFileId = createSkillFileId('old-file-1'); + const newFileId = createSkillFileId('new-file-1'); + + const skill: Skill = { + id: skillId, + spaceId: 'space-1' as never, + userId, + name: 'My Skill', + slug: 'my-skill', + version: 2, + description: 'desc', + prompt: 'prompt', + createdAt: new Date(), + updatedAt: new Date(), + }; + + const oldVersionFile: SkillFile = { + id: oldFileId, + skillVersionId: oldVersionId, + path: 'helper.ts', + content: 'old content', + permissions: 'rw-r--r--', + isBase64: false, + }; + + const newVersionFile: SkillFile = { + id: newFileId, + skillVersionId: latestVersionId, + path: 'helper.ts', + content: 'old content', + permissions: 'rw-r--r--', + isBase64: false, + }; + + let validator: SkillChangeProposalValidator; + let skillsPort: jest.Mocked<ISkillsPort>; + + const buildCommand = ( + overrides: Partial<CreateChangeProposalCommand<ChangeProposalType>> = {}, + ) => + ({ + userId, + organizationId, + spaceId: 'space-1', + type: ChangeProposalType.updateSkillFilePermissions, + artefactId: skillId, + captureMode: ChangeProposalCaptureMode.commit, + message: 'test message', + payload: { + targetId: oldFileId, + oldValue: 'rw-r--r--', + newValue: 'rwxr-xr-x', + }, + ...overrides, + }) as CreateChangeProposalCommand<ChangeProposalType> & MemberContext; + + beforeEach(() => { + skillsPort = { + getSkill: jest.fn(), + getSkillVersion: jest.fn(), + getLatestSkillVersion: jest.fn(), + listSkillVersions: jest.fn(), + getSkillFiles: jest.fn(), + listSkillsBySpace: jest.fn(), + findSkillBySlug: jest.fn(), + } as unknown as jest.Mocked<ISkillsPort>; + + validator = new SkillChangeProposalValidator(skillsPort); + + skillsPort.getSkill.mockResolvedValue(skill); + skillsPort.getLatestSkillVersion.mockResolvedValue({ + id: latestVersionId, + skillId, + version: 2, + userId, + name: 'My Skill', + slug: 'my-skill', + description: 'desc', + prompt: 'prompt', + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('supports', () => { + it('returns true for createSkill', () => { + expect(validator.supports(ChangeProposalType.createSkill)).toBe(true); + }); + + it('returns false for updateStandardName', () => { + expect(validator.supports(ChangeProposalType.updateStandardName)).toBe( + false, + ); + }); + }); + + describe('when type is createSkill', () => { + it('returns artefactVersion 0', async () => { + const command = buildCommand({ + type: ChangeProposalType.createSkill, + artefactId: null, + payload: { + name: 'New Skill', + description: 'A description', + prompt: 'Do something useful', + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 0 }); + }); + + it('does not call skillsPort', async () => { + const command = buildCommand({ + type: ChangeProposalType.createSkill, + artefactId: null, + payload: { + name: 'New Skill', + description: 'A description', + prompt: 'Do something useful', + }, + }); + + await validator.validate(command); + + expect(skillsPort.getSkill).not.toHaveBeenCalled(); + }); + + it('accepts a description exactly at the maximum length', async () => { + const command = buildCommand({ + type: ChangeProposalType.createSkill, + artefactId: null, + payload: { + name: 'New Skill', + description: 'a'.repeat(DESCRIPTION_MAX_LENGTH), + prompt: 'Do something useful', + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 0 }); + }); + + describe('when the description exceeds the maximum length', () => { + it('throws SkillValidationError', async () => { + const command = buildCommand({ + type: ChangeProposalType.createSkill, + artefactId: null, + payload: { + name: 'New Skill', + description: 'a'.repeat(DESCRIPTION_MAX_LENGTH + 1), + prompt: 'Do something useful', + }, + }); + + await expect(validator.validate(command)).rejects.toBeInstanceOf( + SkillValidationError, + ); + }); + }); + }); + + describe('when type is updateSkillDescription', () => { + it('accepts a new value exactly at the maximum length', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillDescription, + payload: { + oldValue: 'desc', + newValue: 'a'.repeat(DESCRIPTION_MAX_LENGTH), + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + + describe('when the new value exceeds the maximum length', () => { + it('throws SkillValidationError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillDescription, + payload: { + oldValue: 'desc', + newValue: 'a'.repeat(DESCRIPTION_MAX_LENGTH + 1), + }, + }); + + await expect(validator.validate(command)).rejects.toBeInstanceOf( + SkillValidationError, + ); + }); + }); + + it('allows shrinking a legacy oversized description back under the cap', async () => { + const legacyDescription = 'a'.repeat(DESCRIPTION_MAX_LENGTH + 50); + skillsPort.getSkill.mockResolvedValue({ + ...skill, + description: legacyDescription, + } as Skill); + + const command = buildCommand({ + type: ChangeProposalType.updateSkillDescription, + payload: { + oldValue: legacyDescription, + newValue: 'a much shorter, valid description', + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + }); + + describe('when file ID matches in latest version', () => { + beforeEach(() => { + skillsPort.getSkillFiles.mockResolvedValue([ + { ...newVersionFile, id: oldFileId }, + ]); + }); + + it('validates successfully', async () => { + const result = await validator.validate(buildCommand()); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + + it('does not call listSkillVersions', async () => { + await validator.validate(buildCommand()); + + expect(skillsPort.listSkillVersions).not.toHaveBeenCalled(); + }); + }); + + describe('when file ID is stale but path matches in latest version', () => { + beforeEach(() => { + skillsPort.getSkillFiles + .mockResolvedValueOnce([newVersionFile]) + .mockResolvedValueOnce([oldVersionFile]); + skillsPort.listSkillVersions.mockResolvedValue([ + { + id: oldVersionId, + skillId, + version: 1, + userId, + name: 'My Skill', + slug: 'my-skill', + description: 'desc', + prompt: 'prompt', + } as SkillVersion, + ]); + }); + + it('falls back to path-based matching', async () => { + const result = await validator.validate(buildCommand()); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + + it('calls listSkillVersions for fallback lookup', async () => { + await validator.validate(buildCommand()); + + expect(skillsPort.listSkillVersions).toHaveBeenCalledWith(skillId); + }); + }); + + describe('when file ID is stale and path does not exist in latest version', () => { + beforeEach(() => { + skillsPort.getSkillFiles + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([oldVersionFile]); + skillsPort.listSkillVersions.mockResolvedValue([ + { + id: oldVersionId, + skillId, + version: 1, + userId, + name: 'My Skill', + slug: 'my-skill', + description: 'desc', + prompt: 'prompt', + } as SkillVersion, + ]); + }); + + it('throws SkillFileNotFoundError', async () => { + await expect(validator.validate(buildCommand())).rejects.toBeInstanceOf( + SkillFileNotFoundError, + ); + }); + }); + + describe('when file ID not found in any version', () => { + beforeEach(() => { + skillsPort.getSkillFiles.mockResolvedValue([]); + skillsPort.listSkillVersions.mockResolvedValue([ + { + id: oldVersionId, + skillId, + version: 1, + userId, + name: 'My Skill', + slug: 'my-skill', + description: 'desc', + prompt: 'prompt', + } as SkillVersion, + ]); + }); + + it('throws SkillFileNotFoundError', async () => { + await expect(validator.validate(buildCommand())).rejects.toBeInstanceOf( + SkillFileNotFoundError, + ); + }); + }); + + describe('when updateSkillFileContent has stale file ID', () => { + beforeEach(() => { + skillsPort.getSkillFiles + .mockResolvedValueOnce([newVersionFile]) + .mockResolvedValueOnce([oldVersionFile]); + skillsPort.listSkillVersions.mockResolvedValue([ + { + id: oldVersionId, + skillId, + version: 1, + userId, + name: 'My Skill', + slug: 'my-skill', + description: 'desc', + prompt: 'prompt', + } as SkillVersion, + ]); + }); + + it('falls back to path-based matching for content validation', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillFileContent, + payload: { + targetId: oldFileId, + oldValue: 'old content', + newValue: 'new content', + } as CollectionItemUpdatePayload<SkillFileId>, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + }); + + describe('when updateSkillMetadata has metadata field in skill entity', () => { + beforeEach(() => { + skillsPort.getSkill.mockResolvedValue({ + ...skill, + metadata: { key1: 'value1' }, + } as Skill); + }); + + describe('when oldValue matches serialized metadata field', () => { + it('validates successfully', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillMetadata, + payload: { + oldValue: '{"key1":"value1"}', + newValue: '{"key1":"value2"}', + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + }); + + describe('when oldValue does not match', () => { + it('throws ChangeProposalPayloadMismatchError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillMetadata, + payload: { + oldValue: '{"key1":"wrong"}', + newValue: '{"key1":"value2"}', + }, + }); + + await expect(validator.validate(command)).rejects.toBeInstanceOf( + ChangeProposalPayloadMismatchError, + ); + }); + }); + }); + + describe('when updateSkillMetadata has null metadata in skill entity', () => { + beforeEach(() => { + skillsPort.getSkill.mockResolvedValue({ + ...skill, + metadata: null, + } as unknown as Skill); + }); + + describe('when oldValue is empty object', () => { + it('validates successfully', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillMetadata, + payload: { + oldValue: '{}', + newValue: '{"key1":"value1"}', + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + }); + }); + + describe('when updateSkillLicense matches skill entity', () => { + beforeEach(() => { + skillsPort.getSkill.mockResolvedValue({ + ...skill, + license: 'MIT', + } as unknown as Skill); + }); + + it('validates successfully', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillLicense, + payload: { + oldValue: 'MIT', + newValue: 'Apache-2.0', + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + }); + + describe('when updateSkillLicense does not match skill entity', () => { + beforeEach(() => { + skillsPort.getSkill.mockResolvedValue({ + ...skill, + license: 'MIT', + } as unknown as Skill); + }); + + it('throws ChangeProposalPayloadMismatchError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillLicense, + payload: { + oldValue: 'GPL', + newValue: 'Apache-2.0', + }, + }); + + await expect(validator.validate(command)).rejects.toBeInstanceOf( + ChangeProposalPayloadMismatchError, + ); + }); + }); + + describe('when updateSkillLicense has undefined license in skill entity', () => { + it('validates with empty string as current value', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillLicense, + payload: { + oldValue: '', + newValue: 'MIT', + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + }); + + describe('when updateSkillCompatibility matches skill entity', () => { + beforeEach(() => { + skillsPort.getSkill.mockResolvedValue({ + ...skill, + compatibility: 'claude', + } as unknown as Skill); + }); + + it('validates successfully', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillCompatibility, + payload: { + oldValue: 'claude', + newValue: 'cursor', + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + }); + + describe('when updateSkillCompatibility does not match skill entity', () => { + beforeEach(() => { + skillsPort.getSkill.mockResolvedValue({ + ...skill, + compatibility: 'claude', + } as unknown as Skill); + }); + + it('throws ChangeProposalPayloadMismatchError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillCompatibility, + payload: { + oldValue: 'cursor', + newValue: 'copilot', + }, + }); + + await expect(validator.validate(command)).rejects.toBeInstanceOf( + ChangeProposalPayloadMismatchError, + ); + }); + }); + + describe('when updateSkillAllowedTools matches skill entity', () => { + beforeEach(() => { + skillsPort.getSkill.mockResolvedValue({ + ...skill, + allowedTools: 'Read,Write', + } as unknown as Skill); + }); + + it('validates successfully', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillAllowedTools, + payload: { + oldValue: 'Read,Write', + newValue: 'Read,Write,Edit', + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + }); + + describe('when updateSkillAllowedTools does not match skill entity', () => { + beforeEach(() => { + skillsPort.getSkill.mockResolvedValue({ + ...skill, + allowedTools: 'Read,Write', + } as unknown as Skill); + }); + + it('throws ChangeProposalPayloadMismatchError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateSkillAllowedTools, + payload: { + oldValue: 'Read', + newValue: 'Read,Write,Edit', + }, + }); + + await expect(validator.validate(command)).rejects.toBeInstanceOf( + ChangeProposalPayloadMismatchError, + ); + }); + }); + + describe('when updateSkillAdditionalProperty', () => { + const buildAdditionalPropertyCommand = (oldValue: string | undefined) => + buildCommand({ + type: ChangeProposalType.updateSkillAdditionalProperty, + payload: { + targetId: 'hooks', + oldValue, + newValue: '{"PreToolUse":["allowed"]}', + } as CollectionItemUpdatePayload<string>, + }); + + describe('when both Skill and SkillVersion have no additionalProperties', () => { + it('validates with oldValue null', async () => { + const result = await validator.validate( + buildAdditionalPropertyCommand('null'), + ); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + }); + + describe('when Skill has additionalProperties but SkillVersion does not', () => { + beforeEach(() => { + skillsPort.getSkill.mockResolvedValue({ + ...skill, + additionalProperties: { + hooks: { PreToolUse: ['blocked'] }, + }, + } as Skill); + }); + + it('uses SkillVersion data and validates with oldValue null', async () => { + const result = await validator.validate( + buildAdditionalPropertyCommand('null'), + ); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + }); + + describe('when both have matching additionalProperties', () => { + beforeEach(() => { + const additionalProperties = { + hooks: { PreToolUse: ['allowed'] }, + }; + skillsPort.getSkill.mockResolvedValue({ + ...skill, + additionalProperties, + } as Skill); + skillsPort.getLatestSkillVersion.mockResolvedValue({ + id: latestVersionId, + skillId, + version: 2, + userId, + name: 'My Skill', + slug: 'my-skill', + description: 'desc', + prompt: 'prompt', + additionalProperties, + }); + }); + + it('validates successfully', async () => { + const result = await validator.validate( + buildAdditionalPropertyCommand( + JSON.stringify({ PreToolUse: ['allowed'] }), + ), + ); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + }); + + describe('when oldValue does not match current value', () => { + beforeEach(() => { + const additionalProperties = { + hooks: { PreToolUse: ['blocked'] }, + }; + skillsPort.getLatestSkillVersion.mockResolvedValue({ + id: latestVersionId, + skillId, + version: 2, + userId, + name: 'My Skill', + slug: 'my-skill', + description: 'desc', + prompt: 'prompt', + additionalProperties, + }); + }); + + it('throws ChangeProposalPayloadMismatchError', async () => { + await expect( + validator.validate(buildAdditionalPropertyCommand('null')), + ).rejects.toBeInstanceOf(ChangeProposalPayloadMismatchError); + }); + }); + + describe('when no SkillVersion exists', () => { + beforeEach(() => { + skillsPort.getLatestSkillVersion.mockResolvedValue(null); + skillsPort.getSkill.mockResolvedValue({ + ...skill, + additionalProperties: { + hooks: { PreToolUse: ['allowed'] }, + }, + } as Skill); + }); + + it('falls back to Skill additionalProperties', async () => { + const result = await validator.validate( + buildAdditionalPropertyCommand( + JSON.stringify({ PreToolUse: ['allowed'] }), + ), + ); + + expect(result).toEqual({ artefactVersion: 2 }); + }); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/validators/SkillChangeProposalValidator.ts b/packages/playbook-change-management/src/application/validators/SkillChangeProposalValidator.ts new file mode 100644 index 000000000..7439bb845 --- /dev/null +++ b/packages/playbook-change-management/src/application/validators/SkillChangeProposalValidator.ts @@ -0,0 +1,261 @@ +import { + ChangeProposalType, + CollectionItemDeletePayload, + CollectionItemUpdatePayload, + CreateChangeProposalCommand, + ISkillsPort, + NewSkillPayload, + Skill, + SkillFile, + SkillFileId, + SkillId, + SkillVersionId, + ScalarUpdatePayload, +} from '@packmind/types'; +import { DESCRIPTION_MAX_LENGTH, SkillValidationError } from '@packmind/skills'; +import { + MemberContext, + canonicalJsonStringify, + serializeSkillMetadata, +} from '@packmind/node-utils'; +import { IChangeProposalValidator } from './IChangeProposalValidator'; +import { ChangeProposalPayloadMismatchError } from '../errors/ChangeProposalPayloadMismatchError'; +import { SkillVersionNotFoundError } from '../errors/SkillVersionNotFoundError'; +import { SkillFileNotFoundError } from '../errors/SkillFileNotFoundError'; + +type ScalarSkillType = + | ChangeProposalType.updateSkillName + | ChangeProposalType.updateSkillDescription + | ChangeProposalType.updateSkillPrompt + | ChangeProposalType.updateSkillMetadata + | ChangeProposalType.updateSkillLicense + | ChangeProposalType.updateSkillCompatibility + | ChangeProposalType.updateSkillAllowedTools; + +const SUPPORTED_TYPES: ReadonlySet<ChangeProposalType> = new Set([ + ChangeProposalType.createSkill, + ChangeProposalType.updateSkillName, + ChangeProposalType.updateSkillDescription, + ChangeProposalType.updateSkillPrompt, + ChangeProposalType.updateSkillMetadata, + ChangeProposalType.updateSkillLicense, + ChangeProposalType.updateSkillCompatibility, + ChangeProposalType.updateSkillAllowedTools, + ChangeProposalType.addSkillFile, + ChangeProposalType.updateSkillFileContent, + ChangeProposalType.updateSkillFilePermissions, + ChangeProposalType.deleteSkillFile, + ChangeProposalType.updateSkillAdditionalProperty, +]); + +const SKILL_FIELD_BY_TYPE: Record<ScalarSkillType, (skill: Skill) => string> = { + [ChangeProposalType.updateSkillName]: (skill) => skill.name, + [ChangeProposalType.updateSkillDescription]: (skill) => skill.description, + [ChangeProposalType.updateSkillPrompt]: (skill) => skill.prompt, + [ChangeProposalType.updateSkillMetadata]: (skill) => + skill.metadata != null ? serializeSkillMetadata(skill.metadata) : '{}', + [ChangeProposalType.updateSkillLicense]: (skill) => skill.license ?? '', + [ChangeProposalType.updateSkillCompatibility]: (skill) => + skill.compatibility ?? '', + [ChangeProposalType.updateSkillAllowedTools]: (skill) => + skill.allowedTools ?? '', +}; + +const SCALAR_TYPES = new Set<ChangeProposalType>([ + ChangeProposalType.updateSkillName, + ChangeProposalType.updateSkillDescription, + ChangeProposalType.updateSkillPrompt, + ChangeProposalType.updateSkillMetadata, + ChangeProposalType.updateSkillLicense, + ChangeProposalType.updateSkillCompatibility, + ChangeProposalType.updateSkillAllowedTools, +]); + +export class SkillChangeProposalValidator implements IChangeProposalValidator { + constructor(private readonly skillsPort: ISkillsPort) {} + + supports(type: ChangeProposalType): boolean { + return SUPPORTED_TYPES.has(type); + } + + async validate( + command: CreateChangeProposalCommand<ChangeProposalType> & MemberContext, + ): Promise<{ artefactVersion: number }> { + if (command.type === ChangeProposalType.createSkill) { + const { description } = command.payload as NewSkillPayload; + if ((description ?? '').length > DESCRIPTION_MAX_LENGTH) { + throw new SkillValidationError([ + { + field: 'description', + message: `description must not exceed ${DESCRIPTION_MAX_LENGTH} characters`, + }, + ]); + } + return { artefactVersion: 0 }; + } + + const skillId = command.artefactId as SkillId; + + const skill = await this.skillsPort.getSkill(skillId); + if (!skill) { + throw new Error(`Skill ${skillId} not found`); + } + + if (SCALAR_TYPES.has(command.type)) { + const payload = command.payload as ScalarUpdatePayload; + const currentValue = + SKILL_FIELD_BY_TYPE[command.type as ScalarSkillType](skill); + if (payload.oldValue !== currentValue) { + throw new ChangeProposalPayloadMismatchError( + command.type, + payload.oldValue, + currentValue, + ); + } + + // Reject only on the submitted new value — never on the existing + // description — so already-valid skills and legacy fix-up edits + // (shrinking an oversized description back under the cap) stay allowed. + if ( + command.type === ChangeProposalType.updateSkillDescription && + (payload.newValue ?? '').length > DESCRIPTION_MAX_LENGTH + ) { + throw new SkillValidationError([ + { + field: 'description', + message: `description must not exceed ${DESCRIPTION_MAX_LENGTH} characters`, + }, + ]); + } + } + + if (command.type === ChangeProposalType.updateSkillFileContent) { + await this.validateFileContent(skill, command); + } + + if (command.type === ChangeProposalType.updateSkillFilePermissions) { + await this.validateFilePermissions(skill, command); + } + + if (command.type === ChangeProposalType.deleteSkillFile) { + await this.validateDeleteFile(skill, command); + } + + if (command.type === ChangeProposalType.updateSkillAdditionalProperty) { + const payload = command.payload as CollectionItemUpdatePayload<string>; + + // Use latest SkillVersion (same source as deployment rendering) + // to avoid mismatch when Skill and SkillVersion are out of sync + const latestVersion = await this.skillsPort.getLatestSkillVersion( + skill.id, + ); + const additionalProps = latestVersion + ? latestVersion.additionalProperties + : skill.additionalProperties; + + // DB stores raw values; canonical stringify to match the JSON-encoded format used by the diff pipeline + const currentValue = canonicalJsonStringify( + additionalProps?.[payload.targetId] ?? null, + ); + const expectedOld = payload.oldValue ?? 'null'; + if (expectedOld !== currentValue) { + throw new ChangeProposalPayloadMismatchError( + command.type, + expectedOld, + currentValue, + ); + } + } + + return { artefactVersion: skill.version }; + } + + private async validateFileContent( + skill: Skill, + command: CreateChangeProposalCommand<ChangeProposalType> & MemberContext, + ): Promise<void> { + const payload = command.payload as CollectionItemUpdatePayload<SkillFileId>; + const { file: targetFile } = await this.resolveSkillFile( + skill, + payload.targetId, + ); + + if (payload.oldValue !== targetFile.content) { + throw new ChangeProposalPayloadMismatchError( + command.type, + payload.oldValue, + targetFile.content, + ); + } + } + + private async validateFilePermissions( + skill: Skill, + command: CreateChangeProposalCommand<ChangeProposalType> & MemberContext, + ): Promise<void> { + const payload = command.payload as CollectionItemUpdatePayload<SkillFileId>; + const { file: targetFile } = await this.resolveSkillFile( + skill, + payload.targetId, + ); + + if (payload.oldValue !== targetFile.permissions) { + throw new ChangeProposalPayloadMismatchError( + command.type, + payload.oldValue, + targetFile.permissions, + ); + } + } + + private async validateDeleteFile( + skill: Skill, + command: CreateChangeProposalCommand<ChangeProposalType> & MemberContext, + ): Promise<void> { + const payload = command.payload as CollectionItemDeletePayload<{ + id: SkillFileId; + }>; + await this.resolveSkillFile(skill, payload.targetId); + } + + private async resolveSkillFile( + skill: Skill, + targetId: SkillFileId, + ): Promise<{ file: SkillFile; latestVersionId: SkillVersionId }> { + const version = await this.skillsPort.getLatestSkillVersion(skill.id); + if (!version) { + throw new SkillVersionNotFoundError(skill.id); + } + + const files = await this.skillsPort.getSkillFiles(version.id); + const targetFile = files.find((f) => f.id === targetId); + if (targetFile) { + return { file: targetFile, latestVersionId: version.id }; + } + + const filePath = await this.findFilePathById(skill.id, targetId); + if (filePath) { + const matchByPath = files.find((f) => f.path === filePath); + if (matchByPath) { + return { file: matchByPath, latestVersionId: version.id }; + } + } + + throw new SkillFileNotFoundError(targetId); + } + + private async findFilePathById( + skillId: SkillId, + fileId: SkillFileId, + ): Promise<string | null> { + const versions = await this.skillsPort.listSkillVersions(skillId); + for (const version of versions) { + const files = await this.skillsPort.getSkillFiles(version.id); + const found = files.find((f) => f.id === fileId); + if (found) { + return found.path; + } + } + return null; + } +} diff --git a/packages/playbook-change-management/src/application/validators/StandardChangeProposalValidator.spec.ts b/packages/playbook-change-management/src/application/validators/StandardChangeProposalValidator.spec.ts new file mode 100644 index 000000000..16f2e918e --- /dev/null +++ b/packages/playbook-change-management/src/application/validators/StandardChangeProposalValidator.spec.ts @@ -0,0 +1,666 @@ +import { + ChangeProposalCaptureMode, + ChangeProposalType, + ChangeProposalViolation, + CreateChangeProposalCommand, + createRuleId, + createStandardId, + createOrganizationId, + createUserId, + IStandardsPort, + Standard, +} from '@packmind/types'; +import { MemberContext } from '@packmind/node-utils'; +import { StandardChangeProposalValidator } from './StandardChangeProposalValidator'; +import { ChangeProposalPayloadMismatchError } from '../errors/ChangeProposalPayloadMismatchError'; +import { ChangeProposalLimitExceededError } from '../errors/ChangeProposalLimitExceededError'; + +describe('StandardChangeProposalValidator', () => { + const standardId = createStandardId('standard-1'); + const organizationId = createOrganizationId('org-1'); + const userId = createUserId('user-1'); + + const standard: Standard = { + id: standardId, + spaceId: 'space-1' as never, + userId, + name: 'My Standard', + slug: 'my-standard', + version: 3, + description: 'A standard description', + scope: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + let validator: StandardChangeProposalValidator; + let standardsPort: jest.Mocked<IStandardsPort>; + + const buildCommand = ( + overrides: Partial<CreateChangeProposalCommand<ChangeProposalType>> = {}, + ) => + ({ + userId, + organizationId, + spaceId: 'space-1', + type: ChangeProposalType.updateStandardName, + artefactId: standardId, + captureMode: ChangeProposalCaptureMode.commit, + message: 'test message', + payload: { + oldValue: 'My Standard', + newValue: 'Renamed Standard', + }, + ...overrides, + }) as CreateChangeProposalCommand<ChangeProposalType> & MemberContext; + + beforeEach(() => { + standardsPort = { + getStandard: jest.fn(), + getStandardVersion: jest.fn(), + getStandardVersionById: jest.fn(), + getLatestStandardVersion: jest.fn(), + listStandardsBySpace: jest.fn(), + findStandardBySlug: jest.fn(), + getRulesByStandardId: jest.fn(), + getLatestRulesByStandardId: jest.fn(), + getRule: jest.fn(), + getRuleCodeExamples: jest.fn(), + listStandardVersions: jest.fn(), + createStandardWithExamples: jest.fn(), + createStandardSamples: jest.fn(), + } as unknown as jest.Mocked<IStandardsPort>; + + validator = new StandardChangeProposalValidator(standardsPort); + + standardsPort.getStandard.mockResolvedValue(standard); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('supports', () => { + it('returns true for updateStandardName', () => { + expect(validator.supports(ChangeProposalType.updateStandardName)).toBe( + true, + ); + }); + + it('returns true for updateStandardDescription', () => { + expect( + validator.supports(ChangeProposalType.updateStandardDescription), + ).toBe(true); + }); + + it('returns true for updateStandardScope', () => { + expect(validator.supports(ChangeProposalType.updateStandardScope)).toBe( + true, + ); + }); + + it('returns true for addRule', () => { + expect(validator.supports(ChangeProposalType.addRule)).toBe(true); + }); + + it('returns true for deleteRule', () => { + expect(validator.supports(ChangeProposalType.deleteRule)).toBe(true); + }); + + it('returns true for updateRule', () => { + expect(validator.supports(ChangeProposalType.updateRule)).toBe(true); + }); + + it('returns true for createStandard', () => { + expect(validator.supports(ChangeProposalType.createStandard)).toBe(true); + }); + + it('returns false for updateCommandDescription', () => { + expect( + validator.supports(ChangeProposalType.updateCommandDescription), + ).toBe(false); + }); + + it('returns false for updateSkillName', () => { + expect(validator.supports(ChangeProposalType.updateSkillName)).toBe( + false, + ); + }); + }); + + describe('when type is createStandard', () => { + it('returns artefactVersion 0', async () => { + const command = buildCommand({ + type: ChangeProposalType.createStandard, + artefactId: null, + payload: { + name: 'New Standard', + description: 'A description', + scope: null, + rules: [], + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 0 }); + }); + + it('does not call standardsPort', async () => { + const command = buildCommand({ + type: ChangeProposalType.createStandard, + artefactId: null, + payload: { + name: 'New Standard', + description: 'A description', + scope: null, + rules: [], + }, + }); + + await validator.validate(command); + + expect(standardsPort.getStandard).not.toHaveBeenCalled(); + }); + + describe('when standard name exceeds 250 characters', () => { + let error: ChangeProposalLimitExceededError; + + beforeEach(async () => { + const command = buildCommand({ + type: ChangeProposalType.createStandard, + artefactId: null, + payload: { + name: 'A'.repeat(251), + description: 'A description', + scope: null, + rules: [], + }, + }); + + error = await validator.validate(command).catch((e) => e); + }); + + it('throws ChangeProposalLimitExceededError', () => { + expect(error).toBeInstanceOf(ChangeProposalLimitExceededError); + }); + + it('sets changeProposal to STANDARD_NAME_TOO_LONG', () => { + expect(error.changeProposal).toBe( + ChangeProposalViolation.STANDARD_NAME_TOO_LONG, + ); + }); + + it('sets wasCreated to false', () => { + expect(error.wasCreated).toBe(false); + }); + }); + + describe('when standard name is exactly 250 characters', () => { + it('returns artefactVersion 0', async () => { + const command = buildCommand({ + type: ChangeProposalType.createStandard, + artefactId: null, + payload: { + name: 'A'.repeat(250), + description: 'A description', + scope: null, + rules: [], + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 0 }); + }); + }); + + describe('when rules count exceeds 500', () => { + let error: ChangeProposalLimitExceededError; + + beforeEach(async () => { + const command = buildCommand({ + type: ChangeProposalType.createStandard, + artefactId: null, + payload: { + name: 'New Standard', + description: 'A description', + scope: null, + rules: Array.from({ length: 501 }, (_, i) => ({ + content: `Rule ${i}`, + })), + }, + }); + + error = await validator.validate(command).catch((e) => e); + }); + + it('throws ChangeProposalLimitExceededError', () => { + expect(error).toBeInstanceOf(ChangeProposalLimitExceededError); + }); + + it('sets changeProposal to TOO_MANY_RULES', () => { + expect(error.changeProposal).toBe( + ChangeProposalViolation.TOO_MANY_RULES, + ); + }); + + it('sets wasCreated to false', () => { + expect(error.wasCreated).toBe(false); + }); + }); + + describe('when rules count is exactly 500', () => { + it('returns artefactVersion 0', async () => { + const command = buildCommand({ + type: ChangeProposalType.createStandard, + artefactId: null, + payload: { + name: 'New Standard', + description: 'A description', + scope: null, + rules: Array.from({ length: 500 }, (_, i) => ({ + content: `Rule ${i}`, + })), + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 0 }); + }); + }); + + describe('when a rule content exceeds 1000 characters', () => { + let error: ChangeProposalLimitExceededError; + + beforeEach(async () => { + const command = buildCommand({ + type: ChangeProposalType.createStandard, + artefactId: null, + payload: { + name: 'New Standard', + description: 'A description', + scope: null, + rules: [{ content: 'A'.repeat(1001) }], + }, + }); + + error = await validator.validate(command).catch((e) => e); + }); + + it('throws ChangeProposalLimitExceededError', () => { + expect(error).toBeInstanceOf(ChangeProposalLimitExceededError); + }); + + it('sets changeProposal to RULE_CONTENT_TOO_LONG', () => { + expect(error.changeProposal).toBe( + ChangeProposalViolation.RULE_CONTENT_TOO_LONG, + ); + }); + + it('sets wasCreated to false', () => { + expect(error.wasCreated).toBe(false); + }); + }); + + describe('when a rule content is exactly 1000 characters', () => { + it('returns artefactVersion 0', async () => { + const command = buildCommand({ + type: ChangeProposalType.createStandard, + artefactId: null, + payload: { + name: 'New Standard', + description: 'A description', + scope: null, + rules: [{ content: 'A'.repeat(1000) }], + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 0 }); + }); + }); + }); + + describe('when validating updateStandardName', () => { + describe('when oldValue matches standard name', () => { + it('validates successfully and returns artefactVersion', async () => { + const result = await validator.validate(buildCommand()); + + expect(result).toEqual({ artefactVersion: 3 }); + }); + }); + + describe('when oldValue does not match standard name', () => { + it('throws ChangeProposalPayloadMismatchError', async () => { + const command = buildCommand({ + payload: { + oldValue: 'Wrong Name', + newValue: 'New Name', + }, + }); + + await expect(validator.validate(command)).rejects.toBeInstanceOf( + ChangeProposalPayloadMismatchError, + ); + }); + }); + }); + + describe('when validating updateStandardDescription', () => { + describe('when oldValue matches standard description', () => { + it('validates successfully and returns artefactVersion', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateStandardDescription, + payload: { + oldValue: 'A standard description', + newValue: 'Updated description', + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 3 }); + }); + }); + + describe('when oldValue does not match standard description', () => { + it('throws ChangeProposalPayloadMismatchError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateStandardDescription, + payload: { + oldValue: 'Wrong description', + newValue: 'Updated description', + }, + }); + + await expect(validator.validate(command)).rejects.toBeInstanceOf( + ChangeProposalPayloadMismatchError, + ); + }); + }); + }); + + describe('when validating updateStandardScope', () => { + describe('when standard has a scope and oldValue matches', () => { + beforeEach(() => { + standardsPort.getStandard.mockResolvedValue({ + ...standard, + scope: '**/*.ts', + }); + }); + + it('validates successfully and returns artefactVersion', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateStandardScope, + payload: { + oldValue: '**/*.ts', + newValue: '**/*.tsx', + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 3 }); + }); + }); + + describe('when standard has null scope and oldValue is empty', () => { + it('validates successfully and returns artefactVersion', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateStandardScope, + payload: { + oldValue: '', + newValue: '**/*.ts', + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 3 }); + }); + }); + + describe('when oldValue does not match standard scope', () => { + beforeEach(() => { + standardsPort.getStandard.mockResolvedValue({ + ...standard, + scope: '**/*.ts', + }); + }); + + it('throws ChangeProposalPayloadMismatchError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateStandardScope, + payload: { + oldValue: 'wrong-scope', + newValue: '**/*.tsx', + }, + }); + + await expect(validator.validate(command)).rejects.toBeInstanceOf( + ChangeProposalPayloadMismatchError, + ); + }); + }); + }); + + describe('when validating addRule', () => { + it('validates successfully and returns artefactVersion', async () => { + const ruleId = createRuleId(); + const command = buildCommand({ + type: ChangeProposalType.addRule, + payload: { + targetId: ruleId, + item: { id: ruleId, content: 'New rule content' }, + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 3 }); + }); + }); + + describe('when validating deleteRule', () => { + const ruleId = createRuleId(); + const realRuleId = createRuleId('real-rule-id'); + + describe('when rule content exists in standard', () => { + beforeEach(() => { + standardsPort.getRulesByStandardId.mockResolvedValue([ + { + id: realRuleId, + content: 'Existing rule', + standardVersionId: 'sv-1' as never, + }, + ]); + }); + + it('validates successfully and returns artefactVersion', async () => { + const command = buildCommand({ + type: ChangeProposalType.deleteRule, + payload: { + targetId: ruleId, + item: { id: ruleId, content: 'Existing rule' }, + }, + }); + + const result = await validator.validate(command); + + expect(result.artefactVersion).toEqual(3); + }); + + it('returns resolvedPayload with real rule ID', async () => { + const command = buildCommand({ + type: ChangeProposalType.deleteRule, + payload: { + targetId: ruleId, + item: { id: ruleId, content: 'Existing rule' }, + }, + }); + + const result = await validator.validate(command); + + expect(result.resolvedPayload).toEqual({ + targetId: realRuleId, + item: { id: realRuleId, content: 'Existing rule' }, + }); + }); + }); + + describe('when rule content does not exist in standard', () => { + beforeEach(() => { + standardsPort.getRulesByStandardId.mockResolvedValue([ + { + id: createRuleId(), + content: 'Different rule', + standardVersionId: 'sv-1' as never, + }, + ]); + }); + + it('throws ChangeProposalPayloadMismatchError', async () => { + const command = buildCommand({ + type: ChangeProposalType.deleteRule, + payload: { + targetId: ruleId, + item: { id: ruleId, content: 'Non-existent rule' }, + }, + }); + + await expect(validator.validate(command)).rejects.toBeInstanceOf( + ChangeProposalPayloadMismatchError, + ); + }); + }); + + describe('when standard has no rules', () => { + beforeEach(() => { + standardsPort.getRulesByStandardId.mockResolvedValue([]); + }); + + it('throws ChangeProposalPayloadMismatchError', async () => { + const command = buildCommand({ + type: ChangeProposalType.deleteRule, + payload: { + targetId: ruleId, + item: { id: ruleId, content: 'Some rule' }, + }, + }); + + await expect(validator.validate(command)).rejects.toBeInstanceOf( + ChangeProposalPayloadMismatchError, + ); + }); + }); + }); + + describe('when validating updateRule', () => { + const ruleId = createRuleId(); + const realRuleId = createRuleId('real-rule-id'); + + describe('when rule content matches oldValue', () => { + let result: Awaited<ReturnType<typeof validator.validate>>; + + beforeEach(async () => { + standardsPort.getRulesByStandardId.mockResolvedValue([ + { + id: realRuleId, + content: 'Existing rule', + standardVersionId: 'sv-1' as never, + }, + ]); + + const command = buildCommand({ + type: ChangeProposalType.updateRule, + payload: { + targetId: ruleId, + oldValue: 'Existing rule', + newValue: 'Updated rule', + }, + }); + + result = await validator.validate(command); + }); + + it('validates successfully and returns artefactVersion', () => { + expect(result.artefactVersion).toEqual(3); + }); + + it('returns resolvedPayload with real rule ID', () => { + expect(result.resolvedPayload).toEqual({ + targetId: realRuleId, + oldValue: 'Existing rule', + newValue: 'Updated rule', + }); + }); + }); + + describe('when rule content does not match oldValue', () => { + beforeEach(() => { + standardsPort.getRulesByStandardId.mockResolvedValue([ + { + id: createRuleId(), + content: 'Different rule', + standardVersionId: 'sv-1' as never, + }, + ]); + }); + + it('throws ChangeProposalPayloadMismatchError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateRule, + payload: { + targetId: ruleId, + oldValue: 'Non-existent rule', + newValue: 'Updated rule', + }, + }); + + await expect(validator.validate(command)).rejects.toBeInstanceOf( + ChangeProposalPayloadMismatchError, + ); + }); + }); + }); + + describe('when validating updateScope', () => { + describe('when the comma-separated value do not exactly match', () => { + beforeEach(() => { + standardsPort.getStandard.mockResolvedValue({ + ...standard, + scope: '**/*.ts,**/*.js', + }); + }); + + it('throws ChangeProposalPayloadMismatchError', async () => { + const command = buildCommand({ + type: ChangeProposalType.updateStandardScope, + payload: { + oldValue: '**/*.ts, **/*.js', + newValue: '**/*.tsx', + }, + }); + + const result = await validator.validate(command); + + expect(result).toEqual({ artefactVersion: 3 }); + }); + }); + }); + + describe('when standard not found', () => { + beforeEach(() => { + standardsPort.getStandard.mockResolvedValue(null); + }); + + it('throws error', async () => { + await expect(validator.validate(buildCommand())).rejects.toThrow( + `Standard ${standardId} not found`, + ); + }); + }); +}); diff --git a/packages/playbook-change-management/src/application/validators/StandardChangeProposalValidator.ts b/packages/playbook-change-management/src/application/validators/StandardChangeProposalValidator.ts new file mode 100644 index 000000000..8f7963bf5 --- /dev/null +++ b/packages/playbook-change-management/src/application/validators/StandardChangeProposalValidator.ts @@ -0,0 +1,203 @@ +import { + ChangeProposalType, + ChangeProposalViolation, + CollectionItemDeletePayload, + CollectionItemUpdatePayload, + CreateChangeProposalCommand, + IStandardsPort, + NewStandardPayload, + Rule, + RuleId, + ScalarUpdatePayload, + Standard, + StandardId, +} from '@packmind/types'; +import { MemberContext } from '@packmind/node-utils'; +import { + ChangeProposalValidationResult, + IChangeProposalValidator, +} from './IChangeProposalValidator'; +import { ChangeProposalLimitExceededError } from '../errors/ChangeProposalLimitExceededError'; +import { ChangeProposalPayloadMismatchError } from '../errors/ChangeProposalPayloadMismatchError'; + +type ScalarStandardType = + | ChangeProposalType.updateStandardName + | ChangeProposalType.updateStandardDescription + | ChangeProposalType.updateStandardScope; + +const SUPPORTED_TYPES: ReadonlySet<ChangeProposalType> = new Set([ + ChangeProposalType.createStandard, + ChangeProposalType.updateStandardName, + ChangeProposalType.updateStandardDescription, + ChangeProposalType.updateStandardScope, + ChangeProposalType.addRule, + ChangeProposalType.updateRule, + ChangeProposalType.deleteRule, +]); + +const STANDARD_FIELD_BY_TYPE: Record< + ScalarStandardType, + (standard: Standard) => string +> = { + [ChangeProposalType.updateStandardName]: (standard) => standard.name, + [ChangeProposalType.updateStandardDescription]: (standard) => + standard.description, + [ChangeProposalType.updateStandardScope]: (standard) => standard.scope ?? '', +}; + +const STANDARD_NAME_MAX_LENGTH = 250; +const RULE_CONTENT_MAX_LENGTH = 1000; +const RULES_MAX_COUNT = 500; + +export class StandardChangeProposalValidator implements IChangeProposalValidator { + constructor(private readonly standardsPort: IStandardsPort) {} + + supports(type: ChangeProposalType): boolean { + return SUPPORTED_TYPES.has(type); + } + + async validate( + command: CreateChangeProposalCommand<ChangeProposalType> & MemberContext, + ): Promise<{ artefactVersion: number }> { + if (command.type === ChangeProposalType.createStandard) { + const payload = command.payload as NewStandardPayload; + + if (payload.name.length > STANDARD_NAME_MAX_LENGTH) { + throw new ChangeProposalLimitExceededError( + ChangeProposalViolation.STANDARD_NAME_TOO_LONG, + STANDARD_NAME_MAX_LENGTH, + payload.name.length, + ); + } + + if (payload.rules.length > RULES_MAX_COUNT) { + throw new ChangeProposalLimitExceededError( + ChangeProposalViolation.TOO_MANY_RULES, + RULES_MAX_COUNT, + payload.rules.length, + ); + } + + const oversizedRule = payload.rules.find( + (rule) => rule.content.length > RULE_CONTENT_MAX_LENGTH, + ); + if (oversizedRule) { + throw new ChangeProposalLimitExceededError( + ChangeProposalViolation.RULE_CONTENT_TOO_LONG, + RULE_CONTENT_MAX_LENGTH, + oversizedRule.content.length, + ); + } + + return { artefactVersion: 0 }; + } + + const standardId = command.artefactId as StandardId; + + const standard = await this.standardsPort.getStandard(standardId); + if (!standard) { + throw new Error(`Standard ${standardId} not found`); + } + + if (command.type === ChangeProposalType.addRule) { + return { artefactVersion: standard.version }; + } + + if (command.type === ChangeProposalType.updateRule) { + return this.validateUpdateRule(standard, command); + } + + if (command.type === ChangeProposalType.deleteRule) { + return this.validateDeleteRule(standard, command); + } + + const payload = command.payload as ScalarUpdatePayload; + const currentValue = + STANDARD_FIELD_BY_TYPE[command.type as ScalarStandardType](standard); + + if (command.type === ChangeProposalType.updateStandardScope) { + if (normalizeScope(payload.oldValue) !== normalizeScope(currentValue)) { + throw new ChangeProposalPayloadMismatchError( + command.type, + payload.oldValue, + currentValue, + ); + } + return { artefactVersion: standard.version }; + } + + if (payload.oldValue !== currentValue) { + throw new ChangeProposalPayloadMismatchError( + command.type, + payload.oldValue, + currentValue, + ); + } + + return { artefactVersion: standard.version }; + } + + private async validateUpdateRule( + standard: Standard, + command: CreateChangeProposalCommand<ChangeProposalType> & MemberContext, + ): Promise<ChangeProposalValidationResult> { + const payload = command.payload as CollectionItemUpdatePayload<RuleId>; + const rules = await this.standardsPort.getRulesByStandardId(standard.id); + const matchingRule = rules.find( + (rule) => rule.content === payload.oldValue, + ); + + if (!matchingRule) { + throw new ChangeProposalPayloadMismatchError( + command.type, + payload.oldValue, + 'rule not found', + ); + } + + return { + artefactVersion: standard.version, + resolvedPayload: { + targetId: matchingRule.id, + oldValue: payload.oldValue, + newValue: payload.newValue, + }, + }; + } + + private async validateDeleteRule( + standard: Standard, + command: CreateChangeProposalCommand<ChangeProposalType> & MemberContext, + ): Promise<ChangeProposalValidationResult> { + const payload = command.payload as CollectionItemDeletePayload< + Omit<Rule, 'standardVersionId'> + >; + const rules = await this.standardsPort.getRulesByStandardId(standard.id); + const matchingRule = rules.find( + (rule) => rule.content === payload.item.content, + ); + + if (!matchingRule) { + throw new ChangeProposalPayloadMismatchError( + command.type, + payload.item.content, + 'rule not found', + ); + } + + return { + artefactVersion: standard.version, + resolvedPayload: { + targetId: matchingRule.id, + item: { id: matchingRule.id, content: matchingRule.content }, + }, + }; + } +} + +function normalizeScope(scope: string): string { + return scope + .split(',') + .map((s) => s.trim()) + .join(', '); +} diff --git a/packages/playbook-change-management/src/domain/errors/ArtefactNotFoundError.ts b/packages/playbook-change-management/src/domain/errors/ArtefactNotFoundError.ts new file mode 100644 index 000000000..de65b72c3 --- /dev/null +++ b/packages/playbook-change-management/src/domain/errors/ArtefactNotFoundError.ts @@ -0,0 +1,8 @@ +import { RecipeId, SkillId, StandardId } from '@packmind/types'; + +export class ArtefactNotFoundError extends Error { + constructor(artefactId: StandardId | RecipeId | SkillId) { + super(`Artefact ${artefactId} not found`); + this.name = 'ArtefactNotFoundError'; + } +} diff --git a/packages/playbook-change-management/src/domain/errors/ArtefactNotInSpaceError.ts b/packages/playbook-change-management/src/domain/errors/ArtefactNotInSpaceError.ts new file mode 100644 index 000000000..5df353d54 --- /dev/null +++ b/packages/playbook-change-management/src/domain/errors/ArtefactNotInSpaceError.ts @@ -0,0 +1,8 @@ +import { RecipeId, SkillId, SpaceId, StandardId } from '@packmind/types'; + +export class ArtefactNotInSpaceError extends Error { + constructor(artefactId: StandardId | RecipeId | SkillId, spaceId: SpaceId) { + super(`Artefact ${artefactId} does not belong to space ${spaceId}`); + this.name = 'ArtefactNotInSpaceError'; + } +} diff --git a/packages/playbook-change-management/src/domain/errors/ChangeProposalConflictError.ts b/packages/playbook-change-management/src/domain/errors/ChangeProposalConflictError.ts new file mode 100644 index 000000000..9061d18d6 --- /dev/null +++ b/packages/playbook-change-management/src/domain/errors/ChangeProposalConflictError.ts @@ -0,0 +1 @@ +export { ChangeProposalConflictError } from '@packmind/types'; diff --git a/packages/playbook-change-management/src/domain/errors/ChangeProposalNotFoundError.ts b/packages/playbook-change-management/src/domain/errors/ChangeProposalNotFoundError.ts new file mode 100644 index 000000000..c6821ad10 --- /dev/null +++ b/packages/playbook-change-management/src/domain/errors/ChangeProposalNotFoundError.ts @@ -0,0 +1,8 @@ +import { ChangeProposalId } from '@packmind/types'; + +export class ChangeProposalNotFoundError extends Error { + constructor(changeProposalId: ChangeProposalId) { + super(`Change proposal with id "${changeProposalId}" was not found`); + this.name = 'ChangeProposalNotFoundError'; + } +} diff --git a/packages/playbook-change-management/src/domain/errors/ChangeProposalNotPendingError.ts b/packages/playbook-change-management/src/domain/errors/ChangeProposalNotPendingError.ts new file mode 100644 index 000000000..9da31aed2 --- /dev/null +++ b/packages/playbook-change-management/src/domain/errors/ChangeProposalNotPendingError.ts @@ -0,0 +1,13 @@ +import { ChangeProposalId, ChangeProposalStatus } from '@packmind/types'; + +export class ChangeProposalNotPendingError extends Error { + constructor( + changeProposalId: ChangeProposalId, + currentStatus: ChangeProposalStatus, + ) { + super( + `Change proposal "${changeProposalId}" is not pending: current status is "${currentStatus}"`, + ); + this.name = 'ChangeProposalNotPendingError'; + } +} diff --git a/packages/playbook-change-management/src/domain/errors/index.ts b/packages/playbook-change-management/src/domain/errors/index.ts new file mode 100644 index 000000000..20e426aee --- /dev/null +++ b/packages/playbook-change-management/src/domain/errors/index.ts @@ -0,0 +1,3 @@ +export { ChangeProposalConflictError } from './ChangeProposalConflictError'; +export { ChangeProposalNotFoundError } from './ChangeProposalNotFoundError'; +export { ChangeProposalNotPendingError } from './ChangeProposalNotPendingError'; diff --git a/packages/playbook-change-management/src/domain/repositories/IChangeProposalRepository.ts b/packages/playbook-change-management/src/domain/repositories/IChangeProposalRepository.ts new file mode 100644 index 000000000..6b0f43476 --- /dev/null +++ b/packages/playbook-change-management/src/domain/repositories/IChangeProposalRepository.ts @@ -0,0 +1,36 @@ +import { + ChangeProposal, + ChangeProposalArtefactId, + ChangeProposalId, + ChangeProposalPayload, + ChangeProposalType, + SpaceId, + UserId, +} from '@packmind/types'; + +export interface IChangeProposalRepository { + save(proposal: ChangeProposal<ChangeProposalType>): Promise<void>; + findById( + changeProposalId: ChangeProposalId, + ): Promise<ChangeProposal<ChangeProposalType> | null>; + findByArtefactId( + spaceId: SpaceId, + artefactId: string, + ): Promise<ChangeProposal<ChangeProposalType>[]>; + findBySpaceId( + spaceId: SpaceId, + ): Promise<ChangeProposal<ChangeProposalType>[]>; + findExistingPending<T extends ChangeProposalType>(criteria: { + spaceId: SpaceId; + createdBy: UserId; + artefactId: ChangeProposalArtefactId<T>; + type: T; + payload: ChangeProposalPayload<T>; + }): Promise<ChangeProposal<T> | null>; + update(proposal: ChangeProposal<ChangeProposalType>): Promise<void>; + cancelPendingByArtefactId( + spaceId: SpaceId, + artefactId: string, + cancelledBy: UserId, + ): Promise<void>; +} diff --git a/packages/playbook-change-management/src/domain/repositories/IPlaybookChangeManagementRepositories.ts b/packages/playbook-change-management/src/domain/repositories/IPlaybookChangeManagementRepositories.ts new file mode 100644 index 000000000..bac8da286 --- /dev/null +++ b/packages/playbook-change-management/src/domain/repositories/IPlaybookChangeManagementRepositories.ts @@ -0,0 +1,5 @@ +import { IChangeProposalRepository } from './IChangeProposalRepository'; + +export interface IPlaybookChangeManagementRepositories { + getChangeProposalRepository(): IChangeProposalRepository; +} diff --git a/packages/playbook-change-management/src/domain/repositories/index.ts b/packages/playbook-change-management/src/domain/repositories/index.ts new file mode 100644 index 000000000..a1f119e9f --- /dev/null +++ b/packages/playbook-change-management/src/domain/repositories/index.ts @@ -0,0 +1,2 @@ +export { IChangeProposalRepository } from './IChangeProposalRepository'; +export { IPlaybookChangeManagementRepositories } from './IPlaybookChangeManagementRepositories'; diff --git a/packages/playbook-change-management/src/index.ts b/packages/playbook-change-management/src/index.ts new file mode 100644 index 000000000..e8d4d3e15 --- /dev/null +++ b/packages/playbook-change-management/src/index.ts @@ -0,0 +1,15 @@ +export * from './domain/errors'; +export * from './domain/repositories'; +export * from './infra/repositories'; +export * from './infra/schemas'; +export * from './application/adapters'; +export * from './application/errors'; +export * from './application/services'; +export * from './application/useCases'; +export { PlaybookChangeManagementHexa } from './PlaybookChangeManagementHexa'; + +// NestJS modules +export { OrganizationsSpacesChangeProposalsModule } from './nest-api/change-proposals/change-proposals.module'; +export { OrganizationsSpacesSkillsChangeProposalsModule } from './nest-api/spaces/skills/change-proposals/skills-change-proposals.module'; +export { OrganizationsSpacesStandardsChangeProposalsModule } from './nest-api/spaces/standards/change-proposals/standards-change-proposals.module'; +export { OrganizationsSpacesRecipesChangeProposalsModule } from './nest-api/spaces/recipes/change-proposals/recipes-change-proposals.module'; diff --git a/packages/playbook-change-management/src/infra/repositories/ChangeProposalRepository.spec.ts b/packages/playbook-change-management/src/infra/repositories/ChangeProposalRepository.spec.ts new file mode 100644 index 000000000..ece58d89a --- /dev/null +++ b/packages/playbook-change-management/src/infra/repositories/ChangeProposalRepository.spec.ts @@ -0,0 +1,451 @@ +import { + createTestDatasourceFixture, + itHandlesSoftDelete, + stubLogger, +} from '@packmind/test-utils'; +import { ChangeProposalRepository } from './ChangeProposalRepository'; +import { ChangeProposalSchema } from '../schemas/ChangeProposalSchema'; +import { + ChangeProposal, + ChangeProposalStatus, + ChangeProposalType, + createSpaceId, + createStandardId, + createUserId, +} from '@packmind/types'; +import { changeProposalFactory } from '../../../test'; + +describe('ChangeProposalRepository', () => { + const fixture = createTestDatasourceFixture([ChangeProposalSchema]); + + let repository: ChangeProposalRepository; + + const spaceAId = createSpaceId('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'); + const spaceBId = createSpaceId('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'); + const createStandardPayload = { + name: 'New Standard', + description: 'A description', + scope: null, + rules: [], + }; + + beforeAll(() => fixture.initialize()); + + beforeEach(() => { + repository = new ChangeProposalRepository( + fixture.datasource.getRepository(ChangeProposalSchema), + stubLogger(), + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + await fixture.cleanup(); + }); + + afterAll(() => fixture.destroy()); + + itHandlesSoftDelete<ChangeProposal<ChangeProposalType>>({ + entityFactory: () => changeProposalFactory({ spaceId: spaceAId }), + getRepository: () => repository, + queryDeletedEntity: async (id) => + fixture.datasource.getRepository(ChangeProposalSchema).findOne({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + where: { id: id as any }, + withDeleted: true, + }), + }); + + describe('save', () => { + it('persists a change proposal', async () => { + const proposal = changeProposalFactory({ spaceId: spaceAId }); + + await repository.save(proposal); + + const found = await repository.findById(proposal.id); + expect(found).toMatchObject({ id: proposal.id, type: proposal.type }); + }); + }); + + describe('findByArtefactId', () => { + const artefactId = createStandardId('cccccccc-cccc-cccc-cccc-cccccccccccc'); + + describe('when proposals exist for the artefact', () => { + let result: ChangeProposal<ChangeProposalType>[]; + + beforeEach(async () => { + const p1 = changeProposalFactory({ + spaceId: spaceAId, + artefactId, + }); + const p2 = changeProposalFactory({ + spaceId: spaceAId, + artefactId, + }); + await repository.save(p1); + await repository.save(p2); + + result = await repository.findByArtefactId(spaceAId, artefactId); + }); + + it('returns all matching proposals', () => { + expect(result).toHaveLength(2); + }); + }); + + describe('when no proposals exist for the artefact', () => { + it('returns an empty array', async () => { + const result = await repository.findByArtefactId( + spaceAId, + createStandardId('dddddddd-dddd-dddd-dddd-dddddddddddd'), + ); + + expect(result).toEqual([]); + }); + }); + + describe('when proposals exist in different spaces for the same artefact', () => { + let result: ChangeProposal<ChangeProposalType>[]; + + beforeEach(async () => { + const p1 = changeProposalFactory({ spaceId: spaceAId, artefactId }); + const p2 = changeProposalFactory({ spaceId: spaceBId, artefactId }); + await repository.save(p1); + await repository.save(p2); + + result = await repository.findByArtefactId(spaceAId, artefactId); + }); + + it('returns only one proposal', () => { + expect(result).toHaveLength(1); + }); + + it('returns proposals from the requested space only', () => { + expect(result[0].spaceId).toEqual(spaceAId); + }); + }); + }); + + describe('findBySpaceId', () => { + describe('when proposals exist in the space', () => { + let result: ChangeProposal<ChangeProposalType>[]; + + beforeEach(async () => { + await repository.save(changeProposalFactory({ spaceId: spaceAId })); + await repository.save(changeProposalFactory({ spaceId: spaceAId })); + await repository.save(changeProposalFactory({ spaceId: spaceBId })); + + result = await repository.findBySpaceId(spaceAId); + }); + + it('returns only proposals from the requested space', () => { + expect(result).toHaveLength(2); + }); + + it('returns proposals with the correct space id', () => { + expect(result.every((p) => p.spaceId === spaceAId)).toBe(true); + }); + }); + + describe('when no proposals exist in the space', () => { + it('returns an empty array', async () => { + await repository.save(changeProposalFactory({ spaceId: spaceBId })); + + const result = await repository.findBySpaceId(spaceAId); + + expect(result).toEqual([]); + }); + }); + }); + + describe('findExistingPending', () => { + const artefactId = createStandardId('eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee'); + const createdBy = createUserId('ffffffff-ffff-ffff-ffff-ffffffffffff'); + const type = ChangeProposalType.updateStandardName; + const payload = { oldValue: 'Old Name', newValue: 'New Name' }; + + describe('when a pending proposal matching all criteria exists with a non-null artefactId', () => { + let result: ChangeProposal<ChangeProposalType> | null; + + beforeEach(async () => { + await repository.save( + changeProposalFactory({ + spaceId: spaceAId, + createdBy, + artefactId, + type, + payload, + status: ChangeProposalStatus.pending, + }), + ); + + result = await repository.findExistingPending({ + spaceId: spaceAId, + createdBy, + artefactId, + type, + payload, + }); + }); + + it('returns the matching proposal', () => { + expect(result).not.toBeNull(); + }); + }); + + describe('when a pending proposal matching all criteria exists with a null artefactId', () => { + let result: ChangeProposal<ChangeProposalType> | null; + + beforeEach(async () => { + await repository.save( + changeProposalFactory({ + spaceId: spaceAId, + createdBy, + artefactId: null, + type: ChangeProposalType.createStandard, + payload: createStandardPayload, + status: ChangeProposalStatus.pending, + }), + ); + + result = await repository.findExistingPending({ + spaceId: spaceAId, + createdBy, + artefactId: null, + type: ChangeProposalType.createStandard, + payload: createStandardPayload, + }); + }); + + it('returns the matching proposal', () => { + expect(result).not.toBeNull(); + }); + }); + + describe('when artefactId is null but only proposals with non-null artefactId exist', () => { + it('returns null', async () => { + await repository.save( + changeProposalFactory({ + spaceId: spaceAId, + createdBy, + artefactId, + type: ChangeProposalType.createStandard, + payload: createStandardPayload, + status: ChangeProposalStatus.pending, + }), + ); + + const result = await repository.findExistingPending({ + spaceId: spaceAId, + createdBy, + artefactId: null, + type: ChangeProposalType.createStandard, + payload: createStandardPayload, + }); + + expect(result).toBeNull(); + }); + }); + + describe('when artefactId is non-null but only proposals with null artefactId exist', () => { + it('returns null', async () => { + await repository.save( + changeProposalFactory({ + spaceId: spaceAId, + createdBy, + artefactId: null, + type: ChangeProposalType.createStandard, + payload: createStandardPayload, + status: ChangeProposalStatus.pending, + }), + ); + + const result = await repository.findExistingPending({ + spaceId: spaceAId, + createdBy, + artefactId, + type: ChangeProposalType.createStandard, + payload: createStandardPayload, + }); + + expect(result).toBeNull(); + }); + }); + + describe('when no pending proposal exists matching the criteria', () => { + it('returns null', async () => { + const result = await repository.findExistingPending({ + spaceId: spaceAId, + createdBy, + artefactId, + type, + payload, + }); + + expect(result).toBeNull(); + }); + }); + }); + + describe('cancelPendingByArtefactId', () => { + const artefactId = createStandardId('cccccccc-cccc-cccc-cccc-cccccccccccc'); + const otherArtefactId = createStandardId( + 'dddddddd-dddd-dddd-dddd-dddddddddddd', + ); + const cancelledBy = createUserId('eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee'); + + describe('when pending proposals exist for the artefact', () => { + let proposals: ChangeProposal<ChangeProposalType>[]; + + beforeEach(async () => { + await repository.save( + changeProposalFactory({ + spaceId: spaceAId, + artefactId, + status: ChangeProposalStatus.pending, + }), + ); + await repository.save( + changeProposalFactory({ + spaceId: spaceAId, + artefactId, + status: ChangeProposalStatus.pending, + }), + ); + await repository.save( + changeProposalFactory({ + spaceId: spaceAId, + artefactId: otherArtefactId, + status: ChangeProposalStatus.pending, + }), + ); + + await repository.cancelPendingByArtefactId( + spaceAId, + artefactId, + cancelledBy, + ); + + proposals = await repository.findByArtefactId(spaceAId, artefactId); + }); + + it('cancels exactly the matching proposals', () => { + expect(proposals).toHaveLength(2); + }); + + it('sets status to rejected for all matching proposals', () => { + expect(proposals.map((p) => p.status)).toEqual([ + ChangeProposalStatus.rejected, + ChangeProposalStatus.rejected, + ]); + }); + + it('sets resolvedBy to the cancelling user for all matching proposals', () => { + expect(proposals.map((p) => p.resolvedBy)).toEqual([ + cancelledBy, + cancelledBy, + ]); + }); + + it('sets resolvedAt to a Date for all matching proposals', () => { + expect(proposals.map((p) => p.resolvedAt)).toEqual([ + expect.any(Date), + expect.any(Date), + ]); + }); + + it('sets decision to null for all matching proposals', () => { + expect(proposals.map((p) => p.decision)).toEqual([null, null]); + }); + + it('does not affect pending proposals for other artefacts', async () => { + const otherProposals = await repository.findByArtefactId( + spaceAId, + otherArtefactId, + ); + + expect(otherProposals[0].status).toBe(ChangeProposalStatus.pending); + }); + }); + + describe('when no proposals exist for the artefact', () => { + it('resolves without throwing', async () => { + await expect( + repository.cancelPendingByArtefactId( + spaceAId, + createStandardId('nonexistent-id'), + cancelledBy, + ), + ).resolves.not.toThrow(); + }); + }); + + describe('when a pending proposal exists for the same artefact in a different space', () => { + beforeEach(async () => { + await repository.save( + changeProposalFactory({ + spaceId: spaceBId, + artefactId, + status: ChangeProposalStatus.pending, + }), + ); + + await repository.cancelPendingByArtefactId( + spaceAId, + artefactId, + cancelledBy, + ); + }); + + it('does not affect proposals in other spaces', async () => { + const proposals = await repository.findByArtefactId( + spaceBId, + artefactId, + ); + + expect(proposals[0].status).toBe(ChangeProposalStatus.pending); + }); + }); + + describe('when an already-applied proposal exists for the same artefact', () => { + beforeEach(async () => { + await repository.save( + changeProposalFactory({ + spaceId: spaceAId, + artefactId, + status: ChangeProposalStatus.applied, + }), + ); + + await repository.cancelPendingByArtefactId( + spaceAId, + artefactId, + cancelledBy, + ); + }); + + it('does not affect already-applied proposals', async () => { + const proposals = await repository.findByArtefactId( + spaceAId, + artefactId, + ); + + expect(proposals[0].status).toBe(ChangeProposalStatus.applied); + }); + }); + }); + + describe('update', () => { + it('persists updated fields', async () => { + const proposal = changeProposalFactory({ spaceId: spaceAId }); + await repository.save(proposal); + + proposal.status = + 'applied' as ChangeProposal<ChangeProposalType>['status']; + await repository.update(proposal); + + const found = await repository.findById(proposal.id); + expect(found?.status).toEqual('applied'); + }); + }); +}); diff --git a/packages/playbook-change-management/src/infra/repositories/ChangeProposalRepository.ts b/packages/playbook-change-management/src/infra/repositories/ChangeProposalRepository.ts new file mode 100644 index 000000000..68ed4e99a --- /dev/null +++ b/packages/playbook-change-management/src/infra/repositories/ChangeProposalRepository.ts @@ -0,0 +1,123 @@ +import { IChangeProposalRepository } from '../../domain/repositories/IChangeProposalRepository'; +import { ChangeProposalSchema } from '../schemas/ChangeProposalSchema'; +import { Repository, SelectQueryBuilder } from 'typeorm'; +import { PackmindLogger } from '@packmind/logger'; +import { SpaceScopedRepository } from '@packmind/node-utils'; +import { + ChangeProposal, + ChangeProposalArtefactId, + ChangeProposalPayload, + ChangeProposalStatus, + ChangeProposalType, + SpaceId, + UserId, +} from '@packmind/types'; + +const origin = 'ChangeProposalRepository'; + +export class ChangeProposalRepository + extends SpaceScopedRepository<ChangeProposal<ChangeProposalType>> + implements IChangeProposalRepository +{ + constructor( + repository: Repository<ChangeProposal<ChangeProposalType>>, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super('changeProposal', repository, ChangeProposalSchema, logger); + this.logger.info('ChangeProposalRepository initialized'); + } + + protected override loggableEntity( + entity: ChangeProposal<ChangeProposalType>, + ): Partial<ChangeProposal<ChangeProposalType>> { + return { + id: entity.id, + type: entity.type, + artefactId: entity.artefactId, + }; + } + + protected override getEntityAlias(): string { + return 'change_proposal'; + } + + protected override applySpaceScope( + qb: SelectQueryBuilder<ChangeProposal<ChangeProposalType>>, + spaceId: string, + ): SelectQueryBuilder<ChangeProposal<ChangeProposalType>> { + return qb.where('change_proposal.space_id = :spaceId', { spaceId }); + } + + async save(proposal: ChangeProposal<ChangeProposalType>): Promise<void> { + await this.add(proposal); + } + + async findByArtefactId( + spaceId: SpaceId, + artefactId: string, + ): Promise<ChangeProposal<ChangeProposalType>[]> { + return this.createScopedQueryBuilder(spaceId) + .andWhere('change_proposal.artefact_id = :artefactId', { artefactId }) + .getMany(); + } + + async findBySpaceId( + spaceId: SpaceId, + ): Promise<ChangeProposal<ChangeProposalType>[]> { + return this.createScopedQueryBuilder(spaceId).getMany(); + } + + async findExistingPending<T extends ChangeProposalType>(criteria: { + spaceId: SpaceId; + createdBy: UserId; + artefactId: ChangeProposalArtefactId<T>; + type: T; + payload: ChangeProposalPayload<T>; + }): Promise<ChangeProposal<T> | null> { + const result = await this.createScopedQueryBuilder(criteria.spaceId) + .andWhere('change_proposal.created_by = :createdBy', { + createdBy: criteria.createdBy, + }) + .andWhere( + criteria.artefactId === null + ? 'change_proposal.artefact_id IS NULL' + : 'change_proposal.artefact_id = :artefactId', + criteria.artefactId === null ? {} : { artefactId: criteria.artefactId }, + ) + .andWhere('change_proposal.type = :type', { type: criteria.type }) + .andWhere('change_proposal.status = :status', { + status: ChangeProposalStatus.pending, + }) + .andWhere('change_proposal.payload = :payload::jsonb', { + payload: JSON.stringify(criteria.payload), + }) + .getOne(); + return (result as ChangeProposal<T> | null) ?? null; + } + + async update(proposal: ChangeProposal<ChangeProposalType>): Promise<void> { + await this.add(proposal); + } + + async cancelPendingByArtefactId( + spaceId: SpaceId, + artefactId: string, + cancelledBy: UserId, + ): Promise<void> { + await this.repository + .createQueryBuilder() + .update() + .set({ + status: ChangeProposalStatus.rejected, + decision: null, + resolvedBy: cancelledBy, + resolvedAt: new Date(), + } as Partial<ChangeProposal<ChangeProposalType>>) + .where('space_id = :spaceId', { spaceId }) + .andWhere('artefact_id = :artefactId', { artefactId }) + .andWhere('status = :status', { + status: ChangeProposalStatus.pending, + }) + .execute(); + } +} diff --git a/packages/playbook-change-management/src/infra/repositories/PlaybookChangeManagementRepositories.ts b/packages/playbook-change-management/src/infra/repositories/PlaybookChangeManagementRepositories.ts new file mode 100644 index 000000000..24727176d --- /dev/null +++ b/packages/playbook-change-management/src/infra/repositories/PlaybookChangeManagementRepositories.ts @@ -0,0 +1,19 @@ +import { DataSource } from 'typeorm'; +import { IPlaybookChangeManagementRepositories } from '../../domain/repositories/IPlaybookChangeManagementRepositories'; +import { IChangeProposalRepository } from '../../domain/repositories/IChangeProposalRepository'; +import { ChangeProposalRepository } from './ChangeProposalRepository'; +import { ChangeProposalSchema } from '../schemas/ChangeProposalSchema'; + +export class PlaybookChangeManagementRepositories implements IPlaybookChangeManagementRepositories { + private readonly changeProposalRepository: IChangeProposalRepository; + + constructor(dataSource: DataSource) { + this.changeProposalRepository = new ChangeProposalRepository( + dataSource.getRepository(ChangeProposalSchema), + ); + } + + getChangeProposalRepository(): IChangeProposalRepository { + return this.changeProposalRepository; + } +} diff --git a/packages/playbook-change-management/src/infra/repositories/index.ts b/packages/playbook-change-management/src/infra/repositories/index.ts new file mode 100644 index 000000000..480f6ddd0 --- /dev/null +++ b/packages/playbook-change-management/src/infra/repositories/index.ts @@ -0,0 +1,2 @@ +export { ChangeProposalRepository } from './ChangeProposalRepository'; +export { PlaybookChangeManagementRepositories } from './PlaybookChangeManagementRepositories'; diff --git a/packages/playbook-change-management/src/infra/schemas/ChangeProposalSchema.ts b/packages/playbook-change-management/src/infra/schemas/ChangeProposalSchema.ts new file mode 100644 index 000000000..0ddf39c03 --- /dev/null +++ b/packages/playbook-change-management/src/infra/schemas/ChangeProposalSchema.ts @@ -0,0 +1,48 @@ +import { EntitySchema } from 'typeorm'; +import { + WithTimestamps, + WithSoftDelete, + uuidSchema, + timestampsSchemas, + softDeleteSchemas, +} from '@packmind/node-utils'; +import { ChangeProposal } from '@packmind/types'; + +export const ChangeProposalSchema = new EntitySchema< + WithSoftDelete<WithTimestamps<ChangeProposal>> +>({ + name: 'ChangeProposal', + tableName: 'change_proposals', + columns: { + type: { type: 'varchar' }, + artefactId: { name: 'artefact_id', type: 'varchar', nullable: true }, + artefactVersion: { name: 'artefact_version', type: 'int' }, + spaceId: { name: 'space_id', type: 'uuid' }, + gitRepoId: { name: 'git_repo_id', type: 'uuid', nullable: true }, + targetId: { name: 'target_id', type: 'uuid', nullable: true }, + payload: { type: 'jsonb' }, + captureMode: { name: 'capture_mode', type: 'varchar' }, + message: { type: 'varchar', length: 1024, default: "''" }, + status: { type: 'varchar' }, + createdBy: { name: 'created_by', type: 'uuid' }, + resolvedBy: { name: 'resolved_by', type: 'uuid', nullable: true }, + resolvedAt: { + name: 'resolved_at', + type: 'timestamp with time zone', + nullable: true, + }, + decision: { + type: 'jsonb', + nullable: true, + default: null, + }, + ...uuidSchema, + ...timestampsSchemas, + ...softDeleteSchemas, + }, + indices: [ + { name: 'idx_change_proposal_artefact', columns: ['artefactId'] }, + { name: 'idx_change_proposal_space', columns: ['spaceId'] }, + { name: 'idx_change_proposal_status', columns: ['status'] }, + ], +}); diff --git a/packages/playbook-change-management/src/infra/schemas/index.ts b/packages/playbook-change-management/src/infra/schemas/index.ts new file mode 100644 index 000000000..b6f4dfdd7 --- /dev/null +++ b/packages/playbook-change-management/src/infra/schemas/index.ts @@ -0,0 +1,4 @@ +import { ChangeProposalSchema } from './ChangeProposalSchema'; + +export { ChangeProposalSchema }; +export const playbookChangeManagementSchemas = [ChangeProposalSchema]; diff --git a/packages/playbook-change-management/src/nest-api/change-proposals/change-proposals.controller.spec.ts b/packages/playbook-change-management/src/nest-api/change-proposals/change-proposals.controller.spec.ts new file mode 100644 index 000000000..d97a097d4 --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/change-proposals/change-proposals.controller.spec.ts @@ -0,0 +1,117 @@ +import { BadRequestException } from '@nestjs/common'; +import { AuthenticatedRequest } from '@packmind/node-utils'; +import { SkillValidationError } from '@packmind/skills'; +import { + AcceptedChangeProposal, + ChangeProposalId, + OrganizationId, + SpaceId, +} from '@packmind/types'; + +import { OrganizationsSpacesChangeProposalsController } from './change-proposals.controller'; +import { ChangeProposalsService } from './change-proposals.service'; + +describe('OrganizationsSpacesChangeProposalsController', () => { + const organizationId = 'org-123' as OrganizationId; + const spaceId = 'space-456' as SpaceId; + const userId = 'user-789'; + const request = { + user: { userId }, + } as AuthenticatedRequest; + + let controller: OrganizationsSpacesChangeProposalsController; + let service: jest.Mocked<ChangeProposalsService>; + + beforeEach(() => { + service = { + applyCreationChangeProposals: jest.fn(), + } as unknown as jest.Mocked<ChangeProposalsService>; + controller = new OrganizationsSpacesChangeProposalsController(service); + }); + + describe('applyCreationChangeProposals', () => { + const body = { + accepted: [] as AcceptedChangeProposal[], + rejected: [] as ChangeProposalId[], + }; + + describe('when the service throws SkillValidationError', () => { + it('translates it to BadRequestException', async () => { + const validationError = new SkillValidationError([ + { + field: 'description', + message: 'description must not exceed 1024 characters', + }, + ]); + validationError.message = + 'A submitted skill has a description longer than 1024 characters. Edit your skill and upload it again.'; + service.applyCreationChangeProposals.mockRejectedValue(validationError); + + await expect( + controller.applyCreationChangeProposals( + organizationId, + spaceId, + body, + request, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('preserves the validation error message', async () => { + const validationError = new SkillValidationError([ + { + field: 'description', + message: 'description must not exceed 1024 characters', + }, + ]); + validationError.message = + 'A submitted skill has a description longer than 1024 characters. Edit your skill and upload it again.'; + service.applyCreationChangeProposals.mockRejectedValue(validationError); + + await expect( + controller.applyCreationChangeProposals( + organizationId, + spaceId, + body, + request, + ), + ).rejects.toThrow(validationError.message); + }); + }); + + describe('when the service throws another error', () => { + it('rethrows it untouched', async () => { + const error = new Error('boom'); + service.applyCreationChangeProposals.mockRejectedValue(error); + + await expect( + controller.applyCreationChangeProposals( + organizationId, + spaceId, + body, + request, + ), + ).rejects.toBe(error); + }); + }); + + describe('when the service resolves', () => { + it('returns the result', async () => { + const result = { + created: { commands: [], standards: [], skills: [] }, + rejected: [], + }; + service.applyCreationChangeProposals.mockResolvedValue(result as never); + + await expect( + controller.applyCreationChangeProposals( + organizationId, + spaceId, + body, + request, + ), + ).resolves.toBe(result); + }); + }); + }); +}); diff --git a/packages/playbook-change-management/src/nest-api/change-proposals/change-proposals.controller.ts b/packages/playbook-change-management/src/nest-api/change-proposals/change-proposals.controller.ts new file mode 100644 index 000000000..461226dee --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/change-proposals/change-proposals.controller.ts @@ -0,0 +1,429 @@ +import { + BadRequestException, + Body, + ConflictException, + Controller, + Get, + Param, + Post, + Req, + Res, + UseGuards, +} from '@nestjs/common'; +import { Response } from 'express'; +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { AuthenticatedRequest } from '@packmind/node-utils'; +import { + AcceptedChangeProposal, + ApplyCreationChangeProposalsResponse, + BatchCreateChangeProposalItem, + BatchCreateChangeProposalsCommand, + BatchCreateChangeProposalsResponse, + ChangeProposalDecision, + ChangeProposalId, + ChangeProposalType, + CheckChangeProposalsCommand, + CheckChangeProposalsResponse, + CreateChangeProposalCommand, + CreateChangeProposalResponse, + ListChangeProposalsBySpaceResponse, + OrganizationId, + PreviewArtifactRenderingCommand, + RecomputeConflictsResponse, + RecipeId, + SkillId, + SpaceId, + StandardId, +} from '@packmind/types'; +import { SkillValidationError } from '@packmind/skills'; +import { ChangeProposalsService } from './change-proposals.service'; +import { OrganizationAccessGuard } from '../shared/organization-access.guard'; +import { + ChangeProposalPayloadMismatchError, + UnsupportedChangeProposalTypeError, +} from '../../application/errors'; + +const origin = 'OrganizationsSpacesChangeProposalsController'; + +/** + * Controller for space-scoped change proposal routes within organizations + * Actual path: /organizations/:orgId/spaces/:spaceId/change-proposals (inherited via RouterModule in AppModule) + */ +@Controller() +@UseGuards(OrganizationAccessGuard) +export class OrganizationsSpacesChangeProposalsController { + constructor( + private readonly changeProposalsService: ChangeProposalsService, + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.INFO, + ), + ) {} + + /** + * Check if change proposals already exist + * POST /organizations/:orgId/spaces/:spaceId/change-proposals/check + */ + @Post('check') + async checkChangeProposals( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Body() body: { proposals: BatchCreateChangeProposalItem[] }, + @Req() request: AuthenticatedRequest, + ): Promise<CheckChangeProposalsResponse> { + if (!body.proposals || body.proposals.length === 0) { + throw new BadRequestException('Proposals array must not be empty'); + } + + this.logger.info( + 'POST /organizations/:orgId/spaces/:spaceId/change-proposals/check - Checking change proposals', + { + organizationId, + spaceId, + count: body.proposals.length, + }, + ); + + const command: CheckChangeProposalsCommand = { + userId: request.user.userId, + organizationId, + spaceId, + proposals: body.proposals, + }; + + return this.changeProposalsService.checkChangeProposals(command); + } + + /** + * Batch create change proposals + * POST /organizations/:orgId/spaces/:spaceId/change-proposals/batch + */ + @Post('batch') + async batchCreateChangeProposals( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Body() body: { proposals: BatchCreateChangeProposalItem[] }, + @Req() request: AuthenticatedRequest, + ): Promise<BatchCreateChangeProposalsResponse> { + if (!body.proposals || body.proposals.length === 0) { + throw new BadRequestException('Proposals array must not be empty'); + } + + this.logger.info( + 'POST /organizations/:orgId/spaces/:spaceId/change-proposals/batch - Batch creating change proposals', + { + organizationId, + spaceId, + count: body.proposals.length, + }, + ); + + const command: BatchCreateChangeProposalsCommand = { + userId: request.user.userId, + organizationId, + spaceId, + proposals: body.proposals, + }; + + const result = + await this.changeProposalsService.batchCreateChangeProposals(command); + + this.logger.info( + 'POST /organizations/:orgId/spaces/:spaceId/change-proposals/batch - Batch creation completed', + { + organizationId, + spaceId, + created: result.created, + errors: result.errors.length, + }, + ); + + return result; + } + + /** + * Apply or reject creation change proposals (e.g., createCommand) + * POST /organizations/:orgId/spaces/:spaceId/change-proposals/apply + */ + @Post('apply') + async applyCreationChangeProposals( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Body() + body: { accepted: AcceptedChangeProposal[]; rejected: ChangeProposalId[] }, + @Req() request: AuthenticatedRequest, + ): Promise<ApplyCreationChangeProposalsResponse> { + const userId = request.user.userId; + + this.logger.info( + 'POST /organizations/:orgId/spaces/:spaceId/change-proposals/apply', + { + organizationId, + spaceId, + acceptedCount: body.accepted.length, + rejectedCount: body.rejected.length, + }, + ); + + let result: ApplyCreationChangeProposalsResponse; + try { + result = await this.changeProposalsService.applyCreationChangeProposals({ + userId, + organizationId, + spaceId, + accepted: body.accepted, + rejected: body.rejected, + }); + } catch (error) { + if (error instanceof SkillValidationError) { + this.logger.warn( + 'Skill validation failed on creation change proposals apply', + { + userId: userId.substring(0, 6) + '*', + organizationId, + spaceId, + errors: error.errors, + }, + ); + throw new BadRequestException(error.message); + } + throw error; + } + + this.logger.info( + 'POST .../change-proposals/apply - Applied creation proposals successfully', + { + organizationId, + spaceId, + created: Object.entries(result.created).map( + ([artefactType, artefactIds]) => + `${artefactType}: ${artefactIds.length}`, + ), + }, + ); + + return result; + } + + /** + * Create a change proposal + * POST /organizations/:orgId/spaces/:spaceId/change-proposals + */ + @Post() + async createChangeProposal( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Body() body: BatchCreateChangeProposalItem, + @Req() request: AuthenticatedRequest, + ): Promise<CreateChangeProposalResponse<ChangeProposalType>> { + this.logger.info( + 'POST /organizations/:orgId/spaces/:spaceId/change-proposals - Creating change proposal', + { + organizationId, + spaceId, + type: body.type, + }, + ); + + try { + const command = { + userId: request.user.userId, + organizationId, + spaceId, + type: body.type, + artefactId: body.artefactId, + payload: body.payload, + captureMode: body.captureMode, + message: body.message, + } as unknown as CreateChangeProposalCommand<ChangeProposalType>; + + const result = + await this.changeProposalsService.createChangeProposal(command); + + this.logger.info( + 'POST /organizations/:orgId/spaces/:spaceId/change-proposals - Change proposal created successfully', + { + organizationId, + spaceId, + type: body.type, + }, + ); + + return result; + } catch (error) { + if (error instanceof UnsupportedChangeProposalTypeError) { + throw new BadRequestException(error.message); + } + + if (error instanceof ChangeProposalPayloadMismatchError) { + throw new ConflictException(error.message); + } + + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /organizations/:orgId/spaces/:spaceId/change-proposals - Failed to create change proposal', + { + organizationId, + spaceId, + type: body.type, + error: errorMessage, + }, + ); + throw error; + } + } + + /** + * List change proposals grouped by artefact type for a space + * GET /organizations/:orgId/spaces/:spaceId/grouped + */ + @Get('grouped') + async listChangeProposalsBySpace( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Req() request: AuthenticatedRequest, + ): Promise<ListChangeProposalsBySpaceResponse> { + this.logger.info( + 'GET /organizations/:orgId/spaces/:spaceId/change-proposals/grouped - Listing grouped change proposals', + { + organizationId, + spaceId, + }, + ); + + try { + const result = + await this.changeProposalsService.listChangeProposalsBySpace({ + userId: request.user.userId, + organizationId, + spaceId, + }); + + this.logger.info( + 'GET /organizations/:orgId/spaces/:spaceId/change-proposals/grouped - Grouped change proposals listed successfully', + { + organizationId, + spaceId, + standardsCount: result.standards.length, + commandsCount: result.commands.length, + skillsCount: result.skills.length, + }, + ); + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'GET /organizations/:orgId/spaces/:spaceId/grouped-change-proposals - Failed to list grouped change proposals', + { + organizationId, + spaceId, + error: errorMessage, + }, + ); + throw error; + } + } + + /** + * Recompute conflicts for change proposals with optional decisions + * POST /organizations/:orgId/spaces/:spaceId/change-proposals/recompute-conflicts + */ + @Post('recompute-conflicts') + async recomputeConflicts( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Body() + body: { + artefactId: StandardId | RecipeId | SkillId; + decisions: Record<ChangeProposalId, ChangeProposalDecision>; + }, + @Req() request: AuthenticatedRequest, + ): Promise<RecomputeConflictsResponse> { + if (!body.artefactId || !body.decisions) { + throw new BadRequestException('artefactId and decisions are required'); + } + + this.logger.info( + 'POST .../change-proposals/recompute-conflicts - Recomputing conflicts', + { + organizationId, + spaceId, + artefactId: body.artefactId, + decisionsCount: Object.keys(body.decisions).length, + }, + ); + + try { + const result = await this.changeProposalsService.recomputeConflicts({ + userId: request.user.userId, + organizationId, + spaceId, + artefactId: body.artefactId, + decisions: body.decisions, + }); + + this.logger.info( + 'POST .../change-proposals/recompute-conflicts - Conflicts recomputed successfully', + { + organizationId, + spaceId, + artefactId: body.artefactId, + conflictsCount: Object.keys(result.conflicts).length, + }, + ); + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST .../change-proposals/recompute-conflicts - Failed to recompute conflicts', + { + organizationId, + spaceId, + artefactId: body.artefactId, + error: errorMessage, + }, + ); + throw error; + } + } + + /** + * Preview how artifacts render for a coding agent, returned as a zip file + * POST /organizations/:orgId/spaces/:spaceId/change-proposals/preview-rendering + */ + @Post('preview-rendering') + async previewArtifactRendering( + @Body() body: PreviewArtifactRenderingCommand, + @Res() response: Response, + ): Promise<void> { + if (!body.codingAgent) { + throw new BadRequestException('codingAgent is required'); + } + + this.logger.info( + 'POST .../change-proposals/preview-rendering - Previewing artifact rendering', + { + codingAgent: body.codingAgent, + recipesCount: body.recipeVersions?.length ?? 0, + standardsCount: body.standardVersions?.length ?? 0, + skillsCount: body.skillVersions?.length ?? 0, + }, + ); + + const result = + await this.changeProposalsService.previewArtifactRendering(body); + + response + .setHeader('Content-Type', 'application/zip') + .setHeader( + 'Content-Disposition', + `attachment; filename="${result.fileName}"`, + ) + .send(Buffer.from(result.fileContent, 'base64')); + } +} diff --git a/packages/playbook-change-management/src/nest-api/change-proposals/change-proposals.module.ts b/packages/playbook-change-management/src/nest-api/change-proposals/change-proposals.module.ts new file mode 100644 index 000000000..c0dd5b5b8 --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/change-proposals/change-proposals.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { OrganizationsSpacesChangeProposalsController } from './change-proposals.controller'; +import { ChangeProposalsService } from './change-proposals.service'; +import { OrganizationAccessGuard } from '../shared/organization-access.guard'; +import { PackmindLogger, LogLevel } from '@packmind/logger'; + +/** + * Module for space-scoped change proposal routes within organizations + * + * This module is registered as a child of OrganizationsSpacesModule via RouterModule, + * automatically inheriting the /organizations/:orgId/spaces/:spaceId path prefix. + * + * OrganizationAccessGuard is provided to ensure proper access validation. + */ +@Module({ + controllers: [OrganizationsSpacesChangeProposalsController], + providers: [ + ChangeProposalsService, + OrganizationAccessGuard, + { + provide: PackmindLogger, + useFactory: () => + new PackmindLogger( + 'OrganizationsSpacesChangeProposalsModule', + LogLevel.INFO, + ), + }, + ], + exports: [ChangeProposalsService], +}) +export class OrganizationsSpacesChangeProposalsModule {} diff --git a/packages/playbook-change-management/src/nest-api/change-proposals/change-proposals.service.ts b/packages/playbook-change-management/src/nest-api/change-proposals/change-proposals.service.ts new file mode 100644 index 000000000..c5e3bdea1 --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/change-proposals/change-proposals.service.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import { PackmindLogger } from '@packmind/logger'; +import { + ApplyCreationChangeProposalsCommand, + ApplyCreationChangeProposalsResponse, + BatchCreateChangeProposalsCommand, + BatchCreateChangeProposalsResponse, + ChangeProposalType, + CheckChangeProposalsCommand, + CheckChangeProposalsResponse, + CreateChangeProposalCommand, + CreateChangeProposalResponse, + ICodingAgentPort, + IPlaybookChangeManagementPort, + ListChangeProposalsBySpaceCommand, + ListChangeProposalsBySpaceResponse, + PreviewArtifactRenderingCommand, + PreviewArtifactRenderingResponse, + RecomputeConflictsCommand, + RecomputeConflictsResponse, +} from '@packmind/types'; +import { + InjectCodingAgentAdapter, + InjectPlaybookChangeManagementAdapter, +} from '../shared/HexaInjection'; + +@Injectable() +export class ChangeProposalsService { + constructor( + @InjectPlaybookChangeManagementAdapter() + private readonly playbookChangeManagementAdapter: IPlaybookChangeManagementPort, + @InjectCodingAgentAdapter() + private readonly codingAgentAdapter: ICodingAgentPort, + private readonly logger: PackmindLogger, + ) {} + + async batchCreateChangeProposals( + command: BatchCreateChangeProposalsCommand, + ): Promise<BatchCreateChangeProposalsResponse> { + return this.playbookChangeManagementAdapter.batchCreateChangeProposals( + command, + ); + } + + async checkChangeProposals( + command: CheckChangeProposalsCommand, + ): Promise<CheckChangeProposalsResponse> { + return this.playbookChangeManagementAdapter.checkChangeProposals(command); + } + async createChangeProposal( + command: CreateChangeProposalCommand<ChangeProposalType>, + ): Promise<CreateChangeProposalResponse<ChangeProposalType>> { + return this.playbookChangeManagementAdapter.createChangeProposal(command); + } + + async applyCreationChangeProposals( + command: ApplyCreationChangeProposalsCommand, + ): Promise<ApplyCreationChangeProposalsResponse> { + return this.playbookChangeManagementAdapter.applyCreationChangeProposals( + command, + ); + } + + async listChangeProposalsBySpace( + command: ListChangeProposalsBySpaceCommand, + ): Promise<ListChangeProposalsBySpaceResponse> { + return this.playbookChangeManagementAdapter.listChangeProposalsBySpace( + command, + ); + } + + async recomputeConflicts( + command: RecomputeConflictsCommand, + ): Promise<RecomputeConflictsResponse> { + return this.playbookChangeManagementAdapter.recomputeConflicts(command); + } + + async previewArtifactRendering( + command: PreviewArtifactRenderingCommand, + ): Promise<PreviewArtifactRenderingResponse> { + return this.codingAgentAdapter.previewArtifactRendering(command); + } +} diff --git a/packages/playbook-change-management/src/nest-api/shared/HexaInjection.ts b/packages/playbook-change-management/src/nest-api/shared/HexaInjection.ts new file mode 100644 index 000000000..7acb5e43e --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/shared/HexaInjection.ts @@ -0,0 +1,13 @@ +import { Inject } from '@nestjs/common'; + +/** + * Token strings must match those in apps/api/src/app/shared/HexaRegistryModule.ts + */ +const PLAYBOOK_CHANGE_MANAGEMENT_ADAPTER_TOKEN = + 'PLAYBOOK_CHANGE_MANAGEMENT_ADAPTER'; +const CODING_AGENT_ADAPTER_TOKEN = 'CODING_AGENT_ADAPTER'; + +export const InjectPlaybookChangeManagementAdapter = () => + Inject(PLAYBOOK_CHANGE_MANAGEMENT_ADAPTER_TOKEN); +export const InjectCodingAgentAdapter = () => + Inject(CODING_AGENT_ADAPTER_TOKEN); diff --git a/packages/playbook-change-management/src/nest-api/shared/organization-access.guard.ts b/packages/playbook-change-management/src/nest-api/shared/organization-access.guard.ts new file mode 100644 index 000000000..2d2620f22 --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/shared/organization-access.guard.ts @@ -0,0 +1,88 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + ForbiddenException, + BadRequestException, +} from '@nestjs/common'; +import { PackmindLogger, LogLevel } from '@packmind/logger'; +import { AuthenticatedRequest } from '@packmind/node-utils'; +import { createOrganizationId, OrganizationId } from '@packmind/types'; + +const origin = 'OrganizationAccessGuard'; + +/** + * Guard that validates the user has access to the organization specified in the URL + * Expected URL pattern: /organizations/:orgId/... + */ +@Injectable() +export class OrganizationAccessGuard implements CanActivate { + constructor( + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.INFO, + ), + ) { + this.logger.info('OrganizationAccessGuard initialized'); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<AuthenticatedRequest>(); + + // Extract orgId from URL params + const orgIdParam = request.params.orgId as string; + + if (!orgIdParam) { + this.logger.warn('Organization ID missing from URL', { + path: request.path, + }); + throw new BadRequestException('Organization ID is required in URL'); + } + + // Validate orgId format and create branded OrganizationId + let requestedOrgId: OrganizationId; + try { + requestedOrgId = createOrganizationId(orgIdParam); + } catch (error) { + this.logger.warn('Invalid organization ID format', { + orgId: orgIdParam, + error: error instanceof Error ? error.message : String(error), + }); + throw new BadRequestException('Invalid organization ID format'); + } + + // Verify user's organization from JWT matches the requested organization + const userOrgId = request.organization?.id; + + if (!userOrgId) { + this.logger.error( + 'User organization not found in request - authentication issue', + { + path: request.path, + userId: request.user?.userId, + }, + ); + throw new ForbiddenException('User organization context missing'); + } + + if (userOrgId !== requestedOrgId) { + this.logger.warn('User attempted to access different organization', { + userOrgId, + requestedOrgId, + path: request.path, + userId: request.user?.userId, + }); + throw new ForbiddenException( + 'Access denied: You do not have access to this organization', + ); + } + + this.logger.info('Organization access granted', { + organizationId: requestedOrgId, + userId: request.user?.userId, + path: request.path, + }); + + return true; + } +} diff --git a/packages/playbook-change-management/src/nest-api/spaces/recipes/change-proposals/recipes-change-proposals.controller.ts b/packages/playbook-change-management/src/nest-api/spaces/recipes/change-proposals/recipes-change-proposals.controller.ts new file mode 100644 index 000000000..027f59920 --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/spaces/recipes/change-proposals/recipes-change-proposals.controller.ts @@ -0,0 +1,122 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { AuthenticatedRequest } from '@packmind/node-utils'; +import { + AcceptedChangeProposal, + ApplyChangeProposalsResponse, + ChangeProposalId, + ListChangeProposalsByArtefactResponse, + OrganizationId, + RecipeId, + SpaceId, +} from '@packmind/types'; +import { RecipesChangeProposalsService } from './recipes-change-proposals.service'; +import { OrganizationAccessGuard } from '../../../shared/organization-access.guard'; + +const origin = 'OrganizationsSpacesRecipesChangeProposalsController'; + +/** + * Controller for recipe-scoped change proposal routes + * Actual path: /organizations/:orgId/spaces/:spaceId/recipes/:recipeId/change-proposals (inherited via RouterModule in AppModule) + */ +@Controller() +@UseGuards(OrganizationAccessGuard) +export class OrganizationsSpacesRecipesChangeProposalsController { + constructor( + private readonly service: RecipesChangeProposalsService, + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.INFO, + ), + ) {} + + /** + * List change proposals for a recipe + * GET /organizations/:orgId/spaces/:spaceId/recipes/:recipeId/change-proposals + */ + @Get() + async listChangeProposalsByRecipe( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Param('recipeId') recipeId: RecipeId, + @Req() request: AuthenticatedRequest, + ): Promise<ListChangeProposalsByArtefactResponse> { + this.logger.info( + 'GET /organizations/:orgId/spaces/:spaceId/recipes/:recipeId/change-proposals', + { organizationId, spaceId, recipeId }, + ); + + const result = await this.service.listChangeProposalsByRecipe({ + userId: request.user.userId, + organizationId, + spaceId, + artefactId: recipeId, + }); + + this.logger.info( + 'GET /organizations/:orgId/spaces/:spaceId/recipes/:recipeId/change-proposals - Listed successfully', + { + organizationId, + spaceId, + recipeId, + count: result.changeProposals.length, + }, + ); + + return result; + } + + /** + * Apply or reject change proposals for a recipe + * POST /organizations/:orgId/spaces/:spaceId/recipes/:recipeId/change-proposals/apply + */ + @Post('apply') + async applyRecipeChangeProposals( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Param('recipeId') recipeId: RecipeId, + @Body() + body: { accepted: AcceptedChangeProposal[]; rejected: ChangeProposalId[] }, + @Req() request: AuthenticatedRequest, + ): Promise<ApplyChangeProposalsResponse<RecipeId>> { + this.logger.info( + 'POST /organizations/:orgId/spaces/:spaceId/recipes/:recipeId/change-proposals/apply', + { + organizationId, + spaceId, + recipeId, + acceptedCount: body.accepted.length, + rejectedCount: body.rejected.length, + }, + ); + + const result = await this.service.applyRecipeChangeProposals({ + userId: request.user.userId, + organizationId, + spaceId, + artefactId: recipeId, + accepted: body.accepted, + rejected: body.rejected, + }); + + this.logger.info( + 'POST /organizations/:orgId/spaces/:spaceId/recipes/:recipeId/change-proposals/apply - Applied successfully', + { + organizationId, + spaceId, + recipeId, + newVersion: result.newArtefactVersion, + }, + ); + + return result; + } +} diff --git a/packages/playbook-change-management/src/nest-api/spaces/recipes/change-proposals/recipes-change-proposals.module.ts b/packages/playbook-change-management/src/nest-api/spaces/recipes/change-proposals/recipes-change-proposals.module.ts new file mode 100644 index 000000000..9949f93fd --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/spaces/recipes/change-proposals/recipes-change-proposals.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { OrganizationsSpacesRecipesChangeProposalsController } from './recipes-change-proposals.controller'; +import { RecipesChangeProposalsService } from './recipes-change-proposals.service'; +import { OrganizationAccessGuard } from '../../../shared/organization-access.guard'; +import { PackmindLogger, LogLevel } from '@packmind/logger'; + +/** + * Module for recipe-scoped change proposal routes + * + * This module is registered as a child of OrganizationsSpacesRecipesModule via RouterModule, + * automatically inheriting the /organizations/:orgId/spaces/:spaceId/recipes/:recipeId path prefix. + */ +@Module({ + controllers: [OrganizationsSpacesRecipesChangeProposalsController], + providers: [ + RecipesChangeProposalsService, + OrganizationAccessGuard, + { + provide: PackmindLogger, + useFactory: () => + new PackmindLogger( + 'OrganizationsSpacesRecipesChangeProposalsModule', + LogLevel.INFO, + ), + }, + ], +}) +export class OrganizationsSpacesRecipesChangeProposalsModule {} diff --git a/packages/playbook-change-management/src/nest-api/spaces/recipes/change-proposals/recipes-change-proposals.service.ts b/packages/playbook-change-management/src/nest-api/spaces/recipes/change-proposals/recipes-change-proposals.service.ts new file mode 100644 index 000000000..4dc33ddae --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/spaces/recipes/change-proposals/recipes-change-proposals.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { + ApplyChangeProposalsCommand, + ApplyChangeProposalsResponse, + IPlaybookChangeManagementPort, + ListChangeProposalsByArtefactCommand, + ListChangeProposalsByArtefactResponse, + RecipeId, +} from '@packmind/types'; +import { InjectPlaybookChangeManagementAdapter } from '../../../shared/HexaInjection'; + +@Injectable() +export class RecipesChangeProposalsService { + constructor( + @InjectPlaybookChangeManagementAdapter() + private readonly playbookChangeManagementAdapter: IPlaybookChangeManagementPort, + ) {} + + async listChangeProposalsByRecipe( + command: ListChangeProposalsByArtefactCommand<RecipeId>, + ): Promise<ListChangeProposalsByArtefactResponse> { + return this.playbookChangeManagementAdapter.listChangeProposalsByArtefact( + command, + ); + } + + async applyRecipeChangeProposals( + command: ApplyChangeProposalsCommand<RecipeId>, + ): Promise<ApplyChangeProposalsResponse<RecipeId>> { + return this.playbookChangeManagementAdapter.applyChangeProposals(command); + } +} diff --git a/packages/playbook-change-management/src/nest-api/spaces/skills/change-proposals/skills-change-proposals.controller.spec.ts b/packages/playbook-change-management/src/nest-api/spaces/skills/change-proposals/skills-change-proposals.controller.spec.ts new file mode 100644 index 000000000..09a4fd583 --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/spaces/skills/change-proposals/skills-change-proposals.controller.spec.ts @@ -0,0 +1,125 @@ +import { BadRequestException } from '@nestjs/common'; +import { AuthenticatedRequest } from '@packmind/node-utils'; +import { SkillValidationError } from '@packmind/skills'; +import { + AcceptedChangeProposal, + ChangeProposalId, + OrganizationId, + SkillId, + SpaceId, +} from '@packmind/types'; + +import { OrganizationsSpacesSkillsChangeProposalsController } from './skills-change-proposals.controller'; +import { SkillsChangeProposalsService } from './skills-change-proposals.service'; + +describe('OrganizationsSpacesSkillsChangeProposalsController', () => { + const organizationId = 'org-123' as OrganizationId; + const spaceId = 'space-456' as SpaceId; + const skillId = 'skill-789' as SkillId; + const userId = 'user-abc'; + const request = { + user: { userId }, + } as AuthenticatedRequest; + + let controller: OrganizationsSpacesSkillsChangeProposalsController; + let service: jest.Mocked<SkillsChangeProposalsService>; + + beforeEach(() => { + service = { + applySkillChangeProposals: jest.fn(), + listChangeProposalsBySkill: jest.fn(), + } as unknown as jest.Mocked<SkillsChangeProposalsService>; + controller = new OrganizationsSpacesSkillsChangeProposalsController( + service, + ); + }); + + describe('applySkillChangeProposals', () => { + const body = { + accepted: [] as AcceptedChangeProposal[], + rejected: [] as ChangeProposalId[], + }; + + describe('when the service throws SkillValidationError', () => { + it('translates it to BadRequestException', async () => { + const validationError = new SkillValidationError([ + { + field: 'description', + message: 'description must not exceed 1024 characters', + }, + ]); + validationError.message = + 'A submitted skill has a description longer than 1024 characters. Edit your skill and upload it again.'; + service.applySkillChangeProposals.mockRejectedValue(validationError); + + await expect( + controller.applySkillChangeProposals( + organizationId, + spaceId, + skillId, + body, + request, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('preserves the use-case-supplied message', async () => { + const validationError = new SkillValidationError([ + { + field: 'description', + message: 'description must not exceed 1024 characters', + }, + ]); + validationError.message = + 'A submitted skill has a description longer than 1024 characters. Edit your skill and upload it again.'; + service.applySkillChangeProposals.mockRejectedValue(validationError); + + await expect( + controller.applySkillChangeProposals( + organizationId, + spaceId, + skillId, + body, + request, + ), + ).rejects.toThrow(validationError.message); + }); + }); + + describe('when the service throws another error', () => { + it('rethrows it untouched', async () => { + const error = new Error('boom'); + service.applySkillChangeProposals.mockRejectedValue(error); + + await expect( + controller.applySkillChangeProposals( + organizationId, + spaceId, + skillId, + body, + request, + ), + ).rejects.toBe(error); + }); + }); + + describe('when the service resolves', () => { + it('returns the result', async () => { + const result = { + newArtefactVersion: 'skill-version-id', + }; + service.applySkillChangeProposals.mockResolvedValue(result as never); + + await expect( + controller.applySkillChangeProposals( + organizationId, + spaceId, + skillId, + body, + request, + ), + ).resolves.toBe(result); + }); + }); + }); +}); diff --git a/packages/playbook-change-management/src/nest-api/spaces/skills/change-proposals/skills-change-proposals.controller.ts b/packages/playbook-change-management/src/nest-api/spaces/skills/change-proposals/skills-change-proposals.controller.ts new file mode 100644 index 000000000..d007bace1 --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/spaces/skills/change-proposals/skills-change-proposals.controller.ts @@ -0,0 +1,144 @@ +import { + BadRequestException, + Body, + Controller, + Get, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { AuthenticatedRequest } from '@packmind/node-utils'; +import { + AcceptedChangeProposal, + ApplyChangeProposalsResponse, + ChangeProposalId, + ListChangeProposalsByArtefactResponse, + OrganizationId, + SkillId, + SpaceId, +} from '@packmind/types'; +import { SkillValidationError } from '@packmind/skills'; +import { SkillsChangeProposalsService } from './skills-change-proposals.service'; +import { OrganizationAccessGuard } from '../../../shared/organization-access.guard'; + +const origin = 'OrganizationsSpacesSkillsChangeProposalsController'; + +/** + * Controller for skill-scoped change proposal routes + * Actual path: /organizations/:orgId/spaces/:spaceId/skills/:skillId/change-proposals (inherited via RouterModule in AppModule) + */ +@Controller() +@UseGuards(OrganizationAccessGuard) +export class OrganizationsSpacesSkillsChangeProposalsController { + constructor( + private readonly service: SkillsChangeProposalsService, + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.INFO, + ), + ) {} + + /** + * List change proposals for a skill + * GET /organizations/:orgId/spaces/:spaceId/skills/:skillId/change-proposals + */ + @Get() + async listChangeProposalsBySkill( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Param('skillId') skillId: SkillId, + @Req() request: AuthenticatedRequest, + ): Promise<ListChangeProposalsByArtefactResponse> { + this.logger.info( + 'GET /organizations/:orgId/spaces/:spaceId/skills/:skillId/change-proposals', + { organizationId, spaceId, skillId }, + ); + + const result = await this.service.listChangeProposalsBySkill({ + userId: request.user.userId, + organizationId, + spaceId, + artefactId: skillId, + }); + + this.logger.info( + 'GET /organizations/:orgId/spaces/:spaceId/skills/:skillId/change-proposals - Listed successfully', + { + organizationId, + spaceId, + skillId, + count: result.changeProposals.length, + }, + ); + + return result; + } + + /** + * Apply or reject change proposals for a skill + * POST /organizations/:orgId/spaces/:spaceId/skills/:skillId/change-proposals/apply + */ + @Post('apply') + async applySkillChangeProposals( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Param('skillId') skillId: SkillId, + @Body() + body: { accepted: AcceptedChangeProposal[]; rejected: ChangeProposalId[] }, + @Req() request: AuthenticatedRequest, + ): Promise<ApplyChangeProposalsResponse<SkillId>> { + const userId = request.user.userId; + + this.logger.info( + 'POST /organizations/:orgId/spaces/:spaceId/skills/:skillId/change-proposals/apply', + { + organizationId, + spaceId, + skillId, + acceptedCount: body.accepted.length, + rejectedCount: body.rejected.length, + }, + ); + + let result: ApplyChangeProposalsResponse<SkillId>; + try { + result = await this.service.applySkillChangeProposals({ + userId, + organizationId, + spaceId, + artefactId: skillId, + accepted: body.accepted, + rejected: body.rejected, + }); + } catch (error) { + if (error instanceof SkillValidationError) { + this.logger.warn( + 'Skill validation failed on skill change proposals apply', + { + userId: userId.substring(0, 6) + '*', + organizationId, + spaceId, + skillId, + errors: error.errors, + }, + ); + throw new BadRequestException(error.message); + } + throw error; + } + + this.logger.info( + 'POST /organizations/:orgId/spaces/:spaceId/skills/:skillId/change-proposals/apply - Applied successfully', + { + organizationId, + spaceId, + skillId, + newVersion: result.newArtefactVersion, + }, + ); + + return result; + } +} diff --git a/packages/playbook-change-management/src/nest-api/spaces/skills/change-proposals/skills-change-proposals.module.ts b/packages/playbook-change-management/src/nest-api/spaces/skills/change-proposals/skills-change-proposals.module.ts new file mode 100644 index 000000000..ce82feae2 --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/spaces/skills/change-proposals/skills-change-proposals.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { OrganizationsSpacesSkillsChangeProposalsController } from './skills-change-proposals.controller'; +import { SkillsChangeProposalsService } from './skills-change-proposals.service'; +import { OrganizationAccessGuard } from '../../../shared/organization-access.guard'; +import { PackmindLogger, LogLevel } from '@packmind/logger'; + +/** + * Module for skill-scoped change proposal routes + * + * This module is registered as a child of OrganizationsSpacesSkillsModule via RouterModule, + * automatically inheriting the /organizations/:orgId/spaces/:spaceId/skills/:skillId path prefix. + */ +@Module({ + controllers: [OrganizationsSpacesSkillsChangeProposalsController], + providers: [ + SkillsChangeProposalsService, + OrganizationAccessGuard, + { + provide: PackmindLogger, + useFactory: () => + new PackmindLogger( + 'OrganizationsSpacesSkillsChangeProposalsModule', + LogLevel.INFO, + ), + }, + ], +}) +export class OrganizationsSpacesSkillsChangeProposalsModule {} diff --git a/packages/playbook-change-management/src/nest-api/spaces/skills/change-proposals/skills-change-proposals.service.ts b/packages/playbook-change-management/src/nest-api/spaces/skills/change-proposals/skills-change-proposals.service.ts new file mode 100644 index 000000000..458c530ce --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/spaces/skills/change-proposals/skills-change-proposals.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { + ApplyChangeProposalsCommand, + ApplyChangeProposalsResponse, + IPlaybookChangeManagementPort, + ListChangeProposalsByArtefactCommand, + ListChangeProposalsByArtefactResponse, + SkillId, +} from '@packmind/types'; +import { InjectPlaybookChangeManagementAdapter } from '../../../shared/HexaInjection'; + +@Injectable() +export class SkillsChangeProposalsService { + constructor( + @InjectPlaybookChangeManagementAdapter() + private readonly playbookChangeManagementAdapter: IPlaybookChangeManagementPort, + ) {} + + async listChangeProposalsBySkill( + command: ListChangeProposalsByArtefactCommand<SkillId>, + ): Promise<ListChangeProposalsByArtefactResponse> { + return this.playbookChangeManagementAdapter.listChangeProposalsByArtefact( + command, + ); + } + + async applySkillChangeProposals( + command: ApplyChangeProposalsCommand<SkillId>, + ): Promise<ApplyChangeProposalsResponse<SkillId>> { + return this.playbookChangeManagementAdapter.applyChangeProposals(command); + } +} diff --git a/packages/playbook-change-management/src/nest-api/spaces/standards/change-proposals/standards-change-proposals.controller.ts b/packages/playbook-change-management/src/nest-api/spaces/standards/change-proposals/standards-change-proposals.controller.ts new file mode 100644 index 000000000..a331c26cc --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/spaces/standards/change-proposals/standards-change-proposals.controller.ts @@ -0,0 +1,122 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { AuthenticatedRequest } from '@packmind/node-utils'; +import { + AcceptedChangeProposal, + ApplyChangeProposalsResponse, + ChangeProposalId, + ListChangeProposalsByArtefactResponse, + OrganizationId, + SpaceId, + StandardId, +} from '@packmind/types'; +import { StandardsChangeProposalsService } from './standards-change-proposals.service'; +import { OrganizationAccessGuard } from '../../../shared/organization-access.guard'; + +const origin = 'OrganizationsSpacesStandardsChangeProposalsController'; + +/** + * Controller for standard-scoped change proposal routes + * Actual path: /organizations/:orgId/spaces/:spaceId/standards/:standardId/change-proposals (inherited via RouterModule in AppModule) + */ +@Controller() +@UseGuards(OrganizationAccessGuard) +export class OrganizationsSpacesStandardsChangeProposalsController { + constructor( + private readonly service: StandardsChangeProposalsService, + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.INFO, + ), + ) {} + + /** + * List change proposals for a standard + * GET /organizations/:orgId/spaces/:spaceId/standards/:standardId/change-proposals + */ + @Get() + async listChangeProposalsByStandard( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Param('standardId') standardId: StandardId, + @Req() request: AuthenticatedRequest, + ): Promise<ListChangeProposalsByArtefactResponse> { + this.logger.info( + 'GET /organizations/:orgId/spaces/:spaceId/standards/:standardId/change-proposals', + { organizationId, spaceId, standardId }, + ); + + const result = await this.service.listChangeProposalsByStandard({ + userId: request.user.userId, + organizationId, + spaceId, + artefactId: standardId, + }); + + this.logger.info( + 'GET /organizations/:orgId/spaces/:spaceId/standards/:standardId/change-proposals - Listed successfully', + { + organizationId, + spaceId, + standardId, + count: result.changeProposals.length, + }, + ); + + return result; + } + + /** + * Apply or reject change proposals for a standard + * POST /organizations/:orgId/spaces/:spaceId/standards/:standardId/change-proposals/apply + */ + @Post('apply') + async applyStandardChangeProposals( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Param('standardId') standardId: StandardId, + @Body() + body: { accepted: AcceptedChangeProposal[]; rejected: ChangeProposalId[] }, + @Req() request: AuthenticatedRequest, + ): Promise<ApplyChangeProposalsResponse<StandardId>> { + this.logger.info( + 'POST /organizations/:orgId/spaces/:spaceId/standards/:standardId/change-proposals/apply', + { + organizationId, + spaceId, + standardId, + acceptedCount: body.accepted.length, + rejectedCount: body.rejected.length, + }, + ); + + const result = await this.service.applyStandardChangeProposals({ + userId: request.user.userId, + organizationId, + spaceId, + artefactId: standardId, + accepted: body.accepted, + rejected: body.rejected, + }); + + this.logger.info( + 'POST /organizations/:orgId/spaces/:spaceId/standards/:standardId/change-proposals/apply - Applied successfully', + { + organizationId, + spaceId, + standardId, + newVersion: result.newArtefactVersion, + }, + ); + + return result; + } +} diff --git a/packages/playbook-change-management/src/nest-api/spaces/standards/change-proposals/standards-change-proposals.module.ts b/packages/playbook-change-management/src/nest-api/spaces/standards/change-proposals/standards-change-proposals.module.ts new file mode 100644 index 000000000..839350475 --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/spaces/standards/change-proposals/standards-change-proposals.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { OrganizationsSpacesStandardsChangeProposalsController } from './standards-change-proposals.controller'; +import { StandardsChangeProposalsService } from './standards-change-proposals.service'; +import { OrganizationAccessGuard } from '../../../shared/organization-access.guard'; +import { PackmindLogger, LogLevel } from '@packmind/logger'; + +/** + * Module for standard-scoped change proposal routes + * + * This module is registered as a child of OrganizationsSpacesStandardsModule via RouterModule, + * automatically inheriting the /organizations/:orgId/spaces/:spaceId/standards/:standardId path prefix. + */ +@Module({ + controllers: [OrganizationsSpacesStandardsChangeProposalsController], + providers: [ + StandardsChangeProposalsService, + OrganizationAccessGuard, + { + provide: PackmindLogger, + useFactory: () => + new PackmindLogger( + 'OrganizationsSpacesStandardsChangeProposalsModule', + LogLevel.INFO, + ), + }, + ], +}) +export class OrganizationsSpacesStandardsChangeProposalsModule {} diff --git a/packages/playbook-change-management/src/nest-api/spaces/standards/change-proposals/standards-change-proposals.service.ts b/packages/playbook-change-management/src/nest-api/spaces/standards/change-proposals/standards-change-proposals.service.ts new file mode 100644 index 000000000..35779643e --- /dev/null +++ b/packages/playbook-change-management/src/nest-api/spaces/standards/change-proposals/standards-change-proposals.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { + ApplyChangeProposalsCommand, + ApplyChangeProposalsResponse, + IPlaybookChangeManagementPort, + ListChangeProposalsByArtefactCommand, + ListChangeProposalsByArtefactResponse, + StandardId, +} from '@packmind/types'; +import { InjectPlaybookChangeManagementAdapter } from '../../../shared/HexaInjection'; + +@Injectable() +export class StandardsChangeProposalsService { + constructor( + @InjectPlaybookChangeManagementAdapter() + private readonly playbookChangeManagementAdapter: IPlaybookChangeManagementPort, + ) {} + + async listChangeProposalsByStandard( + command: ListChangeProposalsByArtefactCommand<StandardId>, + ): Promise<ListChangeProposalsByArtefactResponse> { + return this.playbookChangeManagementAdapter.listChangeProposalsByArtefact( + command, + ); + } + + async applyStandardChangeProposals( + command: ApplyChangeProposalsCommand<StandardId>, + ): Promise<ApplyChangeProposalsResponse<StandardId>> { + return this.playbookChangeManagementAdapter.applyChangeProposals(command); + } +} diff --git a/packages/playbook-change-management/test/changeProposalFactory.ts b/packages/playbook-change-management/test/changeProposalFactory.ts new file mode 100644 index 000000000..686e4faa3 --- /dev/null +++ b/packages/playbook-change-management/test/changeProposalFactory.ts @@ -0,0 +1,34 @@ +import { Factory } from '@packmind/test-utils'; +import { + ChangeProposal, + ChangeProposalCaptureMode, + ChangeProposalStatus, + ChangeProposalType, + createChangeProposalId, + createSpaceId, + createStandardId, + createUserId, +} from '@packmind/types'; +import { v4 as uuidv4 } from 'uuid'; + +export const changeProposalFactory: Factory< + ChangeProposal<ChangeProposalType> +> = (proposal?: Partial<ChangeProposal<ChangeProposalType>>) => { + return { + id: createChangeProposalId(uuidv4()), + type: ChangeProposalType.updateStandardName, + artefactId: createStandardId(uuidv4()), + artefactVersion: 1, + spaceId: createSpaceId(uuidv4()), + payload: { oldValue: 'Old Name', newValue: 'New Name' }, + captureMode: ChangeProposalCaptureMode.commit, + message: '', + status: ChangeProposalStatus.pending, + createdBy: createUserId(uuidv4()), + resolvedBy: null, + resolvedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...proposal, + } as ChangeProposal<ChangeProposalType>; +}; diff --git a/packages/playbook-change-management/test/index.ts b/packages/playbook-change-management/test/index.ts new file mode 100644 index 000000000..f12e5aa68 --- /dev/null +++ b/packages/playbook-change-management/test/index.ts @@ -0,0 +1 @@ +export * from './changeProposalFactory'; diff --git a/packages/playbook-change-management/tsconfig.json b/packages/playbook-change-management/tsconfig.json new file mode 100644 index 000000000..57937963b --- /dev/null +++ b/packages/playbook-change-management/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.effective.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/playbook-change-management/tsconfig.lib.json b/packages/playbook-change-management/tsconfig.lib.json new file mode 100644 index 000000000..33eca2c2c --- /dev/null +++ b/packages/playbook-change-management/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/playbook-change-management/tsconfig.spec.json b/packages/playbook-change-management/tsconfig.spec.json new file mode 100644 index 000000000..0d3c604ea --- /dev/null +++ b/packages/playbook-change-management/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/packages/plugins/.swcrc b/packages/plugins/.swcrc new file mode 100644 index 000000000..8d79fc02a --- /dev/null +++ b/packages/plugins/.swcrc @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "jsc": { + "parser": { "syntax": "typescript", "decorators": false }, + "target": "es2022", + "keepClassNames": true + }, + "module": { "type": "commonjs" }, + "sourceMaps": true +} diff --git a/packages/plugins/package.json b/packages/plugins/package.json new file mode 100644 index 000000000..c94965212 --- /dev/null +++ b/packages/plugins/package.json @@ -0,0 +1,13 @@ +{ + "name": "@packmind/plugins", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "@packmind/crisp": "workspace:*", + "@packmind/node-utils": "workspace:*", + "typeorm": "^0.3.20" + } +} diff --git a/packages/plugins/project.json b/packages/plugins/project.json new file mode 100644 index 000000000..5422c4be2 --- /dev/null +++ b/packages/plugins/project.json @@ -0,0 +1,20 @@ +{ + "name": "plugins", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/plugins/src", + "projectType": "library", + "tags": ["env:node"], + "targets": { + "build": { + "executor": "@nx/js:swc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/plugins", + "tsConfig": "packages/plugins/tsconfig.lib.json", + "packageJson": "packages/plugins/package.json", + "main": "packages/plugins/src/index.ts", + "assets": ["packages/plugins/*.md"] + } + } + } +} diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts new file mode 100644 index 000000000..93bb2f40d --- /dev/null +++ b/packages/plugins/src/index.ts @@ -0,0 +1,20 @@ +import { CrispHexa } from '@packmind/crisp'; +import { ImportPracticeLegacyHexa } from '@packmind/import-practices-legacy'; +import { BaseHexa, BaseHexaOpts } from '@packmind/node-utils'; +import { DataSource } from 'typeorm'; + +/** + * Plugin hexas for API that are registered in addition to core hexas. + * This is the proprietary edition version that includes CrispHexa and ImportPracticeLegacyHexa. + */ +export const apiHexaPlugins: Array< + new (dataSource: DataSource, opts?: Partial<BaseHexaOpts>) => BaseHexa +> = [CrispHexa, ImportPracticeLegacyHexa]; + +/** + * Plugin hexas for MCP server that are registered in addition to core hexas. + * This is the proprietary edition version (currently empty). + */ +export const mcpHexaPlugins: Array< + new (dataSource: DataSource, opts?: Partial<BaseHexaOpts>) => BaseHexa +> = []; diff --git a/packages/plugins/tsconfig.json b/packages/plugins/tsconfig.json new file mode 100644 index 000000000..4b2225629 --- /dev/null +++ b/packages/plugins/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/plugins/tsconfig.lib.json b/packages/plugins/tsconfig.lib.json new file mode 100644 index 000000000..33eca2c2c --- /dev/null +++ b/packages/plugins/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/recipes/src/application/adapter/RecipesAdapter.ts b/packages/recipes/src/application/adapter/RecipesAdapter.ts index 8023e1b90..3bf83ae96 100644 --- a/packages/recipes/src/application/adapter/RecipesAdapter.ts +++ b/packages/recipes/src/application/adapter/RecipesAdapter.ts @@ -293,6 +293,33 @@ export class RecipesAdapter return result.recipes; } + public countBySpaceIds(spaceIds: SpaceId[]): Promise<Map<SpaceId, number>> { + return this.recipesServices.getRecipeService().countBySpaceIds(spaceIds); + } + + /** + * List all recipes across every space of an organization, bypassing space + * membership checks. Used for organization-scoped aggregations where the + * caller is already authorized at the organization level. + */ + public async listAllRecipesByOrganization( + organizationId: OrganizationId, + ): Promise<Recipe[]> { + if (!this.spacesPort) { + this.logger.warn('SpacesPort not available, returning empty results'); + return []; + } + + const spaces = + await this.spacesPort.listSpacesByOrganization(organizationId); + const recipesPerSpace = await Promise.all( + spaces.map((space) => + this.recipesServices.getRecipeService().listRecipesBySpace(space.id), + ), + ); + return recipesPerSpace.flat(); + } + public listRecipeVersions(recipeId: RecipeId) { return this._listRecipeVersions.listRecipeVersions(recipeId); } diff --git a/packages/recipes/src/application/jobs/DeployRecipesDelayedJob.ts b/packages/recipes/src/application/jobs/DeployRecipesDelayedJob.ts index 64172a5f5..d7cc4bff8 100644 --- a/packages/recipes/src/application/jobs/DeployRecipesDelayedJob.ts +++ b/packages/recipes/src/application/jobs/DeployRecipesDelayedJob.ts @@ -148,7 +148,7 @@ export class DeployRecipesDelayedJob extends AbstractAIDelayedJob< await this.deploymentPort.publishArtifacts({ organizationId: input.organizationId, - userId: createUserId('system'), // System user for webhook-triggered deployments + userId: createUserId('system'), // System user for automated background deployments targetIds: targetIdsToDeployTo, recipeVersionIds: input.recipeVersionIds, standardVersionIds: [], diff --git a/packages/recipes/src/application/services/RecipeService.ts b/packages/recipes/src/application/services/RecipeService.ts index 3aadaf7fa..292d828e6 100644 --- a/packages/recipes/src/application/services/RecipeService.ts +++ b/packages/recipes/src/application/services/RecipeService.ts @@ -105,6 +105,10 @@ export class RecipeService { } } + async countBySpaceIds(spaceIds: SpaceId[]): Promise<Map<SpaceId, number>> { + return this.recipeRepository.countBySpaceIds(spaceIds); + } + async listRecipesByUser(userId: UserId): Promise<Recipe[]> { this.logger.info('Listing recipes by user', { userId }); diff --git a/packages/recipes/src/domain/repositories/IRecipeRepository.ts b/packages/recipes/src/domain/repositories/IRecipeRepository.ts index 9b4070210..f86411623 100644 --- a/packages/recipes/src/domain/repositories/IRecipeRepository.ts +++ b/packages/recipes/src/domain/repositories/IRecipeRepository.ts @@ -20,5 +20,6 @@ export interface IRecipeRepository extends IRepository<Recipe> { spaceId: SpaceId, opts?: Pick<QueryOption, 'includeDeleted'>, ): Promise<Recipe[]>; + countBySpaceIds(spaceIds: SpaceId[]): Promise<Map<SpaceId, number>>; markAsMoved(recipeId: RecipeId, destinationSpaceId: SpaceId): Promise<void>; } diff --git a/packages/recipes/src/infra/repositories/RecipeRepository.spec.ts b/packages/recipes/src/infra/repositories/RecipeRepository.spec.ts index c7b439607..a443d5fb1 100644 --- a/packages/recipes/src/infra/repositories/RecipeRepository.spec.ts +++ b/packages/recipes/src/infra/repositories/RecipeRepository.spec.ts @@ -10,6 +10,7 @@ import { import { createOrganizationId, createRecipeId, + createSpaceId, Recipe, WithSoftDelete, } from '@packmind/types'; @@ -128,6 +129,80 @@ describe('RecipeRepository', () => { }); }); + describe('countBySpaceIds', () => { + describe('when counting recipes per space', () => { + let counts: Awaited<ReturnType<typeof recipeRepository.countBySpaceIds>>; + let spaceAId: ReturnType<typeof spaceFactory>['id']; + let spaceBId: ReturnType<typeof spaceFactory>['id']; + let spaceCId: ReturnType<typeof spaceFactory>['id']; + + beforeEach(async () => { + const organizationId = createOrganizationId(uuidv4()); + const spaceA = spaceFactory({ organizationId, slug: 'space-a' }); + const spaceB = spaceFactory({ organizationId, slug: 'space-b' }); + const spaceC = spaceFactory({ organizationId, slug: 'space-c' }); + const spaceRepo = fixture.datasource.getRepository(SpaceSchema); + await spaceRepo.save([spaceA, spaceB, spaceC]); + + await recipeRepository.add(recipeFactory({ spaceId: spaceA.id })); + await recipeRepository.add(recipeFactory({ spaceId: spaceA.id })); + await recipeRepository.add(recipeFactory({ spaceId: spaceB.id })); + + spaceAId = spaceA.id; + spaceBId = spaceB.id; + spaceCId = spaceC.id; + + counts = await recipeRepository.countBySpaceIds([ + spaceA.id, + spaceB.id, + spaceC.id, + ]); + }); + + it('returns the correct count for spaceA', () => { + expect(counts.get(spaceAId)).toBe(2); + }); + + it('returns the correct count for spaceB', () => { + expect(counts.get(spaceBId)).toBe(1); + }); + + it('omits spaceC which has zero recipes', () => { + expect(counts.has(spaceCId)).toBe(false); + }); + }); + + it('returns an empty Map for empty input', async () => { + const counts = await recipeRepository.countBySpaceIds([]); + expect(counts.size).toBe(0); + }); + + it('excludes soft-deleted recipes from the count', async () => { + const organizationId = createOrganizationId(uuidv4()); + const space = spaceFactory({ organizationId }); + const spaceRepo = fixture.datasource.getRepository(SpaceSchema); + await spaceRepo.save(space); + + await recipeRepository.add(recipeFactory({ spaceId: space.id })); + const deletedRecipe = await recipeRepository.add( + recipeFactory({ spaceId: space.id }), + ); + await recipeRepository.deleteById(deletedRecipe.id); + + const counts = await recipeRepository.countBySpaceIds([space.id]); + + expect(counts.get(space.id)).toBe(1); + }); + + it('omits unknown space IDs from the result Map', async () => { + const unknownSpaceId = createSpaceId(uuidv4()); + + const counts = await recipeRepository.countBySpaceIds([unknownSpaceId]); + + expect(counts.has(unknownSpaceId)).toBe(false); + }); + }); + itHandlesSoftDelete<Recipe>({ entityFactory: recipeFactory, getRepository: () => recipeRepository, diff --git a/packages/recipes/src/infra/repositories/RecipeRepository.ts b/packages/recipes/src/infra/repositories/RecipeRepository.ts index 98532d15a..fca789fc2 100644 --- a/packages/recipes/src/infra/repositories/RecipeRepository.ts +++ b/packages/recipes/src/infra/repositories/RecipeRepository.ts @@ -148,6 +148,33 @@ export class RecipeRepository } } + async countBySpaceIds(spaceIds: SpaceId[]): Promise<Map<SpaceId, number>> { + if (spaceIds.length === 0) { + return new Map(); + } + + this.logger.info('Counting recipes by space IDs', { + spaceCount: spaceIds.length, + }); + + try { + const rows = await this.repository + .createQueryBuilder('recipe') + .select('recipe.space_id', 'spaceId') + .addSelect('COUNT(*)', 'count') + .where('recipe.space_id IN (:...spaceIds)', { spaceIds }) + .groupBy('recipe.space_id') + .getRawMany<{ spaceId: SpaceId; count: string }>(); + + return new Map(rows.map((row) => [row.spaceId, Number(row.count)])); + } catch (error) { + this.logger.error('Failed to count recipes by space IDs', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + async markAsMoved( recipeId: RecipeId, destinationSpaceId: SpaceId, diff --git a/packages/skills/src/application/adapter/SkillsAdapter.ts b/packages/skills/src/application/adapter/SkillsAdapter.ts index 67d720512..ab7166997 100644 --- a/packages/skills/src/application/adapter/SkillsAdapter.ts +++ b/packages/skills/src/application/adapter/SkillsAdapter.ts @@ -282,6 +282,33 @@ export class SkillsAdapter implements IBaseAdapter<ISkillsPort>, ISkillsPort { }); } + /** + * List all skills across every space of an organization, bypassing space + * membership checks. Used for organization-scoped aggregations where the + * caller is already authorized at the organization level. + */ + async listAllSkillsByOrganization( + organizationId: OrganizationId, + ): Promise<Skill[]> { + if (!this.spacesPort) { + this.logger.warn('SpacesPort not available, returning empty results'); + return []; + } + + const spaces = + await this.spacesPort.listSpacesByOrganization(organizationId); + const skillsPerSpace = await Promise.all( + spaces.map((space) => + this.services.getSkillService().listSkillsBySpace(space.id), + ), + ); + return skillsPerSpace.flat(); + } + + public countBySpaceIds(spaceIds: SpaceId[]): Promise<Map<SpaceId, number>> { + return this.services.getSkillService().countBySpaceIds(spaceIds); + } + async findSkillBySlug( slug: string, organizationId: OrganizationId, diff --git a/packages/skills/src/application/services/SkillService.ts b/packages/skills/src/application/services/SkillService.ts index 29ef23479..886885324 100644 --- a/packages/skills/src/application/services/SkillService.ts +++ b/packages/skills/src/application/services/SkillService.ts @@ -175,6 +175,10 @@ export class SkillService { } } + async countBySpaceIds(spaceIds: SpaceId[]): Promise<Map<SpaceId, number>> { + return this.skillRepository.countBySpaceIds(spaceIds); + } + async updateSkill( skillId: SkillId, skillData: UpdateSkillData, diff --git a/packages/skills/src/domain/repositories/ISkillRepository.ts b/packages/skills/src/domain/repositories/ISkillRepository.ts index a39393a74..91d776572 100644 --- a/packages/skills/src/domain/repositories/ISkillRepository.ts +++ b/packages/skills/src/domain/repositories/ISkillRepository.ts @@ -18,5 +18,6 @@ export interface ISkillRepository extends IRepository<Skill> { opts?: Pick<QueryOption, 'includeDeleted'>, ): Promise<Skill[]>; findByUserId(userId: UserId): Promise<Skill[]>; + countBySpaceIds(spaceIds: SpaceId[]): Promise<Map<SpaceId, number>>; markAsMoved(skillId: SkillId, destinationSpaceId: SpaceId): Promise<void>; } diff --git a/packages/skills/src/infra/repositories/SkillRepository.spec.ts b/packages/skills/src/infra/repositories/SkillRepository.spec.ts index 42bed12d0..7eff1ec5b 100644 --- a/packages/skills/src/infra/repositories/SkillRepository.spec.ts +++ b/packages/skills/src/infra/repositories/SkillRepository.spec.ts @@ -145,6 +145,80 @@ describe('SkillRepository', () => { }); }); + describe('countBySpaceIds', () => { + describe('when counting skills per space', () => { + let counts: Awaited<ReturnType<typeof skillRepository.countBySpaceIds>>; + let spaceAId: ReturnType<typeof spaceFactory>['id']; + let spaceBId: ReturnType<typeof spaceFactory>['id']; + let spaceCId: ReturnType<typeof spaceFactory>['id']; + + beforeEach(async () => { + const organizationId = createOrganizationId(uuidv4()); + const spaceA = spaceFactory({ organizationId, slug: 'space-a' }); + const spaceB = spaceFactory({ organizationId, slug: 'space-b' }); + const spaceC = spaceFactory({ organizationId, slug: 'space-c' }); + const spaceRepo = fixture.datasource.getRepository(SpaceSchema); + await spaceRepo.save([spaceA, spaceB, spaceC]); + + await skillRepository.add(skillFactory({ spaceId: spaceA.id })); + await skillRepository.add(skillFactory({ spaceId: spaceA.id })); + await skillRepository.add(skillFactory({ spaceId: spaceB.id })); + + spaceAId = spaceA.id; + spaceBId = spaceB.id; + spaceCId = spaceC.id; + + counts = await skillRepository.countBySpaceIds([ + spaceA.id, + spaceB.id, + spaceC.id, + ]); + }); + + it('returns the correct count for spaceA', () => { + expect(counts.get(spaceAId)).toBe(2); + }); + + it('returns the correct count for spaceB', () => { + expect(counts.get(spaceBId)).toBe(1); + }); + + it('omits spaceC which has zero skills', () => { + expect(counts.has(spaceCId)).toBe(false); + }); + }); + + it('returns an empty Map for empty input', async () => { + const counts = await skillRepository.countBySpaceIds([]); + expect(counts.size).toBe(0); + }); + + it('excludes soft-deleted skills from the count', async () => { + const organizationId = createOrganizationId(uuidv4()); + const space = spaceFactory({ organizationId }); + const spaceRepo = fixture.datasource.getRepository(SpaceSchema); + await spaceRepo.save(space); + + await skillRepository.add(skillFactory({ spaceId: space.id })); + const deletedSkill = await skillRepository.add( + skillFactory({ spaceId: space.id }), + ); + await skillRepository.deleteById(deletedSkill.id); + + const counts = await skillRepository.countBySpaceIds([space.id]); + + expect(counts.get(space.id)).toBe(1); + }); + + it('omits unknown space IDs from the result Map', async () => { + const unknownSpaceId = createSpaceId(uuidv4()); + + const counts = await skillRepository.countBySpaceIds([unknownSpaceId]); + + expect(counts.has(unknownSpaceId)).toBe(false); + }); + }); + describe('findByUserId', () => { it('finds all skills created by user', async () => { const userId = createUserId(uuidv4()); diff --git a/packages/skills/src/infra/repositories/SkillRepository.ts b/packages/skills/src/infra/repositories/SkillRepository.ts index 27a440a20..fcf328e85 100644 --- a/packages/skills/src/infra/repositories/SkillRepository.ts +++ b/packages/skills/src/infra/repositories/SkillRepository.ts @@ -137,6 +137,33 @@ export class SkillRepository } } + async countBySpaceIds(spaceIds: SpaceId[]): Promise<Map<SpaceId, number>> { + if (spaceIds.length === 0) { + return new Map(); + } + + this.logger.info('Counting skills by space IDs', { + spaceCount: spaceIds.length, + }); + + try { + const rows = await this.repository + .createQueryBuilder('skill') + .select('skill.space_id', 'spaceId') + .addSelect('COUNT(*)', 'count') + .where('skill.space_id IN (:...spaceIds)', { spaceIds }) + .groupBy('skill.space_id') + .getRawMany<{ spaceId: SpaceId; count: string }>(); + + return new Map(rows.map((row) => [row.spaceId, Number(row.count)])); + } catch (error) { + this.logger.error('Failed to count skills by space IDs', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + async markAsMoved( skillId: SkillId, destinationSpaceId: SpaceId, diff --git a/packages/spaces-management/.swcrc b/packages/spaces-management/.swcrc new file mode 100644 index 000000000..83bfbeb3f --- /dev/null +++ b/packages/spaces-management/.swcrc @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "jsc": { + "parser": { "syntax": "typescript", "decorators": true }, + "transform": { "legacyDecorator": true, "decoratorMetadata": true }, + "target": "es2022", + "keepClassNames": true + }, + "module": { "type": "commonjs" }, + "sourceMaps": true +} diff --git a/packages/spaces-management/eslint.config.mjs b/packages/spaces-management/eslint.config.mjs new file mode 100644 index 000000000..c334bc0bc --- /dev/null +++ b/packages/spaces-management/eslint.config.mjs @@ -0,0 +1,19 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, +]; diff --git a/packages/spaces-management/jest.config.ts b/packages/spaces-management/jest.config.ts new file mode 100644 index 000000000..a9034d5fc --- /dev/null +++ b/packages/spaces-management/jest.config.ts @@ -0,0 +1,23 @@ +const { compilerOptions } = require('../../tsconfig.base.effective.json'); + +const { + pathsToModuleNameMapper, + swcTransformWithDecorators, + standardTransformIgnorePatterns, + standardModuleFileExtensions, +} = require('../../jest-utils.ts'); + +module.exports = { + displayName: 'spaces-management', + preset: '../../jest.preset.ts', + testEnvironment: 'node', + transform: swcTransformWithDecorators, + transformIgnorePatterns: standardTransformIgnorePatterns, + moduleFileExtensions: standardModuleFileExtensions, + coverageDirectory: '../../coverage/packages/spaces-management', + moduleNameMapper: pathsToModuleNameMapper( + compilerOptions.paths, + '<rootDir>/../../', + ), + passWithNoTests: true, +}; diff --git a/packages/spaces-management/package.json b/packages/spaces-management/package.json new file mode 100644 index 000000000..653b1fa8f --- /dev/null +++ b/packages/spaces-management/package.json @@ -0,0 +1,21 @@ +{ + "name": "@packmind/spaces-management", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "@nestjs/common": "^11.1.6", + "@packmind/types": "workspace:*", + "@packmind/node-utils": "workspace:*", + "@packmind/logger": "workspace:*", + "@packmind/accounts": "workspace:*", + "@packmind/spaces": "workspace:*", + "@packmind/standards": "workspace:*", + "@packmind/skills": "workspace:*", + "@packmind/recipes": "workspace:*", + "@packmind/test-utils": "workspace:*", + "typeorm": "0.3.28" + } +} diff --git a/packages/spaces-management/project.json b/packages/spaces-management/project.json new file mode 100644 index 000000000..aecbae506 --- /dev/null +++ b/packages/spaces-management/project.json @@ -0,0 +1,26 @@ +{ + "name": "spaces-management", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/spaces-management/src", + "projectType": "library", + "tags": ["env:node"], + "targets": { + "typecheck": { + "executor": "nx:run-commands", + "options": { + "command": "tsc --noEmit --project packages/spaces-management/tsconfig.lib.json" + } + }, + "build": { + "executor": "@nx/js:swc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/spaces-management", + "tsConfig": "packages/spaces-management/tsconfig.lib.json", + "packageJson": "packages/spaces-management/package.json", + "main": "packages/spaces-management/src/index.ts", + "assets": ["packages/spaces-management/*.md"] + } + } + } +} diff --git a/packages/spaces-management/src/SpacesManagementHexa.ts b/packages/spaces-management/src/SpacesManagementHexa.ts new file mode 100644 index 000000000..da16fbaa9 --- /dev/null +++ b/packages/spaces-management/src/SpacesManagementHexa.ts @@ -0,0 +1,133 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + BaseHexa, + HexaRegistry, + BaseHexaOpts, + PackmindEventEmitterService, +} from '@packmind/node-utils'; +import { + IAccountsPort, + IAccountsPortName, + IRecipesPort, + IRecipesPortName, + ISkillsPort, + ISkillsPortName, + ISpacesManagementPort, + ISpacesManagementPortName, + ISpacesPort, + ISpacesPortName, + IStandardsPort, + IStandardsPortName, +} from '@packmind/types'; +import { DataSource } from 'typeorm'; +import { SpacesManagementAdapter } from './application/adapters/SpacesManagementAdapter'; + +const origin = 'SpacesManagementHexa'; + +/** + * SpacesManagementHexa - Facade for the Spaces Management domain following the Port/Adapter pattern + * + * This class serves as the main entry point for spaces-management functionality + * (e.g., moving artifacts between spaces). + * It exposes the SpacesManagement adapter for cross-domain access following DDD standards. + * + * The Hexa pattern: + * - Constructor: Instantiates the adapter + * - initialize(): Retrieves and sets ports from registry + * - getAdapter(): Exposes the domain adapter for cross-domain access + */ +export class SpacesManagementHexa extends BaseHexa< + BaseHexaOpts, + ISpacesManagementPort +> { + private readonly spacesManagementAdapter: SpacesManagementAdapter; + private spacesPort!: ISpacesPort; + + constructor( + dataSource: DataSource, + opts: Partial<BaseHexaOpts> = { logger: new PackmindLogger(origin) }, + ) { + super(dataSource, opts); + this.logger.info('Constructing SpacesManagementHexa'); + + try { + this.spacesManagementAdapter = new SpacesManagementAdapter(); + this.logger.info('SpacesManagementHexa construction completed'); + } catch (error) { + this.logger.error('Failed to construct SpacesManagementHexa', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * Initialize the hexa with access to the registry for adapter retrieval. + */ + public async initialize(registry: HexaRegistry): Promise<void> { + this.logger.info( + 'Initializing SpacesManagementHexa (adapter retrieval phase)', + ); + + try { + const accountsPort = + registry.getAdapter<IAccountsPort>(IAccountsPortName); + const spacesPort = registry.getAdapter<ISpacesPort>(ISpacesPortName); + this.spacesPort = spacesPort; + const standardsPort = + registry.getAdapter<IStandardsPort>(IStandardsPortName); + const skillsPort = registry.getAdapter<ISkillsPort>(ISkillsPortName); + const recipesPort = registry.getAdapter<IRecipesPort>(IRecipesPortName); + const eventEmitterService = registry.getService( + PackmindEventEmitterService, + ); + + await this.spacesManagementAdapter.initialize({ + [IAccountsPortName]: accountsPort, + [ISpacesPortName]: spacesPort, + [IStandardsPortName]: standardsPort, + [ISkillsPortName]: skillsPort, + [IRecipesPortName]: recipesPort, + eventEmitterService, + }); + + this.logger.info('SpacesManagementHexa initialized successfully'); + } catch (error) { + this.logger.error('Failed to initialize SpacesManagementHexa', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + destroy(): void { + this.logger.info('Destroying SpacesManagementHexa'); + this.logger.info('SpacesManagementHexa destroyed'); + } + + /** + * Get the SpacesManagement adapter for cross-domain access + * Following DDD monorepo architecture standard + */ + public getAdapter(): ISpacesManagementPort { + return this.spacesManagementAdapter.getPort(); + } + + /** + * Get the Spaces port for accessing space data. + * This is used by the service layer to delegate space operations. + */ + public getSpacesPort(): ISpacesPort { + if (!this.spacesPort) { + throw new Error('SpacesPort not initialized. Call initialize() first.'); + } + return this.spacesPort; + } + + /** + * Get the port name for this hexa. + */ + public getPortName(): string { + return ISpacesManagementPortName; + } +} diff --git a/packages/spaces-management/src/application/adapters/SpacesManagementAdapter.spec.ts b/packages/spaces-management/src/application/adapters/SpacesManagementAdapter.spec.ts new file mode 100644 index 000000000..82a0f0736 --- /dev/null +++ b/packages/spaces-management/src/application/adapters/SpacesManagementAdapter.spec.ts @@ -0,0 +1,113 @@ +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { + IAccountsPort, + IAccountsPortName, + IRecipesPort, + IRecipesPortName, + ISkillsPort, + ISkillsPortName, + ISpacesPort, + ISpacesPortName, + IStandardsPort, + IStandardsPortName, + createOrganizationId, + createUserId, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { SpacesManagementAdapter } from './SpacesManagementAdapter'; + +describe('SpacesManagementAdapter', () => { + const userId = createUserId('user-1'); + const organizationId = createOrganizationId('org-1'); + + let adapter: SpacesManagementAdapter; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked<ISpacesPort>; + let standardsPort: jest.Mocked<IStandardsPort>; + let recipesPort: jest.Mocked<IRecipesPort>; + let skillsPort: jest.Mocked<ISkillsPort>; + let eventEmitterService: jest.Mocked<PackmindEventEmitterService>; + + beforeEach(async () => { + const orgAdminUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + const organization = organizationFactory({ id: organizationId }); + + accountsPort = { + getUserById: jest.fn().mockResolvedValue(orgAdminUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort = { + findOrgPagePaginated: jest + .fn() + .mockResolvedValue({ items: [], totalCount: 0 }), + findAdminsForSpaceIds: jest.fn().mockResolvedValue([]), + countUsersForSpaceIds: jest.fn().mockResolvedValue(new Map()), + } as unknown as jest.Mocked<ISpacesPort>; + + standardsPort = { + countBySpaceIds: jest.fn().mockResolvedValue(new Map()), + } as unknown as jest.Mocked<IStandardsPort>; + + recipesPort = { + countBySpaceIds: jest.fn().mockResolvedValue(new Map()), + } as unknown as jest.Mocked<IRecipesPort>; + + skillsPort = { + countBySpaceIds: jest.fn().mockResolvedValue(new Map()), + } as unknown as jest.Mocked<ISkillsPort>; + + eventEmitterService = { + emit: jest.fn().mockReturnValue(true), + } as unknown as jest.Mocked<PackmindEventEmitterService>; + + adapter = new SpacesManagementAdapter(); + await adapter.initialize({ + [IAccountsPortName]: accountsPort, + [ISpacesPortName]: spacesPort, + [IStandardsPortName]: standardsPort, + [IRecipesPortName]: recipesPort, + [ISkillsPortName]: skillsPort, + eventEmitterService, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('listOrganizationSpacesForManagement', () => { + it('returns the use case response', async () => { + const result = await adapter.listOrganizationSpacesForManagement({ + userId: userId as unknown as string, + organizationId: organizationId as unknown as string, + page: 1, + }); + + expect(result).toEqual({ + items: [], + totalCount: 0, + page: 1, + pageSize: 1000, + }); + }); + + it('delegates to findOrgPagePaginated with the correct arguments', async () => { + await adapter.listOrganizationSpacesForManagement({ + userId: userId as unknown as string, + organizationId: organizationId as unknown as string, + page: 1, + }); + + expect(spacesPort.findOrgPagePaginated).toHaveBeenCalledWith( + organizationId, + 1, + 1000, + ); + }); + }); +}); diff --git a/packages/spaces-management/src/application/adapters/SpacesManagementAdapter.ts b/packages/spaces-management/src/application/adapters/SpacesManagementAdapter.ts new file mode 100644 index 000000000..d49c61dec --- /dev/null +++ b/packages/spaces-management/src/application/adapters/SpacesManagementAdapter.ts @@ -0,0 +1,212 @@ +import { + IBaseAdapter, + PackmindEventEmitterService, +} from '@packmind/node-utils'; +import { + IAccountsPort, + IAccountsPortName, + IRecipesPort, + IRecipesPortName, + ISkillsPort, + ISkillsPortName, + ISpacesManagementPort, + CreateSpaceCommand, + CreateSpaceResponse, + ISpacesPort, + ISpacesPortName, + IStandardsPort, + IStandardsPortName, + MoveArtifactsToSpaceCommand, + MoveArtifactsToSpaceResponse, + BrowseSpacesCommand, + BrowseSpacesResponse, + JoinSpaceCommand, + JoinSpaceBySlugCommand, + JoinSpaceResponse, + LeaveSpaceCommand, + LeaveSpaceResponse, + UpdateSpaceCommand, + UpdateSpaceResponse, + DeleteSpaceCommand, + DeleteSpaceResponse, + PinSpaceCommand, + PinSpaceResponse, + UnpinSpaceCommand, + UnpinSpaceResponse, + ListOrganizationSpacesForManagementCommand, + ListOrganizationSpacesForManagementResponse, + createOrganizationId, +} from '@packmind/types'; +import { SpaceNotFoundError } from '@packmind/spaces'; +import { CreateSpaceUseCase } from '../usecases/CreateSpaceUseCase'; +import { MoveArtifactsToSpaceUseCase } from '../usecases/MoveArtifactsToSpaceUseCase'; +import { BrowseSpacesUseCase } from '../usecases/BrowseSpacesUseCase'; +import { JoinSpaceUseCase } from '../usecases/JoinSpaceUseCase'; +import { LeaveSpaceUseCase } from '../usecases/LeaveSpaceUseCase'; +import { UpdateSpaceUseCase } from '../usecases/UpdateSpaceUseCase'; +import { DeleteSpaceUseCase } from '../usecases/DeleteSpaceUseCase'; +import { PinSpaceUseCase } from '../usecases/PinSpaceUseCase'; +import { UnpinSpaceUseCase } from '../usecases/UnpinSpaceUseCase'; +import { ListOrganizationSpacesForManagementUseCase } from '../usecases/ListOrganizationSpacesForManagementUseCase'; + +/** + * SpacesManagementAdapter - Implements the ISpacesManagementPort interface for cross-domain access + * Following the Port/Adapter pattern from DDD monorepo architecture standard + */ +export class SpacesManagementAdapter + implements IBaseAdapter<ISpacesManagementPort>, ISpacesManagementPort +{ + private accountsPort!: IAccountsPort; + private spacesPort!: ISpacesPort; + private standardsPort!: IStandardsPort; + private skillsPort!: ISkillsPort; + private recipesPort!: IRecipesPort; + private eventEmitterService!: PackmindEventEmitterService; + + async createSpace(command: CreateSpaceCommand): Promise<CreateSpaceResponse> { + const useCase = new CreateSpaceUseCase(this.spacesPort, this.accountsPort); + return useCase.execute(command); + } + + async moveArtifactsToSpace( + command: MoveArtifactsToSpaceCommand, + ): Promise<MoveArtifactsToSpaceResponse> { + const useCase = new MoveArtifactsToSpaceUseCase( + this.accountsPort, + this.spacesPort, + this.standardsPort, + this.skillsPort, + this.recipesPort, + this.eventEmitterService, + ); + return useCase.execute(command); + } + + async browseSpaces( + command: BrowseSpacesCommand, + ): Promise<BrowseSpacesResponse> { + const useCase = new BrowseSpacesUseCase(this.accountsPort, this.spacesPort); + return useCase.execute(command); + } + + async joinSpace(command: JoinSpaceCommand): Promise<JoinSpaceResponse> { + const useCase = new JoinSpaceUseCase( + this.accountsPort, + this.spacesPort, + this.eventEmitterService, + ); + return useCase.execute(command); + } + + async joinSpaceBySlug( + command: JoinSpaceBySlugCommand, + ): Promise<JoinSpaceResponse> { + const organizationId = createOrganizationId(command.organizationId); + const space = await this.spacesPort.getSpaceBySlug( + command.spaceSlug, + organizationId, + ); + + if (!space) { + throw new SpaceNotFoundError(command.spaceSlug); + } + + return this.joinSpace({ + ...command, + spaceId: space.id, + }); + } + + async leaveSpace(command: LeaveSpaceCommand): Promise<LeaveSpaceResponse> { + const useCase = new LeaveSpaceUseCase( + this.spacesPort, + this.accountsPort, + this.eventEmitterService, + ); + return useCase.execute(command); + } + + async updateSpace(command: UpdateSpaceCommand): Promise<UpdateSpaceResponse> { + const useCase = new UpdateSpaceUseCase( + this.spacesPort, + this.accountsPort, + this.eventEmitterService, + ); + return useCase.execute(command); + } + + async deleteSpace(command: DeleteSpaceCommand): Promise<DeleteSpaceResponse> { + const useCase = new DeleteSpaceUseCase( + this.accountsPort, + this.spacesPort, + this.eventEmitterService, + ); + return useCase.execute(command); + } + + async pinSpace(command: PinSpaceCommand): Promise<PinSpaceResponse> { + const useCase = new PinSpaceUseCase( + this.spacesPort, + this.accountsPort, + this.eventEmitterService, + ); + return useCase.execute(command); + } + + async unpinSpace(command: UnpinSpaceCommand): Promise<UnpinSpaceResponse> { + const useCase = new UnpinSpaceUseCase( + this.spacesPort, + this.accountsPort, + this.eventEmitterService, + ); + return useCase.execute(command); + } + + async listOrganizationSpacesForManagement( + command: ListOrganizationSpacesForManagementCommand, + ): Promise<ListOrganizationSpacesForManagementResponse> { + const useCase = new ListOrganizationSpacesForManagementUseCase( + this.accountsPort, + this.spacesPort, + this.standardsPort, + this.recipesPort, + this.skillsPort, + ); + return useCase.execute(command); + } + + /** + * Initialize the adapter with ports from registry. + */ + public async initialize(ports: Record<string, unknown>): Promise<void> { + this.accountsPort = ports[IAccountsPortName] as IAccountsPort; + this.spacesPort = ports[ISpacesPortName] as ISpacesPort; + this.standardsPort = ports[IStandardsPortName] as IStandardsPort; + this.skillsPort = ports[ISkillsPortName] as ISkillsPort; + this.recipesPort = ports[IRecipesPortName] as IRecipesPort; + this.eventEmitterService = ports[ + 'eventEmitterService' + ] as PackmindEventEmitterService; + } + + /** + * Check if the adapter is ready to use. + */ + public isReady(): boolean { + return ( + !!this.accountsPort && + !!this.spacesPort && + !!this.standardsPort && + !!this.skillsPort && + !!this.recipesPort && + !!this.eventEmitterService + ); + } + + /** + * Get the port interface this adapter implements. + */ + public getPort(): ISpacesManagementPort { + return this as ISpacesManagementPort; + } +} diff --git a/packages/spaces-management/src/application/usecases/BrowseSpacesUseCase.spec.ts b/packages/spaces-management/src/application/usecases/BrowseSpacesUseCase.spec.ts new file mode 100644 index 000000000..12cee1449 --- /dev/null +++ b/packages/spaces-management/src/application/usecases/BrowseSpacesUseCase.spec.ts @@ -0,0 +1,265 @@ +import { + BrowseSpacesCommand, + createOrganizationId, + createSpaceId, + createUserId, + IAccountsPort, + ISpacesPort, + SpaceType, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { BrowseSpacesUseCase } from './BrowseSpacesUseCase'; + +describe('BrowseSpacesUseCase', () => { + const organizationId = createOrganizationId('org-1'); + const userId = createUserId('user-1'); + + const organization = organizationFactory({ id: organizationId }); + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + + let useCase: BrowseSpacesUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked< + Pick<ISpacesPort, 'listUserSpaces' | 'listSpacesByOrganization'> + >; + + const buildCommand = ( + overrides?: Partial<BrowseSpacesCommand>, + ): BrowseSpacesCommand => ({ + userId: userId as unknown as string, + organizationId: organizationId as unknown as string, + ...overrides, + }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(user), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort = { + listUserSpaces: jest.fn(), + listSpacesByOrganization: jest.fn(), + }; + + useCase = new BrowseSpacesUseCase( + accountsPort, + spacesPort as unknown as ISpacesPort, + ); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('when the user belongs to some spaces', () => { + const memberSpace = spaceFactory({ + id: createSpaceId('space-1'), + name: 'My Team', + organizationId, + type: SpaceType.open, + }); + + const openSpace = spaceFactory({ + id: createSpaceId('space-2'), + name: 'Open Space', + organizationId, + type: SpaceType.open, + }); + + const restrictedSpace = spaceFactory({ + id: createSpaceId('space-3'), + name: 'Restricted Space', + organizationId, + type: SpaceType.restricted, + }); + + const privateSpace = spaceFactory({ + id: createSpaceId('space-4'), + name: 'Secret Space', + organizationId, + type: SpaceType.private, + }); + + const defaultSpace = spaceFactory({ + id: createSpaceId('space-default'), + name: 'Global', + organizationId, + type: SpaceType.open, + isDefaultSpace: true, + }); + + beforeEach(() => { + spacesPort.listUserSpaces.mockResolvedValue({ + spaces: [defaultSpace, memberSpace], + }); + spacesPort.listSpacesByOrganization.mockResolvedValue([ + defaultSpace, + memberSpace, + openSpace, + restrictedSpace, + privateSpace, + ]); + }); + + it('returns user spaces in mySpaces', async () => { + const result = await useCase.execute(buildCommand()); + + expect(result.mySpaces).toEqual([defaultSpace, memberSpace]); + }); + + it('excludes user member spaces from allSpaces', async () => { + const result = await useCase.execute(buildCommand()); + + expect(result.allSpaces).toEqual([ + { + id: openSpace.id, + name: openSpace.name, + slug: openSpace.slug, + type: SpaceType.open, + color: openSpace.color, + }, + { + id: restrictedSpace.id, + name: restrictedSpace.name, + slug: restrictedSpace.slug, + type: SpaceType.restricted, + color: restrictedSpace.color, + }, + ]); + }); + + it('excludes private spaces from allSpaces', async () => { + const result = await useCase.execute(buildCommand()); + + const privateIds = result.allSpaces.filter( + (s) => s.type === SpaceType.private, + ); + expect(privateIds).toEqual([]); + }); + + it('excludes default space from allSpaces', async () => { + const result = await useCase.execute(buildCommand()); + + const defaultIds = result.allSpaces.filter( + (s) => s.id === defaultSpace.id, + ); + expect(defaultIds).toEqual([]); + }); + }); + + describe('when the user only belongs to a private space', () => { + const memberPrivateSpace = spaceFactory({ + id: createSpaceId('space-1'), + name: 'Only Space', + organizationId, + type: SpaceType.private, + }); + + beforeEach(() => { + spacesPort.listUserSpaces.mockResolvedValue({ + spaces: [memberPrivateSpace], + }); + spacesPort.listSpacesByOrganization.mockResolvedValue([ + memberPrivateSpace, + ]); + }); + + it('returns empty allSpaces', async () => { + const result = await useCase.execute(buildCommand()); + + expect(result.allSpaces).toEqual([]); + }); + }); + + describe('when there are no discoverable spaces', () => { + const defaultSpace = spaceFactory({ + id: createSpaceId('space-default'), + name: 'Global', + organizationId, + type: SpaceType.open, + isDefaultSpace: true, + }); + + beforeEach(() => { + spacesPort.listUserSpaces.mockResolvedValue({ + spaces: [defaultSpace], + }); + spacesPort.listSpacesByOrganization.mockResolvedValue([defaultSpace]); + }); + + it('returns empty allSpaces', async () => { + const result = await useCase.execute(buildCommand()); + + expect(result.allSpaces).toEqual([]); + }); + }); + + describe('when the user is an organization admin', () => { + const adminUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + + const memberSpace = spaceFactory({ + id: createSpaceId('space-1'), + name: 'My Team', + organizationId, + type: SpaceType.open, + }); + + const privateSpace = spaceFactory({ + id: createSpaceId('space-4'), + name: 'Secret Space', + organizationId, + type: SpaceType.private, + }); + + const defaultSpace = spaceFactory({ + id: createSpaceId('space-default'), + name: 'Global', + organizationId, + type: SpaceType.open, + isDefaultSpace: true, + }); + + beforeEach(() => { + accountsPort.getUserById.mockResolvedValue(adminUser); + + spacesPort.listUserSpaces.mockResolvedValue({ + spaces: [defaultSpace, memberSpace], + }); + spacesPort.listSpacesByOrganization.mockResolvedValue([ + defaultSpace, + memberSpace, + privateSpace, + ]); + }); + + it('includes private spaces the admin is not a member of', async () => { + const result = await useCase.execute(buildCommand()); + + expect(result.allSpaces).toEqual([ + { + id: privateSpace.id, + name: privateSpace.name, + slug: privateSpace.slug, + type: SpaceType.private, + color: privateSpace.color, + }, + ]); + }); + + it('still excludes default space from allSpaces', async () => { + const result = await useCase.execute(buildCommand()); + + const defaultIds = result.allSpaces.filter( + (s) => s.id === defaultSpace.id, + ); + expect(defaultIds).toEqual([]); + }); + }); +}); diff --git a/packages/spaces-management/src/application/usecases/BrowseSpacesUseCase.ts b/packages/spaces-management/src/application/usecases/BrowseSpacesUseCase.ts new file mode 100644 index 000000000..252e528c8 --- /dev/null +++ b/packages/spaces-management/src/application/usecases/BrowseSpacesUseCase.ts @@ -0,0 +1,56 @@ +import { AbstractMemberUseCase, MemberContext } from '@packmind/node-utils'; +import { + BrowseSpacesCommand, + BrowseSpacesResponse, + BrowsableSpace, + createOrganizationId, + IAccountsPort, + ISpacesPort, + SpaceType, +} from '@packmind/types'; + +export class BrowseSpacesUseCase extends AbstractMemberUseCase< + BrowseSpacesCommand, + BrowseSpacesResponse +> { + constructor( + accountsPort: IAccountsPort, + private readonly spacesPort: ISpacesPort, + ) { + super(accountsPort); + } + + protected async executeForMembers( + command: BrowseSpacesCommand & MemberContext, + ): Promise<BrowseSpacesResponse> { + const organizationId = createOrganizationId(command.organizationId); + + const [userSpacesResponse, allOrgSpaces] = await Promise.all([ + this.spacesPort.listUserSpaces(command), + this.spacesPort.listSpacesByOrganization(organizationId), + ]); + + const isOrgAdmin = command.membership.role === 'admin'; + const userSpaceIds = new Set(userSpacesResponse.spaces.map((s) => s.id)); + + const allSpaces: BrowsableSpace[] = allOrgSpaces + .filter( + (space) => + !space.isDefaultSpace && + !userSpaceIds.has(space.id) && + (isOrgAdmin || space.type !== SpaceType.private), + ) + .map((space) => ({ + id: space.id, + name: space.name, + slug: space.slug, + type: space.type, + color: space.color, + })); + + return { + mySpaces: userSpacesResponse.spaces, + allSpaces, + }; + } +} diff --git a/packages/spaces-management/src/application/usecases/CreateSpaceUseCase.spec.ts b/packages/spaces-management/src/application/usecases/CreateSpaceUseCase.spec.ts new file mode 100644 index 000000000..e7cfa47d4 --- /dev/null +++ b/packages/spaces-management/src/application/usecases/CreateSpaceUseCase.spec.ts @@ -0,0 +1,230 @@ +import { + CreateSpaceCommand, + createOrganizationId, + createUserId, + IAccountsPort, + ISpacesPort, + SpaceType, + UserSpaceRole, +} from '@packmind/types'; +import { OrganizationAdminRequiredError } from '@packmind/node-utils'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { SpaceSlugConflictError } from '@packmind/spaces'; +import { CreateSpaceUseCase } from './CreateSpaceUseCase'; + +describe('CreateSpaceUseCase', () => { + const organizationId = createOrganizationId('org-1'); + const userId = createUserId('user-1'); + + let useCase: CreateSpaceUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked< + Pick<ISpacesPort, 'createSpace' | 'addSpaceMembership'> + >; + + const createdSpace = spaceFactory({ + organizationId, + name: 'My Space', + isDefaultSpace: false, + }); + + const buildCommand = ( + overrides?: Partial<CreateSpaceCommand>, + ): CreateSpaceCommand => ({ + userId: userId as unknown as string, + organizationId: organizationId as unknown as string, + name: 'My Space', + ...overrides, + }); + + beforeEach(() => { + spacesPort = { + createSpace: jest.fn().mockResolvedValue(createdSpace), + addSpaceMembership: jest.fn().mockResolvedValue({ + userId, + spaceId: createdSpace.id, + role: UserSpaceRole.ADMIN, + }), + }; + }); + + afterEach(() => jest.clearAllMocks()); + + describe('when called by an organization admin', () => { + const orgAdminUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + const organization = organizationFactory({ id: organizationId }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(orgAdminUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + useCase = new CreateSpaceUseCase( + spacesPort as unknown as ISpacesPort, + accountsPort, + ); + }); + + describe('when type is open', () => { + it('creates an open space', async () => { + await useCase.execute(buildCommand({ type: SpaceType.open })); + + expect(spacesPort.createSpace).toHaveBeenCalledWith( + expect.objectContaining({ + type: SpaceType.open, + }), + ); + }); + }); + + describe('when type is restricted', () => { + it('creates a restricted space', async () => { + await useCase.execute(buildCommand({ type: SpaceType.restricted })); + + expect(spacesPort.createSpace).toHaveBeenCalledWith( + expect.objectContaining({ + type: SpaceType.restricted, + }), + ); + }); + }); + + describe('when no type provided', () => { + it('defaults to private type', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.createSpace).toHaveBeenCalledWith( + expect.objectContaining({ + type: SpaceType.private, + }), + ); + }); + }); + + it('adds the creator as admin member of the space', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.addSpaceMembership).toHaveBeenCalledWith({ + userId, + spaceId: createdSpace.id, + role: UserSpaceRole.ADMIN, + createdBy: userId, + }); + }); + + it('returns the created space', async () => { + const result = await useCase.execute(buildCommand()); + + expect(result).toEqual(createdSpace); + }); + }); + + describe('when called by a non-admin member', () => { + const memberUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + const organization = organizationFactory({ id: organizationId }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(memberUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + useCase = new CreateSpaceUseCase( + spacesPort as unknown as ISpacesPort, + accountsPort, + ); + }); + + describe('when no type provided', () => { + it('creates a private space', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.createSpace).toHaveBeenCalledWith( + expect.objectContaining({ + type: SpaceType.private, + }), + ); + }); + }); + + describe('when type is open', () => { + it('throws OrganizationAdminRequiredError', async () => { + await expect( + useCase.execute(buildCommand({ type: SpaceType.open })), + ).rejects.toThrow(OrganizationAdminRequiredError); + }); + + it('does not create the space', async () => { + await useCase + .execute(buildCommand({ type: SpaceType.open })) + .catch(() => undefined); + + expect(spacesPort.createSpace).not.toHaveBeenCalled(); + }); + }); + + describe('when type is restricted', () => { + it('throws OrganizationAdminRequiredError', async () => { + await expect( + useCase.execute(buildCommand({ type: SpaceType.restricted })), + ).rejects.toThrow(OrganizationAdminRequiredError); + }); + }); + + it('adds the creator as admin member of the space', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.addSpaceMembership).toHaveBeenCalledWith({ + userId, + spaceId: createdSpace.id, + role: UserSpaceRole.ADMIN, + createdBy: userId, + }); + }); + }); + + describe('when a space with the same slug already exists', () => { + const orgAdminUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + const organization = organizationFactory({ id: organizationId }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(orgAdminUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort.createSpace.mockRejectedValue( + new SpaceSlugConflictError('My Space', organizationId), + ); + + useCase = new CreateSpaceUseCase( + spacesPort as unknown as ISpacesPort, + accountsPort, + ); + }); + + it('propagates the SpaceSlugConflictError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceSlugConflictError, + ); + }); + + it('does not add a membership', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(spacesPort.addSpaceMembership).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/spaces-management/src/application/usecases/CreateSpaceUseCase.ts b/packages/spaces-management/src/application/usecases/CreateSpaceUseCase.ts new file mode 100644 index 000000000..6826db13d --- /dev/null +++ b/packages/spaces-management/src/application/usecases/CreateSpaceUseCase.ts @@ -0,0 +1,61 @@ +import { + AbstractMemberUseCase, + MemberContext, + OrganizationAdminRequiredError, +} from '@packmind/node-utils'; +import { PackmindLogger } from '@packmind/logger'; +import { + CreateSpaceCommand, + CreateSpaceResponse, + createUserId, + IAccountsPort, + ISpacesPort, + SpaceType, + UserSpaceRole, +} from '@packmind/types'; + +export class CreateSpaceUseCase extends AbstractMemberUseCase< + CreateSpaceCommand, + CreateSpaceResponse +> { + constructor( + private readonly spacesPort: ISpacesPort, + accountsPort: IAccountsPort, + protected override readonly logger: PackmindLogger = new PackmindLogger( + 'CreateSpaceUseCase', + ), + ) { + super(accountsPort); + } + + protected async executeForMembers( + command: CreateSpaceCommand & MemberContext, + ): Promise<CreateSpaceResponse> { + const requestedType = command.type ?? SpaceType.private; + + if ( + requestedType !== SpaceType.private && + command.membership.role !== 'admin' + ) { + throw new OrganizationAdminRequiredError({ + userId: command.userId, + organizationId: command.organizationId, + }); + } + + const space = await this.spacesPort.createSpace({ + ...command, + type: requestedType, + }); + + const userId = createUserId(command.userId); + await this.spacesPort.addSpaceMembership({ + userId, + spaceId: space.id, + role: UserSpaceRole.ADMIN, + createdBy: userId, + }); + + return space; + } +} diff --git a/packages/spaces-management/src/application/usecases/DeleteSpaceUseCase.spec.ts b/packages/spaces-management/src/application/usecases/DeleteSpaceUseCase.spec.ts new file mode 100644 index 000000000..5573858bb --- /dev/null +++ b/packages/spaces-management/src/application/usecases/DeleteSpaceUseCase.spec.ts @@ -0,0 +1,376 @@ +import { + createOrganizationId, + createSpaceId, + createUserId, + DeleteSpaceCommand, + IAccountsPort, + ISpacesPort, + UserSpaceRole, +} from '@packmind/types'; +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { SpaceNotFoundError } from '@packmind/spaces'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { CannotDeleteDefaultSpaceError } from '../../domain/errors/CannotDeleteDefaultSpaceError'; +import { SpaceDeletionForbiddenError } from '../../domain/errors/SpaceDeletionForbiddenError'; +import { DeleteSpaceUseCase } from './DeleteSpaceUseCase'; + +describe('DeleteSpaceUseCase', () => { + const organizationId = createOrganizationId('org-1'); + const userId = createUserId('user-1'); + const spaceId = createSpaceId('space-1'); + + let useCase: DeleteSpaceUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked< + Pick< + ISpacesPort, + | 'getSpaceById' + | 'findMembership' + | 'softDeleteMembershipsBySpaceId' + | 'deleteSpace' + > + >; + let eventEmitterService: jest.Mocked< + Pick<PackmindEventEmitterService, 'emit'> + >; + + const existingSpace = spaceFactory({ + id: spaceId, + organizationId, + isDefaultSpace: false, + name: 'My Space', + slug: 'my-space', + }); + + const buildCommand = ( + overrides?: Partial<DeleteSpaceCommand>, + ): DeleteSpaceCommand => ({ + userId: userId as unknown as string, + organizationId: organizationId as unknown as string, + spaceId: spaceId as unknown as string, + ...overrides, + }); + + beforeEach(() => { + spacesPort = { + getSpaceById: jest.fn(), + findMembership: jest.fn(), + softDeleteMembershipsBySpaceId: jest.fn().mockResolvedValue(3), + deleteSpace: jest.fn().mockResolvedValue(undefined), + }; + + eventEmitterService = { + emit: jest.fn().mockReturnValue(true), + }; + }); + + afterEach(() => jest.clearAllMocks()); + + describe('when called by an organization admin', () => { + const orgAdminUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + const organization = organizationFactory({ id: organizationId }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(orgAdminUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort.getSpaceById.mockResolvedValue(existingSpace); + + useCase = new DeleteSpaceUseCase( + accountsPort, + spacesPort as unknown as ISpacesPort, + eventEmitterService as unknown as PackmindEventEmitterService, + ); + }); + + it('soft-deletes all memberships for the space', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.softDeleteMembershipsBySpaceId).toHaveBeenCalledWith( + spaceId, + userId, + ); + }); + + it('soft-deletes the space', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.deleteSpace).toHaveBeenCalledWith(spaceId, userId); + }); + + it('emits a SpaceDeletedEvent', async () => { + await useCase.execute(buildCommand()); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + spaceId, + spaceName: existingSpace.name, + spaceSlug: existingSpace.slug, + }), + }), + ); + }); + + it('does not check space membership', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.findMembership).not.toHaveBeenCalled(); + }); + }); + + describe('when called by a space admin who is not an org admin', () => { + const memberUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + const organization = organizationFactory({ id: organizationId }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(memberUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort.getSpaceById.mockResolvedValue(existingSpace); + spacesPort.findMembership.mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.ADMIN, + createdBy: userId, + updatedBy: userId, + }); + + useCase = new DeleteSpaceUseCase( + accountsPort, + spacesPort as unknown as ISpacesPort, + eventEmitterService as unknown as PackmindEventEmitterService, + ); + }); + + it('soft-deletes all memberships for the space', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.softDeleteMembershipsBySpaceId).toHaveBeenCalledWith( + spaceId, + userId, + ); + }); + + it('soft-deletes the space', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.deleteSpace).toHaveBeenCalledWith(spaceId, userId); + }); + + it('emits a SpaceDeletedEvent', async () => { + await useCase.execute(buildCommand()); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + spaceId, + spaceName: existingSpace.name, + spaceSlug: existingSpace.slug, + }), + }), + ); + }); + + it('checks space membership', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.findMembership).toHaveBeenCalledWith(userId, spaceId); + }); + }); + + describe('when the space is the default space', () => { + const defaultSpace = spaceFactory({ + id: spaceId, + organizationId, + isDefaultSpace: true, + }); + + const orgAdminUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + const organization = organizationFactory({ id: organizationId }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(orgAdminUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort.getSpaceById.mockResolvedValue(defaultSpace); + + useCase = new DeleteSpaceUseCase( + accountsPort, + spacesPort as unknown as ISpacesPort, + eventEmitterService as unknown as PackmindEventEmitterService, + ); + }); + + it('throws CannotDeleteDefaultSpaceError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + CannotDeleteDefaultSpaceError, + ); + }); + + it('does not call deleteSpace', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(spacesPort.deleteSpace).not.toHaveBeenCalled(); + }); + + it('does not emit any event', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(eventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + + describe('when the space is not found', () => { + const orgAdminUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + const organization = organizationFactory({ id: organizationId }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(orgAdminUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort.getSpaceById.mockResolvedValue(null); + + useCase = new DeleteSpaceUseCase( + accountsPort, + spacesPort as unknown as ISpacesPort, + eventEmitterService as unknown as PackmindEventEmitterService, + ); + }); + + it('throws SpaceNotFoundError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceNotFoundError, + ); + }); + }); + + describe('when the space belongs to a different organization', () => { + const spaceFromOtherOrg = spaceFactory({ + id: spaceId, + organizationId: createOrganizationId('other-org'), + isDefaultSpace: false, + }); + + const orgAdminUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + const organization = organizationFactory({ id: organizationId }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(orgAdminUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort.getSpaceById.mockResolvedValue(spaceFromOtherOrg); + + useCase = new DeleteSpaceUseCase( + accountsPort, + spacesPort as unknown as ISpacesPort, + eventEmitterService as unknown as PackmindEventEmitterService, + ); + }); + + it('throws SpaceNotFoundError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceNotFoundError, + ); + }); + }); + + describe('when the user is neither org admin nor space admin', () => { + const memberUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + const organization = organizationFactory({ id: organizationId }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(memberUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort.getSpaceById.mockResolvedValue(existingSpace); + spacesPort.findMembership.mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + createdBy: userId, + updatedBy: userId, + }); + + useCase = new DeleteSpaceUseCase( + accountsPort, + spacesPort as unknown as ISpacesPort, + eventEmitterService as unknown as PackmindEventEmitterService, + ); + }); + + it('throws SpaceDeletionForbiddenError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceDeletionForbiddenError, + ); + }); + + it('does not call deleteSpace', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(spacesPort.deleteSpace).not.toHaveBeenCalled(); + }); + }); + + describe('when the user is an org member but not a space member', () => { + const memberUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + const organization = organizationFactory({ id: organizationId }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(memberUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort.getSpaceById.mockResolvedValue(existingSpace); + spacesPort.findMembership.mockResolvedValue(null); + + useCase = new DeleteSpaceUseCase( + accountsPort, + spacesPort as unknown as ISpacesPort, + eventEmitterService as unknown as PackmindEventEmitterService, + ); + }); + + it('throws SpaceDeletionForbiddenError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceDeletionForbiddenError, + ); + }); + }); +}); diff --git a/packages/spaces-management/src/application/usecases/DeleteSpaceUseCase.ts b/packages/spaces-management/src/application/usecases/DeleteSpaceUseCase.ts new file mode 100644 index 000000000..baad975cc --- /dev/null +++ b/packages/spaces-management/src/application/usecases/DeleteSpaceUseCase.ts @@ -0,0 +1,83 @@ +import { + AbstractMemberUseCase, + MemberContext, + PackmindEventEmitterService, +} from '@packmind/node-utils'; +import { PackmindLogger } from '@packmind/logger'; +import { + createOrganizationId, + createSpaceId, + createUserId, + DeleteSpaceCommand, + DeleteSpaceResponse, + IAccountsPort, + ISpacesPort, + SpaceDeletedEvent, + UserSpaceRole, +} from '@packmind/types'; +import { SpaceNotFoundError } from '@packmind/spaces'; +import { CannotDeleteDefaultSpaceError } from '../../domain/errors/CannotDeleteDefaultSpaceError'; +import { SpaceDeletionForbiddenError } from '../../domain/errors/SpaceDeletionForbiddenError'; + +export class DeleteSpaceUseCase extends AbstractMemberUseCase< + DeleteSpaceCommand, + DeleteSpaceResponse +> { + constructor( + accountsPort: IAccountsPort, + private readonly spacesPort: ISpacesPort, + private readonly eventEmitterService: PackmindEventEmitterService, + protected override readonly logger: PackmindLogger = new PackmindLogger( + 'DeleteSpaceUseCase', + ), + ) { + super(accountsPort); + } + + protected async executeForMembers( + command: DeleteSpaceCommand & MemberContext, + ): Promise<DeleteSpaceResponse> { + const spaceId = createSpaceId(command.spaceId); + const organizationId = createOrganizationId(command.organizationId); + const userId = createUserId(command.userId); + + const space = await this.spacesPort.getSpaceById(spaceId); + + if (!space || space.organizationId !== organizationId) { + throw new SpaceNotFoundError(spaceId); + } + + if (space.isDefaultSpace) { + throw new CannotDeleteDefaultSpaceError(spaceId); + } + + const isOrgAdmin = command.membership.role === 'admin'; + + if (!isOrgAdmin) { + const spaceMembership = await this.spacesPort.findMembership( + userId, + spaceId, + ); + + if (spaceMembership?.role !== UserSpaceRole.ADMIN) { + throw new SpaceDeletionForbiddenError(command.userId, command.spaceId); + } + } + + await this.spacesPort.softDeleteMembershipsBySpaceId(spaceId, userId); + await this.spacesPort.deleteSpace(spaceId, userId); + + this.eventEmitterService.emit( + new SpaceDeletedEvent({ + userId, + organizationId, + source: command.source ?? 'ui', + spaceId, + spaceName: space.name, + spaceSlug: space.slug, + }), + ); + + return {} as DeleteSpaceResponse; + } +} diff --git a/packages/spaces-management/src/application/usecases/JoinSpaceUseCase.spec.ts b/packages/spaces-management/src/application/usecases/JoinSpaceUseCase.spec.ts new file mode 100644 index 000000000..e49268ccf --- /dev/null +++ b/packages/spaces-management/src/application/usecases/JoinSpaceUseCase.spec.ts @@ -0,0 +1,324 @@ +import { PackmindEventEmitterService } from '@packmind/node-utils'; +import { + createOrganizationId, + createSpaceId, + createUserId, + IAccountsPort, + ISpacesPort, + JoinSpaceCommand, + SpaceType, + UserSpaceRole, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { SpaceNotFoundError } from '@packmind/spaces'; +import { JoinSpaceUseCase } from './JoinSpaceUseCase'; +import { SpaceNotJoinableError } from '../../domain/errors/SpaceNotJoinableError'; + +describe('JoinSpaceUseCase', () => { + const organizationId = createOrganizationId('org-1'); + const userId = createUserId('user-1'); + const spaceId = createSpaceId('space-1'); + + const organization = organizationFactory({ id: organizationId }); + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + + let useCase: JoinSpaceUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked< + Pick<ISpacesPort, 'getSpaceById' | 'findMembership' | 'addSpaceMembership'> + >; + let eventEmitterService: jest.Mocked< + Pick<PackmindEventEmitterService, 'emit'> + >; + + const buildCommand = ( + overrides?: Partial<JoinSpaceCommand>, + ): JoinSpaceCommand => ({ + userId: userId as unknown as string, + organizationId: organizationId as unknown as string, + spaceId: spaceId as unknown as string, + ...overrides, + }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(user), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort = { + getSpaceById: jest.fn(), + findMembership: jest.fn(), + addSpaceMembership: jest.fn(), + }; + + eventEmitterService = { + emit: jest.fn().mockReturnValue(true), + }; + + useCase = new JoinSpaceUseCase( + accountsPort, + spacesPort as unknown as ISpacesPort, + eventEmitterService as unknown as PackmindEventEmitterService, + ); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('when joining an open space successfully', () => { + const openSpace = spaceFactory({ + id: spaceId, + name: 'Open Space', + organizationId, + type: SpaceType.open, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(openSpace); + spacesPort.findMembership.mockResolvedValue(null); + spacesPort.addSpaceMembership.mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + createdBy: userId, + }); + }); + + it('adds the user as a member', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.addSpaceMembership).toHaveBeenCalledWith({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + createdBy: userId, + }); + }); + + it('emits SpaceMembersAddedEvent with the joining user', async () => { + await useCase.execute(buildCommand()); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + spaceId, + memberUserIds: [userId], + }), + }), + ); + }); + }); + + describe('when the space does not exist', () => { + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(null); + }); + + it('throws SpaceNotFoundError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceNotFoundError, + ); + }); + + it('does not add a membership', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + expect(spacesPort.addSpaceMembership).not.toHaveBeenCalled(); + }); + }); + + describe('when the space belongs to another organization', () => { + const otherOrgSpace = spaceFactory({ + id: spaceId, + name: 'Other Org Space', + organizationId: createOrganizationId('other-org'), + type: SpaceType.open, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(otherOrgSpace); + }); + + it('throws SpaceNotFoundError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceNotFoundError, + ); + }); + }); + + describe('when the space is private', () => { + const privateSpace = spaceFactory({ + id: spaceId, + name: 'Private Space', + organizationId, + type: SpaceType.private, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(privateSpace); + }); + + it('throws SpaceNotFoundError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceNotFoundError, + ); + }); + }); + + describe('when the space is restricted', () => { + const restrictedSpace = spaceFactory({ + id: spaceId, + name: 'Restricted Space', + organizationId, + type: SpaceType.restricted, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(restrictedSpace); + }); + + it('throws SpaceNotJoinableError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceNotJoinableError, + ); + }); + }); + + describe('when the user is already a member', () => { + const openSpace = spaceFactory({ + id: spaceId, + name: 'Open Space', + organizationId, + type: SpaceType.open, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(openSpace); + spacesPort.findMembership.mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + createdBy: userId, + }); + }); + + it('does not add a duplicate membership', async () => { + await useCase.execute(buildCommand()); + expect(spacesPort.addSpaceMembership).not.toHaveBeenCalled(); + }); + + it('does not emit SpaceMembersAddedEvent', async () => { + await useCase.execute(buildCommand()); + + expect(eventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + + describe('when the user is an organization admin', () => { + const adminUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + + beforeEach(() => { + accountsPort.getUserById.mockResolvedValue(adminUser); + }); + + describe('when joining a private space', () => { + const privateSpace = spaceFactory({ + id: spaceId, + name: 'Private Space', + organizationId, + type: SpaceType.private, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(privateSpace); + spacesPort.findMembership.mockResolvedValue(null); + spacesPort.addSpaceMembership.mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.ADMIN, + createdBy: userId, + }); + }); + + it('adds the user as an admin', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.addSpaceMembership).toHaveBeenCalledWith({ + userId, + spaceId, + role: UserSpaceRole.ADMIN, + createdBy: userId, + }); + }); + }); + + describe('when joining a restricted space', () => { + const restrictedSpace = spaceFactory({ + id: spaceId, + name: 'Restricted Space', + organizationId, + type: SpaceType.restricted, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(restrictedSpace); + spacesPort.findMembership.mockResolvedValue(null); + spacesPort.addSpaceMembership.mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.ADMIN, + createdBy: userId, + }); + }); + + it('adds the user as an admin', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.addSpaceMembership).toHaveBeenCalledWith({ + userId, + spaceId, + role: UserSpaceRole.ADMIN, + createdBy: userId, + }); + }); + }); + + describe('when joining an open space', () => { + const openSpace = spaceFactory({ + id: spaceId, + name: 'Open Space', + organizationId, + type: SpaceType.open, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(openSpace); + spacesPort.findMembership.mockResolvedValue(null); + spacesPort.addSpaceMembership.mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.ADMIN, + createdBy: userId, + }); + }); + + it('adds the user as an admin', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.addSpaceMembership).toHaveBeenCalledWith({ + userId, + spaceId, + role: UserSpaceRole.ADMIN, + createdBy: userId, + }); + }); + }); + }); +}); diff --git a/packages/spaces-management/src/application/usecases/JoinSpaceUseCase.ts b/packages/spaces-management/src/application/usecases/JoinSpaceUseCase.ts new file mode 100644 index 000000000..958b929de --- /dev/null +++ b/packages/spaces-management/src/application/usecases/JoinSpaceUseCase.ts @@ -0,0 +1,85 @@ +import { + AbstractMemberUseCase, + MemberContext, + PackmindEventEmitterService, +} from '@packmind/node-utils'; +import { + createOrganizationId, + createSpaceId, + createUserId, + IAccountsPort, + ISpacesPort, + JoinSpaceCommand, + JoinSpaceResponse, + SpaceMembersAddedEvent, + SpaceType, + UserSpaceRole, +} from '@packmind/types'; +import { SpaceNotFoundError } from '@packmind/spaces'; +import { SpaceNotJoinableError } from '../../domain/errors/SpaceNotJoinableError'; + +export class JoinSpaceUseCase extends AbstractMemberUseCase< + JoinSpaceCommand, + JoinSpaceResponse +> { + constructor( + accountsPort: IAccountsPort, + private readonly spacesPort: ISpacesPort, + private readonly eventEmitterService: PackmindEventEmitterService, + ) { + super(accountsPort); + } + + protected async executeForMembers( + command: JoinSpaceCommand & MemberContext, + ): Promise<JoinSpaceResponse> { + const spaceId = createSpaceId(command.spaceId); + const userId = createUserId(command.userId); + const organizationId = createOrganizationId(command.organizationId); + const isOrgAdmin = command.membership.role === 'admin'; + + const space = await this.spacesPort.getSpaceById(spaceId); + + if (!space || space.organizationId !== organizationId) { + throw new SpaceNotFoundError(spaceId); + } + + if (!isOrgAdmin) { + if (space.type === SpaceType.private) { + throw new SpaceNotFoundError(spaceId); + } + + if (space.type === SpaceType.restricted) { + throw new SpaceNotJoinableError(spaceId); + } + } + + const existingMembership = await this.spacesPort.findMembership( + userId, + spaceId, + ); + + if (existingMembership) { + return {} as JoinSpaceResponse; + } + + await this.spacesPort.addSpaceMembership({ + userId, + spaceId, + role: isOrgAdmin ? UserSpaceRole.ADMIN : UserSpaceRole.MEMBER, + createdBy: userId, + }); + + this.eventEmitterService.emit( + new SpaceMembersAddedEvent({ + userId, + organizationId, + source: command.source ?? 'ui', + spaceId, + memberUserIds: [userId], + }), + ); + + return {} as JoinSpaceResponse; + } +} diff --git a/packages/spaces-management/src/application/usecases/LeaveSpaceUseCase.spec.ts b/packages/spaces-management/src/application/usecases/LeaveSpaceUseCase.spec.ts new file mode 100644 index 000000000..67cf5aea3 --- /dev/null +++ b/packages/spaces-management/src/application/usecases/LeaveSpaceUseCase.spec.ts @@ -0,0 +1,237 @@ +import { + PackmindEventEmitterService, + SpaceMembershipRequiredError, +} from '@packmind/node-utils'; +import { + createOrganizationId, + createSpaceId, + createUserId, + IAccountsPort, + ISpacesPort, + LeaveSpaceCommand, + UserSpaceRole, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { SpaceNotFoundError } from '@packmind/spaces'; +import { LeaveSpaceUseCase } from './LeaveSpaceUseCase'; +import { CannotLeaveDefaultSpaceError } from '../../domain/errors/CannotLeaveDefaultSpaceError'; + +describe('LeaveSpaceUseCase', () => { + const organizationId = createOrganizationId('org-1'); + const userId = createUserId('user-1'); + const spaceId = createSpaceId('space-1'); + + const organization = organizationFactory({ id: organizationId }); + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + + let useCase: LeaveSpaceUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked< + Pick< + ISpacesPort, + 'getSpaceById' | 'findMembership' | 'removeSpaceMembership' + > + >; + let eventEmitterService: jest.Mocked< + Pick<PackmindEventEmitterService, 'emit'> + >; + + const buildCommand = ( + overrides?: Partial<LeaveSpaceCommand>, + ): LeaveSpaceCommand => ({ + userId: userId as unknown as string, + organizationId: organizationId as unknown as string, + spaceId, + ...overrides, + }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(user), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort = { + getSpaceById: jest.fn(), + findMembership: jest.fn().mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + createdBy: userId, + }), + removeSpaceMembership: jest.fn().mockResolvedValue(true), + }; + + eventEmitterService = { + emit: jest.fn().mockReturnValue(true), + }; + + useCase = new LeaveSpaceUseCase( + spacesPort as unknown as ISpacesPort, + accountsPort, + eventEmitterService as unknown as PackmindEventEmitterService, + ); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('when leaving a non-default space as a member', () => { + const space = spaceFactory({ + id: spaceId, + name: 'My Space', + organizationId, + isDefaultSpace: false, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(space); + }); + + it('removes the membership', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.removeSpaceMembership).toHaveBeenCalledWith( + userId, + spaceId, + ); + }); + + it('emits SpaceMembersRemovedEvent', async () => { + await useCase.execute(buildCommand()); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + spaceId, + memberUserIds: [userId], + }), + }), + ); + }); + }); + + describe('when leaving a non-default space as an admin', () => { + const adminUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + + const space = spaceFactory({ + id: spaceId, + name: 'My Space', + organizationId, + isDefaultSpace: false, + }); + + beforeEach(() => { + accountsPort.getUserById.mockResolvedValue(adminUser); + spacesPort.getSpaceById.mockResolvedValue(space); + spacesPort.findMembership.mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.ADMIN, + createdBy: userId, + }); + }); + + it('removes the membership', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.removeSpaceMembership).toHaveBeenCalledWith( + userId, + spaceId, + ); + }); + }); + + describe('when the space does not exist', () => { + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(null); + }); + + it('throws SpaceNotFoundError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceNotFoundError, + ); + }); + + it('does not remove a membership', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(spacesPort.removeSpaceMembership).not.toHaveBeenCalled(); + }); + }); + + describe('when the space belongs to another organization', () => { + const otherOrgSpace = spaceFactory({ + id: spaceId, + name: 'Other Org Space', + organizationId: createOrganizationId('other-org'), + isDefaultSpace: false, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(otherOrgSpace); + }); + + it('throws SpaceNotFoundError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceNotFoundError, + ); + }); + }); + + describe('when the space is the default space', () => { + const defaultSpace = spaceFactory({ + id: spaceId, + name: 'Default Space', + organizationId, + isDefaultSpace: true, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(defaultSpace); + }); + + it('throws CannotLeaveDefaultSpaceError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + CannotLeaveDefaultSpaceError, + ); + }); + + it('does not remove a membership', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(spacesPort.removeSpaceMembership).not.toHaveBeenCalled(); + }); + }); + + describe('when the user is not a member of the space', () => { + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue(null); + }); + + it('throws SpaceMembershipRequiredError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toBeInstanceOf( + SpaceMembershipRequiredError, + ); + }); + + it('does not attempt to remove a membership', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(spacesPort.removeSpaceMembership).not.toHaveBeenCalled(); + }); + + it('does not emit an event', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(eventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/spaces-management/src/application/usecases/LeaveSpaceUseCase.ts b/packages/spaces-management/src/application/usecases/LeaveSpaceUseCase.ts new file mode 100644 index 000000000..ea69c3a2b --- /dev/null +++ b/packages/spaces-management/src/application/usecases/LeaveSpaceUseCase.ts @@ -0,0 +1,61 @@ +import { + AbstractSpaceMemberUseCase, + PackmindEventEmitterService, + SpaceMemberContext, +} from '@packmind/node-utils'; +import { + createOrganizationId, + createUserId, + IAccountsPort, + ISpacesPort, + LeaveSpaceCommand, + LeaveSpaceResponse, + SpaceMembersRemovedEvent, +} from '@packmind/types'; +import { SpaceNotFoundError } from '@packmind/spaces'; +import { CannotLeaveDefaultSpaceError } from '../../domain/errors/CannotLeaveDefaultSpaceError'; + +export class LeaveSpaceUseCase extends AbstractSpaceMemberUseCase< + LeaveSpaceCommand, + LeaveSpaceResponse +> { + constructor( + spacesPort: ISpacesPort, + accountsPort: IAccountsPort, + private readonly eventEmitterService: PackmindEventEmitterService, + ) { + super(spacesPort, accountsPort); + } + + protected async executeForSpaceMembers( + command: LeaveSpaceCommand & SpaceMemberContext, + ): Promise<LeaveSpaceResponse> { + const { spaceId } = command; + const userId = createUserId(command.userId); + const organizationId = createOrganizationId(command.organizationId); + + const space = await this.spacesPort.getSpaceById(spaceId); + + if (!space || space.organizationId !== organizationId) { + throw new SpaceNotFoundError(spaceId); + } + + if (space.isDefaultSpace) { + throw new CannotLeaveDefaultSpaceError(spaceId); + } + + await this.spacesPort.removeSpaceMembership(userId, spaceId); + + this.eventEmitterService.emit( + new SpaceMembersRemovedEvent({ + userId, + organizationId, + source: command.source ?? 'ui', + spaceId, + memberUserIds: [userId], + }), + ); + + return {} as LeaveSpaceResponse; + } +} diff --git a/packages/spaces-management/src/application/usecases/ListOrganizationSpacesForManagementUseCase.spec.ts b/packages/spaces-management/src/application/usecases/ListOrganizationSpacesForManagementUseCase.spec.ts new file mode 100644 index 000000000..1cb90b5cf --- /dev/null +++ b/packages/spaces-management/src/application/usecases/ListOrganizationSpacesForManagementUseCase.spec.ts @@ -0,0 +1,448 @@ +import { + createOrganizationId, + createSpaceId, + createUserId, + IAccountsPort, + IRecipesPort, + ISkillsPort, + ISpacesPort, + IStandardsPort, + ListOrganizationSpacesForManagementCommand, + ORGA_SPACE_MANAGEMENT_PAGE_SIZE, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { ListOrganizationSpacesForManagementUseCase } from './ListOrganizationSpacesForManagementUseCase'; +import { InvalidPageError } from '../../domain/errors/InvalidPageError'; + +describe('ListOrganizationSpacesForManagementUseCase', () => { + const organizationId = createOrganizationId('org-1'); + const userId = createUserId('user-1'); + + const organization = organizationFactory({ id: organizationId }); + const orgAdminUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + const orgMemberUser = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + + let useCase: ListOrganizationSpacesForManagementUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked< + Pick< + ISpacesPort, + | 'findOrgPagePaginated' + | 'findAdminsForSpaceIds' + | 'countUsersForSpaceIds' + | 'findMemberIdsForSpaceIds' + > + >; + let standardsPort: jest.Mocked<Pick<IStandardsPort, 'countBySpaceIds'>>; + let recipesPort: jest.Mocked<Pick<IRecipesPort, 'countBySpaceIds'>>; + let skillsPort: jest.Mocked<Pick<ISkillsPort, 'countBySpaceIds'>>; + + const buildCommand = ( + overrides?: Partial<ListOrganizationSpacesForManagementCommand>, + ): ListOrganizationSpacesForManagementCommand => ({ + userId: userId as unknown as string, + organizationId: organizationId as unknown as string, + page: 1, + ...overrides, + }); + + const buildUseCase = (): ListOrganizationSpacesForManagementUseCase => + new ListOrganizationSpacesForManagementUseCase( + accountsPort, + spacesPort as unknown as ISpacesPort, + standardsPort as unknown as IStandardsPort, + recipesPort as unknown as IRecipesPort, + skillsPort as unknown as ISkillsPort, + ); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(orgAdminUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort = { + findOrgPagePaginated: jest + .fn() + .mockResolvedValue({ items: [], totalCount: 0 }), + findAdminsForSpaceIds: jest.fn().mockResolvedValue([]), + countUsersForSpaceIds: jest.fn().mockResolvedValue(new Map()), + findMemberIdsForSpaceIds: jest.fn().mockResolvedValue(new Map()), + }; + + standardsPort = { + countBySpaceIds: jest.fn().mockResolvedValue(new Map()), + }; + recipesPort = { + countBySpaceIds: jest.fn().mockResolvedValue(new Map()), + }; + skillsPort = { + countBySpaceIds: jest.fn().mockResolvedValue(new Map()), + }; + + useCase = buildUseCase(); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('input validation', () => { + describe('when page < 1', () => { + it('throws InvalidPageError', async () => { + await expect( + useCase.execute(buildCommand({ page: 0 })), + ).rejects.toThrow(InvalidPageError); + }); + }); + + describe('when page is negative', () => { + it('throws InvalidPageError', async () => { + await expect( + useCase.execute(buildCommand({ page: -3 })), + ).rejects.toThrow(InvalidPageError); + }); + }); + + describe('when page is not an integer', () => { + it('throws InvalidPageError', async () => { + await expect( + useCase.execute(buildCommand({ page: 1.5 })), + ).rejects.toThrow(InvalidPageError); + }); + }); + + describe('when validation fails', () => { + it('does not call the spaces port', async () => { + await useCase.execute(buildCommand({ page: 0 })).catch(() => undefined); + + expect(spacesPort.findOrgPagePaginated).not.toHaveBeenCalled(); + }); + }); + }); + + describe('authorization', () => { + it('rejects non-admin callers via AbstractAdminUseCase', async () => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(orgMemberUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + useCase = buildUseCase(); + + await expect(useCase.execute(buildCommand())).rejects.toThrow(/admin/i); + }); + + describe('when caller is not an admin', () => { + it('does not query spaces', async () => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(orgMemberUser), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + useCase = buildUseCase(); + + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(spacesPort.findOrgPagePaginated).not.toHaveBeenCalled(); + }); + }); + }); + + describe('empty page', () => { + describe('when page is past the last page', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + spacesPort.findOrgPagePaginated.mockResolvedValue({ + items: [], + totalCount: 32, + }); + result = await useCase.execute(buildCommand({ page: 99 })); + }); + + it('returns empty items', () => { + expect(result.items).toEqual([]); + }); + + it('returns the real totalCount', () => { + expect(result.totalCount).toBe(32); + }); + + it('returns the requested page number', () => { + expect(result.page).toBe(99); + }); + + it('returns ORGA_SPACE_MANAGEMENT_PAGE_SIZE', () => { + expect(result.pageSize).toBe(ORGA_SPACE_MANAGEMENT_PAGE_SIZE); + }); + }); + + describe('when there are no spaces on the page', () => { + beforeEach(async () => { + spacesPort.findOrgPagePaginated.mockResolvedValue({ + items: [], + totalCount: 0, + }); + await useCase.execute(buildCommand()); + }); + + it('does not query findAdminsForSpaceIds', () => { + expect(spacesPort.findAdminsForSpaceIds).not.toHaveBeenCalled(); + }); + + it('does not query countUsersForSpaceIds', () => { + expect(spacesPort.countUsersForSpaceIds).not.toHaveBeenCalled(); + }); + + it('does not query findMemberIdsForSpaceIds', () => { + expect(spacesPort.findMemberIdsForSpaceIds).not.toHaveBeenCalled(); + }); + + it('does not query standardsPort.countBySpaceIds', () => { + expect(standardsPort.countBySpaceIds).not.toHaveBeenCalled(); + }); + + it('does not query recipesPort.countBySpaceIds', () => { + expect(recipesPort.countBySpaceIds).not.toHaveBeenCalled(); + }); + + it('does not query skillsPort.countBySpaceIds', () => { + expect(skillsPort.countBySpaceIds).not.toHaveBeenCalled(); + }); + }); + }); + + describe('aggregation stitching', () => { + const space1 = spaceFactory({ + id: createSpaceId('space-1'), + organizationId, + isDefaultSpace: true, + name: 'Default Space', + slug: 'default-space', + }); + const space2 = spaceFactory({ + id: createSpaceId('space-2'), + organizationId, + name: 'Team Space', + slug: 'team-space', + }); + + beforeEach(() => { + spacesPort.findOrgPagePaginated.mockResolvedValue({ + items: [space1, space2], + totalCount: 2, + }); + }); + + describe('when stitching admins and counts per space', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + spacesPort.findAdminsForSpaceIds.mockResolvedValue([ + { + spaceId: space1.id, + user: { id: createUserId('u1'), displayName: 'Alice' }, + }, + { + spaceId: space2.id, + user: { id: createUserId('u2'), displayName: 'Bob' }, + }, + { + spaceId: space2.id, + user: { id: createUserId('u3'), displayName: 'Carol' }, + }, + ]); + spacesPort.countUsersForSpaceIds.mockResolvedValue( + new Map([[space2.id, 7]]), + ); + standardsPort.countBySpaceIds.mockResolvedValue( + new Map([[space1.id, 4]]), + ); + recipesPort.countBySpaceIds.mockResolvedValue( + new Map([[space2.id, 3]]), + ); + skillsPort.countBySpaceIds.mockResolvedValue(new Map()); + + result = await useCase.execute(buildCommand()); + }); + + it('returns two items', () => { + expect(result.items).toHaveLength(2); + }); + + it('sets the correct admins for space1', () => { + expect( + result.items[0].admins.map((admin) => admin.displayName), + ).toEqual(['Alice']); + }); + + it('defaults membersCount to 0 for space1 (missing in map)', () => { + expect(result.items[0].membersCount).toBe(0); + }); + + it('sets the correct artifactsCount for space1', () => { + expect(result.items[0].artifactsCount).toBe(4); + }); + + it('sets the correct admins for space2', () => { + expect( + result.items[1].admins.map((admin) => admin.displayName).sort(), + ).toEqual(['Bob', 'Carol']); + }); + + it('sets the correct membersCount for space2', () => { + expect(result.items[1].membersCount).toBe(7); + }); + + it('sets the correct artifactsCount for space2', () => { + expect(result.items[1].artifactsCount).toBe(3); + }); + }); + + it('preserves the underlying space fields in the response items', async () => { + const result = await useCase.execute(buildCommand()); + + expect(result.items[0]).toMatchObject({ + id: space1.id, + name: space1.name, + slug: space1.slug, + organizationId: space1.organizationId, + isDefaultSpace: true, + }); + }); + + describe('when returning pagination metadata', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + spacesPort.findOrgPagePaginated.mockResolvedValue({ + items: [space1, space2], + totalCount: 17, + }); + result = await useCase.execute(buildCommand({ page: 2 })); + }); + + it('returns the correct totalCount', () => { + expect(result.totalCount).toBe(17); + }); + + it('returns the requested page', () => { + expect(result.page).toBe(2); + }); + + it('returns ORGA_SPACE_MANAGEMENT_PAGE_SIZE', () => { + expect(result.pageSize).toBe(ORGA_SPACE_MANAGEMENT_PAGE_SIZE); + }); + }); + + describe('when passing spaceIds to aggregation ports', () => { + let singleId: ReturnType<typeof createSpaceId>; + + beforeEach(async () => { + const single = spaceFactory({ + id: createSpaceId('only'), + organizationId, + }); + singleId = single.id; + spacesPort.findOrgPagePaginated.mockResolvedValue({ + items: [single], + totalCount: 1, + }); + + await useCase.execute(buildCommand()); + }); + + it('passes the spaceId to findAdminsForSpaceIds', () => { + expect(spacesPort.findAdminsForSpaceIds).toHaveBeenCalledWith([ + singleId, + ]); + }); + + it('passes the spaceId to countUsersForSpaceIds', () => { + expect(spacesPort.countUsersForSpaceIds).toHaveBeenCalledWith([ + singleId, + ]); + }); + + it('passes the spaceId to findMemberIdsForSpaceIds', () => { + expect(spacesPort.findMemberIdsForSpaceIds).toHaveBeenCalledWith([ + singleId, + ]); + }); + + it('passes the spaceId to standardsPort.countBySpaceIds', () => { + expect(standardsPort.countBySpaceIds).toHaveBeenCalledWith([singleId]); + }); + + it('passes the spaceId to recipesPort.countBySpaceIds', () => { + expect(recipesPort.countBySpaceIds).toHaveBeenCalledWith([singleId]); + }); + + it('passes the spaceId to skillsPort.countBySpaceIds', () => { + expect(skillsPort.countBySpaceIds).toHaveBeenCalledWith([singleId]); + }); + }); + + it('forwards organizationId, page, and ORGA_SPACE_MANAGEMENT_PAGE_SIZE to findOrgPagePaginated', async () => { + await useCase.execute(buildCommand({ page: 4 })); + + expect(spacesPort.findOrgPagePaginated).toHaveBeenCalledWith( + organizationId, + 4, + ORGA_SPACE_MANAGEMENT_PAGE_SIZE, + ); + }); + + describe('when including memberIds per space', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + let userId1: ReturnType<typeof createUserId>; + let userId2: ReturnType<typeof createUserId>; + let userId3: ReturnType<typeof createUserId>; + + beforeEach(async () => { + userId1 = createUserId('member-1'); + userId2 = createUserId('member-2'); + userId3 = createUserId('member-3'); + + spacesPort.findMemberIdsForSpaceIds.mockResolvedValue( + new Map([ + [space1.id, [userId1, userId2]], + [space2.id, [userId3]], + ]), + ); + + result = await useCase.execute(buildCommand()); + }); + + it('returns the correct memberIds for space1', () => { + expect(result.items[0].memberIds).toEqual([userId1, userId2]); + }); + + it('returns the correct memberIds for space2', () => { + expect(result.items[1].memberIds).toEqual([userId3]); + }); + }); + + describe('when space has no members', () => { + let result: Awaited<ReturnType<typeof useCase.execute>>; + + beforeEach(async () => { + spacesPort.findMemberIdsForSpaceIds.mockResolvedValue(new Map()); + result = await useCase.execute(buildCommand()); + }); + + it('defaults space1 memberIds to empty array', () => { + expect(result.items[0].memberIds).toEqual([]); + }); + + it('defaults space2 memberIds to empty array', () => { + expect(result.items[1].memberIds).toEqual([]); + }); + }); + }); +}); diff --git a/packages/spaces-management/src/application/usecases/ListOrganizationSpacesForManagementUseCase.ts b/packages/spaces-management/src/application/usecases/ListOrganizationSpacesForManagementUseCase.ts new file mode 100644 index 000000000..c9bd44c51 --- /dev/null +++ b/packages/spaces-management/src/application/usecases/ListOrganizationSpacesForManagementUseCase.ts @@ -0,0 +1,112 @@ +import { AbstractAdminUseCase, AdminContext } from '@packmind/node-utils'; +import { PackmindLogger } from '@packmind/logger'; +import { + createOrganizationId, + IAccountsPort, + IRecipesPort, + ISkillsPort, + ISpacesPort, + IStandardsPort, + ListOrganizationSpacesForManagementCommand, + ListOrganizationSpacesForManagementResponse, + ORGA_SPACE_MANAGEMENT_PAGE_SIZE, + SpaceId, + SpaceManagementListItem, + SpaceManagementListItemAdmin, +} from '@packmind/types'; +import { InvalidPageError } from '../../domain/errors/InvalidPageError'; + +const origin = 'ListOrganizationSpacesForManagementUseCase'; + +export class ListOrganizationSpacesForManagementUseCase extends AbstractAdminUseCase< + ListOrganizationSpacesForManagementCommand, + ListOrganizationSpacesForManagementResponse +> { + constructor( + accountsPort: IAccountsPort, + private readonly spacesPort: ISpacesPort, + private readonly standardsPort: IStandardsPort, + private readonly recipesPort: IRecipesPort, + private readonly skillsPort: ISkillsPort, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForAdmins( + command: ListOrganizationSpacesForManagementCommand & AdminContext, + ): Promise<ListOrganizationSpacesForManagementResponse> { + const { organizationId, page } = command; + + if (!Number.isInteger(page) || page < 1) { + throw new InvalidPageError(page); + } + + const brandedOrganizationId = createOrganizationId(organizationId); + const { items: spaces, totalCount } = + await this.spacesPort.findOrgPagePaginated( + brandedOrganizationId, + page, + ORGA_SPACE_MANAGEMENT_PAGE_SIZE, + ); + + if (spaces.length === 0) { + return { + items: [], + totalCount, + page, + pageSize: ORGA_SPACE_MANAGEMENT_PAGE_SIZE, + }; + } + + const spaceIds = spaces.map((space) => space.id); + + const [ + adminRows, + memberCounts, + memberIdsBySpace, + standardsCounts, + recipesCounts, + skillsCounts, + ] = await Promise.all([ + this.spacesPort.findAdminsForSpaceIds(spaceIds), + this.spacesPort.countUsersForSpaceIds(spaceIds), + this.spacesPort.findMemberIdsForSpaceIds(spaceIds), + this.standardsPort.countBySpaceIds(spaceIds), + this.recipesPort.countBySpaceIds(spaceIds), + this.skillsPort.countBySpaceIds(spaceIds), + ]); + + const adminsBySpace = new Map<SpaceId, SpaceManagementListItemAdmin[]>(); + for (const row of adminRows) { + const list = adminsBySpace.get(row.spaceId) ?? []; + list.push({ id: row.user.id, displayName: row.user.displayName }); + adminsBySpace.set(row.spaceId, list); + } + + const items: SpaceManagementListItem[] = spaces.map((space) => ({ + ...space, + admins: adminsBySpace.get(space.id) ?? [], + memberIds: memberIdsBySpace.get(space.id) ?? [], + membersCount: memberCounts.get(space.id) ?? 0, + artifactsCount: + (standardsCounts.get(space.id) ?? 0) + + (recipesCounts.get(space.id) ?? 0) + + (skillsCounts.get(space.id) ?? 0), + })); + + this.logger.info('Listed organization spaces for management', { + organizationId, + page, + itemsCount: items.length, + totalCount, + }); + + return { + items, + totalCount, + page, + pageSize: ORGA_SPACE_MANAGEMENT_PAGE_SIZE, + }; + } +} diff --git a/packages/spaces-management/src/application/usecases/MoveArtifactsToSpaceUseCase.spec.ts b/packages/spaces-management/src/application/usecases/MoveArtifactsToSpaceUseCase.spec.ts new file mode 100644 index 000000000..eaa038005 --- /dev/null +++ b/packages/spaces-management/src/application/usecases/MoveArtifactsToSpaceUseCase.spec.ts @@ -0,0 +1,1201 @@ +import { + createOrganizationId, + createRecipeId, + createSkillId, + createSpaceId, + createStandardId, + createUserId, + IAccountsPort, + IRecipesPort, + ISkillsPort, + ISpacesPort, + IStandardsPort, + MoveArtifactsToSpaceCommand, + StandardDeletedEvent, +} from '@packmind/types'; +import { + PackmindEventEmitterService, + SpaceMembershipRequiredError, +} from '@packmind/node-utils'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { standardFactory } from '@packmind/standards/test/standardFactory'; +import { skillFactory } from '@packmind/skills/test/skillFactory'; +import { recipeFactory } from '@packmind/recipes/test/recipeFactory'; +import { stubLogger } from '@packmind/test-utils'; +import { SpaceNotFoundError } from '@packmind/spaces'; +import { ArtifactNameConflictError } from '../../domain/errors/ArtifactNameConflictError'; +import { ArtifactNotInSourceSpaceError } from '../../domain/errors/ArtifactNotInSourceSpaceError'; +import { ArtifactSlugConflictError } from '../../domain/errors/ArtifactSlugConflictError'; +import { SpaceOwnershipMismatchError } from '../../domain/errors/SpaceOwnershipMismatchError'; +import { MoveArtifactsToSpaceUseCase } from './MoveArtifactsToSpaceUseCase'; + +describe('MoveArtifactsToSpaceUseCase', () => { + const organizationId = createOrganizationId('organization-id'); + const userId = createUserId('user-id'); + const sourceSpaceId = createSpaceId('source-space-id'); + const destinationSpaceId = createSpaceId('destination-space-id'); + + const organization = organizationFactory({ id: organizationId }); + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + + const sourceSpace = spaceFactory({ + id: sourceSpaceId, + organizationId, + name: 'Source Space', + }); + const destinationSpace = spaceFactory({ + id: destinationSpaceId, + organizationId, + name: 'Destination Space', + }); + + let useCase: MoveArtifactsToSpaceUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked<ISpacesPort>; + let standardsPort: jest.Mocked<IStandardsPort>; + let skillsPort: jest.Mocked<ISkillsPort>; + let recipesPort: jest.Mocked<IRecipesPort>; + let eventEmitterService: jest.Mocked< + Pick<PackmindEventEmitterService, 'emit'> + >; + + const buildCommand = ( + overrides?: Partial<MoveArtifactsToSpaceCommand>, + ): MoveArtifactsToSpaceCommand => ({ + userId: userId as unknown as string, + organizationId: organizationId as unknown as string, + sourceSpaceId, + destinationSpaceId, + artifacts: [], + ...overrides, + }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(user), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort = { + getSpaceById: jest.fn().mockImplementation((id) => { + if (id === sourceSpaceId) return Promise.resolve(sourceSpace); + if (id === destinationSpaceId) return Promise.resolve(destinationSpace); + return Promise.resolve(null); + }), + findMembership: jest + .fn() + .mockResolvedValue({ userId, spaceId: sourceSpaceId, role: 'member' }), + } as unknown as jest.Mocked<ISpacesPort>; + + standardsPort = { + markStandardAsMoved: jest.fn().mockResolvedValue(undefined), + duplicateStandardToSpace: jest.fn().mockResolvedValue({ + standard: { id: createStandardId('new-standard-id') }, + ruleMappings: [], + }), + getStandard: jest + .fn() + .mockImplementation((id) => + Promise.resolve(standardFactory({ id, spaceId: sourceSpaceId })), + ), + listStandardsBySpace: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked<IStandardsPort>; + + skillsPort = { + markSkillAsMoved: jest.fn().mockResolvedValue(undefined), + duplicateSkillToSpace: jest + .fn() + .mockResolvedValue({ id: createSkillId('new-skill-id') }), + getSkill: jest + .fn() + .mockImplementation((id) => + Promise.resolve(skillFactory({ id, spaceId: sourceSpaceId })), + ), + listSkillsBySpace: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked<ISkillsPort>; + + recipesPort = { + markRecipeAsMoved: jest.fn().mockResolvedValue(undefined), + duplicateRecipeToSpace: jest + .fn() + .mockResolvedValue({ id: createRecipeId('new-recipe-id') }), + getRecipeByIdInternal: jest + .fn() + .mockImplementation((id) => + Promise.resolve(recipeFactory({ id, spaceId: sourceSpaceId })), + ), + listRecipesBySpace: jest.fn().mockResolvedValue([]), + } as unknown as jest.Mocked<IRecipesPort>; + + eventEmitterService = { + emit: jest.fn().mockReturnValue(true), + }; + + useCase = new MoveArtifactsToSpaceUseCase( + accountsPort, + spacesPort, + standardsPort, + skillsPort, + recipesPort, + eventEmitterService as unknown as PackmindEventEmitterService, + stubLogger(), + ); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('execute', () => { + describe('when the user is not a member of the organization', () => { + beforeEach(() => { + accountsPort.getUserById.mockResolvedValue( + userFactory({ id: userId, memberships: [] }), + ); + }); + + it('throws an access error', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow(); + }); + }); + + describe('when the source space does not exist', () => { + beforeEach(() => { + spacesPort.getSpaceById.mockImplementation((id) => { + if (id === destinationSpaceId) + return Promise.resolve(destinationSpace); + return Promise.resolve(null); + }); + }); + + it('throws SpaceNotFoundError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceNotFoundError, + ); + }); + }); + + describe('when the source space belongs to a different organization', () => { + const otherOrgId = createOrganizationId('other-org-id'); + + beforeEach(() => { + spacesPort.getSpaceById.mockImplementation((id) => { + if (id === sourceSpaceId) + return Promise.resolve( + spaceFactory({ + id: sourceSpaceId, + organizationId: otherOrgId, + }), + ); + if (id === destinationSpaceId) + return Promise.resolve(destinationSpace); + return Promise.resolve(null); + }); + }); + + it('throws SpaceOwnershipMismatchError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceOwnershipMismatchError, + ); + }); + }); + + describe('when the destination space does not exist', () => { + beforeEach(() => { + spacesPort.getSpaceById.mockImplementation((id) => { + if (id === sourceSpaceId) return Promise.resolve(sourceSpace); + return Promise.resolve(null); + }); + }); + + it('throws SpaceNotFoundError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceNotFoundError, + ); + }); + }); + + describe('when the destination space belongs to a different organization', () => { + const otherOrgId = createOrganizationId('other-org-id'); + + beforeEach(() => { + spacesPort.getSpaceById.mockImplementation((id) => { + if (id === sourceSpaceId) return Promise.resolve(sourceSpace); + if (id === destinationSpaceId) + return Promise.resolve( + spaceFactory({ + id: destinationSpaceId, + organizationId: otherOrgId, + }), + ); + return Promise.resolve(null); + }); + }); + + it('throws SpaceOwnershipMismatchError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceOwnershipMismatchError, + ); + }); + }); + + describe('when moving standards', () => { + const standardId1 = createStandardId('standard-1'); + const standardId2 = createStandardId('standard-2'); + + it('returns the correct moved count', async () => { + const result = await useCase.execute( + buildCommand({ + artifacts: [ + { id: standardId1, type: 'standard' }, + { id: standardId2, type: 'standard' }, + ], + }), + ); + + expect(result).toEqual({ movedCount: 2 }); + }); + + it('duplicates before marking the original as moved', async () => { + const callOrder: string[] = []; + standardsPort.markStandardAsMoved.mockImplementation(async () => { + callOrder.push('markAsMoved'); + }); + standardsPort.duplicateStandardToSpace.mockImplementation(async () => { + callOrder.push('duplicate'); + return { + standard: { id: createStandardId('new-standard-id') }, + ruleMappings: [], + } as never; + }); + + await useCase.execute( + buildCommand({ artifacts: [{ id: standardId1, type: 'standard' }] }), + ); + + expect(callOrder).toEqual(['duplicate', 'markAsMoved']); + }); + + it('calls markStandardAsMoved with the correct parameters', async () => { + await useCase.execute( + buildCommand({ artifacts: [{ id: standardId1, type: 'standard' }] }), + ); + + expect(standardsPort.markStandardAsMoved).toHaveBeenCalledWith( + standardId1, + destinationSpaceId, + ); + }); + + it('calls duplicateStandardToSpace with the correct parameters', async () => { + await useCase.execute( + buildCommand({ artifacts: [{ id: standardId1, type: 'standard' }] }), + ); + + expect(standardsPort.duplicateStandardToSpace).toHaveBeenCalledWith( + standardId1, + destinationSpaceId, + userId, + ); + }); + + describe('when emitting events for moved standards', () => { + beforeEach(async () => { + await useCase.execute( + buildCommand({ + artifacts: [ + { id: standardId1, type: 'standard' }, + { id: standardId2, type: 'standard' }, + ], + }), + ); + }); + + it('emits two events per standard (moved + deleted)', () => { + expect(eventEmitterService.emit).toHaveBeenCalledTimes(4); + }); + + it('emits a PlaybookArtefactMovedEvent with standard artifact type', () => { + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + artifactType: 'standard', + oldArtifactId: standardId1, + newArtifactId: createStandardId('new-standard-id'), + sourceSpaceId, + destinationSpaceId, + userId, + organizationId, + }), + }), + ); + }); + + it('emits a StandardDeletedEvent for each moved standard', () => { + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.any(StandardDeletedEvent), + ); + }); + }); + + it('emits a StandardDeletedEvent with the source space', async () => { + await useCase.execute( + buildCommand({ artifacts: [{ id: standardId1, type: 'standard' }] }), + ); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + standardId: standardId1, + spaceId: sourceSpaceId, + userId, + organizationId, + }), + }), + ); + }); + }); + + describe('when moving skills', () => { + const skillId1 = createSkillId('skill-1'); + const skillId2 = createSkillId('skill-2'); + + it('returns the correct moved count', async () => { + const result = await useCase.execute( + buildCommand({ + artifacts: [ + { id: skillId1, type: 'skill' }, + { id: skillId2, type: 'skill' }, + ], + }), + ); + + expect(result).toEqual({ movedCount: 2 }); + }); + + it('duplicates before marking the original as moved', async () => { + const callOrder: string[] = []; + skillsPort.markSkillAsMoved.mockImplementation(async () => { + callOrder.push('markAsMoved'); + }); + skillsPort.duplicateSkillToSpace.mockImplementation(async () => { + callOrder.push('duplicate'); + return {} as never; + }); + + await useCase.execute( + buildCommand({ artifacts: [{ id: skillId1, type: 'skill' }] }), + ); + + expect(callOrder).toEqual(['duplicate', 'markAsMoved']); + }); + + it('calls markSkillAsMoved with the correct parameters', async () => { + await useCase.execute( + buildCommand({ artifacts: [{ id: skillId1, type: 'skill' }] }), + ); + + expect(skillsPort.markSkillAsMoved).toHaveBeenCalledWith( + skillId1, + destinationSpaceId, + ); + }); + + it('calls duplicateSkillToSpace with the correct parameters', async () => { + await useCase.execute( + buildCommand({ artifacts: [{ id: skillId1, type: 'skill' }] }), + ); + + expect(skillsPort.duplicateSkillToSpace).toHaveBeenCalledWith( + skillId1, + destinationSpaceId, + userId, + ); + }); + + it('emits a PlaybookArtefactMovedEvent with artifactType skill', async () => { + await useCase.execute( + buildCommand({ artifacts: [{ id: skillId1, type: 'skill' }] }), + ); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + artifactType: 'skill', + oldArtifactId: skillId1, + newArtifactId: createSkillId('new-skill-id'), + sourceSpaceId, + destinationSpaceId, + userId, + organizationId, + }), + }), + ); + }); + + it('emits a SkillDeletedEvent with the source space', async () => { + await useCase.execute( + buildCommand({ artifacts: [{ id: skillId1, type: 'skill' }] }), + ); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + skillId: skillId1, + spaceId: sourceSpaceId, + userId, + organizationId, + }), + }), + ); + }); + }); + + describe('when moving recipes', () => { + const recipeId1 = createRecipeId('recipe-1'); + const recipeId2 = createRecipeId('recipe-2'); + + it('returns the correct moved count', async () => { + const result = await useCase.execute( + buildCommand({ + artifacts: [ + { id: recipeId1, type: 'command' }, + { id: recipeId2, type: 'command' }, + ], + }), + ); + + expect(result).toEqual({ movedCount: 2 }); + }); + + it('duplicates before marking the original as moved', async () => { + const callOrder: string[] = []; + recipesPort.markRecipeAsMoved.mockImplementation(async () => { + callOrder.push('markAsMoved'); + }); + recipesPort.duplicateRecipeToSpace.mockImplementation(async () => { + callOrder.push('duplicate'); + return {} as never; + }); + + await useCase.execute( + buildCommand({ artifacts: [{ id: recipeId1, type: 'command' }] }), + ); + + expect(callOrder).toEqual(['duplicate', 'markAsMoved']); + }); + + it('calls markRecipeAsMoved with the correct parameters', async () => { + await useCase.execute( + buildCommand({ artifacts: [{ id: recipeId1, type: 'command' }] }), + ); + + expect(recipesPort.markRecipeAsMoved).toHaveBeenCalledWith( + recipeId1, + destinationSpaceId, + ); + }); + + it('calls duplicateRecipeToSpace with the correct parameters', async () => { + await useCase.execute( + buildCommand({ artifacts: [{ id: recipeId1, type: 'command' }] }), + ); + + expect(recipesPort.duplicateRecipeToSpace).toHaveBeenCalledWith( + recipeId1, + destinationSpaceId, + userId, + ); + }); + + it('emits a PlaybookArtefactMovedEvent with artifactType command', async () => { + await useCase.execute( + buildCommand({ artifacts: [{ id: recipeId1, type: 'command' }] }), + ); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + artifactType: 'command', + oldArtifactId: recipeId1, + newArtifactId: createRecipeId('new-recipe-id'), + sourceSpaceId, + destinationSpaceId, + userId, + organizationId, + }), + }), + ); + }); + + it('emits a CommandDeletedEvent with the source space', async () => { + await useCase.execute( + buildCommand({ artifacts: [{ id: recipeId1, type: 'command' }] }), + ); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + id: recipeId1, + spaceId: sourceSpaceId, + userId, + organizationId, + }), + }), + ); + }); + }); + + describe('when moving multiple artifact types at once', () => { + const standardId = createStandardId('standard-1'); + const skillId = createSkillId('skill-1'); + const recipeId = createRecipeId('recipe-1'); + + it('returns the total moved count across all types', async () => { + const result = await useCase.execute( + buildCommand({ + artifacts: [ + { id: standardId, type: 'standard' }, + { id: skillId, type: 'skill' }, + { id: recipeId, type: 'command' }, + ], + }), + ); + + expect(result).toEqual({ movedCount: 3 }); + }); + + it('emits two events per artifact (moved + deleted)', async () => { + await useCase.execute( + buildCommand({ + artifacts: [ + { id: standardId, type: 'standard' }, + { id: skillId, type: 'skill' }, + { id: recipeId, type: 'command' }, + ], + }), + ); + + expect(eventEmitterService.emit).toHaveBeenCalledTimes(6); + }); + }); + + describe('when no artifacts are provided', () => { + it('returns zero moved count', async () => { + const result = await useCase.execute(buildCommand()); + + expect(result).toEqual({ movedCount: 0 }); + }); + + it('does not emit any events', async () => { + await useCase.execute(buildCommand()); + + expect(eventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + + describe('when a standard with the same slug already exists in the destination space', () => { + const standardId = createStandardId('standard-to-move'); + const conflictingSlug = 'git-commit-guidelines'; + + beforeEach(() => { + standardsPort.getStandard.mockResolvedValue( + standardFactory({ + id: standardId, + slug: conflictingSlug, + name: 'My Git Guidelines', + spaceId: sourceSpaceId, + }), + ); + standardsPort.listStandardsBySpace.mockResolvedValue([ + standardFactory({ + slug: conflictingSlug, + name: 'Git commit guidelines', + spaceId: destinationSpaceId, + }), + ]); + }); + + it('throws ArtifactSlugConflictError', async () => { + await expect( + useCase.execute( + buildCommand({ + artifacts: [{ id: standardId, type: 'standard' }], + }), + ), + ).rejects.toThrow(ArtifactSlugConflictError); + }); + + describe('when the move is attempted', () => { + beforeEach(async () => { + await useCase + .execute( + buildCommand({ + artifacts: [{ id: standardId, type: 'standard' }], + }), + ) + .catch(() => { + /* expected */ + }); + }); + + it('does not duplicate the standard', () => { + expect(standardsPort.duplicateStandardToSpace).not.toHaveBeenCalled(); + }); + + it('does not mark the standard as moved', () => { + expect(standardsPort.markStandardAsMoved).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when a skill with the same slug already exists in the destination space', () => { + const skillId = createSkillId('skill-to-move'); + const conflictingSlug = 'commit'; + + beforeEach(() => { + skillsPort.getSkill.mockResolvedValue( + skillFactory({ + id: skillId, + slug: conflictingSlug, + name: 'Commit Skill', + spaceId: sourceSpaceId, + }), + ); + skillsPort.listSkillsBySpace.mockResolvedValue([ + skillFactory({ + slug: conflictingSlug, + name: 'commit', + spaceId: destinationSpaceId, + }), + ]); + }); + + it('throws ArtifactSlugConflictError', async () => { + await expect( + useCase.execute( + buildCommand({ + artifacts: [{ id: skillId, type: 'skill' }], + }), + ), + ).rejects.toThrow(ArtifactSlugConflictError); + }); + + describe('when the move is attempted', () => { + beforeEach(async () => { + await useCase + .execute( + buildCommand({ + artifacts: [{ id: skillId, type: 'skill' }], + }), + ) + .catch(() => { + /* expected */ + }); + }); + + it('does not duplicate the skill', () => { + expect(skillsPort.duplicateSkillToSpace).not.toHaveBeenCalled(); + }); + + it('does not mark the skill as moved', () => { + expect(skillsPort.markSkillAsMoved).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when a command with the same slug already exists in the destination space', () => { + const recipeId = createRecipeId('recipe-to-move'); + const conflictingSlug = 'release-cli'; + + beforeEach(() => { + recipesPort.getRecipeByIdInternal.mockResolvedValue( + recipeFactory({ + id: recipeId, + slug: conflictingSlug, + name: 'Release CLI Command', + spaceId: sourceSpaceId, + }), + ); + recipesPort.listRecipesBySpace.mockResolvedValue([ + recipeFactory({ + slug: conflictingSlug, + name: 'release-cli', + spaceId: destinationSpaceId, + }), + ]); + }); + + it('throws ArtifactSlugConflictError', async () => { + await expect( + useCase.execute( + buildCommand({ + artifacts: [{ id: recipeId, type: 'command' }], + }), + ), + ).rejects.toThrow(ArtifactSlugConflictError); + }); + + describe('when the move is attempted', () => { + beforeEach(async () => { + await useCase + .execute( + buildCommand({ + artifacts: [{ id: recipeId, type: 'command' }], + }), + ) + .catch(() => { + /* expected */ + }); + }); + + it('does not duplicate the recipe', () => { + expect(recipesPort.duplicateRecipeToSpace).not.toHaveBeenCalled(); + }); + + it('does not mark the recipe as moved', () => { + expect(recipesPort.markRecipeAsMoved).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when a standard with the same name already exists in the destination space', () => { + const standardId = createStandardId('standard-to-move'); + + beforeEach(() => { + standardsPort.getStandard.mockResolvedValue( + standardFactory({ + id: standardId, + slug: 'unique-standard-slug', + name: 'Git commit guidelines', + spaceId: sourceSpaceId, + }), + ); + standardsPort.listStandardsBySpace.mockResolvedValue([ + standardFactory({ + slug: 'different-slug', + name: 'Git commit guidelines', + spaceId: destinationSpaceId, + }), + ]); + }); + + it('throws ArtifactNameConflictError', async () => { + await expect( + useCase.execute( + buildCommand({ + artifacts: [{ id: standardId, type: 'standard' }], + }), + ), + ).rejects.toThrow(ArtifactNameConflictError); + }); + + describe('when the move is attempted', () => { + beforeEach(async () => { + await useCase + .execute( + buildCommand({ + artifacts: [{ id: standardId, type: 'standard' }], + }), + ) + .catch(() => { + /* expected */ + }); + }); + + it('does not duplicate the standard', () => { + expect(standardsPort.duplicateStandardToSpace).not.toHaveBeenCalled(); + }); + + it('does not mark the standard as moved', () => { + expect(standardsPort.markStandardAsMoved).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when a skill with the same name already exists in the destination space', () => { + const skillId = createSkillId('skill-to-move'); + + beforeEach(() => { + skillsPort.getSkill.mockResolvedValue( + skillFactory({ + id: skillId, + slug: 'unique-skill-slug', + name: 'commit', + spaceId: sourceSpaceId, + }), + ); + skillsPort.listSkillsBySpace.mockResolvedValue([ + skillFactory({ + slug: 'different-slug', + name: 'commit', + spaceId: destinationSpaceId, + }), + ]); + }); + + it('throws ArtifactNameConflictError', async () => { + await expect( + useCase.execute( + buildCommand({ + artifacts: [{ id: skillId, type: 'skill' }], + }), + ), + ).rejects.toThrow(ArtifactNameConflictError); + }); + + describe('when the move is attempted', () => { + beforeEach(async () => { + await useCase + .execute( + buildCommand({ + artifacts: [{ id: skillId, type: 'skill' }], + }), + ) + .catch(() => { + /* expected */ + }); + }); + + it('does not duplicate the skill', () => { + expect(skillsPort.duplicateSkillToSpace).not.toHaveBeenCalled(); + }); + + it('does not mark the skill as moved', () => { + expect(skillsPort.markSkillAsMoved).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when a command with the same name already exists in the destination space', () => { + const recipeId = createRecipeId('recipe-to-move'); + + beforeEach(() => { + recipesPort.getRecipeByIdInternal.mockResolvedValue( + recipeFactory({ + id: recipeId, + slug: 'unique-recipe-slug', + name: 'release-cli', + spaceId: sourceSpaceId, + }), + ); + recipesPort.listRecipesBySpace.mockResolvedValue([ + recipeFactory({ + slug: 'different-slug', + name: 'release-cli', + spaceId: destinationSpaceId, + }), + ]); + }); + + it('throws ArtifactNameConflictError', async () => { + await expect( + useCase.execute( + buildCommand({ + artifacts: [{ id: recipeId, type: 'command' }], + }), + ), + ).rejects.toThrow(ArtifactNameConflictError); + }); + + describe('when the move is attempted', () => { + beforeEach(async () => { + await useCase + .execute( + buildCommand({ + artifacts: [{ id: recipeId, type: 'command' }], + }), + ) + .catch(() => { + /* expected */ + }); + }); + + it('does not duplicate the recipe', () => { + expect(recipesPort.duplicateRecipeToSpace).not.toHaveBeenCalled(); + }); + + it('does not mark the recipe as moved', () => { + expect(recipesPort.markRecipeAsMoved).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when artifacts have different slugs and names than those in the destination space', () => { + const standardId = createStandardId('standard-to-move'); + + beforeEach(() => { + standardsPort.getStandard.mockResolvedValue( + standardFactory({ + id: standardId, + slug: 'unique-standard', + name: 'Unique Standard', + spaceId: sourceSpaceId, + }), + ); + standardsPort.listStandardsBySpace.mockResolvedValue([ + standardFactory({ + slug: 'other-standard', + name: 'Other Standard', + spaceId: destinationSpaceId, + }), + ]); + }); + + it('moves the artifact successfully', async () => { + const result = await useCase.execute( + buildCommand({ + artifacts: [{ id: standardId, type: 'standard' }], + }), + ); + + expect(result).toEqual({ movedCount: 1 }); + }); + }); + + describe('when the user is not a member of the source space', () => { + beforeEach(() => { + spacesPort.findMembership.mockImplementation((_userId, spaceId) => { + if (spaceId === sourceSpaceId) return Promise.resolve(null); + return Promise.resolve({ + userId, + spaceId: destinationSpaceId, + role: 'member', + } as never); + }); + }); + + it('throws SpaceMembershipRequiredError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceMembershipRequiredError, + ); + }); + }); + + describe('when the user is not a member of the destination space', () => { + beforeEach(() => { + spacesPort.findMembership.mockImplementation((_userId, spaceId) => { + if (spaceId === destinationSpaceId) return Promise.resolve(null); + return Promise.resolve({ + userId, + spaceId: sourceSpaceId, + role: 'member', + } as never); + }); + }); + + it('throws SpaceMembershipRequiredError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceMembershipRequiredError, + ); + }); + }); + + describe('when a standard does not belong to the source space', () => { + const standardId = createStandardId('standard-wrong-space'); + + beforeEach(() => { + standardsPort.getStandard.mockResolvedValue( + standardFactory({ + id: standardId, + spaceId: createSpaceId('other-space'), + }), + ); + }); + + it('throws ArtifactNotInSourceSpaceError', async () => { + await expect( + useCase.execute( + buildCommand({ + artifacts: [{ id: standardId, type: 'standard' }], + }), + ), + ).rejects.toThrow(ArtifactNotInSourceSpaceError); + }); + + it('does not duplicate the standard', async () => { + await useCase + .execute( + buildCommand({ + artifacts: [{ id: standardId, type: 'standard' }], + }), + ) + .catch(() => { + /* expected */ + }); + + expect(standardsPort.duplicateStandardToSpace).not.toHaveBeenCalled(); + }); + + it('does not mark the standard as moved', async () => { + await useCase + .execute( + buildCommand({ + artifacts: [{ id: standardId, type: 'standard' }], + }), + ) + .catch(() => { + /* expected */ + }); + + expect(standardsPort.markStandardAsMoved).not.toHaveBeenCalled(); + }); + }); + + describe('when a skill does not belong to the source space', () => { + const skillId = createSkillId('skill-wrong-space'); + + beforeEach(() => { + skillsPort.getSkill.mockResolvedValue( + skillFactory({ + id: skillId, + spaceId: createSpaceId('other-space'), + }), + ); + }); + + it('throws ArtifactNotInSourceSpaceError', async () => { + await expect( + useCase.execute( + buildCommand({ + artifacts: [{ id: skillId, type: 'skill' }], + }), + ), + ).rejects.toThrow(ArtifactNotInSourceSpaceError); + }); + + it('does not duplicate the skill', async () => { + await useCase + .execute( + buildCommand({ + artifacts: [{ id: skillId, type: 'skill' }], + }), + ) + .catch(() => { + /* expected */ + }); + + expect(skillsPort.duplicateSkillToSpace).not.toHaveBeenCalled(); + }); + + it('does not mark the skill as moved', async () => { + await useCase + .execute( + buildCommand({ + artifacts: [{ id: skillId, type: 'skill' }], + }), + ) + .catch(() => { + /* expected */ + }); + + expect(skillsPort.markSkillAsMoved).not.toHaveBeenCalled(); + }); + }); + + describe('when a command does not belong to the source space', () => { + const recipeId = createRecipeId('recipe-wrong-space'); + + beforeEach(() => { + recipesPort.getRecipeByIdInternal.mockResolvedValue( + recipeFactory({ + id: recipeId, + spaceId: createSpaceId('other-space'), + }), + ); + }); + + it('throws ArtifactNotInSourceSpaceError', async () => { + await expect( + useCase.execute( + buildCommand({ + artifacts: [{ id: recipeId, type: 'command' }], + }), + ), + ).rejects.toThrow(ArtifactNotInSourceSpaceError); + }); + + it('does not duplicate the command', async () => { + await useCase + .execute( + buildCommand({ + artifacts: [{ id: recipeId, type: 'command' }], + }), + ) + .catch(() => { + /* expected */ + }); + + expect(recipesPort.duplicateRecipeToSpace).not.toHaveBeenCalled(); + }); + + it('does not mark the command as moved', async () => { + await useCase + .execute( + buildCommand({ + artifacts: [{ id: recipeId, type: 'command' }], + }), + ) + .catch(() => { + /* expected */ + }); + + expect(recipesPort.markRecipeAsMoved).not.toHaveBeenCalled(); + }); + }); + + describe('when one artifact out of many does not belong to the source space', () => { + const validStandardId = createStandardId('valid-standard'); + const invalidStandardId = createStandardId('invalid-standard'); + + beforeEach(() => { + standardsPort.getStandard.mockImplementation((id) => { + if (id === validStandardId) { + return Promise.resolve( + standardFactory({ id: validStandardId, spaceId: sourceSpaceId }), + ); + } + return Promise.resolve( + standardFactory({ + id: invalidStandardId, + spaceId: createSpaceId('other-space'), + }), + ); + }); + }); + + it('throws ArtifactNotInSourceSpaceError before any move starts', async () => { + await expect( + useCase.execute( + buildCommand({ + artifacts: [ + { id: validStandardId, type: 'standard' }, + { id: invalidStandardId, type: 'standard' }, + ], + }), + ), + ).rejects.toThrow(ArtifactNotInSourceSpaceError); + }); + + it('does not duplicate any artifact (not even the valid one)', async () => { + await useCase + .execute( + buildCommand({ + artifacts: [ + { id: validStandardId, type: 'standard' }, + { id: invalidStandardId, type: 'standard' }, + ], + }), + ) + .catch(() => { + /* expected */ + }); + + expect(standardsPort.duplicateStandardToSpace).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/spaces-management/src/application/usecases/MoveArtifactsToSpaceUseCase.ts b/packages/spaces-management/src/application/usecases/MoveArtifactsToSpaceUseCase.ts new file mode 100644 index 000000000..4bfa6394c --- /dev/null +++ b/packages/spaces-management/src/application/usecases/MoveArtifactsToSpaceUseCase.ts @@ -0,0 +1,399 @@ +import { PackmindLogger } from '@packmind/logger'; +import { + AbstractMemberUseCase, + MemberContext, + PackmindEventEmitterService, + SpaceMembershipRequiredError, +} from '@packmind/node-utils'; +import { + ArtifactReference, + ArtifactType, + CommandDeletedEvent, + createOrganizationId, + createUserId, + IAccountsPort, + IRecipesPort, + ISkillsPort, + ISpacesPort, + IStandardsPort, + MoveArtifactsToSpaceCommand, + MoveArtifactsToSpaceResponse, + OrganizationId, + PackmindEventSource, + PlaybookArtefactMovedEvent, + SkillDeletedEvent, + SpaceId, + StandardDeletedEvent, + UserId, +} from '@packmind/types'; +import { SpaceNotFoundError } from '@packmind/spaces'; +import { ArtifactNameConflictError } from '../../domain/errors/ArtifactNameConflictError'; +import { ArtifactNotInSourceSpaceError } from '../../domain/errors/ArtifactNotInSourceSpaceError'; +import { ArtifactSlugConflictError } from '../../domain/errors/ArtifactSlugConflictError'; +import { SpaceOwnershipMismatchError } from '../../domain/errors/SpaceOwnershipMismatchError'; + +const origin = 'MoveArtifactsToSpaceUseCase'; + +type MoveContext = { + sourceSpaceId: SpaceId; + destinationSpaceId: SpaceId; + userId: UserId; + organizationId: OrganizationId; + source: PackmindEventSource; +}; + +export class MoveArtifactsToSpaceUseCase extends AbstractMemberUseCase< + MoveArtifactsToSpaceCommand, + MoveArtifactsToSpaceResponse +> { + private readonly artifactMovers: Record< + ArtifactType, + (artifact: ArtifactReference, ctx: MoveContext) => Promise<void> + > = { + standard: (artifact, ctx) => + this.moveStandard(artifact as ArtifactReference<'standard'>, ctx), + skill: (artifact, ctx) => + this.moveSkill(artifact as ArtifactReference<'skill'>, ctx), + command: (artifact, ctx) => + this.moveRecipe(artifact as ArtifactReference<'command'>, ctx), + }; + + constructor( + accountsPort: IAccountsPort, + private readonly spacesPort: ISpacesPort, + private readonly standardsPort: IStandardsPort, + private readonly skillsPort: ISkillsPort, + private readonly recipesPort: IRecipesPort, + private readonly eventEmitterService: PackmindEventEmitterService, + logger: PackmindLogger = new PackmindLogger(origin), + ) { + super(accountsPort, logger); + } + + protected async executeForMembers( + command: MoveArtifactsToSpaceCommand & MemberContext, + ): Promise<MoveArtifactsToSpaceResponse> { + const organizationId = createOrganizationId(command.organizationId); + const userId = createUserId(command.userId); + + const sourceSpace = await this.spacesPort.getSpaceById( + command.sourceSpaceId, + ); + if (!sourceSpace) { + throw new SpaceNotFoundError(command.sourceSpaceId); + } + if (sourceSpace.organizationId !== organizationId) { + throw new SpaceOwnershipMismatchError( + command.sourceSpaceId, + organizationId, + ); + } + + const destinationSpace = await this.spacesPort.getSpaceById( + command.destinationSpaceId, + ); + if (!destinationSpace) { + throw new SpaceNotFoundError(command.destinationSpaceId); + } + if (destinationSpace.organizationId !== organizationId) { + throw new SpaceOwnershipMismatchError( + command.destinationSpaceId, + organizationId, + ); + } + + const sourceMembership = await this.spacesPort.findMembership( + userId, + command.sourceSpaceId, + ); + if (!sourceMembership) { + throw new SpaceMembershipRequiredError(userId, command.sourceSpaceId); + } + + const destMembership = await this.spacesPort.findMembership( + userId, + command.destinationSpaceId, + ); + if (!destMembership) { + throw new SpaceMembershipRequiredError( + userId, + command.destinationSpaceId, + ); + } + + await this.validateAndResolveArtifacts( + command.artifacts, + command.sourceSpaceId, + command.destinationSpaceId, + destinationSpace.name, + organizationId, + userId, + ); + + const ctx: MoveContext = { + sourceSpaceId: command.sourceSpaceId, + destinationSpaceId: command.destinationSpaceId, + userId, + organizationId, + source: command.source ?? 'ui', + }; + + let movedCount = 0; + for (const artifact of command.artifacts) { + await this.artifactMovers[artifact.type](artifact, ctx); + movedCount++; + } + + return { movedCount }; + } + + private async moveStandard( + artifact: ArtifactReference<'standard'>, + ctx: MoveContext, + ): Promise<void> { + const { standard: newStandard, ruleMappings } = + await this.standardsPort.duplicateStandardToSpace( + artifact.id, + ctx.destinationSpaceId, + ctx.userId, + ); + await this.standardsPort.markStandardAsMoved( + artifact.id, + ctx.destinationSpaceId, + ); + this.emitMovedEvent( + artifact.type, + artifact.id, + newStandard.id, + ctx, + ruleMappings, + ); + this.eventEmitterService.emit( + new StandardDeletedEvent({ + standardId: artifact.id, + spaceId: ctx.sourceSpaceId, + userId: ctx.userId, + organizationId: ctx.organizationId, + source: ctx.source, + }), + ); + } + + private async moveSkill( + artifact: ArtifactReference<'skill'>, + ctx: MoveContext, + ): Promise<void> { + const newSkill = await this.skillsPort.duplicateSkillToSpace( + artifact.id, + ctx.destinationSpaceId, + ctx.userId, + ); + await this.skillsPort.markSkillAsMoved(artifact.id, ctx.destinationSpaceId); + this.emitMovedEvent(artifact.type, artifact.id, newSkill.id, ctx); + this.eventEmitterService.emit( + new SkillDeletedEvent({ + skillId: artifact.id, + spaceId: ctx.sourceSpaceId, + userId: ctx.userId, + organizationId: ctx.organizationId, + source: ctx.source, + }), + ); + } + + private async moveRecipe( + artifact: ArtifactReference<'command'>, + ctx: MoveContext, + ): Promise<void> { + const newRecipe = await this.recipesPort.duplicateRecipeToSpace( + artifact.id, + ctx.destinationSpaceId, + ctx.userId, + ); + await this.recipesPort.markRecipeAsMoved( + artifact.id, + ctx.destinationSpaceId, + ); + this.emitMovedEvent(artifact.type, artifact.id, newRecipe.id, ctx); + this.eventEmitterService.emit( + new CommandDeletedEvent({ + id: artifact.id, + spaceId: ctx.sourceSpaceId, + userId: ctx.userId, + organizationId: ctx.organizationId, + source: ctx.source, + }), + ); + } + + private async validateAndResolveArtifacts( + artifacts: ArtifactReference[], + sourceSpaceId: SpaceId, + destinationSpaceId: SpaceId, + destinationSpaceName: string, + organizationId: OrganizationId, + userId: UserId, + ): Promise<void> { + const standardArtifacts = artifacts.filter((a) => a.type === 'standard'); + const skillArtifacts = artifacts.filter((a) => a.type === 'skill'); + const commandArtifacts = artifacts.filter((a) => a.type === 'command'); + + // Phase 1: Fetch all artifacts and validate they belong to the source space + const resolvedStandards = await Promise.all( + standardArtifacts.map((a) => + this.standardsPort.getStandard((a as ArtifactReference<'standard'>).id), + ), + ); + for (let i = 0; i < resolvedStandards.length; i++) { + const standard = resolvedStandards[i]; + if (!standard || standard.spaceId !== sourceSpaceId) { + throw new ArtifactNotInSourceSpaceError( + 'standard', + (standardArtifacts[i] as ArtifactReference<'standard'>).id, + sourceSpaceId, + ); + } + } + + const resolvedSkills = await Promise.all( + skillArtifacts.map((a) => + this.skillsPort.getSkill((a as ArtifactReference<'skill'>).id), + ), + ); + for (let i = 0; i < resolvedSkills.length; i++) { + const skill = resolvedSkills[i]; + if (!skill || skill.spaceId !== sourceSpaceId) { + throw new ArtifactNotInSourceSpaceError( + 'skill', + (skillArtifacts[i] as ArtifactReference<'skill'>).id, + sourceSpaceId, + ); + } + } + + const resolvedRecipes = await Promise.all( + commandArtifacts.map((a) => + this.recipesPort.getRecipeByIdInternal( + (a as ArtifactReference<'command'>).id, + ), + ), + ); + for (let i = 0; i < resolvedRecipes.length; i++) { + const recipe = resolvedRecipes[i]; + if (!recipe || recipe.spaceId !== sourceSpaceId) { + throw new ArtifactNotInSourceSpaceError( + 'command', + (commandArtifacts[i] as ArtifactReference<'command'>).id, + sourceSpaceId, + ); + } + } + + // Phase 2: Check name/slug conflicts against destination + if (standardArtifacts.length > 0) { + const destStandards = await this.standardsPort.listStandardsBySpace( + destinationSpaceId, + organizationId, + userId as unknown as string, + ); + const destSlugs = new Set(destStandards.map((s) => s.slug)); + const destNames = new Set(destStandards.map((s) => s.name)); + + for (const standard of resolvedStandards) { + if (!standard) continue; + if (destSlugs.has(standard.slug)) { + throw new ArtifactSlugConflictError( + 'standard', + standard.slug, + destinationSpaceName, + ); + } + if (destNames.has(standard.name)) { + throw new ArtifactNameConflictError( + 'standard', + standard.name, + destinationSpaceName, + ); + } + } + } + + if (skillArtifacts.length > 0) { + const destSkills = await this.skillsPort.listSkillsBySpace( + destinationSpaceId, + organizationId, + userId as unknown as string, + ); + const destSlugs = new Set(destSkills.map((s) => s.slug)); + const destNames = new Set(destSkills.map((s) => s.name)); + + for (const skill of resolvedSkills) { + if (!skill) continue; + if (destSlugs.has(skill.slug)) { + throw new ArtifactSlugConflictError( + 'skill', + skill.slug, + destinationSpaceName, + ); + } + if (destNames.has(skill.name)) { + throw new ArtifactNameConflictError( + 'skill', + skill.name, + destinationSpaceName, + ); + } + } + } + + if (commandArtifacts.length > 0) { + const destRecipes = await this.recipesPort.listRecipesBySpace({ + spaceId: destinationSpaceId, + organizationId, + userId, + }); + const destSlugs = new Set(destRecipes.map((r) => r.slug)); + const destNames = new Set(destRecipes.map((r) => r.name)); + + for (const recipe of resolvedRecipes) { + if (!recipe) continue; + if (destSlugs.has(recipe.slug)) { + throw new ArtifactSlugConflictError( + 'command', + recipe.slug, + destinationSpaceName, + ); + } + if (destNames.has(recipe.name)) { + throw new ArtifactNameConflictError( + 'command', + recipe.name, + destinationSpaceName, + ); + } + } + } + } + + private emitMovedEvent( + artifactType: ArtifactType, + oldArtifactId: string, + newArtifactId: string, + ctx: MoveContext, + ruleMappings?: Array<{ oldRuleId: string; newRuleId: string }>, + ): void { + this.eventEmitterService.emit( + new PlaybookArtefactMovedEvent({ + artifactType, + oldArtifactId, + newArtifactId, + sourceSpaceId: ctx.sourceSpaceId, + destinationSpaceId: ctx.destinationSpaceId, + userId: ctx.userId, + organizationId: ctx.organizationId, + source: ctx.source, + ruleMappings, + }), + ); + } +} diff --git a/packages/spaces-management/src/application/usecases/PinSpaceUseCase.spec.ts b/packages/spaces-management/src/application/usecases/PinSpaceUseCase.spec.ts new file mode 100644 index 000000000..d04c6e486 --- /dev/null +++ b/packages/spaces-management/src/application/usecases/PinSpaceUseCase.spec.ts @@ -0,0 +1,203 @@ +import { + PackmindEventEmitterService, + SpaceMembershipRequiredError, +} from '@packmind/node-utils'; +import { + createOrganizationId, + createSpaceId, + createUserId, + IAccountsPort, + ISpacesPort, + PinSpaceCommand, + UserSpaceRole, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { SpaceNotFoundError } from '@packmind/spaces'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { PinSpaceUseCase } from './PinSpaceUseCase'; +import { CannotPinDefaultSpaceError } from '../../domain/errors/CannotPinDefaultSpaceError'; + +describe('PinSpaceUseCase', () => { + const organizationId = createOrganizationId('org-1'); + const userId = createUserId('user-1'); + const spaceId = createSpaceId('space-1'); + + const organization = organizationFactory({ id: organizationId }); + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + + let useCase: PinSpaceUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked< + Pick< + ISpacesPort, + 'getSpaceById' | 'findMembership' | 'updateMembershipPinned' + > + >; + let eventEmitterService: jest.Mocked< + Pick<PackmindEventEmitterService, 'emit'> + >; + + const buildCommand = ( + overrides?: Partial<PinSpaceCommand>, + ): PinSpaceCommand => ({ + userId: userId as unknown as string, + organizationId: organizationId as unknown as string, + spaceId, + ...overrides, + }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(user), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort = { + getSpaceById: jest.fn(), + findMembership: jest.fn(), + updateMembershipPinned: jest.fn(), + }; + + eventEmitterService = { + emit: jest.fn().mockReturnValue(true), + }; + + useCase = new PinSpaceUseCase( + spacesPort as unknown as ISpacesPort, + accountsPort, + eventEmitterService as unknown as PackmindEventEmitterService, + ); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('when membership exists and space is not default', () => { + const space = spaceFactory({ + id: spaceId, + name: 'Test Space', + organizationId, + isDefaultSpace: false, + }); + + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + pinned: false, + createdBy: userId, + }); + spacesPort.getSpaceById.mockResolvedValue(space); + spacesPort.updateMembershipPinned.mockResolvedValue(true); + }); + + it('calls updateMembershipPinned with true', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.updateMembershipPinned).toHaveBeenCalledWith( + userId, + spaceId, + true, + ); + }); + + it('emits SpacePinnedEvent', async () => { + await useCase.execute(buildCommand()); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + spaceId, + userId, + organizationId, + }), + }), + ); + }); + }); + + describe('when the space is the default space', () => { + const defaultSpace = spaceFactory({ + id: spaceId, + name: 'Global', + organizationId, + isDefaultSpace: true, + }); + + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + pinned: false, + createdBy: userId, + }); + spacesPort.getSpaceById.mockResolvedValue(defaultSpace); + }); + + it('throws CannotPinDefaultSpaceError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + CannotPinDefaultSpaceError, + ); + }); + + it('does not call updateMembershipPinned', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(spacesPort.updateMembershipPinned).not.toHaveBeenCalled(); + }); + }); + + describe('when membership does not exist', () => { + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue(null); + }); + + it('throws SpaceMembershipRequiredError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toBeInstanceOf( + SpaceMembershipRequiredError, + ); + }); + + it('does not call updateMembershipPinned', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(spacesPort.updateMembershipPinned).not.toHaveBeenCalled(); + }); + + it('does not emit an event', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(eventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + + describe('when the space does not exist', () => { + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + pinned: false, + createdBy: userId, + }); + spacesPort.getSpaceById.mockResolvedValue(null); + }); + + it('throws SpaceNotFoundError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceNotFoundError, + ); + }); + + it('does not call updateMembershipPinned', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(spacesPort.updateMembershipPinned).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/spaces-management/src/application/usecases/PinSpaceUseCase.ts b/packages/spaces-management/src/application/usecases/PinSpaceUseCase.ts new file mode 100644 index 000000000..34527500c --- /dev/null +++ b/packages/spaces-management/src/application/usecases/PinSpaceUseCase.ts @@ -0,0 +1,59 @@ +import { + AbstractSpaceMemberUseCase, + PackmindEventEmitterService, + SpaceMemberContext, +} from '@packmind/node-utils'; +import { + createOrganizationId, + createUserId, + IAccountsPort, + ISpacesPort, + PinSpaceCommand, + PinSpaceResponse, + SpacePinnedEvent, +} from '@packmind/types'; +import { SpaceNotFoundError } from '@packmind/spaces'; +import { CannotPinDefaultSpaceError } from '../../domain/errors/CannotPinDefaultSpaceError'; + +export class PinSpaceUseCase extends AbstractSpaceMemberUseCase< + PinSpaceCommand, + PinSpaceResponse +> { + constructor( + spacesPort: ISpacesPort, + accountsPort: IAccountsPort, + private readonly eventEmitterService: PackmindEventEmitterService, + ) { + super(spacesPort, accountsPort); + } + + protected async executeForSpaceMembers( + command: PinSpaceCommand & SpaceMemberContext, + ): Promise<PinSpaceResponse> { + const { spaceId } = command; + const userId = createUserId(command.userId); + const organizationId = createOrganizationId(command.organizationId); + + const space = await this.spacesPort.getSpaceById(spaceId); + if (!space) { + throw new SpaceNotFoundError(spaceId); + } + + if (space.isDefaultSpace) { + throw new CannotPinDefaultSpaceError(spaceId); + } + + await this.spacesPort.updateMembershipPinned(userId, spaceId, true); + + this.eventEmitterService.emit( + new SpacePinnedEvent({ + userId, + organizationId, + source: command.source ?? 'ui', + spaceId, + }), + ); + + return {} as PinSpaceResponse; + } +} diff --git a/packages/spaces-management/src/application/usecases/UnpinSpaceUseCase.spec.ts b/packages/spaces-management/src/application/usecases/UnpinSpaceUseCase.spec.ts new file mode 100644 index 000000000..0726e7660 --- /dev/null +++ b/packages/spaces-management/src/application/usecases/UnpinSpaceUseCase.spec.ts @@ -0,0 +1,203 @@ +import { + PackmindEventEmitterService, + SpaceMembershipRequiredError, +} from '@packmind/node-utils'; +import { + createOrganizationId, + createSpaceId, + createUserId, + IAccountsPort, + ISpacesPort, + UnpinSpaceCommand, + UserSpaceRole, +} from '@packmind/types'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { SpaceNotFoundError } from '@packmind/spaces'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { UnpinSpaceUseCase } from './UnpinSpaceUseCase'; +import { CannotPinDefaultSpaceError } from '../../domain/errors/CannotPinDefaultSpaceError'; + +describe('UnpinSpaceUseCase', () => { + const organizationId = createOrganizationId('org-1'); + const userId = createUserId('user-1'); + const spaceId = createSpaceId('space-1'); + + const organization = organizationFactory({ id: organizationId }); + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + + let useCase: UnpinSpaceUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked< + Pick< + ISpacesPort, + 'getSpaceById' | 'findMembership' | 'updateMembershipPinned' + > + >; + let eventEmitterService: jest.Mocked< + Pick<PackmindEventEmitterService, 'emit'> + >; + + const buildCommand = ( + overrides?: Partial<UnpinSpaceCommand>, + ): UnpinSpaceCommand => ({ + userId: userId as unknown as string, + organizationId: organizationId as unknown as string, + spaceId, + ...overrides, + }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(user), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort = { + getSpaceById: jest.fn(), + findMembership: jest.fn(), + updateMembershipPinned: jest.fn(), + }; + + eventEmitterService = { + emit: jest.fn().mockReturnValue(true), + }; + + useCase = new UnpinSpaceUseCase( + spacesPort as unknown as ISpacesPort, + accountsPort, + eventEmitterService as unknown as PackmindEventEmitterService, + ); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('when membership exists and space is not default', () => { + const space = spaceFactory({ + id: spaceId, + name: 'Test Space', + organizationId, + isDefaultSpace: false, + }); + + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + pinned: true, + createdBy: userId, + }); + spacesPort.getSpaceById.mockResolvedValue(space); + spacesPort.updateMembershipPinned.mockResolvedValue(true); + }); + + it('calls updateMembershipPinned with false', async () => { + await useCase.execute(buildCommand()); + + expect(spacesPort.updateMembershipPinned).toHaveBeenCalledWith( + userId, + spaceId, + false, + ); + }); + + it('emits SpaceUnpinnedEvent', async () => { + await useCase.execute(buildCommand()); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + spaceId, + userId, + organizationId, + }), + }), + ); + }); + }); + + describe('when the space is the default space', () => { + const defaultSpace = spaceFactory({ + id: spaceId, + name: 'Global', + organizationId, + isDefaultSpace: true, + }); + + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + pinned: true, + createdBy: userId, + }); + spacesPort.getSpaceById.mockResolvedValue(defaultSpace); + }); + + it('throws CannotPinDefaultSpaceError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + CannotPinDefaultSpaceError, + ); + }); + + it('does not call updateMembershipPinned', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(spacesPort.updateMembershipPinned).not.toHaveBeenCalled(); + }); + }); + + describe('when membership does not exist', () => { + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue(null); + }); + + it('throws SpaceMembershipRequiredError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toBeInstanceOf( + SpaceMembershipRequiredError, + ); + }); + + it('does not call updateMembershipPinned', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(spacesPort.updateMembershipPinned).not.toHaveBeenCalled(); + }); + + it('does not emit an event', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(eventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + + describe('when the space does not exist', () => { + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + pinned: true, + createdBy: userId, + }); + spacesPort.getSpaceById.mockResolvedValue(null); + }); + + it('throws SpaceNotFoundError', async () => { + await expect(useCase.execute(buildCommand())).rejects.toThrow( + SpaceNotFoundError, + ); + }); + + it('does not call updateMembershipPinned', async () => { + await useCase.execute(buildCommand()).catch(() => undefined); + + expect(spacesPort.updateMembershipPinned).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/spaces-management/src/application/usecases/UnpinSpaceUseCase.ts b/packages/spaces-management/src/application/usecases/UnpinSpaceUseCase.ts new file mode 100644 index 000000000..713e8d8ff --- /dev/null +++ b/packages/spaces-management/src/application/usecases/UnpinSpaceUseCase.ts @@ -0,0 +1,59 @@ +import { + AbstractSpaceMemberUseCase, + PackmindEventEmitterService, + SpaceMemberContext, +} from '@packmind/node-utils'; +import { + createOrganizationId, + createUserId, + IAccountsPort, + ISpacesPort, + UnpinSpaceCommand, + UnpinSpaceResponse, + SpaceUnpinnedEvent, +} from '@packmind/types'; +import { SpaceNotFoundError } from '@packmind/spaces'; +import { CannotPinDefaultSpaceError } from '../../domain/errors/CannotPinDefaultSpaceError'; + +export class UnpinSpaceUseCase extends AbstractSpaceMemberUseCase< + UnpinSpaceCommand, + UnpinSpaceResponse +> { + constructor( + spacesPort: ISpacesPort, + accountsPort: IAccountsPort, + private readonly eventEmitterService: PackmindEventEmitterService, + ) { + super(spacesPort, accountsPort); + } + + protected async executeForSpaceMembers( + command: UnpinSpaceCommand & SpaceMemberContext, + ): Promise<UnpinSpaceResponse> { + const { spaceId } = command; + const userId = createUserId(command.userId); + const organizationId = createOrganizationId(command.organizationId); + + const space = await this.spacesPort.getSpaceById(spaceId); + if (!space) { + throw new SpaceNotFoundError(spaceId); + } + + if (space.isDefaultSpace) { + throw new CannotPinDefaultSpaceError(spaceId); + } + + await this.spacesPort.updateMembershipPinned(userId, spaceId, false); + + this.eventEmitterService.emit( + new SpaceUnpinnedEvent({ + userId, + organizationId, + source: command.source ?? 'ui', + spaceId, + }), + ); + + return {} as UnpinSpaceResponse; + } +} diff --git a/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.spec.ts b/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.spec.ts new file mode 100644 index 000000000..fa70472cb --- /dev/null +++ b/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.spec.ts @@ -0,0 +1,441 @@ +import { + createOrganizationId, + createSpaceId, + createUserId, + IAccountsPort, + ISpacesPort, + SpaceColor, + SpaceRenamedEvent, + SpaceType, + SpaceVisibilityUpdatedEvent, + UpdateSpaceCommand, + UserSpaceRole, +} from '@packmind/types'; +import { + OrganizationAdminRequiredError, + PackmindEventEmitterService, + SpaceAdminRequiredError, +} from '@packmind/node-utils'; +import { userFactory } from '@packmind/accounts/test/userFactory'; +import { organizationFactory } from '@packmind/accounts/test/organizationFactory'; +import { SpaceNotFoundError } from '@packmind/spaces'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { userSpaceMembershipFactory } from '@packmind/spaces/test/userSpaceMembershipFactory'; +import { CannotRenameDefaultSpaceError } from '../../domain/errors/CannotRenameDefaultSpaceError'; +import { CannotUpdateDefaultSpaceVisibilityError } from '../../domain/errors/CannotUpdateDefaultSpaceVisibilityError'; +import { InvalidSpaceColorError } from '../../domain/errors/InvalidSpaceColorError'; +import { UpdateSpaceUseCase } from './UpdateSpaceUseCase'; + +describe('UpdateSpaceUseCase', () => { + const organizationId = createOrganizationId('org-1'); + const userId = createUserId('user-1'); + const spaceId = createSpaceId('space-1'); + + const organization = organizationFactory({ id: organizationId }); + const user = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'member' }], + }); + const adminMembership = userSpaceMembershipFactory({ + userId, + spaceId, + role: UserSpaceRole.ADMIN, + }); + + let useCase: UpdateSpaceUseCase; + let accountsPort: jest.Mocked<IAccountsPort>; + let spacesPort: jest.Mocked< + Pick<ISpacesPort, 'getSpaceById' | 'updateSpace' | 'findMembership'> + >; + let eventEmitterService: jest.Mocked< + Pick<PackmindEventEmitterService, 'emit'> + >; + + const buildCommand = ( + overrides?: Partial<UpdateSpaceCommand>, + ): UpdateSpaceCommand => ({ + userId: userId as unknown as string, + organizationId: organizationId as unknown as string, + spaceId, + ...overrides, + }); + + beforeEach(() => { + accountsPort = { + getUserById: jest.fn().mockResolvedValue(user), + getOrganizationById: jest.fn().mockResolvedValue(organization), + } as unknown as jest.Mocked<IAccountsPort>; + + spacesPort = { + getSpaceById: jest.fn(), + updateSpace: jest.fn(), + findMembership: jest.fn().mockResolvedValue(adminMembership), + }; + + eventEmitterService = { + emit: jest.fn().mockReturnValue(true), + }; + + useCase = new UpdateSpaceUseCase( + spacesPort as unknown as ISpacesPort, + accountsPort, + eventEmitterService as unknown as PackmindEventEmitterService, + ); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('when updating space type', () => { + const existingSpace = spaceFactory({ + id: spaceId, + organizationId, + type: SpaceType.open, + }); + const updatedSpace = spaceFactory({ + id: spaceId, + organizationId, + type: SpaceType.private, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(existingSpace); + spacesPort.updateSpace.mockResolvedValue(updatedSpace); + }); + + it('returns the updated space', async () => { + const result = await useCase.execute( + buildCommand({ type: SpaceType.private }), + ); + + expect(result).toEqual(updatedSpace); + }); + + it('emits SpaceVisibilityUpdatedEvent', async () => { + await useCase.execute(buildCommand({ type: SpaceType.private })); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.any(SpaceVisibilityUpdatedEvent), + ); + }); + }); + + describe('when updating only the name', () => { + const existingSpace = spaceFactory({ + id: spaceId, + organizationId, + name: 'oddity', + type: SpaceType.open, + }); + const updatedSpace = spaceFactory({ + ...existingSpace, + name: 'security', + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(existingSpace); + spacesPort.updateSpace.mockResolvedValue(updatedSpace); + }); + + it('updates the name', async () => { + await useCase.execute(buildCommand({ name: 'security' })); + + expect(spacesPort.updateSpace).toHaveBeenCalledWith( + spaceId, + expect.objectContaining({ name: 'security' }), + ); + }); + + describe('when the name changes', () => { + it('emits SpaceRenamedEvent', async () => { + await useCase.execute(buildCommand({ name: 'security' })); + + expect(eventEmitterService.emit).toHaveBeenCalledWith( + expect.any(SpaceRenamedEvent), + ); + }); + }); + + it('does not emit SpaceVisibilityUpdatedEvent', async () => { + await useCase.execute(buildCommand({ name: 'security' })); + + expect(eventEmitterService.emit).not.toHaveBeenCalledWith( + expect.any(SpaceVisibilityUpdatedEvent), + ); + }); + }); + + describe('when updating the color', () => { + const existingSpace = spaceFactory({ + id: spaceId, + organizationId, + color: 'blue', + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(existingSpace); + spacesPort.updateSpace.mockImplementation(async (_id, fields) => ({ + ...existingSpace, + ...fields, + })); + }); + + it('updates the color', async () => { + await useCase.execute(buildCommand({ color: 'purple' as SpaceColor })); + + expect(spacesPort.updateSpace).toHaveBeenCalledWith( + spaceId, + expect.objectContaining({ color: 'purple' }), + ); + }); + + describe('when only color changes', () => { + it('does not emit SpaceRenamedEvent', async () => { + await useCase.execute(buildCommand({ color: 'purple' as SpaceColor })); + + expect(eventEmitterService.emit).not.toHaveBeenCalledWith( + expect.any(SpaceRenamedEvent), + ); + }); + }); + + describe('when color is invalid', () => { + it('throws InvalidSpaceColorError', async () => { + await expect( + useCase.execute( + buildCommand({ color: 'chartreuse' as unknown as SpaceColor }), + ), + ).rejects.toBeInstanceOf(InvalidSpaceColorError); + }); + }); + }); + + describe('when no fields are provided', () => { + const existingSpace = spaceFactory({ + id: spaceId, + organizationId, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(existingSpace); + }); + + it('returns the existing space without updating', async () => { + const result = await useCase.execute(buildCommand()); + + expect(result).toEqual(existingSpace); + }); + + it('does not emit any event', async () => { + await useCase.execute(buildCommand()); + + expect(eventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + + describe('when space is the default space', () => { + const defaultSpace = spaceFactory({ + id: spaceId, + organizationId, + isDefaultSpace: true, + name: 'Global', + type: SpaceType.open, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(defaultSpace); + }); + + describe('when updating type', () => { + it('throws CannotUpdateDefaultSpaceVisibilityError', async () => { + await expect( + useCase.execute(buildCommand({ type: SpaceType.private })), + ).rejects.toThrow(CannotUpdateDefaultSpaceVisibilityError); + }); + + it('does not call updateSpace', async () => { + await useCase + .execute(buildCommand({ type: SpaceType.private })) + .catch(() => undefined); + + expect(spacesPort.updateSpace).not.toHaveBeenCalled(); + }); + + it('does not emit any event', async () => { + await useCase + .execute(buildCommand({ type: SpaceType.private })) + .catch(() => undefined); + + expect(eventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + + describe('when renaming', () => { + it('throws CannotRenameDefaultSpaceError', async () => { + await expect( + useCase.execute(buildCommand({ name: 'Not Global' })), + ).rejects.toBeInstanceOf(CannotRenameDefaultSpaceError); + }); + }); + + describe('when updating color', () => { + it('allows updating the color', async () => { + spacesPort.updateSpace.mockImplementation(async (_id, fields) => ({ + ...defaultSpace, + ...fields, + })); + + await useCase.execute(buildCommand({ color: 'purple' as SpaceColor })); + + expect(spacesPort.updateSpace).toHaveBeenCalledWith( + spaceId, + expect.objectContaining({ color: 'purple' }), + ); + }); + }); + }); + + describe('when space is not found', () => { + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(null); + }); + + it('throws SpaceNotFoundError', async () => { + await expect( + useCase.execute(buildCommand({ type: SpaceType.private })), + ).rejects.toThrow(SpaceNotFoundError); + }); + + it('does not emit any event', async () => { + await useCase + .execute(buildCommand({ type: SpaceType.private })) + .catch(() => undefined); + + expect(eventEmitterService.emit).not.toHaveBeenCalled(); + }); + }); + + describe('when changing visibility to open or restricted', () => { + const existingSpace = spaceFactory({ + id: spaceId, + organizationId, + type: SpaceType.private, + }); + + beforeEach(() => { + spacesPort.getSpaceById.mockResolvedValue(existingSpace); + }); + + describe('when org member changes type to open', () => { + it('throws OrganizationAdminRequiredError', async () => { + await expect( + useCase.execute(buildCommand({ type: SpaceType.open })), + ).rejects.toThrow(OrganizationAdminRequiredError); + }); + }); + + describe('when org member changes type to restricted', () => { + it('throws OrganizationAdminRequiredError', async () => { + await expect( + useCase.execute(buildCommand({ type: SpaceType.restricted })), + ).rejects.toThrow(OrganizationAdminRequiredError); + }); + }); + + it('allows org admin to change type to open', async () => { + const orgAdmin = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + accountsPort.getUserById.mockResolvedValue(orgAdmin); + + const updatedSpace = spaceFactory({ + id: spaceId, + organizationId, + type: SpaceType.open, + }); + spacesPort.updateSpace.mockResolvedValue(updatedSpace); + + const result = await useCase.execute( + buildCommand({ type: SpaceType.open }), + ); + + expect(result).toEqual(updatedSpace); + }); + + it('allows org member to change type to private', async () => { + const openSpace = spaceFactory({ + id: spaceId, + organizationId, + type: SpaceType.open, + }); + spacesPort.getSpaceById.mockResolvedValue(openSpace); + + const updatedSpace = spaceFactory({ + id: spaceId, + organizationId, + type: SpaceType.private, + }); + spacesPort.updateSpace.mockResolvedValue(updatedSpace); + + const result = await useCase.execute( + buildCommand({ type: SpaceType.private }), + ); + + expect(result).toEqual(updatedSpace); + }); + }); + + describe('when user is not a space admin', () => { + beforeEach(() => { + spacesPort.findMembership.mockResolvedValue( + userSpaceMembershipFactory({ + userId, + spaceId, + role: UserSpaceRole.MEMBER, + }), + ); + }); + + it('throws SpaceAdminRequiredError', async () => { + await expect( + useCase.execute(buildCommand({ type: SpaceType.private })), + ).rejects.toThrow(SpaceAdminRequiredError); + }); + }); + + describe('when caller is an org admin without space admin role', () => { + const existingSpace = spaceFactory({ + id: spaceId, + organizationId, + name: 'oddity', + }); + + beforeEach(() => { + const orgAdmin = userFactory({ + id: userId, + memberships: [{ userId, organizationId, role: 'admin' }], + }); + accountsPort.getUserById.mockResolvedValue(orgAdmin); + spacesPort.getSpaceById.mockResolvedValue(existingSpace); + spacesPort.updateSpace.mockImplementation(async (_id, fields) => ({ + ...existingSpace, + ...fields, + })); + }); + + it('does not check space membership for org admins', async () => { + await useCase.execute(buildCommand({ name: 'security' })); + + expect(spacesPort.findMembership).not.toHaveBeenCalled(); + }); + + it('updates the space with the provided fields', async () => { + await useCase.execute(buildCommand({ name: 'security' })); + + expect(spacesPort.updateSpace).toHaveBeenCalledWith( + spaceId, + expect.objectContaining({ name: 'security' }), + ); + }); + }); +}); diff --git a/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.ts b/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.ts new file mode 100644 index 000000000..7ef27f7ae --- /dev/null +++ b/packages/spaces-management/src/application/usecases/UpdateSpaceUseCase.ts @@ -0,0 +1,128 @@ +import { + AbstractSpaceAdminUseCase, + MemberContext, + OrganizationAdminRequiredError, + PackmindEventEmitterService, + SpaceAdminContext, +} from '@packmind/node-utils'; +import { PackmindLogger } from '@packmind/logger'; +import { + createOrganizationId, + createUserId, + IAccountsPort, + ISpacesPort, + isSpaceColor, + SpaceRenamedEvent, + SpaceType, + SpaceVisibilityUpdatedEvent, + UpdateSpaceCommand, + UpdateSpaceResponse, +} from '@packmind/types'; +import { SpaceNotFoundError } from '@packmind/spaces'; +import { CannotRenameDefaultSpaceError } from '../../domain/errors/CannotRenameDefaultSpaceError'; +import { CannotUpdateDefaultSpaceVisibilityError } from '../../domain/errors/CannotUpdateDefaultSpaceVisibilityError'; +import { InvalidSpaceColorError } from '../../domain/errors/InvalidSpaceColorError'; + +export class UpdateSpaceUseCase extends AbstractSpaceAdminUseCase< + UpdateSpaceCommand, + UpdateSpaceResponse +> { + constructor( + spacesPort: ISpacesPort, + accountsPort: IAccountsPort, + private readonly eventEmitterService: PackmindEventEmitterService, + logger: PackmindLogger = new PackmindLogger('UpdateSpaceUseCase'), + ) { + super(spacesPort, accountsPort, logger); + } + + protected override async executeForMembers( + command: UpdateSpaceCommand & MemberContext, + ): Promise<UpdateSpaceResponse> { + if (command.membership.role === 'admin') { + return this.executeForSpaceAdmins(command); + } + return super.executeForMembers(command); + } + + protected async executeForSpaceAdmins( + command: UpdateSpaceCommand & SpaceAdminContext, + ): Promise<UpdateSpaceResponse> { + const organizationId = createOrganizationId(command.organizationId); + const userId = createUserId(command.userId); + const { spaceId } = command; + + const space = await this.spacesPort.getSpaceById(spaceId); + if (!space || space.organizationId !== organizationId) { + throw new SpaceNotFoundError(spaceId); + } + + const isRenaming = + command.name !== undefined && command.name !== space.name; + if (isRenaming && space.isDefaultSpace) { + throw new CannotRenameDefaultSpaceError(spaceId); + } + + if (command.type !== undefined && space.isDefaultSpace) { + throw new CannotUpdateDefaultSpaceVisibilityError(spaceId); + } + + if ( + command.type !== undefined && + command.type !== SpaceType.private && + command.membership.role !== 'admin' + ) { + throw new OrganizationAdminRequiredError({ + userId: command.userId, + organizationId: command.organizationId, + }); + } + + if (command.color !== undefined && !isSpaceColor(command.color)) { + throw new InvalidSpaceColorError(command.color as string); + } + + const hasChanges = + command.name !== undefined || + command.type !== undefined || + command.color !== undefined; + + if (!hasChanges) { + return space; + } + + const updatedSpace = await this.spacesPort.updateSpace(spaceId, { + name: command.name, + type: command.type, + color: command.color, + }); + + if (isRenaming) { + this.eventEmitterService.emit( + new SpaceRenamedEvent({ + userId, + organizationId, + source: command.source ?? 'ui', + spaceId, + spaceSlug: updatedSpace.slug, + oldName: space.name, + newName: updatedSpace.name, + }), + ); + } + + if (command.type !== undefined && command.type !== space.type) { + this.eventEmitterService.emit( + new SpaceVisibilityUpdatedEvent({ + userId, + organizationId, + source: command.source ?? 'ui', + spaceId, + newVisibility: command.type, + }), + ); + } + + return updatedSpace; + } +} diff --git a/packages/spaces-management/src/domain/errors/ArtifactNameConflictError.ts b/packages/spaces-management/src/domain/errors/ArtifactNameConflictError.ts new file mode 100644 index 000000000..8459d8dad --- /dev/null +++ b/packages/spaces-management/src/domain/errors/ArtifactNameConflictError.ts @@ -0,0 +1,14 @@ +import { ArtifactType } from '@packmind/types'; + +export class ArtifactNameConflictError extends Error { + constructor( + artifactType: ArtifactType, + artifactName: string, + spaceName: string, + ) { + super( + `A ${artifactType} with the name "${artifactName}" already exists in the "${spaceName}" space`, + ); + this.name = 'ArtifactNameConflictError'; + } +} diff --git a/packages/spaces-management/src/domain/errors/ArtifactNotInSourceSpaceError.ts b/packages/spaces-management/src/domain/errors/ArtifactNotInSourceSpaceError.ts new file mode 100644 index 000000000..6dc98f084 --- /dev/null +++ b/packages/spaces-management/src/domain/errors/ArtifactNotInSourceSpaceError.ts @@ -0,0 +1,14 @@ +import { SpaceId } from '@packmind/types'; + +export class ArtifactNotInSourceSpaceError extends Error { + constructor( + artifactType: string, + artifactId: string, + expectedSpaceId: SpaceId, + ) { + super( + `${artifactType} ${artifactId} does not belong to the expected source space ${expectedSpaceId}`, + ); + this.name = 'ArtifactNotInSourceSpaceError'; + } +} diff --git a/packages/spaces-management/src/domain/errors/ArtifactSlugConflictError.ts b/packages/spaces-management/src/domain/errors/ArtifactSlugConflictError.ts new file mode 100644 index 000000000..4dd99d802 --- /dev/null +++ b/packages/spaces-management/src/domain/errors/ArtifactSlugConflictError.ts @@ -0,0 +1,14 @@ +import { ArtifactType } from '@packmind/types'; + +export class ArtifactSlugConflictError extends Error { + constructor( + artifactType: ArtifactType, + artifactSlug: string, + spaceName: string, + ) { + super( + `A ${artifactType} with the slug "${artifactSlug}" already exists in the "${spaceName}" space`, + ); + this.name = 'ArtifactSlugConflictError'; + } +} diff --git a/packages/spaces-management/src/domain/errors/CannotDeleteDefaultSpaceError.ts b/packages/spaces-management/src/domain/errors/CannotDeleteDefaultSpaceError.ts new file mode 100644 index 000000000..e86dc03c1 --- /dev/null +++ b/packages/spaces-management/src/domain/errors/CannotDeleteDefaultSpaceError.ts @@ -0,0 +1,6 @@ +export class CannotDeleteDefaultSpaceError extends Error { + constructor(spaceId: string) { + super(`Cannot delete the default space ${spaceId}`); + this.name = 'CannotDeleteDefaultSpaceError'; + } +} diff --git a/packages/spaces-management/src/domain/errors/CannotLeaveDefaultSpaceError.ts b/packages/spaces-management/src/domain/errors/CannotLeaveDefaultSpaceError.ts new file mode 100644 index 000000000..1bd1be496 --- /dev/null +++ b/packages/spaces-management/src/domain/errors/CannotLeaveDefaultSpaceError.ts @@ -0,0 +1,6 @@ +export class CannotLeaveDefaultSpaceError extends Error { + constructor(spaceId: string) { + super(`Cannot leave default space ${spaceId}`); + this.name = 'CannotLeaveDefaultSpaceError'; + } +} diff --git a/packages/spaces-management/src/domain/errors/CannotPinDefaultSpaceError.ts b/packages/spaces-management/src/domain/errors/CannotPinDefaultSpaceError.ts new file mode 100644 index 000000000..e3f58fcc8 --- /dev/null +++ b/packages/spaces-management/src/domain/errors/CannotPinDefaultSpaceError.ts @@ -0,0 +1,6 @@ +export class CannotPinDefaultSpaceError extends Error { + constructor(spaceId: string) { + super(`Cannot pin or unpin the default space ${spaceId}`); + this.name = 'CannotPinDefaultSpaceError'; + } +} diff --git a/packages/spaces-management/src/domain/errors/CannotRenameDefaultSpaceError.ts b/packages/spaces-management/src/domain/errors/CannotRenameDefaultSpaceError.ts new file mode 100644 index 000000000..2894cb07f --- /dev/null +++ b/packages/spaces-management/src/domain/errors/CannotRenameDefaultSpaceError.ts @@ -0,0 +1,6 @@ +export class CannotRenameDefaultSpaceError extends Error { + constructor(spaceId: string) { + super(`Cannot rename the default space ${spaceId}`); + this.name = 'CannotRenameDefaultSpaceError'; + } +} diff --git a/packages/spaces-management/src/domain/errors/CannotUpdateDefaultSpaceVisibilityError.ts b/packages/spaces-management/src/domain/errors/CannotUpdateDefaultSpaceVisibilityError.ts new file mode 100644 index 000000000..b44d29320 --- /dev/null +++ b/packages/spaces-management/src/domain/errors/CannotUpdateDefaultSpaceVisibilityError.ts @@ -0,0 +1,6 @@ +export class CannotUpdateDefaultSpaceVisibilityError extends Error { + constructor(spaceId: string) { + super(`Cannot update visibility of default space ${spaceId}`); + this.name = 'CannotUpdateDefaultSpaceVisibilityError'; + } +} diff --git a/packages/spaces-management/src/domain/errors/InvalidPageError.spec.ts b/packages/spaces-management/src/domain/errors/InvalidPageError.spec.ts new file mode 100644 index 000000000..2dfa200e4 --- /dev/null +++ b/packages/spaces-management/src/domain/errors/InvalidPageError.spec.ts @@ -0,0 +1,23 @@ +import { InvalidPageError } from './InvalidPageError'; + +describe('InvalidPageError', () => { + it('is an instance of Error', () => { + const err = new InvalidPageError(0); + expect(err).toBeInstanceOf(Error); + }); + + it('has name InvalidPageError', () => { + const err = new InvalidPageError(0); + expect(err.name).toBe('InvalidPageError'); + }); + + it('includes the page number in the message', () => { + const err = new InvalidPageError(0); + expect(err.message).toContain('0'); + }); + + it('coerces non-numeric input to a string in the message', () => { + const err = new InvalidPageError('abc'); + expect(err.message).toContain('abc'); + }); +}); diff --git a/packages/spaces-management/src/domain/errors/InvalidPageError.ts b/packages/spaces-management/src/domain/errors/InvalidPageError.ts new file mode 100644 index 000000000..4556d2bb9 --- /dev/null +++ b/packages/spaces-management/src/domain/errors/InvalidPageError.ts @@ -0,0 +1,8 @@ +export class InvalidPageError extends Error { + constructor(page: unknown) { + super( + `Invalid page value: ${String(page)}. Page must be a positive integer.`, + ); + this.name = 'InvalidPageError'; + } +} diff --git a/packages/spaces-management/src/domain/errors/InvalidSpaceColorError.ts b/packages/spaces-management/src/domain/errors/InvalidSpaceColorError.ts new file mode 100644 index 000000000..a394fc134 --- /dev/null +++ b/packages/spaces-management/src/domain/errors/InvalidSpaceColorError.ts @@ -0,0 +1,6 @@ +export class InvalidSpaceColorError extends Error { + constructor(color: string) { + super(`Space color '${color}' is not a valid palette value`); + this.name = 'InvalidSpaceColorError'; + } +} diff --git a/packages/spaces-management/src/domain/errors/SpaceDeletionForbiddenError.ts b/packages/spaces-management/src/domain/errors/SpaceDeletionForbiddenError.ts new file mode 100644 index 000000000..7ee8cd158 --- /dev/null +++ b/packages/spaces-management/src/domain/errors/SpaceDeletionForbiddenError.ts @@ -0,0 +1,6 @@ +export class SpaceDeletionForbiddenError extends Error { + constructor(userId: string, spaceId: string) { + super(`User ${userId} is not authorized to delete space ${spaceId}`); + this.name = 'SpaceDeletionForbiddenError'; + } +} diff --git a/packages/spaces-management/src/domain/errors/SpaceNotJoinableError.ts b/packages/spaces-management/src/domain/errors/SpaceNotJoinableError.ts new file mode 100644 index 000000000..60b0a618d --- /dev/null +++ b/packages/spaces-management/src/domain/errors/SpaceNotJoinableError.ts @@ -0,0 +1,6 @@ +export class SpaceNotJoinableError extends Error { + constructor(spaceId: string) { + super(`Space ${spaceId} is not joinable`); + this.name = 'SpaceNotJoinableError'; + } +} diff --git a/packages/spaces-management/src/domain/errors/SpaceOwnershipMismatchError.ts b/packages/spaces-management/src/domain/errors/SpaceOwnershipMismatchError.ts new file mode 100644 index 000000000..080a7770b --- /dev/null +++ b/packages/spaces-management/src/domain/errors/SpaceOwnershipMismatchError.ts @@ -0,0 +1,8 @@ +import { OrganizationId, SpaceId } from '@packmind/types'; + +export class SpaceOwnershipMismatchError extends Error { + constructor(spaceId: SpaceId, organizationId: OrganizationId) { + super(`Space ${spaceId} does not belong to organization ${organizationId}`); + this.name = 'SpaceOwnershipMismatchError'; + } +} diff --git a/packages/spaces-management/src/index.ts b/packages/spaces-management/src/index.ts new file mode 100644 index 000000000..071d4b034 --- /dev/null +++ b/packages/spaces-management/src/index.ts @@ -0,0 +1,13 @@ +// @packmind/spaces-management - Move Artifacts To Space feature +export { SpacesManagementHexa } from './SpacesManagementHexa'; +export { SpacesManagementAdapter } from './application/adapters/SpacesManagementAdapter'; +export { SpacesManagementModule } from './nest-api/spaces-management/spaces-management.module'; +export { CreateSpaceUseCase } from './application/usecases/CreateSpaceUseCase'; +export { MoveArtifactsToSpaceUseCase } from './application/usecases/MoveArtifactsToSpaceUseCase'; +export { SpaceNotFoundError } from '@packmind/spaces'; +export { SpaceOwnershipMismatchError } from './domain/errors/SpaceOwnershipMismatchError'; +export { ArtifactNotInSourceSpaceError } from './domain/errors/ArtifactNotInSourceSpaceError'; +export { BrowseSpacesUseCase } from './application/usecases/BrowseSpacesUseCase'; +export { JoinSpaceUseCase } from './application/usecases/JoinSpaceUseCase'; +export { SpaceNotJoinableError } from './domain/errors/SpaceNotJoinableError'; +export { UpdateSpaceUseCase } from './application/usecases/UpdateSpaceUseCase'; diff --git a/packages/spaces-management/src/nest-api/shared/organization-access.guard.ts b/packages/spaces-management/src/nest-api/shared/organization-access.guard.ts new file mode 100644 index 000000000..2d2620f22 --- /dev/null +++ b/packages/spaces-management/src/nest-api/shared/organization-access.guard.ts @@ -0,0 +1,88 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + ForbiddenException, + BadRequestException, +} from '@nestjs/common'; +import { PackmindLogger, LogLevel } from '@packmind/logger'; +import { AuthenticatedRequest } from '@packmind/node-utils'; +import { createOrganizationId, OrganizationId } from '@packmind/types'; + +const origin = 'OrganizationAccessGuard'; + +/** + * Guard that validates the user has access to the organization specified in the URL + * Expected URL pattern: /organizations/:orgId/... + */ +@Injectable() +export class OrganizationAccessGuard implements CanActivate { + constructor( + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.INFO, + ), + ) { + this.logger.info('OrganizationAccessGuard initialized'); + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<AuthenticatedRequest>(); + + // Extract orgId from URL params + const orgIdParam = request.params.orgId as string; + + if (!orgIdParam) { + this.logger.warn('Organization ID missing from URL', { + path: request.path, + }); + throw new BadRequestException('Organization ID is required in URL'); + } + + // Validate orgId format and create branded OrganizationId + let requestedOrgId: OrganizationId; + try { + requestedOrgId = createOrganizationId(orgIdParam); + } catch (error) { + this.logger.warn('Invalid organization ID format', { + orgId: orgIdParam, + error: error instanceof Error ? error.message : String(error), + }); + throw new BadRequestException('Invalid organization ID format'); + } + + // Verify user's organization from JWT matches the requested organization + const userOrgId = request.organization?.id; + + if (!userOrgId) { + this.logger.error( + 'User organization not found in request - authentication issue', + { + path: request.path, + userId: request.user?.userId, + }, + ); + throw new ForbiddenException('User organization context missing'); + } + + if (userOrgId !== requestedOrgId) { + this.logger.warn('User attempted to access different organization', { + userOrgId, + requestedOrgId, + path: request.path, + userId: request.user?.userId, + }); + throw new ForbiddenException( + 'Access denied: You do not have access to this organization', + ); + } + + this.logger.info('Organization access granted', { + organizationId: requestedOrgId, + userId: request.user?.userId, + path: request.path, + }); + + return true; + } +} diff --git a/packages/spaces-management/src/nest-api/spaces-management/spaces-management.controller.spec.ts b/packages/spaces-management/src/nest-api/spaces-management/spaces-management.controller.spec.ts new file mode 100644 index 000000000..c49d89853 --- /dev/null +++ b/packages/spaces-management/src/nest-api/spaces-management/spaces-management.controller.spec.ts @@ -0,0 +1,761 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { PackmindLogger } from '@packmind/logger'; +import { stubLogger } from '@packmind/test-utils'; +import { + AuthenticatedRequest, + SpaceAdminRequiredError, + SpaceMembershipRequiredError, +} from '@packmind/node-utils'; +import { + createOrganizationId, + createSpaceId, + createStandardId, + createSkillId, + ArtifactReference, + ListOrganizationSpacesForManagementResponse, + MoveArtifactsToSpaceResponse, + SpaceColor, + SpaceType, +} from '@packmind/types'; +import { OrganizationAdminRequiredError } from '@packmind/node-utils'; +import { SpaceNotFoundError, SpaceSlugConflictError } from '@packmind/spaces'; +import { spaceFactory } from '@packmind/spaces/test/spaceFactory'; +import { ArtifactNameConflictError } from '../../domain/errors/ArtifactNameConflictError'; +import { ArtifactNotInSourceSpaceError } from '../../domain/errors/ArtifactNotInSourceSpaceError'; +import { ArtifactSlugConflictError } from '../../domain/errors/ArtifactSlugConflictError'; +import { CannotDeleteDefaultSpaceError } from '../../domain/errors/CannotDeleteDefaultSpaceError'; +import { InvalidPageError } from '../../domain/errors/InvalidPageError'; +import { SpaceDeletionForbiddenError } from '../../domain/errors/SpaceDeletionForbiddenError'; +import { CannotRenameDefaultSpaceError } from '../../domain/errors/CannotRenameDefaultSpaceError'; +import { CannotUpdateDefaultSpaceVisibilityError } from '../../domain/errors/CannotUpdateDefaultSpaceVisibilityError'; +import { InvalidSpaceColorError } from '../../domain/errors/InvalidSpaceColorError'; +import { SpaceNotJoinableError } from '../../domain/errors/SpaceNotJoinableError'; +import { SpaceOwnershipMismatchError } from '../../domain/errors/SpaceOwnershipMismatchError'; +import { SpacesManagementController } from './spaces-management.controller'; +import { SpacesManagementService } from './spaces-management.service'; + +describe('SpacesManagementController', () => { + let controller: SpacesManagementController; + let service: jest.Mocked<SpacesManagementService>; + let logger: jest.Mocked<PackmindLogger>; + + const organizationId = createOrganizationId('org-123'); + const mockRequest = { + user: { + userId: 'user-123', + email: 'test@example.com', + }, + } as unknown as AuthenticatedRequest; + + beforeEach(() => { + logger = stubLogger(); + service = { + createSpace: jest.fn(), + updateSpace: jest.fn(), + moveArtifactsToSpace: jest.fn(), + browseSpaces: jest.fn(), + joinSpace: jest.fn(), + deleteSpace: jest.fn(), + listOrganizationSpacesForManagement: jest.fn(), + } as unknown as jest.Mocked<SpacesManagementService>; + controller = new SpacesManagementController(service, logger); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createSpace', () => { + describe('when creating a space with a valid name and type', () => { + const mockSpace = spaceFactory({ + id: createSpaceId('space-1'), + name: 'New Space', + slug: 'new-space', + organizationId, + }); + let result: typeof mockSpace; + + beforeEach(async () => { + service.createSpace.mockResolvedValue(mockSpace); + result = await controller.createSpace( + organizationId, + { name: 'New Space', type: SpaceType.restricted }, + mockRequest, + ); + }); + + it('returns the created space', () => { + expect(result).toEqual(mockSpace); + }); + + it('calls service with correct params including type', () => { + expect(service.createSpace).toHaveBeenCalledWith({ + name: 'New Space', + type: SpaceType.restricted, + organizationId, + userId: 'user-123', + }); + }); + }); + + describe('when creating a space without specifying type', () => { + const mockSpace = spaceFactory({ + id: createSpaceId('space-1'), + name: 'New Space', + slug: 'new-space', + organizationId, + }); + + beforeEach(async () => { + service.createSpace.mockResolvedValue(mockSpace); + await controller.createSpace( + organizationId, + { name: 'New Space' }, + mockRequest, + ); + }); + + it('calls service with undefined type', () => { + expect(service.createSpace).toHaveBeenCalledWith({ + name: 'New Space', + type: undefined, + organizationId, + userId: 'user-123', + }); + }); + }); + + describe('when name is empty', () => { + it('throws BadRequestException', async () => { + await expect( + controller.createSpace(organizationId, { name: '' }, mockRequest), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('when slug conflicts', () => { + it('throws ConflictException', async () => { + service.createSpace.mockRejectedValue( + new SpaceSlugConflictError('new-space', organizationId), + ); + + await expect( + controller.createSpace( + organizationId, + { name: 'New Space' }, + mockRequest, + ), + ).rejects.toThrow(ConflictException); + }); + }); + }); + + describe('moveArtifactsToSpace', () => { + describe('when moving artifacts with valid params', () => { + const sourceSpaceId = createSpaceId('source-space'); + const destinationSpaceId = createSpaceId('dest-space'); + const expectedResponse: MoveArtifactsToSpaceResponse = { + movedCount: 3, + }; + let result: MoveArtifactsToSpaceResponse; + + const artifacts: ArtifactReference[] = [ + { id: createStandardId('std-1'), type: 'standard' }, + { id: createStandardId('std-2'), type: 'standard' }, + { id: createSkillId('skill-1'), type: 'skill' }, + ]; + + beforeEach(async () => { + service.moveArtifactsToSpace.mockResolvedValue(expectedResponse); + result = await controller.moveArtifactsToSpace( + organizationId, + mockRequest, + { + sourceSpaceId, + destinationSpaceId, + artifacts, + }, + ); + }); + + it('returns the move result', () => { + expect(result).toEqual({ movedCount: 3 }); + }); + + it('calls service with correct command', () => { + expect(service.moveArtifactsToSpace).toHaveBeenCalledWith({ + userId: 'user-123', + organizationId, + sourceSpaceId, + destinationSpaceId, + artifacts, + }); + }); + }); + + describe('when sourceSpaceId is missing', () => { + it('throws BadRequestException', async () => { + await expect( + controller.moveArtifactsToSpace(organizationId, mockRequest, { + sourceSpaceId: '' as never, + destinationSpaceId: createSpaceId('dest-space'), + artifacts: [], + }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('when destinationSpaceId is missing', () => { + it('throws BadRequestException', async () => { + await expect( + controller.moveArtifactsToSpace(organizationId, mockRequest, { + sourceSpaceId: createSpaceId('source-space'), + destinationSpaceId: '' as never, + artifacts: [], + }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('when artifacts is empty', () => { + it('throws BadRequestException', async () => { + await expect( + controller.moveArtifactsToSpace(organizationId, mockRequest, { + sourceSpaceId: createSpaceId('source-space'), + destinationSpaceId: createSpaceId('dest-space'), + artifacts: [], + }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('when an artifact has an invalid type', () => { + it('throws BadRequestException', async () => { + await expect( + controller.moveArtifactsToSpace(organizationId, mockRequest, { + sourceSpaceId: createSpaceId('source-space'), + destinationSpaceId: createSpaceId('dest-space'), + artifacts: [ + { id: createStandardId('std-1'), type: 'invalid' as never }, + ], + }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('when the space is not found', () => { + it('throws NotFoundException', async () => { + service.moveArtifactsToSpace.mockRejectedValue( + new SpaceNotFoundError('space-id'), + ); + + await expect( + controller.moveArtifactsToSpace(organizationId, mockRequest, { + sourceSpaceId: createSpaceId('source-space'), + destinationSpaceId: createSpaceId('dest-space'), + artifacts: [{ id: createStandardId('std-1'), type: 'standard' }], + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('when the space belongs to a different organization', () => { + it('throws ForbiddenException', async () => { + service.moveArtifactsToSpace.mockRejectedValue( + new SpaceOwnershipMismatchError( + createSpaceId('source-space'), + organizationId, + ), + ); + + await expect( + controller.moveArtifactsToSpace(organizationId, mockRequest, { + sourceSpaceId: createSpaceId('source-space'), + destinationSpaceId: createSpaceId('dest-space'), + artifacts: [{ id: createStandardId('std-1'), type: 'standard' }], + }), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('when the user is not a member of the space', () => { + it('throws ForbiddenException', async () => { + service.moveArtifactsToSpace.mockRejectedValue( + new SpaceMembershipRequiredError('user-123', 'source-space'), + ); + + await expect( + controller.moveArtifactsToSpace(organizationId, mockRequest, { + sourceSpaceId: createSpaceId('source-space'), + destinationSpaceId: createSpaceId('dest-space'), + artifacts: [{ id: createStandardId('std-1'), type: 'standard' }], + }), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('when an artifact does not belong to the source space', () => { + it('throws UnprocessableEntityException', async () => { + service.moveArtifactsToSpace.mockRejectedValue( + new ArtifactNotInSourceSpaceError( + 'standard', + 'std-1', + createSpaceId('source-space'), + ), + ); + + await expect( + controller.moveArtifactsToSpace(organizationId, mockRequest, { + sourceSpaceId: createSpaceId('source-space'), + destinationSpaceId: createSpaceId('dest-space'), + artifacts: [{ id: createStandardId('std-1'), type: 'standard' }], + }), + ).rejects.toThrow(UnprocessableEntityException); + }); + }); + + describe('when an artifact name conflicts in the destination space', () => { + it('throws ConflictException', async () => { + service.moveArtifactsToSpace.mockRejectedValue( + new ArtifactNameConflictError( + 'standard', + 'My Standard', + 'Dest Space', + ), + ); + + await expect( + controller.moveArtifactsToSpace(organizationId, mockRequest, { + sourceSpaceId: createSpaceId('source-space'), + destinationSpaceId: createSpaceId('dest-space'), + artifacts: [{ id: createStandardId('std-1'), type: 'standard' }], + }), + ).rejects.toThrow(ConflictException); + }); + }); + + describe('when an artifact slug conflicts in the destination space', () => { + it('throws ConflictException', async () => { + service.moveArtifactsToSpace.mockRejectedValue( + new ArtifactSlugConflictError( + 'standard', + 'my-standard', + 'Dest Space', + ), + ); + + await expect( + controller.moveArtifactsToSpace(organizationId, mockRequest, { + sourceSpaceId: createSpaceId('source-space'), + destinationSpaceId: createSpaceId('dest-space'), + artifacts: [{ id: createStandardId('std-1'), type: 'standard' }], + }), + ).rejects.toThrow(ConflictException); + }); + }); + }); + + describe('browseSpaces', () => { + describe('when browsing spaces successfully', () => { + const mockResponse = { + mySpaces: [ + spaceFactory({ + id: createSpaceId('space-1'), + name: 'My Space', + organizationId, + }), + ], + allSpaces: [ + { + id: createSpaceId('space-2'), + name: 'Open Space', + type: SpaceType.open, + }, + ], + }; + let result: typeof mockResponse; + + beforeEach(async () => { + service.browseSpaces.mockResolvedValue(mockResponse); + result = await controller.browseSpaces(organizationId, mockRequest); + }); + + it('returns the browse response', () => { + expect(result).toEqual(mockResponse); + }); + + it('calls service with correct params', () => { + expect(service.browseSpaces).toHaveBeenCalledWith({ + userId: 'user-123', + organizationId, + }); + }); + }); + }); + + describe('joinSpace', () => { + const spaceId = 'space-1'; + + describe('when joining a space successfully', () => { + beforeEach(async () => { + service.joinSpace.mockResolvedValue(undefined); + await controller.joinSpace(organizationId, spaceId, mockRequest); + }); + + it('calls service with correct params', () => { + expect(service.joinSpace).toHaveBeenCalledWith({ + userId: 'user-123', + organizationId, + spaceId, + }); + }); + }); + + describe('when the space is not found', () => { + beforeEach(() => { + service.joinSpace.mockRejectedValue(new SpaceNotFoundError(spaceId)); + }); + + it('throws NotFoundException', async () => { + await expect( + controller.joinSpace(organizationId, spaceId, mockRequest), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('when the space is not joinable', () => { + beforeEach(() => { + service.joinSpace.mockRejectedValue(new SpaceNotJoinableError(spaceId)); + }); + + it('throws ForbiddenException', async () => { + await expect( + controller.joinSpace(organizationId, spaceId, mockRequest), + ).rejects.toThrow(ForbiddenException); + }); + }); + }); + + describe('updateSpace', () => { + const spaceId = createSpaceId('space-1'); + const mockUpdatedSpace = spaceFactory({ + id: spaceId, + organizationId, + name: 'Updated', + color: 'purple', + }); + + describe('when the body includes a color', () => { + beforeEach(() => { + service.updateSpace.mockResolvedValue(mockUpdatedSpace); + }); + + it('forwards color to the service', async () => { + await controller.updateSpace( + organizationId, + spaceId, + { color: 'purple' as SpaceColor }, + mockRequest, + ); + + expect(service.updateSpace).toHaveBeenCalledWith( + expect.objectContaining({ color: 'purple' }), + ); + }); + }); + + describe('when the service throws CannotRenameDefaultSpaceError', () => { + beforeEach(() => { + service.updateSpace.mockRejectedValue( + new CannotRenameDefaultSpaceError(spaceId), + ); + }); + + it('responds with 422', async () => { + await expect( + controller.updateSpace( + organizationId, + spaceId, + { name: 'x' }, + mockRequest, + ), + ).rejects.toThrow(UnprocessableEntityException); + }); + }); + + describe('when the service throws SpaceAdminRequiredError', () => { + beforeEach(() => { + service.updateSpace.mockRejectedValue( + new SpaceAdminRequiredError('user-123', spaceId), + ); + }); + + it('responds with 403', async () => { + await expect( + controller.updateSpace( + organizationId, + spaceId, + { name: 'x' }, + mockRequest, + ), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('when the service throws OrganizationAdminRequiredError', () => { + beforeEach(() => { + service.updateSpace.mockRejectedValue( + new OrganizationAdminRequiredError({ + userId: 'user-123', + organizationId, + }), + ); + }); + + it('responds with 403', async () => { + await expect( + controller.updateSpace( + organizationId, + spaceId, + { type: SpaceType.open }, + mockRequest, + ), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('when the service throws CannotUpdateDefaultSpaceVisibilityError', () => { + beforeEach(() => { + service.updateSpace.mockRejectedValue( + new CannotUpdateDefaultSpaceVisibilityError(spaceId), + ); + }); + + it('responds with 422', async () => { + await expect( + controller.updateSpace( + organizationId, + spaceId, + { type: SpaceType.private }, + mockRequest, + ), + ).rejects.toThrow(UnprocessableEntityException); + }); + }); + + describe('when the service throws InvalidSpaceColorError', () => { + beforeEach(() => { + service.updateSpace.mockRejectedValue( + new InvalidSpaceColorError('chartreuse'), + ); + }); + + it('responds with 400', async () => { + await expect( + controller.updateSpace( + organizationId, + spaceId, + { color: 'chartreuse' as unknown as SpaceColor }, + mockRequest, + ), + ).rejects.toThrow(BadRequestException); + }); + }); + }); + + describe('deleteSpace', () => { + const spaceId = 'space-1'; + + describe('when deleting a space successfully', () => { + beforeEach(async () => { + service.deleteSpace.mockResolvedValue(undefined); + await controller.deleteSpace(organizationId, spaceId, mockRequest); + }); + + it('calls service with correct params', () => { + expect(service.deleteSpace).toHaveBeenCalledWith({ + userId: 'user-123', + organizationId, + spaceId, + }); + }); + }); + + describe('when the space is not found', () => { + beforeEach(() => { + service.deleteSpace.mockRejectedValue(new SpaceNotFoundError(spaceId)); + }); + + it('throws NotFoundException', async () => { + await expect( + controller.deleteSpace(organizationId, spaceId, mockRequest), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('when the space is the default space', () => { + beforeEach(() => { + service.deleteSpace.mockRejectedValue( + new CannotDeleteDefaultSpaceError(spaceId), + ); + }); + + it('throws UnprocessableEntityException', async () => { + await expect( + controller.deleteSpace(organizationId, spaceId, mockRequest), + ).rejects.toThrow(UnprocessableEntityException); + }); + }); + + describe('when the user is not authorized to delete the space', () => { + beforeEach(() => { + service.deleteSpace.mockRejectedValue( + new SpaceDeletionForbiddenError('user-123', spaceId), + ); + }); + + it('throws ForbiddenException', async () => { + await expect( + controller.deleteSpace(organizationId, spaceId, mockRequest), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('when the user is not an organization admin', () => { + beforeEach(() => { + service.deleteSpace.mockRejectedValue( + new OrganizationAdminRequiredError({ + userId: 'user-123', + organizationId, + }), + ); + }); + + it('throws ForbiddenException', async () => { + await expect( + controller.deleteSpace(organizationId, spaceId, mockRequest), + ).rejects.toThrow(ForbiddenException); + }); + }); + }); + + describe('listOrganizationSpacesForManagement', () => { + const expectedResponse: ListOrganizationSpacesForManagementResponse = { + items: [], + totalCount: 0, + page: 1, + pageSize: 8, + }; + + describe('when listing spaces successfully with an explicit page', () => { + let result: ListOrganizationSpacesForManagementResponse; + + beforeEach(async () => { + service.listOrganizationSpacesForManagement.mockResolvedValue( + expectedResponse, + ); + result = await controller.listOrganizationSpacesForManagement( + organizationId, + '1', + mockRequest, + ); + }); + + it('returns the listing response', () => { + expect(result).toEqual(expectedResponse); + }); + + it('calls service with the parsed page', () => { + expect( + service.listOrganizationSpacesForManagement, + ).toHaveBeenCalledWith({ + userId: 'user-123', + organizationId, + page: 1, + }); + }); + }); + + describe('when page query param is not provided', () => { + beforeEach(async () => { + service.listOrganizationSpacesForManagement.mockResolvedValue( + expectedResponse, + ); + await controller.listOrganizationSpacesForManagement( + organizationId, + undefined, + mockRequest, + ); + }); + + it('defaults page to 1', () => { + expect( + service.listOrganizationSpacesForManagement, + ).toHaveBeenCalledWith(expect.objectContaining({ page: 1 })); + }); + }); + + describe('when the page value is not a positive integer', () => { + beforeEach(() => { + service.listOrganizationSpacesForManagement.mockRejectedValue( + new InvalidPageError('abc'), + ); + }); + + it('throws BadRequestException', async () => { + await expect( + controller.listOrganizationSpacesForManagement( + organizationId, + 'abc', + mockRequest, + ), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('when the caller is not an organization admin', () => { + beforeEach(() => { + service.listOrganizationSpacesForManagement.mockRejectedValue( + new OrganizationAdminRequiredError({ + userId: 'user-123', + organizationId, + }), + ); + }); + + it('throws ForbiddenException', async () => { + await expect( + controller.listOrganizationSpacesForManagement( + organizationId, + '1', + mockRequest, + ), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('when the service throws an unknown error', () => { + beforeEach(() => { + service.listOrganizationSpacesForManagement.mockRejectedValue( + new Error('unexpected'), + ); + }); + + it('re-throws the error', async () => { + await expect( + controller.listOrganizationSpacesForManagement( + organizationId, + '1', + mockRequest, + ), + ).rejects.toThrow('unexpected'); + }); + }); + }); +}); diff --git a/packages/spaces-management/src/nest-api/spaces-management/spaces-management.controller.ts b/packages/spaces-management/src/nest-api/spaces-management/spaces-management.controller.ts new file mode 100644 index 000000000..d8761124a --- /dev/null +++ b/packages/spaces-management/src/nest-api/spaces-management/spaces-management.controller.ts @@ -0,0 +1,550 @@ +import { + BadRequestException, + Body, + ConflictException, + Controller, + Delete, + ForbiddenException, + Get, + HttpCode, + NotFoundException, + Patch, + Post, + Param, + Query, + Req, + UnprocessableEntityException, + UseGuards, +} from '@nestjs/common'; +import { LogLevel, PackmindLogger } from '@packmind/logger'; +import { + AuthenticatedRequest, + SpaceAdminRequiredError, + SpaceMembershipRequiredError, +} from '@packmind/node-utils'; +import { + ArtifactType, + ListOrganizationSpacesForManagementResponse, + MoveArtifactsToSpaceResponse, + OrganizationId, + PackmindCommandBody, + MoveArtifactsToSpaceCommand, + Space, + SpaceColor, + SpaceType, + BrowseSpacesResponse, + SpaceId, +} from '@packmind/types'; +import { SpaceNotFoundError, SpaceSlugConflictError } from '@packmind/spaces'; +import { OrganizationAdminRequiredError } from '@packmind/node-utils'; +import { ArtifactNameConflictError } from '../../domain/errors/ArtifactNameConflictError'; +import { ArtifactNotInSourceSpaceError } from '../../domain/errors/ArtifactNotInSourceSpaceError'; +import { ArtifactSlugConflictError } from '../../domain/errors/ArtifactSlugConflictError'; +import { CannotLeaveDefaultSpaceError } from '../../domain/errors/CannotLeaveDefaultSpaceError'; +import { CannotDeleteDefaultSpaceError } from '../../domain/errors/CannotDeleteDefaultSpaceError'; +import { CannotPinDefaultSpaceError } from '../../domain/errors/CannotPinDefaultSpaceError'; +import { InvalidPageError } from '../../domain/errors/InvalidPageError'; +import { SpaceDeletionForbiddenError } from '../../domain/errors/SpaceDeletionForbiddenError'; +import { SpaceNotJoinableError } from '../../domain/errors/SpaceNotJoinableError'; +import { CannotRenameDefaultSpaceError } from '../../domain/errors/CannotRenameDefaultSpaceError'; +import { CannotUpdateDefaultSpaceVisibilityError } from '../../domain/errors/CannotUpdateDefaultSpaceVisibilityError'; +import { InvalidSpaceColorError } from '../../domain/errors/InvalidSpaceColorError'; +import { SpaceOwnershipMismatchError } from '../../domain/errors/SpaceOwnershipMismatchError'; +import { SpacesManagementService } from './spaces-management.service'; +import { OrganizationAccessGuard } from '../shared/organization-access.guard'; + +const origin = 'SpacesManagementController'; + +@Controller() +@UseGuards(OrganizationAccessGuard) +export class SpacesManagementController { + constructor( + private readonly spacesManagementService: SpacesManagementService, + private readonly logger: PackmindLogger = new PackmindLogger( + origin, + LogLevel.INFO, + ), + ) { + this.logger.info('SpacesManagementController initialized'); + } + + /** + * Create a new space within an organization + * POST /organizations/:orgId/spaces-management + */ + @Post() + async createSpace( + @Param('orgId') organizationId: OrganizationId, + @Body() body: { name: string; type?: SpaceType }, + @Req() request: AuthenticatedRequest, + ): Promise<Space> { + const userId = request.user.userId; + const name = body.name?.trim(); + + if (!name) { + throw new BadRequestException('Space name is required'); + } + + this.logger.info( + 'POST /organizations/:orgId/spaces-management - Creating space', + { + organizationId, + userId, + }, + ); + + try { + return await this.spacesManagementService.createSpace({ + name, + type: body.type, + organizationId, + userId, + }); + } catch (error) { + if (error instanceof OrganizationAdminRequiredError) { + throw new ForbiddenException(error.message); + } + if (error instanceof SpaceSlugConflictError) { + this.logger.warn( + 'POST /organizations/:orgId/spaces-management - Space slug conflict', + { + organizationId, + userId, + }, + ); + throw new ConflictException(error.message); + } + + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /organizations/:orgId/spaces-management - Failed to create space', + { + organizationId, + userId, + error: errorMessage, + }, + ); + throw error; + } + } + + /** + * List the organization's spaces for the admin management page. + * GET /organizations/:orgId/spaces-management/listing?page=N + */ + @Get('listing') + async listOrganizationSpacesForManagement( + @Param('orgId') organizationId: OrganizationId, + @Query('page') pageParam: string | undefined, + @Req() request: AuthenticatedRequest, + ): Promise<ListOrganizationSpacesForManagementResponse> { + const userId = request.user.userId; + const page = pageParam === undefined ? 1 : Number(pageParam); + + this.logger.info( + 'GET /organizations/:orgId/spaces-management/listing - Listing spaces for management', + { organizationId, userId, page }, + ); + + try { + return await this.spacesManagementService.listOrganizationSpacesForManagement( + { + userId, + organizationId, + page, + }, + ); + } catch (error) { + if (error instanceof InvalidPageError) { + throw new BadRequestException(error.message); + } + if (error instanceof OrganizationAdminRequiredError) { + throw new ForbiddenException(error.message); + } + throw error; + } + } + + /** + * Browse spaces in the organization + * GET /organizations/:orgId/spaces-management/browse + */ + @Get('browse') + async browseSpaces( + @Param('orgId') organizationId: OrganizationId, + @Req() request: AuthenticatedRequest, + ): Promise<BrowseSpacesResponse> { + const userId = request.user.userId; + + this.logger.info( + 'GET /organizations/:orgId/spaces-management/browse - Browsing spaces', + { organizationId, userId }, + ); + + return this.spacesManagementService.browseSpaces({ + userId, + organizationId, + }); + } + + /** + * Self-join an open space + * POST /organizations/:orgId/spaces-management/:spaceId/join + */ + @Post(':spaceId/join') + @HttpCode(204) + async joinSpace( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: string, + @Req() request: AuthenticatedRequest, + ): Promise<void> { + const userId = request.user.userId; + + this.logger.info( + 'POST /organizations/:orgId/spaces-management/:spaceId/join - Joining space', + { organizationId, userId, spaceId }, + ); + + try { + await this.spacesManagementService.joinSpace({ + userId, + organizationId, + spaceId, + }); + } catch (error) { + if (error instanceof SpaceNotFoundError) { + throw new NotFoundException(error.message); + } + if (error instanceof SpaceNotJoinableError) { + throw new ForbiddenException(error.message); + } + throw error; + } + } + + /** + * Self-join a space by its slug + * POST /organizations/:orgId/spaces-management/by-slug/:spaceSlug/join + */ + @Post('by-slug/:spaceSlug/join') + @HttpCode(204) + async joinSpaceBySlug( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceSlug') spaceSlug: string, + @Req() request: AuthenticatedRequest, + ): Promise<void> { + const userId = request.user.userId; + + this.logger.info( + 'POST /organizations/:orgId/spaces-management/by-slug/:spaceSlug/join - Joining space by slug', + { organizationId, userId, spaceSlug }, + ); + + try { + await this.spacesManagementService.joinSpaceBySlug({ + userId, + organizationId, + spaceSlug, + }); + } catch (error) { + if (error instanceof SpaceNotFoundError) { + throw new NotFoundException(error.message); + } + if (error instanceof SpaceNotJoinableError) { + throw new ForbiddenException(error.message); + } + throw error; + } + } + + /** + * Leave a space (user-initiated self-removal) + * POST /organizations/:orgId/spaces-management/:spaceId/leave + */ + @Post(':spaceId/leave') + @HttpCode(204) + async leaveSpace( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Req() request: AuthenticatedRequest, + ): Promise<void> { + const userId = request.user.userId; + + this.logger.info( + 'POST /organizations/:orgId/spaces-management/:spaceId/leave - Leaving space', + { organizationId, spaceId }, + ); + + try { + await this.spacesManagementService.leaveSpace({ + userId, + organizationId, + spaceId, + }); + } catch (error) { + if ( + error instanceof SpaceNotFoundError || + error instanceof SpaceMembershipRequiredError + ) { + throw new NotFoundException(error.message); + } + if (error instanceof CannotLeaveDefaultSpaceError) { + throw new UnprocessableEntityException(error.message); + } + throw error; + } + } + + /** + * Update a space's settings + * PATCH /organizations/:orgId/spaces-management/:spaceId + */ + @Patch(':spaceId') + async updateSpace( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Body() body: { name?: string; type?: SpaceType; color?: SpaceColor }, + @Req() request: AuthenticatedRequest, + ): Promise<Space> { + const userId = request.user.userId; + + this.logger.info( + 'PATCH /organizations/:orgId/spaces-management/:spaceId - Updating space', + { organizationId, userId, spaceId }, + ); + + try { + return await this.spacesManagementService.updateSpace({ + userId, + organizationId, + spaceId, + name: body.name?.trim() || undefined, + type: body.type, + color: body.color, + }); + } catch (error) { + if (error instanceof SpaceNotFoundError) { + throw new NotFoundException(error.message); + } + if (error instanceof SpaceSlugConflictError) { + throw new ConflictException(error.message); + } + if (error instanceof CannotRenameDefaultSpaceError) { + throw new UnprocessableEntityException(error.message); + } + if (error instanceof CannotUpdateDefaultSpaceVisibilityError) { + throw new UnprocessableEntityException(error.message); + } + if (error instanceof SpaceAdminRequiredError) { + throw new ForbiddenException(error.message); + } + if (error instanceof OrganizationAdminRequiredError) { + throw new ForbiddenException(error.message); + } + if (error instanceof InvalidSpaceColorError) { + throw new BadRequestException(error.message); + } + throw error; + } + } + + /** + * Delete a space + * DELETE /organizations/:orgId/spaces-management/:spaceId + */ + @Delete(':spaceId') + @HttpCode(204) + async deleteSpace( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: string, + @Req() request: AuthenticatedRequest, + ): Promise<void> { + const userId = request.user.userId; + + this.logger.info( + 'DELETE /organizations/:orgId/spaces-management/:spaceId - Deleting space', + { organizationId, userId, spaceId }, + ); + + try { + await this.spacesManagementService.deleteSpace({ + userId, + organizationId, + spaceId, + }); + } catch (error) { + if (error instanceof SpaceNotFoundError) { + throw new NotFoundException(error.message); + } + if (error instanceof CannotDeleteDefaultSpaceError) { + throw new UnprocessableEntityException(error.message); + } + if (error instanceof SpaceDeletionForbiddenError) { + throw new ForbiddenException(error.message); + } + if (error instanceof OrganizationAdminRequiredError) { + throw new ForbiddenException(error.message); + } + throw error; + } + } + + /** + * Move artifacts between spaces + * POST /organizations/:orgId/spaces-management/move + */ + @Post('move') + async moveArtifactsToSpace( + @Param('orgId') organizationId: OrganizationId, + @Req() request: AuthenticatedRequest, + @Body() body: PackmindCommandBody<MoveArtifactsToSpaceCommand>, + ): Promise<MoveArtifactsToSpaceResponse> { + const userId = request.user.userId; + + if (!body.sourceSpaceId || !body.destinationSpaceId) { + throw new BadRequestException( + 'sourceSpaceId and destinationSpaceId are required', + ); + } + + if (!body.artifacts?.length) { + throw new BadRequestException('artifacts must not be empty'); + } + + const validTypes: ArtifactType[] = ['standard', 'skill', 'command']; + const invalidArtifact = body.artifacts.find( + (a) => !validTypes.includes(a.type), + ); + if (invalidArtifact) { + throw new BadRequestException( + `Invalid artifact type: ${invalidArtifact.type}`, + ); + } + + this.logger.info( + 'POST /organizations/:orgId/spaces-management/move - Moving artifacts', + { + organizationId, + userId, + sourceSpaceId: body.sourceSpaceId, + destinationSpaceId: body.destinationSpaceId, + }, + ); + + try { + return await this.spacesManagementService.moveArtifactsToSpace({ + userId, + organizationId, + ...body, + }); + } catch (error) { + if (error instanceof SpaceNotFoundError) { + throw new NotFoundException(error.message); + } + if ( + error instanceof SpaceOwnershipMismatchError || + error instanceof SpaceMembershipRequiredError + ) { + throw new ForbiddenException(error.message); + } + if (error instanceof ArtifactNotInSourceSpaceError) { + throw new UnprocessableEntityException(error.message); + } + if ( + error instanceof ArtifactNameConflictError || + error instanceof ArtifactSlugConflictError + ) { + throw new ConflictException(error.message); + } + + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + 'POST /organizations/:orgId/spaces-management/move - Failed to move artifacts', + { + organizationId, + userId, + error: errorMessage, + }, + ); + throw error; + } + } + + /** + * Pin a space for the current user + * POST /organizations/:orgId/spaces-management/:spaceId/pin + */ + @Post(':spaceId/pin') + @HttpCode(204) + async pinSpace( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Req() request: AuthenticatedRequest, + ): Promise<void> { + const userId = request.user.userId; + + this.logger.info( + 'POST /organizations/:orgId/spaces-management/:spaceId/pin - Pinning space', + { organizationId, userId, spaceId }, + ); + + try { + await this.spacesManagementService.pinSpace({ + userId, + organizationId, + spaceId, + }); + } catch (error) { + if (error instanceof CannotPinDefaultSpaceError) { + throw new UnprocessableEntityException(error.message); + } + if (error instanceof SpaceMembershipRequiredError) { + throw new NotFoundException(error.message); + } + if (error instanceof SpaceNotFoundError) { + throw new NotFoundException(error.message); + } + throw error; + } + } + + /** + * Unpin a space for the current user + * POST /organizations/:orgId/spaces-management/:spaceId/unpin + */ + @Post(':spaceId/unpin') + @HttpCode(204) + async unpinSpace( + @Param('orgId') organizationId: OrganizationId, + @Param('spaceId') spaceId: SpaceId, + @Req() request: AuthenticatedRequest, + ): Promise<void> { + const userId = request.user.userId; + + this.logger.info( + 'POST /organizations/:orgId/spaces-management/:spaceId/unpin - Unpinning space', + { organizationId, userId, spaceId }, + ); + + try { + await this.spacesManagementService.unpinSpace({ + userId, + organizationId, + spaceId, + }); + } catch (error) { + if (error instanceof CannotPinDefaultSpaceError) { + throw new UnprocessableEntityException(error.message); + } + if (error instanceof SpaceMembershipRequiredError) { + throw new NotFoundException(error.message); + } + if (error instanceof SpaceNotFoundError) { + throw new NotFoundException(error.message); + } + throw error; + } + } +} diff --git a/packages/spaces-management/src/nest-api/spaces-management/spaces-management.module.ts b/packages/spaces-management/src/nest-api/spaces-management/spaces-management.module.ts new file mode 100644 index 000000000..db236a77b --- /dev/null +++ b/packages/spaces-management/src/nest-api/spaces-management/spaces-management.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { SpacesManagementController } from './spaces-management.controller'; +import { SpacesManagementService } from './spaces-management.service'; +import { OrganizationAccessGuard } from '../shared/organization-access.guard'; +import { PackmindLogger, LogLevel } from '@packmind/logger'; + +@Module({ + controllers: [SpacesManagementController], + providers: [ + SpacesManagementService, + OrganizationAccessGuard, + { + provide: PackmindLogger, + useFactory: () => + new PackmindLogger('SpacesManagementModule', LogLevel.INFO), + }, + ], +}) +export class SpacesManagementModule {} diff --git a/packages/spaces-management/src/nest-api/spaces-management/spaces-management.service.ts b/packages/spaces-management/src/nest-api/spaces-management/spaces-management.service.ts new file mode 100644 index 000000000..5e3f5bb4b --- /dev/null +++ b/packages/spaces-management/src/nest-api/spaces-management/spaces-management.service.ts @@ -0,0 +1,134 @@ +import { Injectable } from '@nestjs/common'; +import { PackmindLogger } from '@packmind/logger'; +import { + CreateSpaceCommand, + CreateSpaceResponse, + ISpacesManagementPort, + ListOrganizationSpacesForManagementCommand, + ListOrganizationSpacesForManagementResponse, + MoveArtifactsToSpaceCommand, + MoveArtifactsToSpaceResponse, + BrowseSpacesCommand, + BrowseSpacesResponse, + JoinSpaceCommand, + JoinSpaceBySlugCommand, + JoinSpaceResponse, + LeaveSpaceCommand, + LeaveSpaceResponse, + UpdateSpaceCommand, + UpdateSpaceResponse, + DeleteSpaceCommand, + DeleteSpaceResponse, + PinSpaceCommand, + PinSpaceResponse, + UnpinSpaceCommand, + UnpinSpaceResponse, +} from '@packmind/types'; +import { SpacesManagementHexa } from '../../SpacesManagementHexa'; + +@Injectable() +export class SpacesManagementService { + constructor( + private readonly spacesManagementHexa: SpacesManagementHexa, + private readonly logger: PackmindLogger, + ) {} + + private get spacesManagementAdapter(): ISpacesManagementPort { + return this.spacesManagementHexa.getAdapter(); + } + + async createSpace(command: CreateSpaceCommand): Promise<CreateSpaceResponse> { + this.logger.info('Creating space', { + organizationId: command.organizationId, + }); + return this.spacesManagementAdapter.createSpace(command); + } + + async moveArtifactsToSpace( + command: MoveArtifactsToSpaceCommand, + ): Promise<MoveArtifactsToSpaceResponse> { + this.logger.info('Moving artifacts to space', { + organizationId: command.organizationId, + sourceSpaceId: command.sourceSpaceId, + destinationSpaceId: command.destinationSpaceId, + }); + return this.spacesManagementAdapter.moveArtifactsToSpace(command); + } + + async browseSpaces( + command: BrowseSpacesCommand, + ): Promise<BrowseSpacesResponse> { + this.logger.info('Browsing spaces', { + organizationId: command.organizationId, + }); + return this.spacesManagementAdapter.browseSpaces(command); + } + + async listOrganizationSpacesForManagement( + command: ListOrganizationSpacesForManagementCommand, + ): Promise<ListOrganizationSpacesForManagementResponse> { + this.logger.info('Listing organization spaces for management', { + organizationId: command.organizationId, + page: command.page, + }); + return this.spacesManagementAdapter.listOrganizationSpacesForManagement( + command, + ); + } + + async joinSpace(command: JoinSpaceCommand): Promise<JoinSpaceResponse> { + this.logger.info('Joining space', { + organizationId: command.organizationId, + spaceId: command.spaceId, + }); + return this.spacesManagementAdapter.joinSpace(command); + } + + async joinSpaceBySlug( + command: JoinSpaceBySlugCommand, + ): Promise<JoinSpaceResponse> { + this.logger.info('Joining space by slug', { + organizationId: command.organizationId, + spaceSlug: command.spaceSlug, + }); + return this.spacesManagementAdapter.joinSpaceBySlug(command); + } + + async leaveSpace(command: LeaveSpaceCommand): Promise<LeaveSpaceResponse> { + this.logger.info('Leaving space', { + organizationId: command.organizationId, + spaceId: command.spaceId, + }); + return this.spacesManagementAdapter.leaveSpace(command); + } + + async updateSpace(command: UpdateSpaceCommand): Promise<UpdateSpaceResponse> { + this.logger.info('Updating space', { + organizationId: command.organizationId, + spaceId: command.spaceId, + }); + return this.spacesManagementAdapter.updateSpace(command); + } + + async deleteSpace(command: DeleteSpaceCommand): Promise<DeleteSpaceResponse> { + this.logger.info('Deleting space', { + organizationId: command.organizationId, + spaceId: command.spaceId, + }); + return this.spacesManagementAdapter.deleteSpace(command); + } + + async pinSpace(command: PinSpaceCommand): Promise<PinSpaceResponse> { + this.logger.info('Pinning space', { + spaceId: command.spaceId, + }); + return this.spacesManagementAdapter.pinSpace(command); + } + + async unpinSpace(command: UnpinSpaceCommand): Promise<UnpinSpaceResponse> { + this.logger.info('Unpinning space', { + spaceId: command.spaceId, + }); + return this.spacesManagementAdapter.unpinSpace(command); + } +} diff --git a/packages/spaces-management/tsconfig.json b/packages/spaces-management/tsconfig.json new file mode 100644 index 000000000..57937963b --- /dev/null +++ b/packages/spaces-management/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.effective.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/spaces-management/tsconfig.lib.json b/packages/spaces-management/tsconfig.lib.json new file mode 100644 index 000000000..33eca2c2c --- /dev/null +++ b/packages/spaces-management/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/spaces-management/tsconfig.spec.json b/packages/spaces-management/tsconfig.spec.json new file mode 100644 index 000000000..0d3c604ea --- /dev/null +++ b/packages/spaces-management/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/packages/standards/src/application/adapter/StandardsAdapter.ts b/packages/standards/src/application/adapter/StandardsAdapter.ts index 5db78c870..933f61f79 100644 --- a/packages/standards/src/application/adapter/StandardsAdapter.ts +++ b/packages/standards/src/application/adapter/StandardsAdapter.ts @@ -407,6 +407,10 @@ export class StandardsAdapter return standardsPerSpace.flat(); } + countBySpaceIds(spaceIds: SpaceId[]): Promise<Map<SpaceId, number>> { + return this.services.getStandardService().countBySpaceIds(spaceIds); + } + getRuleCodeExamples(id: RuleId): Promise<RuleExample[]> { return this.repositories.getRuleExampleRepository().findByRuleId(id); } diff --git a/packages/standards/src/application/services/StandardService.ts b/packages/standards/src/application/services/StandardService.ts index 0123b4bca..80c6275d3 100644 --- a/packages/standards/src/application/services/StandardService.ts +++ b/packages/standards/src/application/services/StandardService.ts @@ -119,6 +119,10 @@ export class StandardService { } } + async countBySpaceIds(spaceIds: SpaceId[]): Promise<Map<SpaceId, number>> { + return this.standardRepository.countBySpaceIds(spaceIds); + } + async listStandardsByUser(userId: UserId): Promise<Standard[]> { this.logger.info('Listing standards by user', { userId }); diff --git a/packages/standards/src/domain/repositories/IStandardRepository.ts b/packages/standards/src/domain/repositories/IStandardRepository.ts index 095e56f86..f615181b9 100644 --- a/packages/standards/src/domain/repositories/IStandardRepository.ts +++ b/packages/standards/src/domain/repositories/IStandardRepository.ts @@ -18,6 +18,7 @@ export interface IStandardRepository extends IRepository<Standard> { opts?: Pick<QueryOption, 'includeDeleted'>, ): Promise<Standard[]>; findByUserId(userId: UserId): Promise<Standard[]>; + countBySpaceIds(spaceIds: SpaceId[]): Promise<Map<SpaceId, number>>; markAsMoved( standardId: StandardId, destinationSpaceId: SpaceId, diff --git a/packages/standards/src/infra/repositories/StandardRepository.spec.ts b/packages/standards/src/infra/repositories/StandardRepository.spec.ts index 7325a8b78..cae6e9bad 100644 --- a/packages/standards/src/infra/repositories/StandardRepository.spec.ts +++ b/packages/standards/src/infra/repositories/StandardRepository.spec.ts @@ -220,6 +220,90 @@ describe('StandardRepository', () => { }); }); + describe('countBySpaceIds', () => { + describe('when counting standards per space', () => { + let counts: Awaited< + ReturnType<typeof standardRepository.countBySpaceIds> + >; + let spaceAId: ReturnType<typeof spaceFactory>['id']; + let spaceBId: ReturnType<typeof spaceFactory>['id']; + let spaceCId: ReturnType<typeof spaceFactory>['id']; + + beforeEach(async () => { + const organizationId = createOrganizationId(uuidv4()); + const spaceA = spaceFactory({ organizationId, slug: 'space-a' }); + const spaceB = spaceFactory({ organizationId, slug: 'space-b' }); + const spaceC = spaceFactory({ organizationId, slug: 'space-c' }); + const spaceRepo = fixture.datasource.getRepository(SpaceSchema); + await spaceRepo.save([spaceA, spaceB, spaceC]); + + await standardRepository.add( + standardFactory({ spaceId: spaceA.id, slug: 'std-a-1' }), + ); + await standardRepository.add( + standardFactory({ spaceId: spaceA.id, slug: 'std-a-2' }), + ); + await standardRepository.add( + standardFactory({ spaceId: spaceB.id, slug: 'std-b-1' }), + ); + + spaceAId = spaceA.id; + spaceBId = spaceB.id; + spaceCId = spaceC.id; + + counts = await standardRepository.countBySpaceIds([ + spaceA.id, + spaceB.id, + spaceC.id, + ]); + }); + + it('returns the correct count for spaceA', () => { + expect(counts.get(spaceAId)).toBe(2); + }); + + it('returns the correct count for spaceB', () => { + expect(counts.get(spaceBId)).toBe(1); + }); + + it('omits spaceC which has zero standards', () => { + expect(counts.has(spaceCId)).toBe(false); + }); + }); + + it('returns an empty Map for empty input', async () => { + const counts = await standardRepository.countBySpaceIds([]); + expect(counts.size).toBe(0); + }); + + it('excludes soft-deleted standards from the count', async () => { + const organizationId = createOrganizationId(uuidv4()); + const space = spaceFactory({ organizationId, slug: 'space-soft-delete' }); + const spaceRepo = fixture.datasource.getRepository(SpaceSchema); + await spaceRepo.save(space); + + await standardRepository.add( + standardFactory({ spaceId: space.id, slug: 'alive-std' }), + ); + const deletedStandard = await standardRepository.add( + standardFactory({ spaceId: space.id, slug: 'deleted-std' }), + ); + await standardRepository.deleteById(deletedStandard.id); + + const counts = await standardRepository.countBySpaceIds([space.id]); + + expect(counts.get(space.id)).toBe(1); + }); + + it('omits unknown space IDs from the result Map', async () => { + const unknownSpaceId = createSpaceId(uuidv4()); + + const counts = await standardRepository.countBySpaceIds([unknownSpaceId]); + + expect(counts.has(unknownSpaceId)).toBe(false); + }); + }); + itHandlesSoftDelete<Standard>({ entityFactory: standardFactory, getRepository: () => standardRepository, diff --git a/packages/standards/src/infra/repositories/StandardRepository.ts b/packages/standards/src/infra/repositories/StandardRepository.ts index cb1076351..30b1f05db 100644 --- a/packages/standards/src/infra/repositories/StandardRepository.ts +++ b/packages/standards/src/infra/repositories/StandardRepository.ts @@ -175,6 +175,40 @@ export class StandardRepository } } + async countBySpaceIds(spaceIds: SpaceId[]): Promise<Map<SpaceId, number>> { + if (spaceIds.length === 0) { + return new Map(); + } + + this.logger.info('Counting standards by space IDs', { + spaceIdsCount: spaceIds.length, + }); + + try { + const rows = await this.repository + .createQueryBuilder('standard') + .select('standard.space_id', 'spaceId') + .addSelect('COUNT(*)', 'count') + .where('standard.space_id IN (:...spaceIds)', { spaceIds }) + .groupBy('standard.space_id') + .getRawMany<{ spaceId: SpaceId; count: string }>(); + + const counts = new Map<SpaceId, number>( + rows.map((row) => [row.spaceId, Number(row.count)]), + ); + + this.logger.info('Counted standards by space IDs', { + spacesWithStandards: counts.size, + }); + return counts; + } catch (error) { + this.logger.error('Failed to count standards by space IDs', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + async markAsMoved( standardId: StandardId, destinationSpaceId: SpaceId, diff --git a/packages/test-utils/src/factories/git/gitRepoFactory.ts b/packages/test-utils/src/factories/git/gitRepoFactory.ts index 8f36181b8..87268b4c2 100644 --- a/packages/test-utils/src/factories/git/gitRepoFactory.ts +++ b/packages/test-utils/src/factories/git/gitRepoFactory.ts @@ -11,6 +11,7 @@ export const gitRepoFactory: Factory<GitRepo> = ( repo: 'test-repo', branch: 'main', providerId: createGitProviderId(uuidv4()), + type: 'standard', ...gitRepo, }; }; diff --git a/packages/types/src/deployments/DistributionStatus.ts b/packages/types/src/deployments/DistributionStatus.ts index ab7fa56c4..2536bb821 100644 --- a/packages/types/src/deployments/DistributionStatus.ts +++ b/packages/types/src/deployments/DistributionStatus.ts @@ -1,6 +1,9 @@ export enum DistributionStatus { in_progress = 'in_progress', + pending_merge = 'pending_merge', success = 'success', failure = 'failure', no_changes = 'no_changes', + to_be_removed = 'to_be_removed', + removed = 'removed', } diff --git a/packages/types/src/deployments/Marketplace.ts b/packages/types/src/deployments/Marketplace.ts new file mode 100644 index 000000000..774fb1eda --- /dev/null +++ b/packages/types/src/deployments/Marketplace.ts @@ -0,0 +1,51 @@ +import { OrganizationId } from '../accounts/Organization'; +import { UserId } from '../accounts/User'; +import { GitRepoId } from '../git/GitRepoId'; +import { WithSoftDelete, WithTimestamps } from '../database/types'; +import { MarketplaceDescriptor } from './MarketplaceDescriptor'; +import { MarketplaceErrorKind } from './MarketplaceErrorKind'; +import { MarketplaceId } from './MarketplaceId'; +import { MarketplaceState } from './MarketplaceState'; +import { MarketplaceVendor } from './MarketplaceVendor'; + +/** + * A marketplace linked at the organization level. + * + * Owns a reference to a `GitRepo` (with `type='marketplace'`) used to fetch + * the marketplace descriptor. The descriptor and `pluginCount` are + * denormalized onto the row to make the list endpoint fast; the + * reconciliation background job keeps them fresh. + */ +export type Marketplace = WithSoftDelete< + WithTimestamps<{ + id: MarketplaceId; + organizationId: OrganizationId; + gitRepoId: GitRepoId; + name: string; + vendor: MarketplaceVendor; + addedBy: UserId; + linkedAt: Date; + state: MarketplaceState; + lastValidatedAt: Date | null; + descriptor: MarketplaceDescriptor; + pluginCount: number; + /** Sub-classification of an `unreachable` state; `null` otherwise. */ + errorKind: MarketplaceErrorKind | null; + /** Short, PII-safe, user-facing explanation of the last failure; `null` when healthy. */ + errorDetail: string | null; + /** URL of the open "Packmind sync" PR on the marketplace repo, or `null` when none is open. */ + pendingPrUrl: string | null; + /** Slugs whose served plugin is built from a package that has changed since last publish. */ + outdatedPluginSlugs: string[] | null; + /** + * Random opaque token used to identify this marketplace's tracking endpoint. + * Generated at link time; backfilled by migration for existing rows. + * Baked into published plugin sidecars so the SessionStart hook can POST + * heartbeats without needing user credentials. + * + * `null` only on rows created before the migration ran (backfill sets a + * value for all existing rows, so this should never be null in practice). + */ + trackingToken: string | null; + }> +>; diff --git a/packages/types/src/deployments/MarketplaceDescriptor.ts b/packages/types/src/deployments/MarketplaceDescriptor.ts new file mode 100644 index 000000000..ee863a18e --- /dev/null +++ b/packages/types/src/deployments/MarketplaceDescriptor.ts @@ -0,0 +1,30 @@ +import { MarketplaceVendor } from './MarketplaceVendor'; +import { PluginRef } from './PluginRef'; + +/** + * Normalized representation of a marketplace descriptor file (e.g. + * `marketplace.json`) once parsed by a vendor-specific parser. + * + * `raw` preserves the original JSON object so the reconciliation job can + * deep-compare against future fetches without re-parsing. + * + * The descriptor intentionally carries ONLY vendor-standard fields. Any + * Packmind-specific managed-plugin state lives in the standalone + * `packmind-lock.json` file at the marketplace repo root (see + * `PackmindMarketplaceLock`). + */ +export type MarketplaceDescriptor = { + vendor: MarketplaceVendor; + name: string; + version?: string; + plugins: PluginRef[]; + /** + * Plugin slugs Packmind expects to find in the descriptor but that were + * absent on the latest reconciliation sweep AND not covered by a + * `to_be_removed` distribution. Populated by + * `MarketplaceReconciliationDelayedJob` to drive the "Drift detected" + * indicator on the marketplace details view. + */ + driftedPluginSlugs?: string[]; + raw: unknown; +}; diff --git a/packages/types/src/deployments/MarketplaceDescriptorFilename.ts b/packages/types/src/deployments/MarketplaceDescriptorFilename.ts new file mode 100644 index 000000000..29ed123ff --- /dev/null +++ b/packages/types/src/deployments/MarketplaceDescriptorFilename.ts @@ -0,0 +1,25 @@ +/** + * Ordered candidate paths for the Claude Code marketplace descriptor. + * + * The official layout places the manifest at `.claude-plugin/marketplace.json` + * in the repository root + * (https://code.claude.com/docs/en/plugin-marketplaces). Some ad-hoc repos + * keep a bare `marketplace.json` at the root instead, so we accept it as a + * fallback. The descriptor lookup probes these paths in order and the first + * existing file wins. + * + * Single source of truth — used by `LinkMarketplaceUseCase`, + * `ValidateMarketplaceUrlUseCase` (at link / validation time) and + * `MarketplaceReconciliationDelayedJob` (re-fetch for health checks), all via + * `fetchMarketplaceDescriptorFile`. + */ +export const MARKETPLACE_DESCRIPTOR_PATHS = [ + '.claude-plugin/marketplace.json', + 'marketplace.json', +] as const; + +/** + * Primary (official) descriptor path. Used for display in errors and logs; the + * full lookup probes every entry in `MARKETPLACE_DESCRIPTOR_PATHS` in order. + */ +export const MARKETPLACE_DESCRIPTOR_FILENAME = MARKETPLACE_DESCRIPTOR_PATHS[0]; diff --git a/packages/types/src/deployments/MarketplaceDistribution.ts b/packages/types/src/deployments/MarketplaceDistribution.ts new file mode 100644 index 000000000..12a509042 --- /dev/null +++ b/packages/types/src/deployments/MarketplaceDistribution.ts @@ -0,0 +1,57 @@ +import { OrganizationId } from '../accounts/Organization'; +import { UserId } from '../accounts/User'; +import { WithSoftDelete, WithTimestamps } from '../database/types'; +import { DistributionSource } from './Distribution'; +import { DistributionStatus } from './DistributionStatus'; +import { MarketplaceDistributionId } from './MarketplaceDistributionId'; +import { MarketplaceId } from './MarketplaceId'; +import { PackageId } from './Package'; +import { PublishFailureReason } from './PublishFailureReason'; +import { VersionFingerprint } from './VersionFingerprint'; + +/** + * Persistent record of a single attempt to publish a Packmind package as a + * managed plugin on a linked marketplace. + * + * Mirrors the code-repository `Distribution` shape so the frontend can + * surface progress, success, no-op, and failure to the user through the + * same conceptual model. One row per click — never reused. + * + * Soft-delete-aware: rows survive package/marketplace removal for audit. + */ +export type MarketplaceDistribution = WithSoftDelete< + WithTimestamps<{ + id: MarketplaceDistributionId; + organizationId: OrganizationId; + marketplaceId: MarketplaceId; + packageId: PackageId; + pluginSlug: string; + authorId: UserId; + status: DistributionStatus; + source: DistributionSource; + prUrl?: string; + gitCommit?: string; + error?: string; + failureReason?: PublishFailureReason; + contentHash?: string; + /** Artifact-version fingerprint captured at publish time; used to flag the marketplace outdated. */ + versionFingerprint?: VersionFingerprint; + /** + * Timestamp set by the reconciliation sweep the moment it confirms the + * publish actually landed on the marketplace's default branch + * (`pending_merge → success`, matched via the `packmind-lock.json` + * content hash). Null while the rolling sync PR is still open — this is + * the honest "published" date, as opposed to `createdAt` which only + * records when the publish was requested. + */ + publishConfirmedAt?: Date | null; + /** + * Timestamp captured the moment a removal is requested from the UI. + * Set synchronously by `MarkPluginForRemovalUseCase` while `status` stays + * `success` — the status only flips to `to_be_removed` once the deletion + * lands on the rolling sync branch. Lets the UI surface a "removal pending" + * state immediately and makes a repeated request idempotent. + */ + removalRequestedAt?: Date | null; + }> +>; diff --git a/packages/types/src/deployments/MarketplaceDistributionId.ts b/packages/types/src/deployments/MarketplaceDistributionId.ts new file mode 100644 index 000000000..1c63dd138 --- /dev/null +++ b/packages/types/src/deployments/MarketplaceDistributionId.ts @@ -0,0 +1,5 @@ +import { Branded, brandedIdFactory } from '../brandedTypes'; + +export type MarketplaceDistributionId = Branded<'MarketplaceDistributionId'>; +export const createMarketplaceDistributionId = + brandedIdFactory<MarketplaceDistributionId>(); diff --git a/packages/types/src/deployments/MarketplaceErrorKind.ts b/packages/types/src/deployments/MarketplaceErrorKind.ts new file mode 100644 index 000000000..a363bc3c3 --- /dev/null +++ b/packages/types/src/deployments/MarketplaceErrorKind.ts @@ -0,0 +1,15 @@ +/** + * Sub-classification of a marketplace fetch failure, set by the reconcile + * job so the UI can show a specific message without inspecting raw errors. + * + * Orthogonal to `MarketplaceState`: it is only meaningful when the state is + * `unreachable`, and is `null` on every successful (healthy/drift) check. + * + * - `auth_failed`: the configured credentials are invalid/expired (401/403). + * - `repo_not_found`: the upstream repository is gone / renamed (404 on repo). + * - `network_transient`: a recoverable network/timeout blip. + */ +export type MarketplaceErrorKind = + | 'auth_failed' + | 'repo_not_found' + | 'network_transient'; diff --git a/packages/types/src/deployments/MarketplaceId.ts b/packages/types/src/deployments/MarketplaceId.ts new file mode 100644 index 000000000..a42f6ef0b --- /dev/null +++ b/packages/types/src/deployments/MarketplaceId.ts @@ -0,0 +1,4 @@ +import { Branded, brandedIdFactory } from '../brandedTypes'; + +export type MarketplaceId = Branded<'MarketplaceId'>; +export const createMarketplaceId = brandedIdFactory<MarketplaceId>(); diff --git a/packages/types/src/deployments/MarketplaceListItem.ts b/packages/types/src/deployments/MarketplaceListItem.ts new file mode 100644 index 000000000..b7dc773ec --- /dev/null +++ b/packages/types/src/deployments/MarketplaceListItem.ts @@ -0,0 +1,44 @@ +import { Marketplace } from './Marketplace'; +import { GitProviderId, GitProviderVendor } from '../git/GitProvider'; + +/** + * Git repository coordinates surfaced alongside a marketplace in the list + * endpoint so the UI can show which provider backs the marketplace and link + * out to the repository. + * + * `gitProviderId` lets the UI group marketplaces by `GitProvider` (used by + * the Git connections page to render per-connection marketplace lists + * without an extra round-trip). + * + * `url` is the repository's web URL (not the API URL) so it can be opened + * directly in a browser; it is empty when the provider vendor is unknown. + */ +export type MarketplaceRepositoryInfo = { + gitProviderId: GitProviderId; + owner: string; + repo: string; + branch: string; + providerSource: GitProviderVendor; + url: string; +}; + +/** + * Presentation DTO returned by `ListMarketplacesUseCase`. + * + * Enriches the domain `Marketplace` with the display name of the user who + * added it and with the backing repository's coordinates (`repository`). + * `pluginCount` already lives on the domain entity (denormalized for fast + * reads), so it is inherited via the intersection — no need to re-declare it + * here. + * + * `repository` is `null` when the backing `GitRepo` can no longer be resolved + * (e.g. it was hard-deleted out from under the marketplace row). + * + * Per `standard-typescript-good-practices.md`, presentation DTOs that enrich + * a domain type are expressed as an intersection so structural drift on the + * domain type is caught at compile time. + */ +export type MarketplaceListItem = Marketplace & { + addedByUserName: string; + repository: MarketplaceRepositoryInfo | null; +}; diff --git a/packages/types/src/deployments/MarketplaceState.ts b/packages/types/src/deployments/MarketplaceState.ts new file mode 100644 index 000000000..4063ed057 --- /dev/null +++ b/packages/types/src/deployments/MarketplaceState.ts @@ -0,0 +1,18 @@ +/** + * Health state of a linked marketplace, computed by the periodic + * reconciliation job (and by the publish use case when descriptor problems + * are detected at publish time). + * + * - `healthy`: descriptor matches the snapshot stored at link time. + * - `drift`: descriptor was successfully fetched but differs from the + * snapshot — admins should review. + * - `unreachable`: the descriptor could not be fetched (network error, + * provider down, repo removed, etc.). + * - `bad_format`: the descriptor was reachable but missing or unparseable + * (broken contract — admins must fix `marketplace.json`). + */ +export type MarketplaceState = + | 'healthy' + | 'drift' + | 'unreachable' + | 'bad_format'; diff --git a/packages/types/src/deployments/MarketplaceVendor.ts b/packages/types/src/deployments/MarketplaceVendor.ts new file mode 100644 index 000000000..368d4052b --- /dev/null +++ b/packages/types/src/deployments/MarketplaceVendor.ts @@ -0,0 +1,9 @@ +/** + * Marketplace vendor discriminator. + * + * Typed as a discriminated union to stay extensible — additional vendors + * (e.g. `'cursor'`) are appended here without touching consumers. + * + * v1 ships with `'anthropic'` only. + */ +export type MarketplaceVendor = 'anthropic'; diff --git a/packages/types/src/deployments/PackmindMarketplaceLock.ts b/packages/types/src/deployments/PackmindMarketplaceLock.ts new file mode 100644 index 000000000..953635b8d --- /dev/null +++ b/packages/types/src/deployments/PackmindMarketplaceLock.ts @@ -0,0 +1,38 @@ +import { UserId } from '../accounts/User'; + +/** + * Filename of the standalone Packmind marketplace lock file. The file is + * always located at the marketplace repository root, regardless of where + * the vendor descriptor (e.g. `marketplace.json`) lives. + * + * Single source of truth for the filename. Re-exported as a path constant + * by `packages/deployments/src/application/services/packmindMarketplaceLock.ts`. + */ +export const PACKMIND_MARKETPLACE_LOCK_FILENAME = 'packmind-lock.json'; + +/** + * Per-plugin lock entry. Captures the canonical Packmind-managed state for + * a single plugin slug on the marketplace — used for drift detection, + * idempotency on republish, and to mark the slug as Packmind-managed (i.e. + * exempt from the unmanaged-name-collision check). + */ +export type PackmindMarketplaceLockPluginEntry = { + version: string; + contentHash: string; + /** ISO-8601 timestamp of the last successful publish for this slug. */ + lastPublishedAt: string; + lastPublishedBy: UserId; +}; + +/** + * Top-level shape of the standalone Packmind marketplace lock file. + * + * The lock is a pure plugin map — it does NOT carry marketplace identity, + * organization identity, or any other Packmind backend metadata. Plugin + * slugs are the only keys. Lift any future cross-cutting metadata into a + * separate file rather than expanding this shape. + */ +export type PackmindMarketplaceLock = { + schemaVersion: 1; + plugins: Record<string, PackmindMarketplaceLockPluginEntry>; +}; diff --git a/packages/types/src/deployments/PluginInstallation.ts b/packages/types/src/deployments/PluginInstallation.ts new file mode 100644 index 000000000..6f801a63f --- /dev/null +++ b/packages/types/src/deployments/PluginInstallation.ts @@ -0,0 +1,78 @@ +import { OrganizationId } from '../accounts/Organization'; +import { UserId } from '../accounts/User'; +import { WithSoftDelete, WithTimestamps } from '../database/types'; +import { MarketplaceId } from './MarketplaceId'; +import { PackageId } from './Package'; +import { PluginInstallationId } from './PluginInstallationId'; + +/** + * Scope at which a Packmind plugin is enabled in Claude Code. + * + * - `user` — enabled in the user's global `~/.claude/settings.json` + * - `project` — enabled in the project's committed `.claude/settings.json` + * - `local` — enabled in the user's uncommitted `.claude/settings.local.json` + */ +export type PluginInstallScope = 'user' | 'project' | 'local'; + +/** + * Heartbeat record: evidence that a plugin was active in a Claude Code session. + * + * Each row represents a unique (marketplace, pluginSlug, scope, identityKey, repoKey) + * combination. The UNIQUE index on those five columns collapses repeated heartbeats + * into a single row. `createdAt` marks the first-seen time (preserved as the + * earliest value on merge); `updatedAt` is bumped to the last-seen time on every + * heartbeat. + * + * ### Absent-field key rule (§7.1) + * Both `identityKey` and `repoKey` are NOT NULL — the domain guarantees a + * non-null string. They may be empty-string (`''`) per the semantics below: + * + * - `identityKey` = `userId` ?? `anonymousIdHash` ?? `''` + * - `repoKey` = `''` when `scope === 'user'`, else the normalized `owner/repo` + * slug of `repoRemoteUrl` ?? the raw `repoRemoteUrl` ?? `''` + * + * This forces all identity-less heartbeats for the same (plugin, scope, repo) + * into one row and lets the UNIQUE index work correctly (Postgres treats NULLs + * as distinct, so a nullable key would defeat the index). + */ +export type PluginInstallation = WithSoftDelete< + WithTimestamps<{ + id: PluginInstallationId; + organizationId: OrganizationId; + marketplaceId: MarketplaceId; + pluginSlug: string; + /** Best-effort resolution from `pluginSlug`; `null` when unresolvable. */ + packageId: PackageId | null; + /** + * Version of the plugin the heartbeat reported as installed, read from the + * installed `.claude-plugin/plugin.json` manifest. Refreshed to the latest + * reported value on every heartbeat. `null` for rows created before install + * tracking captured a version, or when the version could not be resolved. + */ + installedVersion: string | null; + scope: PluginInstallScope; + /** Set only when the API-key JWT was verified against the token's org. */ + userId: UserId | null; + /** SHA-256 hash of the lowercased Claude account email (pseudonymous dedup key). */ + anonymousIdHash: string | null; + /** Masked display form of the Claude account email, e.g. `b**.s***@acme.com`. */ + anonymousEmailMasked: string | null; + /** + * Computed key, NOT NULL. + * Value: `userId ?? anonymousIdHash ?? ''` + */ + identityKey: string; + /** + * Raw git remote URL, e.g. `https://github.com/acme/frontend.git`. + * Always `null` when `scope === 'user'`: a user-scope install is global and + * not bound to a repository, so the repo is never tracked. + */ + repoRemoteUrl: string | null; + /** + * Computed key, NOT NULL. + * Value: `''` when `scope === 'user'`, else the normalized `owner/repo` slug + * of `repoRemoteUrl` ?? the raw `repoRemoteUrl` ?? `''`. + */ + repoKey: string; + }> +>; diff --git a/packages/types/src/deployments/PluginInstallationId.ts b/packages/types/src/deployments/PluginInstallationId.ts new file mode 100644 index 000000000..cd2c46e41 --- /dev/null +++ b/packages/types/src/deployments/PluginInstallationId.ts @@ -0,0 +1,5 @@ +import { Branded, brandedIdFactory } from '../brandedTypes'; + +export type PluginInstallationId = Branded<'PluginInstallationId'>; +export const createPluginInstallationId = + brandedIdFactory<PluginInstallationId>(); diff --git a/packages/types/src/deployments/PluginRef.ts b/packages/types/src/deployments/PluginRef.ts new file mode 100644 index 000000000..52dac8c66 --- /dev/null +++ b/packages/types/src/deployments/PluginRef.ts @@ -0,0 +1,37 @@ +/** + * Source coordinates that tell a marketplace consumer (e.g. Claude Code) + * how to fetch a plugin entry's content. + * + * Currently only the `git-subdir` shape is emitted by Packmind's publish + * pipeline — the type discriminator is left as a string literal so other + * vendor-specific source kinds (HTTP archive, registry, etc.) can be added + * as a union later without touching call sites that already accept + * `PluginSource`. + */ +export type PluginSource = { + source: 'git-subdir'; + url: string; + path: string; +}; + +/** + * A single plugin entry declared inside a marketplace descriptor + * (e.g. `marketplace.json`). + * + * Vendor-agnostic shape — concrete parsers in + * `packages/deployments/.../parsers/` translate vendor-specific JSON into this + * normalized form. + * + * `source` is optional on the type because parsers must tolerate legacy or + * unmanaged plugin entries on disk that pre-date the Packmind-published + * `source` block. Packmind-managed publishes always write a populated + * `source` field through `applyPluginDescriptorMutation`, so the disk state + * converges as soon as a managed plugin is republished. + */ +export type PluginRef = { + slug: string; + name: string; + version?: string; + description?: string; + source?: PluginSource; +}; diff --git a/packages/types/src/deployments/PublishFailureReason.ts b/packages/types/src/deployments/PublishFailureReason.ts new file mode 100644 index 000000000..de1874999 --- /dev/null +++ b/packages/types/src/deployments/PublishFailureReason.ts @@ -0,0 +1,18 @@ +/** + * Categorical reason a marketplace publish attempt failed. + * + * Surfaced on `MarketplaceDistribution.failureReason` and on + * `PluginPublishFailedEvent` so the UI, analytics, and listeners can branch + * on a stable, low-cardinality value without parsing error messages. + * + * - `descriptor_missing`: `marketplace.json` could not be located or parsed. + * - `name_conflict_unmanaged`: an unmanaged plugin on the marketplace already + * exposes the same name as the package being published. + * - `invalid_token`: the Git provider token was missing/expired/invalid. + * - `other`: catch-all for unexpected failures (network, Git, etc.). + */ +export type PublishFailureReason = + | 'descriptor_missing' + | 'name_conflict_unmanaged' + | 'invalid_token' + | 'other'; diff --git a/packages/types/src/deployments/VersionFingerprint.spec.ts b/packages/types/src/deployments/VersionFingerprint.spec.ts new file mode 100644 index 000000000..1e2ee6536 --- /dev/null +++ b/packages/types/src/deployments/VersionFingerprint.spec.ts @@ -0,0 +1,53 @@ +import { + versionFingerprintsEqual, + VersionFingerprint, +} from './VersionFingerprint'; + +const fp = (over: Partial<VersionFingerprint> = {}): VersionFingerprint => ({ + recipes: {}, + standards: {}, + skills: {}, + ...over, +}); + +describe('versionFingerprintsEqual', () => { + describe('when either side is undefined', () => { + it('returns false for an undefined first argument', () => { + expect(versionFingerprintsEqual(undefined, fp())).toBe(false); + }); + + it('returns false for an undefined second argument', () => { + expect(versionFingerprintsEqual(fp(), undefined)).toBe(false); + }); + }); + + describe('when both fingerprints carry identical maps', () => { + it('returns true regardless of key order', () => { + expect( + versionFingerprintsEqual( + fp({ recipes: { a: 1, b: 2 } }), + fp({ recipes: { b: 2, a: 1 } }), + ), + ).toBe(true); + }); + }); + + describe('when a version number changed', () => { + it('returns false', () => { + expect( + versionFingerprintsEqual( + fp({ recipes: { a: 1 } }), + fp({ recipes: { a: 2 } }), + ), + ).toBe(false); + }); + }); + + describe('when an artifact was added or removed', () => { + it('returns false', () => { + expect( + versionFingerprintsEqual(fp({ skills: { s: 1 } }), fp({ skills: {} })), + ).toBe(false); + }); + }); +}); diff --git a/packages/types/src/deployments/VersionFingerprint.ts b/packages/types/src/deployments/VersionFingerprint.ts new file mode 100644 index 000000000..631c0aa33 --- /dev/null +++ b/packages/types/src/deployments/VersionFingerprint.ts @@ -0,0 +1,36 @@ +/** + * Snapshot of the current version numbers of every artifact in a package at + * the moment a plugin was published to a marketplace. Keyed by artifact id so + * additions/removals are detected too. Compared against the package's current + * fingerprint on reconcile to flag a marketplace as "outdated". + */ +export type VersionFingerprint = { + recipes: Record<string, number>; + standards: Record<string, number>; + skills: Record<string, number>; +}; + +/** + * Stable equality between two fingerprints. Returns `false` when either side + * is absent (e.g. a distribution published before fingerprints existed) so + * such rows are treated as "cannot determine" by the caller, never outdated. + */ +export function versionFingerprintsEqual( + a: VersionFingerprint | undefined, + b: VersionFingerprint | undefined, +): boolean { + if (!a || !b) { + return false; + } + const sameMap = (x: Record<string, number>, y: Record<string, number>) => { + const xk = Object.keys(x).sort(); + const yk = Object.keys(y).sort(); + if (xk.length !== yk.length) return false; + return xk.every((k, i) => k === yk[i] && x[k] === y[k]); + }; + return ( + sameMap(a.recipes, b.recipes) && + sameMap(a.standards, b.standards) && + sameMap(a.skills, b.skills) + ); +} diff --git a/packages/types/src/deployments/contracts/IFindMarketplaceDistributionByIdUseCase.ts b/packages/types/src/deployments/contracts/IFindMarketplaceDistributionByIdUseCase.ts new file mode 100644 index 000000000..ec07494d5 --- /dev/null +++ b/packages/types/src/deployments/contracts/IFindMarketplaceDistributionByIdUseCase.ts @@ -0,0 +1,28 @@ +import { IUseCase, PackmindCommand } from '../../UseCase'; +import { MarketplaceDistribution } from '../MarketplaceDistribution'; +import { MarketplaceDistributionId } from '../MarketplaceDistributionId'; + +/** + * Member-scoped command used by the controller's polling endpoint to look up + * a single `MarketplaceDistribution` row by id, scoped to the caller's + * organization. + */ +export type FindMarketplaceDistributionByIdCommand = PackmindCommand & { + marketplaceDistributionId: MarketplaceDistributionId; +}; + +/** + * Response wraps the optional row so the contract can be extended later + * (e.g. to carry a transient PR url or a join-loaded marketplace) without + * a breaking change to every caller. `marketplaceDistribution` is `null` + * when the row is missing or belongs to another organization — callers + * should map that to HTTP 404. + */ +export type FindMarketplaceDistributionByIdResponse = { + marketplaceDistribution: MarketplaceDistribution | null; +}; + +export type IFindMarketplaceDistributionByIdUseCase = IUseCase< + FindMarketplaceDistributionByIdCommand, + FindMarketplaceDistributionByIdResponse +>; diff --git a/packages/types/src/deployments/contracts/IGetMarketplaceDistributionChangesUseCase.ts b/packages/types/src/deployments/contracts/IGetMarketplaceDistributionChangesUseCase.ts new file mode 100644 index 000000000..0b89f7678 --- /dev/null +++ b/packages/types/src/deployments/contracts/IGetMarketplaceDistributionChangesUseCase.ts @@ -0,0 +1,72 @@ +import { IUseCase, PackmindCommand } from '../../UseCase'; +import { MarketplaceDistributionId } from '../MarketplaceDistributionId'; +import { MarketplaceId } from '../MarketplaceId'; + +export type SourcePackageChangeKind = 'added' | 'updated' | 'removed'; + +/** + * Marketplace-facing artifact taxonomy used by the plugin detail "Changes" + * tab. Maps Packmind's domain artifacts (recipes / standards / skills) onto + * the vocabulary the marketplace prototype exposes to organisation members. + */ +export type MarketplaceArtifactKind = 'command' | 'standard' | 'skill'; + +/** + * One artifact-level change between what a distribution captured at publish + * time and what the source package currently looks like. Surfaced as a flat + * list by `GetMarketplaceDistributionChangesUseCase`. + * + * `publishedVersion` is `null` for added artifacts; `currentVersion` is `null` + * for removed artifacts. For updated artifacts both fields are populated. + */ +export type SourcePackageChange = { + kind: SourcePackageChangeKind; + artifactKind: MarketplaceArtifactKind; + name: string; + slug: string; + publishedVersion: number | null; + currentVersion: number | null; +}; + +/** + * Where the source package sits relative to what is published on the + * marketplace. Drives the Changes-tab action button: + * + * - `in_sync_main` → no action button (already on the marketplace's + * default branch). + * - `in_sync_pr` → "View pull request" — every source change is already + * captured by the open sync PR; the user just needs to wait for the + * review/merge. + * - `outdated_pr` → "Publish" — a sync PR is open but the source has + * drifted further since it was opened; publishing amends the PR. + * - `outdated_main` → "Publish" — no sync PR currently includes this + * package; publishing opens (or amends) one. + */ +export type SourcePackagePublishState = + | 'in_sync_main' + | 'in_sync_pr' + | 'outdated_pr' + | 'outdated_main'; + +export type GetMarketplaceDistributionChangesCommand = PackmindCommand & { + marketplaceId: MarketplaceId; + distributionId: MarketplaceDistributionId; +}; + +/** + * View state for the Changes tab. `changes` is the diff between the source + * package and the latest *success* baseline (i.e. what's actually live on + * the marketplace's default branch) — that's the natural list to surface, + * regardless of whether an intermediate PR is open. `prUrl` is populated + * when a sync PR currently includes this package. + */ +export type GetMarketplaceDistributionChangesResponse = { + state: SourcePackagePublishState; + changes: SourcePackageChange[]; + prUrl: string | null; +}; + +export type IGetMarketplaceDistributionChangesUseCase = IUseCase< + GetMarketplaceDistributionChangesCommand, + GetMarketplaceDistributionChangesResponse +>; diff --git a/packages/types/src/deployments/contracts/ILinkMarketplaceUseCase.ts b/packages/types/src/deployments/contracts/ILinkMarketplaceUseCase.ts new file mode 100644 index 000000000..96a9b2800 --- /dev/null +++ b/packages/types/src/deployments/contracts/ILinkMarketplaceUseCase.ts @@ -0,0 +1,34 @@ +import { IUseCase, PackmindCommand } from '../../UseCase'; +import { GitProviderId } from '../../git/GitProvider'; +import { Marketplace } from '../Marketplace'; + +/** + * Command used by an organization admin to link a Git repository as a + * marketplace for the organization (private path — repo accessed through a + * connected `GitProvider`). + */ +export type LinkMarketplaceCommand = PackmindCommand & { + gitProviderId: GitProviderId; + owner: string; + repo: string; + branch: string; + name: string; +}; + +/** + * Response returned by `ILinkMarketplaceUseCase`. The domain `Marketplace` + * entity is enriched with the display name of the admin who added it so the + * frontend can render the "added by" column without a second round-trip. + * + * Per `standard-typescript-good-practices.md`, presentation DTOs enriching a + * domain type use an intersection so structural drift on the domain type is + * caught at compile time. + */ +export type LinkMarketplaceResponse = Marketplace & { + addedByUserName: string; +}; + +export type ILinkMarketplaceUseCase = IUseCase< + LinkMarketplaceCommand, + LinkMarketplaceResponse +>; diff --git a/packages/types/src/deployments/contracts/IListMarketplaceDistributionsForPackageUseCase.ts b/packages/types/src/deployments/contracts/IListMarketplaceDistributionsForPackageUseCase.ts new file mode 100644 index 000000000..80057cc54 --- /dev/null +++ b/packages/types/src/deployments/contracts/IListMarketplaceDistributionsForPackageUseCase.ts @@ -0,0 +1,23 @@ +import { IUseCase, PackmindCommand } from '../../UseCase'; +import { MarketplaceDistribution } from '../MarketplaceDistribution'; +import { PackageId } from '../Package'; + +/** + * Member-scoped command used by the frontend status helper to fetch every + * marketplace distribution row for a single package (newest first). + */ +export type ListMarketplaceDistributionsForPackageCommand = PackmindCommand & { + packageId: PackageId; +}; + +/** + * Response — list of marketplace distribution rows, ordered most recent + * first. Empty when the package has never been published to a marketplace. + */ +export type ListMarketplaceDistributionsForPackageResponse = + MarketplaceDistribution[]; + +export type IListMarketplaceDistributionsForPackageUseCase = IUseCase< + ListMarketplaceDistributionsForPackageCommand, + ListMarketplaceDistributionsForPackageResponse +>; diff --git a/packages/types/src/deployments/contracts/IListMarketplaceDistributionsUseCase.ts b/packages/types/src/deployments/contracts/IListMarketplaceDistributionsUseCase.ts new file mode 100644 index 000000000..b6aa7e82d --- /dev/null +++ b/packages/types/src/deployments/contracts/IListMarketplaceDistributionsUseCase.ts @@ -0,0 +1,50 @@ +import { SpaceColor } from '../../spaces/SpaceColor'; +import { SpaceId } from '../../spaces/SpaceId'; +import { IUseCase, PackmindCommand } from '../../UseCase'; +import { MarketplaceDistribution } from '../MarketplaceDistribution'; +import { MarketplaceId } from '../MarketplaceId'; + +/** + * Presentation DTO enriching a domain `MarketplaceDistribution` with the + * Packmind package name, the display name of the author who originally + * published the plugin, and the owning Space (id, name, palette color). + * Expressed as an intersection so structural drift on the domain type is + * caught at compile time per `standard-typescript-good-practices.md`. + * + * `space` is `null` when the source package or its space can no longer be + * resolved (either was hard-deleted out from under the distribution row). + * + * `lastPublishedOnMainAt` is the `publishConfirmedAt` of the most recent + * `success` distribution for this package on this marketplace, regardless of + * whether the latest row itself is `success` or `pending_merge`. This lets + * the UI keep showing "last published Xd ago" while a new PR is in flight, + * and tell apart a never-landed-on-main first publish from a re-publish. + * Null when no `success` has ever landed for this package. + */ +export type MarketplaceDistributionListItem = MarketplaceDistribution & { + packageName: string; + packageSlug: string; + authorName: string; + space: { + id: SpaceId; + name: string; + color: SpaceColor; + } | null; + lastPublishedOnMainAt: Date | null; +}; + +/** + * Command used by any organization member to list the distributions for a + * given marketplace owned by the caller's organization. + */ +export type ListMarketplaceDistributionsCommand = PackmindCommand & { + marketplaceId: MarketplaceId; +}; + +export type ListMarketplaceDistributionsResponse = + MarketplaceDistributionListItem[]; + +export type IListMarketplaceDistributionsUseCase = IUseCase< + ListMarketplaceDistributionsCommand, + ListMarketplaceDistributionsResponse +>; diff --git a/packages/types/src/deployments/contracts/IListMarketplacePluginInstallsUseCase.ts b/packages/types/src/deployments/contracts/IListMarketplacePluginInstallsUseCase.ts new file mode 100644 index 000000000..39c5d5e6c --- /dev/null +++ b/packages/types/src/deployments/contracts/IListMarketplacePluginInstallsUseCase.ts @@ -0,0 +1,35 @@ +import { IUseCase, PackmindCommand } from '../../UseCase'; +import { PluginInstallation } from '../PluginInstallation'; +import { MarketplaceId } from '../MarketplaceId'; + +/** + * Presentation DTO enriching a `PluginInstallation` row with an optional + * resolved display name for attributed installs. + * + * Expressed as an intersection type (TypeScript good practices standard) so + * structural drift on the domain type is caught at compile time. + */ +export type PluginInstallationListItem = PluginInstallation & { + /** Display name of the attributed user, or `null` when anonymous / unresolved. */ + userDisplayName: string | null; +}; + +/** + * Command used by any organization member to list all tracked plugin installs + * for a given marketplace owned by the caller's organization. + */ +export type ListMarketplacePluginInstallsCommand = PackmindCommand & { + marketplaceId: MarketplaceId; +}; + +/** + * Response — all installation rows for the marketplace, enriched with display + * names. Volume is low; the frontend groups and counts client-side. + */ +export type ListMarketplacePluginInstallsResponse = + PluginInstallationListItem[]; + +export type IListMarketplacePluginInstallsUseCase = IUseCase< + ListMarketplacePluginInstallsCommand, + ListMarketplacePluginInstallsResponse +>; diff --git a/packages/types/src/deployments/contracts/IListMarketplacesUseCase.ts b/packages/types/src/deployments/contracts/IListMarketplacesUseCase.ts new file mode 100644 index 000000000..6af2a3858 --- /dev/null +++ b/packages/types/src/deployments/contracts/IListMarketplacesUseCase.ts @@ -0,0 +1,20 @@ +import { IUseCase, PackmindCommand } from '../../UseCase'; +import { MarketplaceListItem } from '../MarketplaceListItem'; + +/** + * Command used by any organization member to list marketplaces linked to the + * caller's organization. No additional parameters are required beyond the + * standard auth context carried by `PackmindCommand`. + */ +export type ListMarketplacesCommand = PackmindCommand; + +/** + * Response — list of presentation DTOs enriched with `addedByUserName` and + * carrying the denormalized `pluginCount` from the row. + */ +export type ListMarketplacesResponse = MarketplaceListItem[]; + +export type IListMarketplacesUseCase = IUseCase< + ListMarketplacesCommand, + ListMarketplacesResponse +>; diff --git a/packages/types/src/deployments/contracts/IMarkPluginForRemovalUseCase.ts b/packages/types/src/deployments/contracts/IMarkPluginForRemovalUseCase.ts new file mode 100644 index 000000000..eba59a804 --- /dev/null +++ b/packages/types/src/deployments/contracts/IMarkPluginForRemovalUseCase.ts @@ -0,0 +1,35 @@ +import { IUseCase, PackmindCommand } from '../../UseCase'; +import { MarketplaceDistribution } from '../MarketplaceDistribution'; +import { MarketplaceDistributionId } from '../MarketplaceDistributionId'; +import { MarketplaceId } from '../MarketplaceId'; +import { PackageId } from '../Package'; + +/** + * Command used by an organization admin to mark a published marketplace plugin + * distribution as `to_be_removed`. + * + * The target distribution can be resolved either directly by + * `distributionId`, or indirectly by `packageId` (in which case the latest + * `success`-state distribution for the `(package, marketplace)` pair is + * selected). The two shapes form a discriminated union so callers cannot pass + * both at once. + */ +export type MarkPluginForRemovalCommand = PackmindCommand & { + marketplaceId: MarketplaceId; +} & ( + | { distributionId: MarketplaceDistributionId; packageId?: never } + | { packageId: PackageId; distributionId?: never } + ); + +/** + * Response — returns the mutated distribution row so the frontend can update + * its cache without a refetch. + */ +export type MarkPluginForRemovalResponse = { + distribution: MarketplaceDistribution; +}; + +export type IMarkPluginForRemovalUseCase = IUseCase< + MarkPluginForRemovalCommand, + MarkPluginForRemovalResponse +>; diff --git a/packages/types/src/deployments/contracts/IPublishPackageOnMarketplaceUseCase.ts b/packages/types/src/deployments/contracts/IPublishPackageOnMarketplaceUseCase.ts new file mode 100644 index 000000000..d9c112ac4 --- /dev/null +++ b/packages/types/src/deployments/contracts/IPublishPackageOnMarketplaceUseCase.ts @@ -0,0 +1,44 @@ +import { IUseCase, PackmindCommand } from '../../UseCase'; +import { DistributionSource } from '../Distribution'; +import { MarketplaceDistributionId } from '../MarketplaceDistributionId'; +import { MarketplaceId } from '../MarketplaceId'; +import { PackageId } from '../Package'; + +/** + * Command issued by an org member to publish a Packmind package as a managed + * plugin on a linked marketplace. + * + * The publish use case extends `AbstractMemberUseCase` — any member of the + * organization owning both the marketplace and the package can trigger it. + * + * `distributionSource` defaults to `'app'` when omitted, mirroring the + * convention used by the code-repository distribution pipeline. It is + * distinct from `PackmindCommand.source` (`PackmindEventSource`), which + * already disambiguates UI/CLI/MCP call sites for analytics events. + */ +export type PublishPackageOnMarketplaceCommand = PackmindCommand & { + marketplaceId: MarketplaceId; + packageId: PackageId; + distributionSource?: DistributionSource; +}; + +/** + * Response returned by `IPublishPackageOnMarketplaceUseCase`. + * + * The use case is asynchronous-by-handoff: it persists an `in_progress` + * marketplace distribution row, enqueues the BullMQ publish job, and returns + * the freshly created row identifier so the frontend can poll for the final + * status (success / failure / no_changes). + */ +export type PublishPackageOnMarketplaceResponse = { + marketplaceDistributionId: MarketplaceDistributionId; + status: 'in_progress'; + marketplaceId: MarketplaceId; + packageId: PackageId; + pluginSlug: string; +}; + +export type IPublishPackageOnMarketplaceUseCase = IUseCase< + PublishPackageOnMarketplaceCommand, + PublishPackageOnMarketplaceResponse +>; diff --git a/packages/types/src/deployments/contracts/IRenderPackageAsPluginUseCase.ts b/packages/types/src/deployments/contracts/IRenderPackageAsPluginUseCase.ts index e4ef3f2da..1a84d13db 100644 --- a/packages/types/src/deployments/contracts/IRenderPackageAsPluginUseCase.ts +++ b/packages/types/src/deployments/contracts/IRenderPackageAsPluginUseCase.ts @@ -15,6 +15,21 @@ export type RenderPackageAsPluginCommand = PackmindCommand & { gitRemoteUrl?: string; /** Git branch of the render target. */ gitBranch?: string; + /** + * When present (marketplace mode only), instructs the deployer to bundle + * install-tracking hook files into the plugin. Absent → no tracking files + * emitted (standalone renders are unchanged). + */ + installTracking?: { + /** Base URL of the Packmind API, e.g. `https://app.packmind.io/api`. */ + apiBaseUrl: string; + /** Marketplace name, used as the hook's `marketplaceName` payload field. */ + marketplaceName: string; + /** Plugin slug matching `MarketplaceDistribution.pluginSlug`. */ + pluginSlug: string; + /** Write-only tracking token baked into the published plugin. */ + trackingToken: string; + }; }; export type RenderedPluginFile = { diff --git a/packages/types/src/deployments/contracts/ISyncMarketplaceNowUseCase.ts b/packages/types/src/deployments/contracts/ISyncMarketplaceNowUseCase.ts new file mode 100644 index 000000000..b996d81c3 --- /dev/null +++ b/packages/types/src/deployments/contracts/ISyncMarketplaceNowUseCase.ts @@ -0,0 +1,34 @@ +import { IUseCase, PackmindCommand } from '../../UseCase'; +import { MarketplaceErrorKind } from '../MarketplaceErrorKind'; +import { MarketplaceId } from '../MarketplaceId'; +import { MarketplaceState } from '../MarketplaceState'; + +/** + * Triggers an immediate, on-demand reconciliation of a single marketplace — + * the same sweep the repeatable cron runs, but kicked synchronously by an org + * member from the marketplace details view. Lets users refresh marketplace + * state without waiting for the next scheduled reconciliation (the stop-gap + * for the up-to-30-min lag until Git webhooks land). + * + * Member-scoped: any org member who can see the marketplace can refresh it. + * The reconciliation only syncs Packmind's view to the repo's reality + * (drift detection + `to_be_removed → removed` transitions); it never lets the + * caller choose an outcome. + */ +export type SyncMarketplaceNowCommand = PackmindCommand & { + marketplaceId: MarketplaceId; +}; + +export type SyncMarketplaceNowResponse = { + state: MarketplaceState; + lastValidatedAt: Date; + errorKind: MarketplaceErrorKind | null; + errorDetail: string | null; + pendingPrUrl: string | null; + outdatedPluginSlugs: string[] | null; +}; + +export type ISyncMarketplaceNowUseCase = IUseCase< + SyncMarketplaceNowCommand, + SyncMarketplaceNowResponse +>; diff --git a/packages/types/src/deployments/contracts/ITrackPluginInstallHeartbeatUseCase.ts b/packages/types/src/deployments/contracts/ITrackPluginInstallHeartbeatUseCase.ts new file mode 100644 index 000000000..73f547d36 --- /dev/null +++ b/packages/types/src/deployments/contracts/ITrackPluginInstallHeartbeatUseCase.ts @@ -0,0 +1,60 @@ +import { IPublicUseCase, PublicPackmindCommand } from '../../UseCase'; +import { MarketplaceId } from '../MarketplaceId'; +import { PluginInstallScope } from '../PluginInstallation'; + +/** + * Command issued by the public tracking endpoint when a SessionStart hook + * POSTs a heartbeat. + * + * The `trackingToken` header identifies the marketplace (not the caller). + * Identity is established by: + * - `verifiedUserId` — set by the API layer after verifying the `Authorization` + * JWT against the token's org. The client NEVER self-claims a userId. + * - `anonymousIdHash` / `anonymousEmailMasked` — pseudonymous fallback derived + * client-side from the Claude account email (hash never hits the wire as raw email). + */ +export type TrackPluginInstallHeartbeatCommand = PublicPackmindCommand & { + /** Org-scoped opaque token from the `X-Packmind-Tracking-Token` header. */ + trackingToken: string; + /** Slug of the installed plugin (matches `MarketplaceDistribution.pluginSlug`). */ + pluginSlug: string; + /** Name of the marketplace, used for deduplication logging only. */ + marketplaceName: string; + scope: PluginInstallScope; + /** + * Version reported as installed, read from the installed plugin manifest by + * the SessionStart hook. Omitted when the hook could not resolve a version. + */ + installedVersion?: string | null; + /** Raw git remote URL of the active project; omitted when no git remote. */ + repoRemoteUrl?: string | null; + /** SHA-256 hash of lowercased Claude account email. */ + anonymousIdHash?: string | null; + /** Masked display email, e.g. `b**.s***@acme.com`. */ + anonymousEmailMasked?: string | null; + /** + * Verified Packmind user id — resolved by the API layer from the + * `Authorization` JWT. The use case enforces the cross-org guard by + * comparing `verifiedUserOrgId` against the marketplace's organizationId; + * never provided by the client directly. + */ + verifiedUserId?: string | null; + /** + * Organization id extracted from the API key JWT — used by the use case to + * enforce the cross-org guard (spec §7.3, §9): + * "Cross-org API key → Ignore key, fall back to anonymous." + * Null when no Authorization header is present or the key is invalid. + */ + verifiedUserOrgId?: string | null; +}; + +export type TrackPluginInstallHeartbeatResponse = { + /** `true` when a new installation row was created (first-seen), `false` on update. */ + created: boolean; + marketplaceId: MarketplaceId; +}; + +export type ITrackPluginInstallHeartbeatUseCase = IPublicUseCase< + TrackPluginInstallHeartbeatCommand, + TrackPluginInstallHeartbeatResponse +>; diff --git a/packages/types/src/deployments/contracts/IUnlinkMarketplaceUseCase.ts b/packages/types/src/deployments/contracts/IUnlinkMarketplaceUseCase.ts new file mode 100644 index 000000000..b3bbe616f --- /dev/null +++ b/packages/types/src/deployments/contracts/IUnlinkMarketplaceUseCase.ts @@ -0,0 +1,25 @@ +import { IUseCase, PackmindCommand } from '../../UseCase'; +import { MarketplaceId } from '../MarketplaceId'; + +/** + * Command used by an organization admin to unlink a previously linked + * marketplace. The marketplace must belong to the caller's organization. + */ +export type UnlinkMarketplaceCommand = PackmindCommand & { + marketplaceId: MarketplaceId; +}; + +/** + * Minimal response — the caller already knows the rest of the marketplace + * row at this point. Returning the id keeps the contract symmetric with + * other delete-style use cases and simplifies cache invalidation on the + * frontend. + */ +export type UnlinkMarketplaceResponse = { + marketplaceId: MarketplaceId; +}; + +export type IUnlinkMarketplaceUseCase = IUseCase< + UnlinkMarketplaceCommand, + UnlinkMarketplaceResponse +>; diff --git a/packages/types/src/deployments/contracts/IValidateMarketplaceUrlUseCase.ts b/packages/types/src/deployments/contracts/IValidateMarketplaceUrlUseCase.ts new file mode 100644 index 000000000..08f34b3c4 --- /dev/null +++ b/packages/types/src/deployments/contracts/IValidateMarketplaceUrlUseCase.ts @@ -0,0 +1,29 @@ +import { IUseCase, PackmindCommand } from '../../UseCase'; + +/** + * Command used by an organization admin to pre-flight validate a public + * marketplace URL before submitting the link form. Pre-flight runs through a + * tokenless `GitProvider` matching the URL host. + */ +export type ValidateMarketplaceUrlCommand = PackmindCommand & { + url: string; +}; + +/** + * Successful validation response. Marketplace-specific errors (descriptor + * missing/unknown/malformed, URL unreachable) are thrown as typed domain + * errors rather than encoded into the response shape — the frontend maps + * those into the `not-public | not-found | malformed | not-reachable` + * categories at the gateway layer. + */ +export type ValidateMarketplaceUrlResponse = { + kind: 'verified'; + repoPath: string; + defaultBranch: string; + pluginCount: number; +}; + +export type IValidateMarketplaceUrlUseCase = IUseCase< + ValidateMarketplaceUrlCommand, + ValidateMarketplaceUrlResponse +>; diff --git a/packages/types/src/deployments/contracts/index.ts b/packages/types/src/deployments/contracts/index.ts index cdd3a7d1f..215ab8817 100644 --- a/packages/types/src/deployments/contracts/index.ts +++ b/packages/types/src/deployments/contracts/index.ts @@ -42,3 +42,16 @@ export * from './ITrackPluginDeletedUseCase'; export * from './IListActiveDistributedPackagesBySpace'; export * from './IListDriftedPackagesByOrg'; export * from './IGetLastDistributionDateByProvidersUseCase'; +export * from './ILinkMarketplaceUseCase'; +export * from './IUnlinkMarketplaceUseCase'; +export * from './IListMarketplaceDistributionsForPackageUseCase'; +export * from './IListMarketplacesUseCase'; +export * from './IPublishPackageOnMarketplaceUseCase'; +export * from './IFindMarketplaceDistributionByIdUseCase'; +export * from './IValidateMarketplaceUrlUseCase'; +export * from './IMarkPluginForRemovalUseCase'; +export * from './ISyncMarketplaceNowUseCase'; +export * from './IListMarketplaceDistributionsUseCase'; +export * from './IGetMarketplaceDistributionChangesUseCase'; +export * from './ITrackPluginInstallHeartbeatUseCase'; +export * from './IListMarketplacePluginInstallsUseCase'; diff --git a/packages/types/src/deployments/errors/GitProviderTokenInvalidError.ts b/packages/types/src/deployments/errors/GitProviderTokenInvalidError.ts new file mode 100644 index 000000000..cd7737226 --- /dev/null +++ b/packages/types/src/deployments/errors/GitProviderTokenInvalidError.ts @@ -0,0 +1,21 @@ +/** + * Error thrown when the Git provider token bound to a marketplace's git + * repo is missing, expired, or otherwise rejected by the provider. + * + * The token itself is never echoed in the message or in logs — the + * user-facing copy is the constant in this class and downstream code MUST + * NOT include token bytes when rethrowing or logging. + */ +export class GitProviderTokenInvalidError extends Error { + /** + * Exact verbatim user-facing message defined by the feature spec. + * Do not modify without coordinating with the spec / docs. + */ + public static readonly USER_FACING_MESSAGE = + 'The package could not be published. Reason: Invalid or expired Git token.'; + + constructor() { + super(GitProviderTokenInvalidError.USER_FACING_MESSAGE); + this.name = 'GitProviderTokenInvalidError'; + } +} diff --git a/packages/types/src/deployments/errors/MarketplaceAlreadyLinkedError.ts b/packages/types/src/deployments/errors/MarketplaceAlreadyLinkedError.ts new file mode 100644 index 000000000..7831935c5 --- /dev/null +++ b/packages/types/src/deployments/errors/MarketplaceAlreadyLinkedError.ts @@ -0,0 +1,18 @@ +/** + * Error thrown when an organization attempts to link a marketplace that is + * already linked to the same organization. + * + * The message follows the contract defined in the GH-541 functional spec + * (AC-5) and must remain stable for downstream UI/API consumers. + */ +export class MarketplaceAlreadyLinkedError extends Error { + constructor( + public readonly owner: string, + public readonly repo: string, + ) { + super( + `The marketplace ${owner}/${repo} has already been linked to your organization`, + ); + this.name = 'MarketplaceAlreadyLinkedError'; + } +} diff --git a/packages/types/src/deployments/errors/MarketplaceDescriptorBadFormatError.ts b/packages/types/src/deployments/errors/MarketplaceDescriptorBadFormatError.ts new file mode 100644 index 000000000..6620a2c1e --- /dev/null +++ b/packages/types/src/deployments/errors/MarketplaceDescriptorBadFormatError.ts @@ -0,0 +1,25 @@ +import { MARKETPLACE_DESCRIPTOR_FILENAME } from '../MarketplaceDescriptorFilename'; + +/** + * Error thrown when the marketplace descriptor file was reachable but + * cannot be parsed into a `MarketplaceDescriptor` (malformed JSON, + * unknown vendor, missing required fields, etc.) at publish time. + * + * The publish use case sets the marketplace state to `bad_format` before + * raising this error so admins see the broken contract in the marketplace + * list. + */ +export class MarketplaceDescriptorBadFormatError extends Error { + constructor( + public readonly owner: string, + public readonly repo: string, + public readonly reason?: string, + ) { + super( + `Marketplace descriptor "${MARKETPLACE_DESCRIPTOR_FILENAME}" in repository ${owner}/${repo} is missing or has a bad format${ + reason ? `: ${reason}` : '' + }`, + ); + this.name = 'MarketplaceDescriptorBadFormatError'; + } +} diff --git a/packages/types/src/deployments/errors/MarketplaceDescriptorNotFoundError.ts b/packages/types/src/deployments/errors/MarketplaceDescriptorNotFoundError.ts new file mode 100644 index 000000000..ff95e115d --- /dev/null +++ b/packages/types/src/deployments/errors/MarketplaceDescriptorNotFoundError.ts @@ -0,0 +1,17 @@ +import { MARKETPLACE_DESCRIPTOR_FILENAME } from '../MarketplaceDescriptorFilename'; + +/** + * Error thrown when the target Git repository does not expose a marketplace + * descriptor file (`marketplace.json`). + */ +export class MarketplaceDescriptorNotFoundError extends Error { + constructor( + public readonly owner: string, + public readonly repo: string, + ) { + super( + `Marketplace descriptor "${MARKETPLACE_DESCRIPTOR_FILENAME}" not found in repository ${owner}/${repo}`, + ); + this.name = 'MarketplaceDescriptorNotFoundError'; + } +} diff --git a/packages/types/src/deployments/errors/MarketplaceDescriptorParseError.ts b/packages/types/src/deployments/errors/MarketplaceDescriptorParseError.ts new file mode 100644 index 000000000..67628ece0 --- /dev/null +++ b/packages/types/src/deployments/errors/MarketplaceDescriptorParseError.ts @@ -0,0 +1,17 @@ +/** + * Error thrown when a marketplace descriptor parser claims the content but + * subsequently fails to parse or validate it. + * + * The original error (e.g. a Zod `ZodError` or a `SyntaxError` from + * `JSON.parse`) is carried in `cause` for diagnostics; the message is kept + * generic for safe surfacing in API responses. + */ +export class MarketplaceDescriptorParseError extends Error { + constructor( + message: string, + public readonly cause: unknown, + ) { + super(message); + this.name = 'MarketplaceDescriptorParseError'; + } +} diff --git a/packages/types/src/deployments/errors/MarketplaceNotFoundError.ts b/packages/types/src/deployments/errors/MarketplaceNotFoundError.ts new file mode 100644 index 000000000..25a6b442f --- /dev/null +++ b/packages/types/src/deployments/errors/MarketplaceNotFoundError.ts @@ -0,0 +1,13 @@ +import { MarketplaceId } from '../MarketplaceId'; + +/** + * Error thrown when a marketplace cannot be located by id within the caller's + * organization (either it never existed or it has already been unlinked / + * soft-deleted). + */ +export class MarketplaceNotFoundError extends Error { + constructor(public readonly marketplaceId: MarketplaceId) { + super(`Marketplace with id "${marketplaceId}" was not found`); + this.name = 'MarketplaceNotFoundError'; + } +} diff --git a/packages/types/src/deployments/errors/MarketplacePluginNameConflictError.ts b/packages/types/src/deployments/errors/MarketplacePluginNameConflictError.ts new file mode 100644 index 000000000..9a86bb205 --- /dev/null +++ b/packages/types/src/deployments/errors/MarketplacePluginNameConflictError.ts @@ -0,0 +1,19 @@ +/** + * Error thrown when a Packmind package cannot be published as a managed + * plugin because the marketplace already exposes an **unmanaged** plugin + * with the same name/slug. + * + * Packmind refuses to clobber unmanaged entries — admins must rename the + * package or remove the colliding plugin from `marketplace.json` first. + */ +export class MarketplacePluginNameConflictError extends Error { + constructor( + public readonly pluginSlug: string, + public readonly marketplaceName: string, + ) { + super( + `Cannot publish: plugin "${pluginSlug}" already exists on marketplace "${marketplaceName}" and is not managed by Packmind`, + ); + this.name = 'MarketplacePluginNameConflictError'; + } +} diff --git a/packages/types/src/deployments/errors/MarketplaceUrlNotReachableError.ts b/packages/types/src/deployments/errors/MarketplaceUrlNotReachableError.ts new file mode 100644 index 000000000..36c74f38c --- /dev/null +++ b/packages/types/src/deployments/errors/MarketplaceUrlNotReachableError.ts @@ -0,0 +1,16 @@ +/** + * Error thrown by `ValidateMarketplaceUrlUseCase` when no tokenless Git + * provider can reach the host derived from the supplied public URL (e.g. + * the host is unknown, the URL is malformed, or every reachable provider + * returned an unrecoverable transport error). + * + * The original URL is preserved so the API surface and frontend can map this + * error to the playground prototype's `not-public | not-reachable` UX + * categories without re-parsing the input. + */ +export class MarketplaceUrlNotReachableError extends Error { + constructor(public readonly url: string) { + super(`Marketplace URL "${url}" is not reachable`); + this.name = 'MarketplaceUrlNotReachableError'; + } +} diff --git a/packages/types/src/deployments/errors/PluginDistributionInvalidStateError.ts b/packages/types/src/deployments/errors/PluginDistributionInvalidStateError.ts new file mode 100644 index 000000000..85c9e06d0 --- /dev/null +++ b/packages/types/src/deployments/errors/PluginDistributionInvalidStateError.ts @@ -0,0 +1,21 @@ +import { DistributionStatus } from '../DistributionStatus'; +import { MarketplaceDistributionId } from '../MarketplaceDistributionId'; + +/** + * Error thrown when an attempted state transition on a `MarketplaceDistribution` + * is rejected because the current status is not one of the allowed source + * statuses for that transition (e.g. marking a distribution for removal when it + * is not in `success`/`pending_merge`). + */ +export class PluginDistributionInvalidStateError extends Error { + constructor( + public readonly distributionId: MarketplaceDistributionId, + public readonly from: DistributionStatus, + public readonly expected: DistributionStatus[], + ) { + super( + `Marketplace plugin distribution "${distributionId}" is in status "${from}" but the operation requires one of [${expected.join(', ')}]`, + ); + this.name = 'PluginDistributionInvalidStateError'; + } +} diff --git a/packages/types/src/deployments/errors/PluginDistributionNotFoundError.ts b/packages/types/src/deployments/errors/PluginDistributionNotFoundError.ts new file mode 100644 index 000000000..df4a139f0 --- /dev/null +++ b/packages/types/src/deployments/errors/PluginDistributionNotFoundError.ts @@ -0,0 +1,22 @@ +import { MarketplaceDistributionId } from '../MarketplaceDistributionId'; + +/** + * Error thrown when a marketplace plugin distribution cannot be located in the + * caller's organization — either because the distribution id is unknown, the + * row has been soft-deleted, or no successful distribution exists for a given + * `(package, marketplace)` pair when resolving by `packageId`. + */ +export class PluginDistributionNotFoundError extends Error { + constructor( + public readonly identifier: + | { distributionId: MarketplaceDistributionId } + | { packageId: string; marketplaceId: string }, + ) { + const message = + 'distributionId' in identifier + ? `Marketplace plugin distribution with id "${identifier.distributionId}" was not found` + : `No active marketplace plugin distribution was found for package "${identifier.packageId}" on marketplace "${identifier.marketplaceId}"`; + super(message); + this.name = 'PluginDistributionNotFoundError'; + } +} diff --git a/packages/types/src/deployments/errors/UnknownMarketplaceDescriptorError.ts b/packages/types/src/deployments/errors/UnknownMarketplaceDescriptorError.ts new file mode 100644 index 000000000..392517eb0 --- /dev/null +++ b/packages/types/src/deployments/errors/UnknownMarketplaceDescriptorError.ts @@ -0,0 +1,12 @@ +/** + * Error thrown when no registered `IMarketplaceDescriptorParser` claims the + * marketplace descriptor content (i.e. the vendor format is not supported). + */ +export class UnknownMarketplaceDescriptorError extends Error { + constructor() { + super( + 'No registered marketplace descriptor parser recognised the provided content', + ); + this.name = 'UnknownMarketplaceDescriptorError'; + } +} diff --git a/packages/types/src/deployments/errors/index.ts b/packages/types/src/deployments/errors/index.ts index 52164b57f..0e072737f 100644 --- a/packages/types/src/deployments/errors/index.ts +++ b/packages/types/src/deployments/errors/index.ts @@ -1 +1,12 @@ export * from './InvalidArtifactIdError'; +export * from './MarketplaceAlreadyLinkedError'; +export * from './MarketplaceDescriptorBadFormatError'; +export * from './MarketplaceDescriptorNotFoundError'; +export * from './UnknownMarketplaceDescriptorError'; +export * from './MarketplaceDescriptorParseError'; +export * from './MarketplaceNotFoundError'; +export * from './MarketplacePluginNameConflictError'; +export * from './MarketplaceUrlNotReachableError'; +export * from './GitProviderTokenInvalidError'; +export * from './PluginDistributionNotFoundError'; +export * from './PluginDistributionInvalidStateError'; diff --git a/packages/types/src/deployments/events/MarketplaceLinkedEvent.ts b/packages/types/src/deployments/events/MarketplaceLinkedEvent.ts new file mode 100644 index 000000000..81d3c9a6d --- /dev/null +++ b/packages/types/src/deployments/events/MarketplaceLinkedEvent.ts @@ -0,0 +1,22 @@ +import { UserId } from '../../accounts'; +import { UserEvent } from '../../events'; +import { GitRepoId } from '../../git'; +import { MarketplaceId } from '../MarketplaceId'; + +/** + * Payload emitted when an organization admin successfully links a marketplace. + * + * `userId` and `organizationId` are automatically merged in via the + * `UserEvent` base. `addedBy` captures the admin who performed the action and + * is denormalized onto the `Marketplace` row (it may diverge from `userId` in + * the future, e.g. for impersonation/audit replays). + */ +export interface MarketplaceLinkedPayload { + marketplaceId: MarketplaceId; + gitRepoId: GitRepoId; + addedBy: UserId; +} + +export class MarketplaceLinkedEvent extends UserEvent<MarketplaceLinkedPayload> { + static override readonly eventName = 'deployments.marketplace.linked'; +} diff --git a/packages/types/src/deployments/events/MarketplacePluginRemovalInitiatedEvent.ts b/packages/types/src/deployments/events/MarketplacePluginRemovalInitiatedEvent.ts new file mode 100644 index 000000000..ba3889c94 --- /dev/null +++ b/packages/types/src/deployments/events/MarketplacePluginRemovalInitiatedEvent.ts @@ -0,0 +1,35 @@ +import { UserEvent } from '../../events'; +import { MarketplaceDistributionId } from '../MarketplaceDistributionId'; +import { MarketplaceId } from '../MarketplaceId'; +import { PackageId } from '../Package'; + +/** + * Trigger that caused the plugin removal flow to start. + * + * - `from_marketplace`: org admin clicked "Remove" on the marketplace details + * view (manual trigger, single distribution). + * - `from_packmind_package`: cascade fired after a Packmind package was + * deleted, flipping every linked-marketplace distribution to + * `to_be_removed`. + */ +export type MarketplacePluginRemovalTrigger = + | 'from_marketplace' + | 'from_packmind_package'; + +/** + * Payload emitted when a marketplace plugin removal is initiated (either + * manually by an admin or via the package-delete cascade). `userId` and + * `organizationId` are merged in via the `UserEvent` base. + */ +export interface MarketplacePluginRemovalInitiatedPayload { + marketplaceId: MarketplaceId; + distributionId: MarketplaceDistributionId; + packageId: PackageId; + packageSlug: string; + pluginSlug: string; + trigger: MarketplacePluginRemovalTrigger; +} + +export class MarketplacePluginRemovalInitiatedEvent extends UserEvent<MarketplacePluginRemovalInitiatedPayload> { + static override readonly eventName = 'deployments.distribution.retired'; +} diff --git a/packages/types/src/deployments/events/MarketplaceUnlinkedEvent.ts b/packages/types/src/deployments/events/MarketplaceUnlinkedEvent.ts new file mode 100644 index 000000000..37f15ac13 --- /dev/null +++ b/packages/types/src/deployments/events/MarketplaceUnlinkedEvent.ts @@ -0,0 +1,17 @@ +import { UserEvent } from '../../events'; +import { GitRepoId } from '../../git'; +import { MarketplaceId } from '../MarketplaceId'; + +/** + * Payload emitted when an organization admin successfully unlinks a + * marketplace. `userId` and `organizationId` are automatically merged in via + * the `UserEvent` base. + */ +export interface MarketplaceUnlinkedPayload { + marketplaceId: MarketplaceId; + gitRepoId: GitRepoId; +} + +export class MarketplaceUnlinkedEvent extends UserEvent<MarketplaceUnlinkedPayload> { + static override readonly eventName = 'deployments.marketplace.unlinked'; +} diff --git a/packages/types/src/deployments/events/PluginInstallTrackedEvent.ts b/packages/types/src/deployments/events/PluginInstallTrackedEvent.ts new file mode 100644 index 000000000..a94579d2b --- /dev/null +++ b/packages/types/src/deployments/events/PluginInstallTrackedEvent.ts @@ -0,0 +1,29 @@ +import { UserId } from '../../accounts/User'; +import { SystemEvent } from '../../events'; +import { MarketplaceId } from '../MarketplaceId'; +import { PluginInstallScope } from '../PluginInstallation'; + +/** + * Emitted by `TrackPluginInstallHeartbeatUseCase` on **first-seen** creation + * of a `PluginInstallation` row. + * + * The endpoint is public (no mandatory Packmind auth), so `userId` is optional: + * it is set only when the API-key JWT was verified against the marketplace's org. + * Amplitude uses `userId` when present, `anonymousIdHash` otherwise. + */ +export interface PluginInstallTrackedPayload { + organizationId: string; + marketplaceId: MarketplaceId; + /** Best-effort resolved Packmind package id (`null` when slug is unknown). */ + packageId?: string | null; + pluginSlug: string; + scope: PluginInstallScope; + /** Verified Packmind user id (set only when API-key JWT was valid + org-matched). */ + userId?: UserId | null; + /** SHA-256 hash of the lowercased Claude account email (anonymous identity). */ + anonymousIdHash?: string | null; +} + +export class PluginInstallTrackedEvent extends SystemEvent<PluginInstallTrackedPayload> { + static override readonly eventName = 'deployments.plugin.install-tracked'; +} diff --git a/packages/types/src/deployments/events/PluginPublishAttemptedEvent.ts b/packages/types/src/deployments/events/PluginPublishAttemptedEvent.ts new file mode 100644 index 000000000..d32438472 --- /dev/null +++ b/packages/types/src/deployments/events/PluginPublishAttemptedEvent.ts @@ -0,0 +1,24 @@ +import { UserEvent } from '../../events'; +import { MarketplaceDistributionId } from '../MarketplaceDistributionId'; +import { MarketplaceId } from '../MarketplaceId'; +import { PackageId } from '../Package'; + +/** + * Emitted as soon as a publish attempt is enqueued — before the BullMQ job + * runs. Used by the analytics listener to fire `plugin_publish_attempted` and + * by any subscriber that needs to track in-flight publishes. + * + * `isFirstPublishForPackage` reflects whether the (package, marketplace) + * pair already had any prior distribution row — useful to disambiguate the + * "create" vs "refresh" funnels in analytics. + */ +export interface PluginPublishAttemptedPayload { + marketplaceDistributionId: MarketplaceDistributionId; + marketplaceId: MarketplaceId; + packageId: PackageId; + isFirstPublishForPackage: boolean; +} + +export class PluginPublishAttemptedEvent extends UserEvent<PluginPublishAttemptedPayload> { + static override readonly eventName = 'deployments.plugin.publish-attempted'; +} diff --git a/packages/types/src/deployments/events/PluginPublishFailedEvent.ts b/packages/types/src/deployments/events/PluginPublishFailedEvent.ts new file mode 100644 index 000000000..acea740d7 --- /dev/null +++ b/packages/types/src/deployments/events/PluginPublishFailedEvent.ts @@ -0,0 +1,24 @@ +import { UserEvent } from '../../events'; +import { MarketplaceDistributionId } from '../MarketplaceDistributionId'; +import { MarketplaceId } from '../MarketplaceId'; +import { PackageId } from '../Package'; +import { PublishFailureReason } from '../PublishFailureReason'; + +/** + * Emitted by the publish job when a marketplace publish attempt fails. + * + * `failureReason` is a stable, low-cardinality categorical value so analytics + * dashboards and any downstream listener can branch on it without parsing + * error messages. The raw error string remains stored on the + * `MarketplaceDistribution` row for debugging. + */ +export interface PluginPublishFailedPayload { + marketplaceDistributionId: MarketplaceDistributionId; + marketplaceId: MarketplaceId; + packageId: PackageId; + failureReason: PublishFailureReason; +} + +export class PluginPublishFailedEvent extends UserEvent<PluginPublishFailedPayload> { + static override readonly eventName = 'deployments.plugin.publish-failed'; +} diff --git a/packages/types/src/deployments/events/PluginPublishedEvent.ts b/packages/types/src/deployments/events/PluginPublishedEvent.ts new file mode 100644 index 000000000..31c19664c --- /dev/null +++ b/packages/types/src/deployments/events/PluginPublishedEvent.ts @@ -0,0 +1,23 @@ +import { UserEvent } from '../../events'; +import { MarketplaceDistributionId } from '../MarketplaceDistributionId'; +import { MarketplaceId } from '../MarketplaceId'; +import { PackageId } from '../Package'; + +/** + * Emitted by the publish job after a successful publish (or a no-op short + * circuit). `wasNoop=true` indicates the publish converged without a new git + * commit — either via the content-hash idempotency check or via the git + * provider's `NO_CHANGES_DETECTED` signal. + */ +export interface PluginPublishedPayload { + marketplaceDistributionId: MarketplaceDistributionId; + marketplaceId: MarketplaceId; + packageId: PackageId; + prUrl?: string; + wasNoop: boolean; + commitCountAfter?: number; +} + +export class PluginPublishedEvent extends UserEvent<PluginPublishedPayload> { + static override readonly eventName = 'deployments.plugin.published'; +} diff --git a/packages/types/src/deployments/events/index.ts b/packages/types/src/deployments/events/index.ts index 0229e6d90..6e2364a46 100644 --- a/packages/types/src/deployments/events/index.ts +++ b/packages/types/src/deployments/events/index.ts @@ -3,4 +3,12 @@ export * from './ArtifactsPulledEvent'; export * from './DeploymentCompletedEvent'; export * from './PackagesDeletedEvent'; export * from './PluginDeletedEvent'; +export * from './PluginPublishAttemptedEvent'; +export * from './PluginPublishedEvent'; +export * from './PluginPublishFailedEvent'; export * from './PluginRenderedEvent'; +export * from './MarketplaceLinkedEvent'; +export * from './MarketplacePluginRemovalInitiatedEvent'; +export * from './MarketplaceUnlinkedEvent'; +export * from './PackagesDeletedEvent'; +export * from './PluginInstallTrackedEvent'; diff --git a/packages/types/src/deployments/index.ts b/packages/types/src/deployments/index.ts index 9f0cd6e7c..1a5b6b861 100644 --- a/packages/types/src/deployments/index.ts +++ b/packages/types/src/deployments/index.ts @@ -1,8 +1,22 @@ // Entity types +export * from './PluginInstallationId'; +export * from './PluginInstallation'; export * from './TargetId'; export * from './Target'; export * from './TargetWithRepository'; +export * from './MarketplaceId'; +export * from './MarketplaceVendor'; +export * from './MarketplaceState'; +export * from './MarketplaceErrorKind'; +export * from './VersionFingerprint'; +export * from './PluginRef'; +export * from './MarketplaceDescriptor'; +export * from './MarketplaceDescriptorFilename'; +export * from './PackmindMarketplaceLock'; +export * from './Marketplace'; +export * from './MarketplaceListItem'; export * from './DistributionStatus'; +export * from './PublishFailureReason'; export * from './RenderMode'; export * from './RenderModeConfigurationId'; export * from './RenderModeConfiguration'; @@ -20,6 +34,8 @@ export * from './Distribution'; export * from './DistributionOperation'; export * from './DistributedPackageId'; export * from './DistributedPackage'; +export * from './MarketplaceDistributionId'; +export * from './MarketplaceDistribution'; // Contracts export * from './contracts'; @@ -32,3 +48,9 @@ export * from './events'; // Ports export * from './ports'; + +// Parsers +export * from './parsers'; + +// Jobs +export * from './jobs'; diff --git a/packages/types/src/deployments/jobs/MarketplaceReconciliationJob.ts b/packages/types/src/deployments/jobs/MarketplaceReconciliationJob.ts new file mode 100644 index 000000000..de43f2478 --- /dev/null +++ b/packages/types/src/deployments/jobs/MarketplaceReconciliationJob.ts @@ -0,0 +1,27 @@ +import { MarketplaceErrorKind } from '../MarketplaceErrorKind'; +import { MarketplaceId } from '../MarketplaceId'; +import { MarketplaceState } from '../MarketplaceState'; + +/** + * Input payload for the marketplace reconciliation BullMQ job. + * + * The job is per-marketplace — the worker uses `marketplaceId` to load the + * marketplace row, fetch its `marketplace.json` via the git port, parse it, + * and persist the resulting state (`healthy | drift | unreachable`). + */ +export interface MarketplaceReconciliationJobInput { + marketplaceId: MarketplaceId; +} + +/** + * Output payload returned by the reconciliation worker. Reflects the state + * persisted on the `Marketplace` row at the end of the run. + */ +export interface MarketplaceReconciliationJobOutput { + state: MarketplaceState; + lastValidatedAt: Date; + errorKind: MarketplaceErrorKind | null; + errorDetail: string | null; + pendingPrUrl: string | null; + outdatedPluginSlugs: string[] | null; +} diff --git a/packages/types/src/deployments/jobs/PublishPluginToMarketplaceJob.ts b/packages/types/src/deployments/jobs/PublishPluginToMarketplaceJob.ts new file mode 100644 index 000000000..73ba4b5d5 --- /dev/null +++ b/packages/types/src/deployments/jobs/PublishPluginToMarketplaceJob.ts @@ -0,0 +1,36 @@ +import { OrganizationId } from '../../accounts/Organization'; +import { UserId } from '../../accounts/User'; +import { MarketplaceDistributionId } from '../MarketplaceDistributionId'; +import { MarketplaceId } from '../MarketplaceId'; +import { PackageId } from '../Package'; + +/** + * Identifier of the BullMQ queue carrying marketplace plugin publish jobs. + * + * Workers consuming this queue must run single-concurrency to serialize Git + * operations against the rolling `packmind/sync` PR. + */ +export const PUBLISH_PLUGIN_TO_MARKETPLACE_QUEUE = + 'publish-plugin-to-marketplace'; + +/** + * Input payload for the marketplace plugin publish BullMQ job. + * + * The worker uses these ids to (re)load the distribution row, marketplace, + * package, and acting user so all heavy lifting can happen off the request + * thread. + */ +export interface PublishPluginToMarketplaceJobInput { + marketplaceDistributionId: MarketplaceDistributionId; + marketplaceId: MarketplaceId; + packageId: PackageId; + organizationId: OrganizationId; + userId: UserId; +} + +/** + * Output payload — the job is fire-and-forget for the caller. The terminal + * state is persisted on `MarketplaceDistribution.status`; the void output is + * intentional. + */ +export type PublishPluginToMarketplaceJobOutput = void; diff --git a/packages/types/src/deployments/jobs/RemovePluginFromMarketplaceJob.ts b/packages/types/src/deployments/jobs/RemovePluginFromMarketplaceJob.ts new file mode 100644 index 000000000..a95562921 --- /dev/null +++ b/packages/types/src/deployments/jobs/RemovePluginFromMarketplaceJob.ts @@ -0,0 +1,38 @@ +import { OrganizationId } from '../../accounts/Organization'; +import { UserId } from '../../accounts/User'; +import { MarketplaceDistributionId } from '../MarketplaceDistributionId'; +import { MarketplaceId } from '../MarketplaceId'; +import { PackageId } from '../Package'; + +/** + * Identifier of the BullMQ queue carrying marketplace plugin removal jobs. + * + * Like the publish queue, workers consuming this queue must run + * single-concurrency to serialize Git operations against the rolling + * `packmind/sync` PR. + */ +export const REMOVE_PLUGIN_FROM_MARKETPLACE_QUEUE = + 'remove-plugin-from-marketplace'; + +/** + * Input payload for the marketplace plugin removal BullMQ job. + * + * Mirrors {@link PublishPluginToMarketplaceJobInput}: the worker uses these + * ids to (re)load the distribution row, marketplace, package, and acting user + * so the Git deletion commit happens off the request thread. + */ +export interface RemovePluginFromMarketplaceJobInput { + marketplaceDistributionId: MarketplaceDistributionId; + marketplaceId: MarketplaceId; + packageId: PackageId; + organizationId: OrganizationId; + userId: UserId; +} + +/** + * Output payload — the job is fire-and-forget for the caller. The distribution + * stays in `to_be_removed`; the terminal `removed` transition is owned by the + * reconciliation job once the deletion PR merges. The void output is + * intentional. + */ +export type RemovePluginFromMarketplaceJobOutput = void; diff --git a/packages/types/src/deployments/jobs/index.ts b/packages/types/src/deployments/jobs/index.ts new file mode 100644 index 000000000..7660e5806 --- /dev/null +++ b/packages/types/src/deployments/jobs/index.ts @@ -0,0 +1,3 @@ +export * from './MarketplaceReconciliationJob'; +export * from './PublishPluginToMarketplaceJob'; +export * from './RemovePluginFromMarketplaceJob'; diff --git a/packages/types/src/deployments/parsers/IMarketplaceDescriptorParser.ts b/packages/types/src/deployments/parsers/IMarketplaceDescriptorParser.ts new file mode 100644 index 000000000..b13752a8e --- /dev/null +++ b/packages/types/src/deployments/parsers/IMarketplaceDescriptorParser.ts @@ -0,0 +1,24 @@ +import { MarketplaceDescriptor } from '../MarketplaceDescriptor'; + +/** + * Strategy contract implemented by every vendor-specific marketplace + * descriptor parser. + * + * The `MarketplaceDescriptorParserRegistry` iterates registered parsers in + * priority order and delegates to the first parser whose `canParse` returns + * true. Implementations must throw `MarketplaceDescriptorParseError` when + * `parse` is called on a structurally invalid descriptor. + */ +export interface IMarketplaceDescriptorParser { + /** + * Returns true when this parser claims responsibility for the given raw + * (already JSON-parsed) descriptor object. + */ + canParse(rawJson: unknown): boolean; + + /** + * Converts the raw descriptor into the normalized `MarketplaceDescriptor` + * shape. Throws `MarketplaceDescriptorParseError` on validation failures. + */ + parse(rawJson: unknown): MarketplaceDescriptor; +} diff --git a/packages/types/src/deployments/parsers/index.ts b/packages/types/src/deployments/parsers/index.ts new file mode 100644 index 000000000..8b21898ab --- /dev/null +++ b/packages/types/src/deployments/parsers/index.ts @@ -0,0 +1 @@ +export * from './IMarketplaceDescriptorParser'; diff --git a/packages/types/src/deployments/ports/IDeploymentPort.ts b/packages/types/src/deployments/ports/IDeploymentPort.ts index c15f08a8d..e827f325d 100644 --- a/packages/types/src/deployments/ports/IDeploymentPort.ts +++ b/packages/types/src/deployments/ports/IDeploymentPort.ts @@ -41,7 +41,19 @@ import { IPullContentResponse, IListActiveDistributedPackagesBySpaceUseCase, IListDriftedPackagesByOrgUseCase, + FindMarketplaceDistributionByIdCommand, + FindMarketplaceDistributionByIdResponse, + GetMarketplaceDistributionChangesCommand, + GetMarketplaceDistributionChangesResponse, + LinkMarketplaceCommand, + LinkMarketplaceResponse, ListActiveDistributedPackagesBySpaceCommand, + ListMarketplaceDistributionsCommand, + ListMarketplaceDistributionsResponse, + MarkPluginForRemovalCommand, + MarkPluginForRemovalResponse, + SyncMarketplaceNowCommand, + SyncMarketplaceNowResponse, ListActiveDistributedPackagesBySpaceResponse, ListDriftedPackagesByOrgCommand, ListDriftedPackagesByOrgResponse, @@ -49,6 +61,10 @@ import { ListDistributionsByRecipeCommand, ListDistributionsByStandardCommand, ListDistributionsBySkillCommand, + ListMarketplaceDistributionsForPackageCommand, + ListMarketplaceDistributionsForPackageResponse, + ListMarketplacesCommand, + ListMarketplacesResponse, ListPackagesBySpaceCommand, ListPackagesBySpaceResponse, ListPackagesCommand, @@ -59,6 +75,8 @@ import { NotifyDistributionResponse, PublishArtifactsCommand, PublishArtifactsResponse, + PublishPackageOnMarketplaceCommand, + PublishPackageOnMarketplaceResponse, PublishPackagesCommand, PullContentCommand, RemovePackageFromTargetsCommand, @@ -67,10 +85,18 @@ import { RenderPackageAsPluginResponse, TrackPluginDeletedCommand, TrackPluginDeletedResponse, + TrackPluginInstallHeartbeatCommand, + TrackPluginInstallHeartbeatResponse, + ListMarketplacePluginInstallsCommand, + ListMarketplacePluginInstallsResponse, + UnlinkMarketplaceCommand, + UnlinkMarketplaceResponse, UpdatePackageCommand, UpdatePackageResponse, UpdateRenderModeConfigurationCommand, UpdateTargetCommand, + ValidateMarketplaceUrlCommand, + ValidateMarketplaceUrlResponse, } from '../contracts'; import { Distribution } from '../Distribution'; import { PackagesDeployment } from '../PackagesDeployment'; @@ -595,4 +621,184 @@ export interface IDeploymentPort { getLastDistributionDateByProviders( command: GetLastDistributionDateByProvidersCommand, ): Promise<GetLastDistributionDateByProvidersResponse>; + + /** + * Links a Git repository as an organization-level marketplace. + * + * Admin-only at the use-case boundary. Validates the target git provider, + * fetches and parses `marketplace.json`, persists a marketplace-typed + * `GitRepo` together with a `Marketplace` row, emits + * `MarketplaceLinkedEvent`, and seeds the reconciliation job. + * + * @param command - Command containing git provider, owner/repo/branch, and display name + * @returns Promise of the created marketplace enriched with `addedByUserName` + */ + linkMarketplace( + command: LinkMarketplaceCommand, + ): Promise<LinkMarketplaceResponse>; + + /** + * Unlinks a marketplace from the caller's organization. + * + * Admin-only. Soft-deletes the `Marketplace` row and the underlying + * marketplace-typed `GitRepo`, removes the reconciliation job, and emits + * `MarketplaceUnlinkedEvent`. The underlying Git repository is never + * touched. + * + * @param command - Command containing the marketplace id + * @returns Promise resolving to the unlinked marketplace id + */ + unlinkMarketplace( + command: UnlinkMarketplaceCommand, + ): Promise<UnlinkMarketplaceResponse>; + + /** + * Lists all marketplaces linked to the caller's organization. Open to any + * organization member. + * + * @param command - Command carrying the organization/user context + * @returns Promise of presentation DTOs enriched with `addedByUserName` and `pluginCount` + */ + listMarketplaces( + command: ListMarketplacesCommand, + ): Promise<ListMarketplacesResponse>; + + /** + * Pre-flight validation of a public marketplace URL. Resolves a tokenless + * git provider for the URL host, fetches `marketplace.json` and validates + * the descriptor through the parser registry. + * + * @param command - Command containing the marketplace URL + * @returns Promise of `{ kind: 'verified', repoPath, defaultBranch, pluginCount }` + */ + validateMarketplaceUrl( + command: ValidateMarketplaceUrlCommand, + ): Promise<ValidateMarketplaceUrlResponse>; + + /** + * Publishes a Packmind package as a managed plugin on a linked marketplace. + * + * Member-scoped — any org member of both the package's organization and the + * marketplace's organization can trigger the publish. Persists an + * `in_progress` `MarketplaceDistribution` row, enqueues the BullMQ publish + * job (single-worker concurrency), and emits + * `PluginPublishAttemptedEvent`. The terminal status (`success`, `failure`, + * or `no_changes`) is written by the worker and observable through + * `findMarketplaceDistributionById`. + * + * @param command - Command containing marketplaceId, packageId and auth context + * @returns Promise resolving to the in-progress distribution metadata + * @throws MarketplaceNotFoundError when the marketplace is missing or + * belongs to a different organization + * @throws GitProviderTokenInvalidError when the marketplace git provider's + * token is missing or expired + * @throws MarketplaceDescriptorNotFoundError / MarketplaceDescriptorBadFormatError + * when `marketplace.json` is unreachable or unparseable + * @throws MarketplacePluginNameConflictError when an unmanaged plugin + * already exposes the same slug + */ + publishPackageOnMarketplace( + command: PublishPackageOnMarketplaceCommand, + ): Promise<PublishPackageOnMarketplaceResponse>; + + /** + * Lists every marketplace distribution row attached to a package — newest + * first. Used by the frontend status helper to poll the publish lifecycle. + * + * @param command - Command containing packageId and auth context + * @returns Promise of the marketplace distribution rows (empty when none) + */ + listMarketplaceDistributionsForPackage( + command: ListMarketplaceDistributionsForPackageCommand, + ): Promise<ListMarketplaceDistributionsForPackageResponse>; + + /** + * Looks up a single marketplace distribution row by id, scoped to the + * caller's organization. The wrapped `marketplaceDistribution` is `null` + * when the row is missing or belongs to another organization (callers + * should map that to HTTP 404). + */ + findMarketplaceDistributionById( + command: FindMarketplaceDistributionByIdCommand, + ): Promise<FindMarketplaceDistributionByIdResponse>; + + /** + * Marks a published marketplace plugin distribution as `to_be_removed`. + * + * Admin-only. Resolves the target distribution by `distributionId` or by + * `packageId` (latest `success`-state distribution for the + * `(package, marketplace)` pair). Emits + * `MarketplacePluginRemovalInitiatedEvent` with `trigger='from_marketplace'`. + * + * @param command - Command containing the marketplace id and either + * `distributionId` or `packageId` (discriminated union) + * @returns Promise resolving to the mutated distribution row + */ + markPluginForRemoval( + command: MarkPluginForRemovalCommand, + ): Promise<MarkPluginForRemovalResponse>; + + /** + * Runs an immediate, on-demand reconciliation of a single marketplace and + * returns the resulting state. Member-scoped stop-gap so an org member can + * refresh marketplace state (drift + `to_be_removed → removed` transitions) + * without waiting for the next scheduled reconciliation sweep. + * + * @param command - Command carrying the marketplace id and auth context + * @returns Promise resolving to the new state and validation timestamp + */ + syncMarketplaceNow( + command: SyncMarketplaceNowCommand, + ): Promise<SyncMarketplaceNowResponse>; + + /** + * Lists all marketplace distributions for a given marketplace owned by the + * caller's organization, enriched with package name and author display name. + * + * Open to any organization member. + * + * @param command - Command carrying the marketplace id and auth context + * @returns Promise of presentation DTOs (`MarketplaceDistributionListItem[]`) + */ + listMarketplaceDistributions( + command: ListMarketplaceDistributionsCommand, + ): Promise<ListMarketplaceDistributionsResponse>; + + /** + * Returns the artifact-level diff between a marketplace distribution's + * captured VersionFingerprint and the source package's current state. + * Drives the plugin detail "Changes" tab. Returns `[]` when nothing has + * drifted, when no fingerprint was captured, or when the source package + * has been hard-deleted. + * + * Open to any organization member. + */ + getMarketplaceDistributionChanges( + command: GetMarketplaceDistributionChangesCommand, + ): Promise<GetMarketplaceDistributionChangesResponse>; + + /** + * Processes a SessionStart heartbeat from a published Packmind plugin. + * + * Public path — the `trackingToken` in the command is the sole credential. + * The API layer pre-resolves `verifiedUserId` before calling this method. + * + * @param command - Heartbeat payload carrying token, slug, scope, and optional identity + * @returns Whether the row was created (first-seen) and the resolved marketplace id + */ + trackPluginInstallHeartbeat( + command: TrackPluginInstallHeartbeatCommand, + ): Promise<TrackPluginInstallHeartbeatResponse>; + + /** + * Lists all tracked plugin installations for a marketplace. + * + * Open to any org member (read-only). Enriches each row with user display names. + * + * @param command - Command carrying the marketplace id and auth context + * @returns Promise of presentation DTOs (`PluginInstallationListItem[]`) + */ + listMarketplacePluginInstalls( + command: ListMarketplacePluginInstallsCommand, + ): Promise<ListMarketplacePluginInstallsResponse>; } diff --git a/packages/types/src/git/GitRepo.ts b/packages/types/src/git/GitRepo.ts index b77aa1e88..0306991ad 100644 --- a/packages/types/src/git/GitRepo.ts +++ b/packages/types/src/git/GitRepo.ts @@ -1,5 +1,6 @@ import { GitRepoId } from './GitRepoId'; import { GitProviderId } from './GitProvider'; +import { GitRepoType } from './GitRepoType'; export type GitRepo = { id: GitRepoId; @@ -7,4 +8,5 @@ export type GitRepo = { repo: string; branch: string; providerId: GitProviderId; + type: GitRepoType; }; diff --git a/packages/types/src/git/GitRepoType.ts b/packages/types/src/git/GitRepoType.ts new file mode 100644 index 000000000..fa9cca452 --- /dev/null +++ b/packages/types/src/git/GitRepoType.ts @@ -0,0 +1,12 @@ +/** + * Discriminates a GitRepo by its purpose within Packmind. + * + * - `standard`: the default value. The repository is used by Packmind to + * deploy standards, recipes, and skills. + * - `marketplace`: the repository is linked at the organization level as a + * marketplace source (see `packages/deployments` Marketplace entity). + * + * All pre-existing GitRepo finders default to `type='standard'` to prevent + * marketplace repositories from leaking into skill/standard deployment flows. + */ +export type GitRepoType = 'standard' | 'marketplace'; diff --git a/packages/types/src/git/errors.ts b/packages/types/src/git/errors.ts index fffef1939..55cd0b59c 100644 --- a/packages/types/src/git/errors.ts +++ b/packages/types/src/git/errors.ts @@ -211,3 +211,26 @@ export class UnsupportedGitProviderError extends Error { } } } + +/** + * Error thrown when attempting to link a marketplace whose `(owner, repo)` + * coordinates already match an existing standard (non-marketplace) GitRepo + * in the same organization. Linking the repo as a marketplace would create a + * cross-type collision; the link is rejected so the admin can resolve the + * conflict explicitly. + */ +export class GitRepoAlreadyLinkedAsStandardError extends Error { + constructor( + public readonly owner: string, + public readonly repo: string, + ) { + super( + `Repository ${owner}/${repo} is already linked as a standard Git repository in this organization`, + ); + this.name = 'GitRepoAlreadyLinkedAsStandardError'; + + if (hasCaptureStackTrace(Error)) { + Error.captureStackTrace(this, GitRepoAlreadyLinkedAsStandardError); + } + } +} diff --git a/packages/types/src/git/index.ts b/packages/types/src/git/index.ts index 871e9a3f2..f9177184e 100644 --- a/packages/types/src/git/index.ts +++ b/packages/types/src/git/index.ts @@ -3,6 +3,7 @@ export * from './GitCommit'; export * from './GitProvider'; export * from './GitRepo'; export * from './OrganizationGitHubApp'; +export * from './GitRepoType'; export * from './errors'; export * from './contracts'; export * from './ports'; diff --git a/packages/types/src/git/ports/IGitPort.ts b/packages/types/src/git/ports/IGitPort.ts index 8da392d07..66801620b 100644 --- a/packages/types/src/git/ports/IGitPort.ts +++ b/packages/types/src/git/ports/IGitPort.ts @@ -84,6 +84,58 @@ export interface IGitPort { branch?: string, ): Promise<{ sha: string; content: string } | null>; + /** + * Ensure a branch exists on a git repository, creating it from a base branch + * if missing. No-op when the target branch already exists. + * + * Used by the marketplace-publish flow to bootstrap the rolling `packmind/sync` + * branch from the marketplace's default branch on the first publish. + * + * @param repo - The git repository (its `branch` field is the BASE branch used when creating) + * @param branch - The target branch name to ensure exists + * @returns Promise resolving when the branch is guaranteed to exist + */ + createBranchFromBase(repo: GitRepo, branch: string): Promise<void>; + + /** + * Open a pull request on a git repository, or update an existing one when a PR + * with the same `head → base` already exists (rolling-PR semantics). + * + * Idempotent: if a PR matching `head → base` is already open, the existing one + * is returned untouched (no second PR is created). Used by the + * marketplace-publish flow to keep a single "Packmind sync" PR per marketplace. + * + * @param repo - The git repository (its `branch` field is the BASE branch) + * @param command - PR head / title / body + * @returns Promise of the PR URL and number + */ + openOrUpdatePullRequest( + repo: GitRepo, + command: { + head: string; + title: string; + body?: string; + }, + ): Promise<{ url: string; number: number; wasCreated: boolean }>; + + /** + * Find the open "Packmind sync" pull request on a marketplace repo, or + * `null` when none is open. Used by reconcile to surface a pending PR. + */ + findOpenSyncPullRequest( + repo: GitRepo, + head: string, + ): Promise<{ url: string; number: number } | null>; + + /** + * Probe a marketplace repo's reachability with the configured credentials, + * distinguishing auth failure / repo gone / transient network error. + */ + checkMarketplaceRepoExists(repo: GitRepo): Promise<{ + exists: boolean; + reason?: 'auth_failed' | 'repo_not_found' | 'network_transient'; + }>; + /** * Add a new git provider for an organization * diff --git a/packages/types/src/linter/ports/ILinterPort.ts b/packages/types/src/linter/ports/ILinterPort.ts index a18c686a2..35ad92a09 100644 --- a/packages/types/src/linter/ports/ILinterPort.ts +++ b/packages/types/src/linter/ports/ILinterPort.ts @@ -45,6 +45,8 @@ import type { UpdateDetectionProgramStatusCommand, UpdateRuleDetectionHeuristicsCommand, UpdateRuleDetectionHeuristicsResponse, + UpdateHeuristicsFollowingChatbotInputCommand, + UpdateHeuristicsFollowingChatbotInputResponse, UpdateRuleDetectionStatusAfterUpdateCommand, UpdateRuleDetectionStatusAfterUpdateResponse, GetDetectionHeuristicsCommand, @@ -168,6 +170,10 @@ export interface ILinterPort { command: UpdateRuleDetectionHeuristicsCommand, ): Promise<UpdateRuleDetectionHeuristicsResponse>; + updateHeuristicsFollowingChatbotInput( + command: UpdateHeuristicsFollowingChatbotInputCommand, + ): Promise<UpdateHeuristicsFollowingChatbotInputResponse>; + getDetectionHeuristics( command: GetDetectionHeuristicsCommand, ): Promise<GetDetectionHeuristicsResponse>; diff --git a/packages/types/src/recipes/ports/IRecipesPort.ts b/packages/types/src/recipes/ports/IRecipesPort.ts index 266460e2b..4d0c49cfe 100644 --- a/packages/types/src/recipes/ports/IRecipesPort.ts +++ b/packages/types/src/recipes/ports/IRecipesPort.ts @@ -81,6 +81,22 @@ export interface IRecipesPort { */ listRecipesBySpace(command: ListRecipesBySpaceCommand): Promise<Recipe[]>; + /** + * List all recipes across every space of an organization, without space + * membership checks. Intended for organization-scoped aggregations + * (e.g. governance drift) where the caller has already been authorized at + * the organization level. + */ + listAllRecipesByOrganization( + organizationId: OrganizationId, + ): Promise<Recipe[]>; + + /** + * Count recipes grouped by space ID, omitting spaces with zero recipes. + * Used for management listing aggregations. + */ + countBySpaceIds(spaceIds: SpaceId[]): Promise<Map<SpaceId, number>>; + // =========================== // RECIPE VERSION MANAGEMENT // =========================== diff --git a/packages/types/src/skills/ports/ISkillsPort.ts b/packages/types/src/skills/ports/ISkillsPort.ts index 1c50d5ee6..648cf3075 100644 --- a/packages/types/src/skills/ports/ISkillsPort.ts +++ b/packages/types/src/skills/ports/ISkillsPort.ts @@ -32,6 +32,18 @@ export interface ISkillsPort { userId: string, opts?: Pick<QueryOption, 'includeDeleted'>, ): Promise<Skill[]>; + /** + * List all skills across every space of an organization, without space + * membership checks. Intended for organization-scoped aggregations + * (e.g. governance drift) where the caller has already been authorized at + * the organization level. + */ + listAllSkillsByOrganization(organizationId: OrganizationId): Promise<Skill[]>; + /** + * Count skills grouped by space ID, omitting spaces with zero skills. + * Used for management listing aggregations. + */ + countBySpaceIds(spaceIds: SpaceId[]): Promise<Map<SpaceId, number>>; findSkillBySlug( slug: string, organizationId: OrganizationId, diff --git a/packages/types/src/spaces-management/contracts/IBrowseSpacesUseCase.ts b/packages/types/src/spaces-management/contracts/IBrowseSpacesUseCase.ts index 5258ce36a..947e6c525 100644 --- a/packages/types/src/spaces-management/contracts/IBrowseSpacesUseCase.ts +++ b/packages/types/src/spaces-management/contracts/IBrowseSpacesUseCase.ts @@ -1,5 +1,6 @@ import { IUseCase, PackmindCommand } from '../../UseCase'; import { Space, SpaceType } from '../../spaces/Space'; +import { SpaceColor } from '../../spaces/SpaceColor'; import { SpaceId } from '../../spaces/SpaceId'; export type BrowsableSpace = { @@ -7,6 +8,7 @@ export type BrowsableSpace = { name: string; slug: string; type: SpaceType; + color: SpaceColor; }; export type BrowseSpacesCommand = PackmindCommand; diff --git a/packages/types/src/spaces-management/contracts/IDeleteSpaceUseCase.ts b/packages/types/src/spaces-management/contracts/IDeleteSpaceUseCase.ts new file mode 100644 index 000000000..4021ba6fa --- /dev/null +++ b/packages/types/src/spaces-management/contracts/IDeleteSpaceUseCase.ts @@ -0,0 +1,8 @@ +import { IUseCase, PackmindCommand } from '../../UseCase'; + +export type DeleteSpaceCommand = PackmindCommand & { spaceId: string }; +export type DeleteSpaceResponse = Record<string, never>; +export type IDeleteSpaceUseCase = IUseCase< + DeleteSpaceCommand, + DeleteSpaceResponse +>; diff --git a/packages/types/src/spaces-management/contracts/IListOrganizationSpacesForManagementUseCase.spec.ts b/packages/types/src/spaces-management/contracts/IListOrganizationSpacesForManagementUseCase.spec.ts new file mode 100644 index 000000000..9d2fe0fa5 --- /dev/null +++ b/packages/types/src/spaces-management/contracts/IListOrganizationSpacesForManagementUseCase.spec.ts @@ -0,0 +1,82 @@ +import { + ListOrganizationSpacesForManagementCommand, + ListOrganizationSpacesForManagementResponse, + IListOrganizationSpacesForManagementUseCase, + SpaceManagementListItem, + SpaceManagementListItemAdmin, + ORGA_SPACE_MANAGEMENT_PAGE_SIZE, +} from './IListOrganizationSpacesForManagementUseCase'; + +describe('IListOrganizationSpacesForManagementUseCase contract', () => { + it('exposes the page-size constant required by the spec', () => { + expect(ORGA_SPACE_MANAGEMENT_PAGE_SIZE).toBe(1000); + }); + + it('shapes the Command type with userId, organizationId, and page', () => { + const command: ListOrganizationSpacesForManagementCommand = { + userId: 'user-1' as ListOrganizationSpacesForManagementCommand['userId'], + organizationId: + 'org-1' as ListOrganizationSpacesForManagementCommand['organizationId'], + page: 1, + }; + expect(command.page).toBe(1); + }); + + it('shapes the Response type with items', () => { + const response: ListOrganizationSpacesForManagementResponse = { + items: [], + totalCount: 0, + page: 1, + pageSize: ORGA_SPACE_MANAGEMENT_PAGE_SIZE, + }; + expect(response.items).toHaveLength(0); + }); + + it('shapes the Response type with pageSize', () => { + const response: ListOrganizationSpacesForManagementResponse = { + items: [], + totalCount: 0, + page: 1, + pageSize: ORGA_SPACE_MANAGEMENT_PAGE_SIZE, + }; + expect(response.pageSize).toBe(ORGA_SPACE_MANAGEMENT_PAGE_SIZE); + }); + + it('describes admins as a list of {id, displayName}', () => { + const admin: SpaceManagementListItemAdmin = { + id: 'user-1' as SpaceManagementListItemAdmin['id'], + displayName: 'Ada Lovelace', + }; + expect(admin.displayName).toBe('Ada Lovelace'); + }); + + it('aliases the IUseCase contract for the command/response pair', () => { + const factory = (): IListOrganizationSpacesForManagementUseCase | null => + null; + expect(factory()).toBeNull(); + }); + + it('declares SpaceManagementListItem as Space enriched with aggregations', () => { + const mockItem = { + admins: [], + memberIds: [], + membersCount: 5, + artifactsCount: 3, + } as unknown as SpaceManagementListItem; + + const buildItem = ( + item: SpaceManagementListItem, + ): Pick< + SpaceManagementListItem, + 'admins' | 'memberIds' | 'membersCount' | 'artifactsCount' + > => ({ + admins: item.admins, + memberIds: item.memberIds, + membersCount: item.membersCount, + artifactsCount: item.artifactsCount, + }); + + const result = buildItem(mockItem); + expect(result.membersCount).toBe(5); + }); +}); diff --git a/packages/types/src/spaces-management/contracts/IListOrganizationSpacesForManagementUseCase.ts b/packages/types/src/spaces-management/contracts/IListOrganizationSpacesForManagementUseCase.ts new file mode 100644 index 000000000..cce88590e --- /dev/null +++ b/packages/types/src/spaces-management/contracts/IListOrganizationSpacesForManagementUseCase.ts @@ -0,0 +1,33 @@ +import { IUseCase, PackmindCommand } from '../../UseCase'; +import { Space } from '../../spaces/Space'; +import { UserId } from '../../accounts/User'; + +export const ORGA_SPACE_MANAGEMENT_PAGE_SIZE = 1000; + +export type SpaceManagementListItemAdmin = { + id: UserId; + displayName: string; +}; + +export type SpaceManagementListItem = Space & { + admins: SpaceManagementListItemAdmin[]; + memberIds: UserId[]; + membersCount: number; + artifactsCount: number; +}; + +export type ListOrganizationSpacesForManagementCommand = PackmindCommand & { + page: number; +}; + +export type ListOrganizationSpacesForManagementResponse = { + items: SpaceManagementListItem[]; + totalCount: number; + page: number; + pageSize: number; +}; + +export type IListOrganizationSpacesForManagementUseCase = IUseCase< + ListOrganizationSpacesForManagementCommand, + ListOrganizationSpacesForManagementResponse +>; diff --git a/packages/types/src/spaces-management/contracts/IPinSpaceUseCase.ts b/packages/types/src/spaces-management/contracts/IPinSpaceUseCase.ts new file mode 100644 index 000000000..6bb62226d --- /dev/null +++ b/packages/types/src/spaces-management/contracts/IPinSpaceUseCase.ts @@ -0,0 +1,7 @@ +import { IUseCase, SpaceMemberCommand } from '../../UseCase'; + +export type PinSpaceCommand = SpaceMemberCommand; + +export type PinSpaceResponse = Record<string, never>; + +export type IPinSpaceUseCase = IUseCase<PinSpaceCommand, PinSpaceResponse>; diff --git a/packages/types/src/spaces-management/contracts/IUnpinSpaceUseCase.ts b/packages/types/src/spaces-management/contracts/IUnpinSpaceUseCase.ts new file mode 100644 index 000000000..53a18450d --- /dev/null +++ b/packages/types/src/spaces-management/contracts/IUnpinSpaceUseCase.ts @@ -0,0 +1,10 @@ +import { IUseCase, SpaceMemberCommand } from '../../UseCase'; + +export type UnpinSpaceCommand = SpaceMemberCommand; + +export type UnpinSpaceResponse = Record<string, never>; + +export type IUnpinSpaceUseCase = IUseCase< + UnpinSpaceCommand, + UnpinSpaceResponse +>; diff --git a/packages/types/src/spaces-management/contracts/IUpdateSpaceUseCase.ts b/packages/types/src/spaces-management/contracts/IUpdateSpaceUseCase.ts index c1e0bc8db..fd34fe141 100644 --- a/packages/types/src/spaces-management/contracts/IUpdateSpaceUseCase.ts +++ b/packages/types/src/spaces-management/contracts/IUpdateSpaceUseCase.ts @@ -1,9 +1,11 @@ -import { IUseCase, SpaceAdminCommand } from '../../UseCase'; +import { IUseCase, SpaceMemberCommand } from '../../UseCase'; import { Space, SpaceType } from '../../spaces/Space'; +import { SpaceColor } from '../../spaces/SpaceColor'; -export type UpdateSpaceCommand = SpaceAdminCommand & { +export type UpdateSpaceCommand = SpaceMemberCommand & { name?: string; type?: SpaceType; + color?: SpaceColor; }; export type UpdateSpaceResponse = Space; diff --git a/packages/types/src/spaces-management/contracts/index.ts b/packages/types/src/spaces-management/contracts/index.ts index e738ab437..ab5ea7016 100644 --- a/packages/types/src/spaces-management/contracts/index.ts +++ b/packages/types/src/spaces-management/contracts/index.ts @@ -3,3 +3,7 @@ export * from './IBrowseSpacesUseCase'; export * from './IJoinSpaceUseCase'; export * from './IUpdateSpaceUseCase'; export * from './ILeaveSpaceUseCase'; +export * from './IDeleteSpaceUseCase'; +export * from './IPinSpaceUseCase'; +export * from './IUnpinSpaceUseCase'; +export * from './IListOrganizationSpacesForManagementUseCase'; diff --git a/packages/types/src/spaces-management/ports/ISpacesManagementPort.ts b/packages/types/src/spaces-management/ports/ISpacesManagementPort.ts index 1277a30e0..62dcb4b27 100644 --- a/packages/types/src/spaces-management/ports/ISpacesManagementPort.ts +++ b/packages/types/src/spaces-management/ports/ISpacesManagementPort.ts @@ -23,6 +23,22 @@ import { LeaveSpaceCommand, LeaveSpaceResponse, } from '../contracts/ILeaveSpaceUseCase'; +import { + DeleteSpaceCommand, + DeleteSpaceResponse, +} from '../contracts/IDeleteSpaceUseCase'; +import { + PinSpaceCommand, + PinSpaceResponse, +} from '../contracts/IPinSpaceUseCase'; +import { + UnpinSpaceCommand, + UnpinSpaceResponse, +} from '../contracts/IUnpinSpaceUseCase'; +import { + ListOrganizationSpacesForManagementCommand, + ListOrganizationSpacesForManagementResponse, +} from '../contracts/IListOrganizationSpacesForManagementUseCase'; /** * Port interface for cross-domain access to Spaces Management functionality @@ -32,7 +48,7 @@ export const ISpacesManagementPortName = 'ISpacesManagementPort' as const; export interface ISpacesManagementPort { /** - * Create a private space and add the creator as admin member. + * Create a space and add the creator as admin member. */ createSpace(command: CreateSpaceCommand): Promise<CreateSpaceResponse>; @@ -59,7 +75,7 @@ export interface ISpacesManagementPort { joinSpaceBySlug(command: JoinSpaceBySlugCommand): Promise<JoinSpaceResponse>; /** - * Update a space's settings (name, type). + * Update a space's settings (name, type, color). */ updateSpace(command: UpdateSpaceCommand): Promise<UpdateSpaceResponse>; @@ -67,4 +83,27 @@ export interface ISpacesManagementPort { * Leave a space (user-initiated self-removal). */ leaveSpace(command: LeaveSpaceCommand): Promise<LeaveSpaceResponse>; + + /** + * Delete a space and all its memberships. + */ + deleteSpace(command: DeleteSpaceCommand): Promise<DeleteSpaceResponse>; + + /** + * Pin a space for the current user. + */ + pinSpace(command: PinSpaceCommand): Promise<PinSpaceResponse>; + + /** + * Unpin a space for the current user. + */ + unpinSpace(command: UnpinSpaceCommand): Promise<UnpinSpaceResponse>; + + /** + * List paginated organization spaces enriched with admins, member counts, + * and artifact counts for the management view. + */ + listOrganizationSpacesForManagement( + command: ListOrganizationSpacesForManagementCommand, + ): Promise<ListOrganizationSpacesForManagementResponse>; } diff --git a/packages/types/src/sse/SSEEvent.ts b/packages/types/src/sse/SSEEvent.ts index 2f3fe6134..ce6079e80 100644 --- a/packages/types/src/sse/SSEEvent.ts +++ b/packages/types/src/sse/SSEEvent.ts @@ -1,4 +1,5 @@ import { UserOrganizationRole } from '../accounts/User'; +import { PublishFailureReason } from '../deployments/PublishFailureReason'; // Base SSE Event structure export interface SSEEvent<TData = unknown> { @@ -20,7 +21,8 @@ export type SSEEventType = | 'DETECTION_HEURISTICS_UPDATED' | 'USER_CONTEXT_CHANGE' | 'DISTRIBUTION_STATUS_CHANGE' - | 'CHANGE_PROPOSAL_UPDATE'; + | 'CHANGE_PROPOSAL_UPDATE' + | 'MARKETPLACE_PUBLISH_COMPLETED'; // Hello World event for testing export interface HelloWorldEvent extends SSEEvent<{ message: string }> { @@ -96,6 +98,26 @@ export interface ChangeProposalUpdateEvent extends SSEEvent<{ type: 'CHANGE_PROPOSAL_UPDATE'; } +export type MarketplacePublishCompletedStatus = + | 'success' + | 'no_changes' + | 'failure'; + +// Marketplace publish completed event for user-facing notifications +export interface MarketplacePublishCompletedEvent extends SSEEvent<{ + marketplaceDistributionId: string; + marketplaceId: string; + packageId: string; + pluginSlug: string; + packageName: string; + marketplaceName: string; + status: MarketplacePublishCompletedStatus; + prUrl?: string; + failureReason?: PublishFailureReason; +}> { + type: 'MARKETPLACE_PUBLISH_COMPLETED'; +} + // Union type of all possible SSE events export type AnySSEEvent = | HelloWorldEvent @@ -106,7 +128,8 @@ export type AnySSEEvent = | DetectionHeuristicsUpdatedEvent | UserContextChangeEvent | DistributionStatusChangeEvent - | ChangeProposalUpdateEvent; + | ChangeProposalUpdateEvent + | MarketplacePublishCompletedEvent; // Event creation helpers export function createHelloWorldEvent(message: string): HelloWorldEvent { @@ -228,3 +251,21 @@ export function createChangeProposalUpdateEvent( timestamp: new Date().toISOString(), }; } + +export function createMarketplacePublishCompletedEvent(params: { + marketplaceDistributionId: string; + marketplaceId: string; + packageId: string; + pluginSlug: string; + packageName: string; + marketplaceName: string; + status: MarketplacePublishCompletedStatus; + prUrl?: string; + failureReason?: PublishFailureReason; +}): MarketplacePublishCompletedEvent { + return { + type: 'MARKETPLACE_PUBLISH_COMPLETED', + data: params, + timestamp: new Date().toISOString(), + }; +} diff --git a/packages/types/src/standards/ports/IStandardsPort.ts b/packages/types/src/standards/ports/IStandardsPort.ts index 057962cd1..2c956cce8 100644 --- a/packages/types/src/standards/ports/IStandardsPort.ts +++ b/packages/types/src/standards/ports/IStandardsPort.ts @@ -54,6 +54,7 @@ export interface IStandardsPort { listAllStandardsByOrganization( organizationId: OrganizationId, ): Promise<Standard[]>; + countBySpaceIds(spaceIds: SpaceId[]): Promise<Map<SpaceId, number>>; getRuleCodeExamples(id: RuleId): Promise<RuleExample[]>; findStandardBySlug( slug: string, diff --git a/packages/ui/.claude/rules/packmind/standard-front-end-ui-and-design-systems.md b/packages/ui/.claude/rules/packmind/standard-front-end-ui-and-design-systems.md index 652f284c2..9e13c7355 100644 --- a/packages/ui/.claude/rules/packmind/standard-front-end-ui-and-design-systems.md +++ b/packages/ui/.claude/rules/packmind/standard-front-end-ui-and-design-systems.md @@ -3,12 +3,12 @@ name: 'Front-end UI and Design Systems' paths: - "apps/frontend/**/*.tsx" alwaysApply: false -description: 'Adopt guidelines for using Chakra UI v3 through the @packmind/ui design system in React applications to ensure consistent UI implementation and visual consistency, applying this standard when building or modifying any frontend components.' +description: 'This standard establishes guidelines for using Chakra UI v3 through the @packmind/ui design system to ensure consistent UI implementation across the frontend application. The @packmind/ui package provides a mapping of Chakra UI components with the same names and props, enhanced with Packmind-specific theming and semantic tokens. Apply this standard when building or modifying any React components in the frontend application to maintain visual consistency and leverage the centralized design system.' --- # Standard: Front-end UI and Design Systems -Adopt guidelines for using Chakra UI v3 through the @packmind/ui design system in React applications to ensure consistent UI implementation and visual consistency, applying this standard when building or modifying any frontend components. : +This standard establishes guidelines for using Chakra UI v3 through the @packmind/ui design system to ensure consistent UI implementation across the frontend application. The @packmind/ui package prov... : * Never use vanilla HTML tags (div, span, button, input, etc.) in frontend component code; always use corresponding @packmind/ui components (PMBox, PMText, PMButton, PMInput, etc.) to ensure consistent styling and theming. * Prefer using the design token 'full' instead of the literal value '100%' for width or height properties in UI components to maintain consistency with the design system. * Use components imported from '@packmind/ui' instead of '@chakra-ui' packages to maintain a consistent UI abstraction layer, e.g., import { PMButton } from '@packmind/ui'; not import { Button } from '@chakra-ui/react'; diff --git a/packages/ui/.cursor/rules/packmind/standard-front-end-ui-and-design-systems.mdc b/packages/ui/.cursor/rules/packmind/standard-front-end-ui-and-design-systems.mdc index 6d195fdfe..bf86c4baa 100644 --- a/packages/ui/.cursor/rules/packmind/standard-front-end-ui-and-design-systems.mdc +++ b/packages/ui/.cursor/rules/packmind/standard-front-end-ui-and-design-systems.mdc @@ -4,7 +4,7 @@ alwaysApply: false --- # Standard: Front-end UI and Design Systems -Adopt guidelines for using Chakra UI v3 through the @packmind/ui design system in React applications to ensure consistent UI implementation and visual consistency, applying this standard when building or modifying any frontend components. : +This standard establishes guidelines for using Chakra UI v3 through the @packmind/ui design system to ensure consistent UI implementation across the frontend application. The @packmind/ui package prov... : * Never use vanilla HTML tags (div, span, button, input, etc.) in frontend component code; always use corresponding @packmind/ui components (PMBox, PMText, PMButton, PMInput, etc.) to ensure consistent styling and theming. * Prefer using the design token 'full' instead of the literal value '100%' for width or height properties in UI components to maintain consistency with the design system. * Use components imported from '@packmind/ui' instead of '@chakra-ui' packages to maintain a consistent UI abstraction layer, e.g., import { PMButton } from '@packmind/ui'; not import { Button } from '@chakra-ui/react'; diff --git a/packages/ui/.github/instructions/packmind-front-end-ui-and-design-systems.instructions.md b/packages/ui/.github/instructions/packmind-front-end-ui-and-design-systems.instructions.md index f1df66d4e..dcf56e2e3 100644 --- a/packages/ui/.github/instructions/packmind-front-end-ui-and-design-systems.instructions.md +++ b/packages/ui/.github/instructions/packmind-front-end-ui-and-design-systems.instructions.md @@ -3,7 +3,7 @@ applyTo: 'apps/frontend/**/*.tsx' --- # Standard: Front-end UI and Design Systems -Adopt guidelines for using Chakra UI v3 through the @packmind/ui design system in React applications to ensure consistent UI implementation and visual consistency, applying this standard when building or modifying any frontend components. : +This standard establishes guidelines for using Chakra UI v3 through the @packmind/ui design system to ensure consistent UI implementation across the frontend application. The @packmind/ui package prov... : * Never use vanilla HTML tags (div, span, button, input, etc.) in frontend component code; always use corresponding @packmind/ui components (PMBox, PMText, PMButton, PMInput, etc.) to ensure consistent styling and theming. * Prefer using the design token 'full' instead of the literal value '100%' for width or height properties in UI components to maintain consistency with the design system. * Use components imported from '@packmind/ui' instead of '@chakra-ui' packages to maintain a consistent UI abstraction layer, e.g., import { PMButton } from '@packmind/ui'; not import { Button } from '@chakra-ui/react'; diff --git a/packages/ui/.gitlab/duo/chat-rules.md b/packages/ui/.gitlab/duo/chat-rules.md index 653ecb71e..e8382e3dd 100644 --- a/packages/ui/.gitlab/duo/chat-rules.md +++ b/packages/ui/.gitlab/duo/chat-rules.md @@ -11,7 +11,7 @@ Failure to follow these standards may lead to inconsistencies, errors, or rework # Standard: Front-end UI and Design Systems -Adopt guidelines for using Chakra UI v3 through the @packmind/ui design system in React applications to ensure consistent UI implementation and visual consistency, applying this standard when building or modifying any frontend components. : +This standard establishes guidelines for using Chakra UI v3 through the @packmind/ui design system to ensure consistent UI implementation across the frontend application. The @packmind/ui package prov... : * Never use vanilla HTML tags (div, span, button, input, etc.) in frontend component code; always use corresponding @packmind/ui components (PMBox, PMText, PMButton, PMInput, etc.) to ensure consistent styling and theming. * Prefer using the design token 'full' instead of the literal value '100%' for width or height properties in UI components to maintain consistency with the design system. * Use components imported from '@packmind/ui' instead of '@chakra-ui' packages to maintain a consistent UI abstraction layer, e.g., import { PMButton } from '@packmind/ui'; not import { Button } from '@chakra-ui/react'; diff --git a/packages/ui/.packmind/standards-index.md b/packages/ui/.packmind/standards-index.md index 39db73872..a574ad3fe 100644 --- a/packages/ui/.packmind/standards-index.md +++ b/packages/ui/.packmind/standards-index.md @@ -4,7 +4,7 @@ This standards index contains all available coding standards that can be used by ## Available Standards -- [Front-end UI and Design Systems](./standards/front-end-ui-and-design-systems.md) : Adopt guidelines for using Chakra UI v3 through the @packmind/ui design system in React applications to ensure consistent UI implementation and visual consistency, applying this standard when building or modifying any frontend components. +- [Front-end UI and Design Systems](./standards/front-end-ui-and-design-systems.md) : This standard establishes guidelines for using Chakra UI v3 through the @packmind/ui design system to ensure consistent UI implementation across the frontend application. The @packmind/ui package provides a mapping of Chakra UI components with the same names and props, enhanced with Packmind-specific theming and semantic tokens. Apply this standard when building or modifying any React components in the frontend application to maintain visual consistency and leverage the centralized design system. --- diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index cec16baa2..db3c769c7 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -27,7 +27,7 @@ Failure to follow these standards may lead to inconsistencies, errors, or rework # Standard: Front-end UI and Design Systems -Adopt guidelines for using Chakra UI v3 through the @packmind/ui design system in React applications to ensure consistent UI implementation and visual consistency, applying this standard when building or modifying any frontend components. : +This standard establishes guidelines for using Chakra UI v3 through the @packmind/ui design system to ensure consistent UI implementation across the frontend application. The @packmind/ui package prov... : * Never use vanilla HTML tags (div, span, button, input, etc.) in frontend component code; always use corresponding @packmind/ui components (PMBox, PMText, PMButton, PMInput, etc.) to ensure consistent styling and theming. * Prefer using the design token 'full' instead of the literal value '100%' for width or height properties in UI components to maintain consistency with the design system. * Use components imported from '@packmind/ui' instead of '@chakra-ui' packages to maintain a consistent UI abstraction layer, e.g., import { PMButton } from '@packmind/ui'; not import { Button } from '@chakra-ui/react'; diff --git a/packages/ui/packmind-lock.json b/packages/ui/packmind-lock.json index 3bbea99d0..d8300b171 100644 --- a/packages/ui/packmind-lock.json +++ b/packages/ui/packmind-lock.json @@ -6,12 +6,14 @@ "agents": [ "agents_md", "claude", + "codex", "copilot", "cursor", "gitlab_duo", + "opencode", "packmind" ], - "installedAt": "2026-06-16T03:12:28.934Z", + "installedAt": "2026-07-10T03:07:55.211Z", "artifacts": { "user:standard:front-end-ui-and-design-systems": { "name": "Front-end UI and Design Systems", diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index e237e1815..03ecefca7 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -5,6 +5,7 @@ export * from './lib/components/typography/PMText'; export * from './lib/components/typography/PMEm'; export * from './lib/components/typography/PMLink'; export * from './lib/components/typography/PMList'; +export * from './lib/components/typography/PMBlockquote'; export * from './lib/components/form/PMInput/PMInput'; export * from './lib/components/form/PMTextArea/PMTextArea'; export * from './lib/components/form/PMLabel/PMLabel'; diff --git a/packages/ui/src/lib/components/content/PMFeatureFlag/PMFeatureFlag.tsx b/packages/ui/src/lib/components/content/PMFeatureFlag/PMFeatureFlag.tsx index 9798eb920..c0f953891 100644 --- a/packages/ui/src/lib/components/content/PMFeatureFlag/PMFeatureFlag.tsx +++ b/packages/ui/src/lib/components/content/PMFeatureFlag/PMFeatureFlag.tsx @@ -15,6 +15,11 @@ export const ORGA_SPACE_MANAGEMENT_FEATURE_KEY = 'orga-space-management'; export const GOVERNANCE_FEATURE_KEY = 'governance'; +export const MARKETPLACES_FEATURE_KEY = 'marketplaces'; + +export const MARKETPLACE_PLUGIN_REMOVAL_FEATURE_KEY = + 'marketplace-plugin-removal'; + export const DEFAULT_FEATURE_DOMAIN_MAP: Record<string, readonly string[]> = { [ADD_CHANGE_PROPOSALS_IN_WEBAPP_FEATURE_KEY]: [ '@packmind.com', @@ -22,6 +27,8 @@ export const DEFAULT_FEATURE_DOMAIN_MAP: Record<string, readonly string[]> = { ], [ORGA_SPACE_MANAGEMENT_FEATURE_KEY]: ['@packmind.com', '@promyze.com'], [GOVERNANCE_FEATURE_KEY]: ['joan.racenet@packmind.com'], + [MARKETPLACES_FEATURE_KEY]: ['@packmind.com', '@promyze.com'], + [MARKETPLACE_PLUGIN_REMOVAL_FEATURE_KEY]: ['@packmind.com', '@promyze.com'], }; const isExactEmailEntry = (entry: string): boolean => diff --git a/packages/ui/src/lib/components/content/index.ts b/packages/ui/src/lib/components/content/index.ts index 0eb9a29a1..78b665138 100644 --- a/packages/ui/src/lib/components/content/index.ts +++ b/packages/ui/src/lib/components/content/index.ts @@ -8,7 +8,6 @@ export type { IPMPageProps } from './PMPage/PMPage'; export { PMPopover } from './PMPopover'; export * from './PMEmptyState/PMEmptyState'; export { PMBadge } from './PMBadge/PMBadge'; -export type { BadgeProps as PMBadgeProps } from './PMBadge/PMBadge'; export { PMAvatar } from './PMAvatar/PMAvatar'; export { PMAvatarGroup } from './PMAvatar/PMAvatarGroup'; export { PMBreadcrumb } from './PMBreadcrumb/PMBreadcrumb'; diff --git a/packages/ui/src/lib/components/feedback/PMConfirmationModal/PMConfirmationModal.tsx b/packages/ui/src/lib/components/feedback/PMConfirmationModal/PMConfirmationModal.tsx index e5edd7454..ffd77f894 100644 --- a/packages/ui/src/lib/components/feedback/PMConfirmationModal/PMConfirmationModal.tsx +++ b/packages/ui/src/lib/components/feedback/PMConfirmationModal/PMConfirmationModal.tsx @@ -19,7 +19,7 @@ export type PMConfirmationModalProps = { /** Title displayed in the modal header */ title: string; /** Message displayed in the modal body */ - message: string; + message: ReactNode; /** Text for the confirm button (defaults to "Delete") */ confirmText?: string; /** Text for the cancel button (defaults to "Cancel") */ diff --git a/packages/ui/src/lib/components/form/PMButton/PMButton.recipe.ts b/packages/ui/src/lib/components/form/PMButton/PMButton.recipe.ts index 45ee50a80..0f8906062 100644 --- a/packages/ui/src/lib/components/form/PMButton/PMButton.recipe.ts +++ b/packages/ui/src/lib/components/form/PMButton/PMButton.recipe.ts @@ -55,14 +55,15 @@ export const pmButtonRecipe = defineRecipe({ }, }, danger: { - bg: '{colors.red.500}', - borderColor: '{colors.red.500}', + bg: '{colors.red.800}', + color: '{colors.red.100}', + borderColor: '{colors.red.800}', _hover: { - bg: '{colors.red.900}', + bg: '{colors.red.700}', }, _disabled: { bg: '{colors.red.900}', - color: '{colors.red.500}', + color: '{colors.red.300}', borderColor: '{colors.red.1000}', }, }, diff --git a/packages/ui/src/lib/components/form/PMRadioGroup/PMRadioGroup.recipe.ts b/packages/ui/src/lib/components/form/PMRadioGroup/PMRadioGroup.recipe.ts index 7682ead46..9baa925db 100644 --- a/packages/ui/src/lib/components/form/PMRadioGroup/PMRadioGroup.recipe.ts +++ b/packages/ui/src/lib/components/form/PMRadioGroup/PMRadioGroup.recipe.ts @@ -1,33 +1,13 @@ import { defineSlotRecipe } from '@chakra-ui/react'; +import { radioGroupAnatomy } from '@chakra-ui/react/anatomy'; /* based on https://github.com/chakra-ui/chakra-ui/blob/main/packages/react/src/theme/recipes/radio-group.ts */ export const pmRadioGroup = defineSlotRecipe({ - slots: ['root', 'item', 'itemControl', 'itemIndicator', 'itemText'], + slots: radioGroupAnatomy.keys(), base: { - root: { - gap: '2', - }, - item: { - gap: '2.5', - }, itemControl: { - borderColor: '{colors.border.primary}', - borderWidth: '2px', - bg: 'transparent', - width: '5', - height: '5', - _before: { - display: 'none', - }, - '& .dot': { - display: 'none', - }, - _checked: { - bg: '{colors.blue.200}', - borderColor: '{colors.blue.200}', - }, + _checked: {}, '&[data-checked]': { - bg: '{colors.blue.200}', borderColor: '{colors.blue.200}', }, _hover: { @@ -37,13 +17,9 @@ export const pmRadioGroup = defineSlotRecipe({ outlineColor: '{colors.blue.200}', }, }, - itemIndicator: { - _before: { - display: 'none', - }, - }, itemText: { - color: '{colors.text.primary}', + fontSize: 'sm', + color: '{colors.text.secondary}', }, }, }); diff --git a/packages/ui/src/lib/components/typography/PMBlockquote.tsx b/packages/ui/src/lib/components/typography/PMBlockquote.tsx new file mode 100644 index 000000000..6cd077712 --- /dev/null +++ b/packages/ui/src/lib/components/typography/PMBlockquote.tsx @@ -0,0 +1 @@ +export { Blockquote as PMBlockquote } from '@chakra-ui/react'; diff --git a/packmind-lock.json b/packmind-lock.json index d704edb6b..1b9ed0fdf 100644 --- a/packmind-lock.json +++ b/packmind-lock.json @@ -3,20 +3,110 @@ "packageSlugs": [ "@backend/backend-standards", "@frontend/playground", + "@global/packmind-proprietary", "@global/packmind-standards", + "@global/proprietary-standards", "@testing/cli-e2e", "@testing/unit-tests" ], "agents": [ "agents_md", "claude", + "codex", "copilot", "cursor", "gitlab_duo", + "opencode", "packmind" ], - "installedAt": "2026-06-16T03:12:25.740Z", + "installedAt": "2026-07-10T03:07:51.654Z", "artifacts": { + "user:skill:agent-skill-frontmatter-audit": { + "name": "agent-skill-frontmatter-audit", + "type": "skill", + "id": "66ec2fbb-571a-4e4a-bc8e-8d0128bf3d5b", + "version": 1, + "spaceId": "02d1f9c9-a449-4794-b911-186cccd48884", + "packageIds": [ + "b7e72473-52d8-4266-9d86-bd33b6395747" + ], + "source": "user", + "files": [ + { + "path": ".agents/skills/agent-skill-frontmatter-audit/SKILL.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".claude/skills/agent-skill-frontmatter-audit/SKILL.md", + "agent": "claude", + "isSkillDefinition": true + }, + { + "path": ".cursor/skills/agent-skill-frontmatter-audit/SKILL.md", + "agent": "cursor", + "isSkillDefinition": true + }, + { + "path": ".github/skills/agent-skill-frontmatter-audit/SKILL.md", + "agent": "copilot", + "isSkillDefinition": true + }, + { + "path": ".gitlab/duo/skills/agent-skill-frontmatter-audit/SKILL.md", + "agent": "gitlab_duo", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/agent-skill-frontmatter-audit/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true + } + ] + }, + "user:skill:amplitude-listener-events-adoption": { + "name": "amplitude-listener-events-adoption", + "type": "skill", + "id": "fa0e7599-5f56-48cf-bf3b-891f0b9f6f38", + "version": 1, + "spaceId": "02d1f9c9-a449-4794-b911-186cccd48884", + "packageIds": [ + "b7e72473-52d8-4266-9d86-bd33b6395747" + ], + "source": "user", + "files": [ + { + "path": ".agents/skills/amplitude-listener-events-adoption/SKILL.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".claude/skills/amplitude-listener-events-adoption/SKILL.md", + "agent": "claude", + "isSkillDefinition": true + }, + { + "path": ".cursor/skills/amplitude-listener-events-adoption/SKILL.md", + "agent": "cursor", + "isSkillDefinition": true + }, + { + "path": ".github/skills/amplitude-listener-events-adoption/SKILL.md", + "agent": "copilot", + "isSkillDefinition": true + }, + { + "path": ".gitlab/duo/skills/amplitude-listener-events-adoption/SKILL.md", + "agent": "gitlab_duo", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/amplitude-listener-events-adoption/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true + } + ] + }, "user:skill:cli-e2e-test-authoring": { "name": "cli-e2e-test-authoring", "type": "skill", @@ -28,25 +118,1262 @@ ], "source": "user", "files": [ + { + "path": ".agents/skills/cli-e2e-test-authoring/SKILL.md", + "agent": "codex", + "isSkillDefinition": true + }, { "path": ".claude/skills/cli-e2e-test-authoring/SKILL.md", "agent": "claude", "isSkillDefinition": true }, { - "path": ".cursor/skills/cli-e2e-test-authoring/SKILL.md", - "agent": "cursor", - "isSkillDefinition": true + "path": ".cursor/skills/cli-e2e-test-authoring/SKILL.md", + "agent": "cursor", + "isSkillDefinition": true + }, + { + "path": ".github/skills/cli-e2e-test-authoring/SKILL.md", + "agent": "copilot", + "isSkillDefinition": true + }, + { + "path": ".gitlab/duo/skills/cli-e2e-test-authoring/SKILL.md", + "agent": "gitlab_duo", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/cli-e2e-test-authoring/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true + } + ] + }, + "user:skill:datadog-analysis": { + "name": "datadog-analysis", + "type": "skill", + "id": "0bddffe8-a496-4cf2-85f5-5525453eb54b", + "version": 2, + "spaceId": "02d1f9c9-a449-4794-b911-186cccd48884", + "packageIds": [ + "b7e72473-52d8-4266-9d86-bd33b6395747" + ], + "source": "user", + "files": [ + { + "path": ".agents/skills/datadog-analysis/references/datadog_mcp.md", + "agent": "codex" + }, + { + "path": ".agents/skills/datadog-analysis/references/known_patterns.md", + "agent": "codex" + }, + { + "path": ".agents/skills/datadog-analysis/references/report-template.md", + "agent": "codex" + }, + { + "path": ".agents/skills/datadog-analysis/SKILL.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".claude/skills/datadog-analysis/references/datadog_mcp.md", + "agent": "claude" + }, + { + "path": ".claude/skills/datadog-analysis/references/known_patterns.md", + "agent": "claude" + }, + { + "path": ".claude/skills/datadog-analysis/references/report-template.md", + "agent": "claude" + }, + { + "path": ".claude/skills/datadog-analysis/SKILL.md", + "agent": "claude", + "isSkillDefinition": true + }, + { + "path": ".cursor/skills/datadog-analysis/references/datadog_mcp.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/datadog-analysis/references/known_patterns.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/datadog-analysis/references/report-template.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/datadog-analysis/SKILL.md", + "agent": "cursor", + "isSkillDefinition": true + }, + { + "path": ".github/skills/datadog-analysis/references/datadog_mcp.md", + "agent": "copilot" + }, + { + "path": ".github/skills/datadog-analysis/references/known_patterns.md", + "agent": "copilot" + }, + { + "path": ".github/skills/datadog-analysis/references/report-template.md", + "agent": "copilot" + }, + { + "path": ".github/skills/datadog-analysis/SKILL.md", + "agent": "copilot", + "isSkillDefinition": true + }, + { + "path": ".gitlab/duo/skills/datadog-analysis/references/datadog_mcp.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/datadog-analysis/references/known_patterns.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/datadog-analysis/references/report-template.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/datadog-analysis/SKILL.md", + "agent": "gitlab_duo", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/datadog-analysis/references/datadog_mcp.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/datadog-analysis/references/known_patterns.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/datadog-analysis/references/report-template.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/datadog-analysis/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true + } + ] + }, + "user:skill:doc-audit": { + "name": "doc-audit", + "type": "skill", + "id": "7683e9a7-4d61-40be-a993-9de987dba976", + "version": 2, + "spaceId": "02d1f9c9-a449-4794-b911-186cccd48884", + "packageIds": [ + "b7e72473-52d8-4266-9d86-bd33b6395747" + ], + "source": "user", + "files": [ + { + "path": ".agents/skills/doc-audit/references/section-audit-instructions.md", + "agent": "codex" + }, + { + "path": ".agents/skills/doc-audit/SKILL.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".claude/skills/doc-audit/references/section-audit-instructions.md", + "agent": "claude" + }, + { + "path": ".claude/skills/doc-audit/SKILL.md", + "agent": "claude", + "isSkillDefinition": true + }, + { + "path": ".cursor/skills/doc-audit/references/section-audit-instructions.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/doc-audit/SKILL.md", + "agent": "cursor", + "isSkillDefinition": true + }, + { + "path": ".github/skills/doc-audit/references/section-audit-instructions.md", + "agent": "copilot" + }, + { + "path": ".github/skills/doc-audit/SKILL.md", + "agent": "copilot", + "isSkillDefinition": true + }, + { + "path": ".gitlab/duo/skills/doc-audit/references/section-audit-instructions.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/doc-audit/SKILL.md", + "agent": "gitlab_duo", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/doc-audit/references/section-audit-instructions.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/doc-audit/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true + } + ] + }, + "user:skill:feature-spec": { + "name": "feature-spec", + "type": "skill", + "id": "856e81e9-f291-45c2-8108-30be503122d0", + "version": 1, + "spaceId": "02d1f9c9-a449-4794-b911-186cccd48884", + "packageIds": [ + "b7e72473-52d8-4266-9d86-bd33b6395747" + ], + "source": "user", + "files": [ + { + "path": ".agents/skills/feature-spec/phase-1-resolve-source.md", + "agent": "codex" + }, + { + "path": ".agents/skills/feature-spec/phase-2-discovery-and-spec.md", + "agent": "codex" + }, + { + "path": ".agents/skills/feature-spec/phase-3-implementation.md", + "agent": "codex" + }, + { + "path": ".agents/skills/feature-spec/phase-4-finalize.md", + "agent": "codex" + }, + { + "path": ".agents/skills/feature-spec/references/context-schema.md", + "agent": "codex" + }, + { + "path": ".agents/skills/feature-spec/references/implementation-plan-prompt.md", + "agent": "codex" + }, + { + "path": ".agents/skills/feature-spec/references/spec-templates.md", + "agent": "codex" + }, + { + "path": ".agents/skills/feature-spec/SKILL.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".claude/skills/feature-spec/phase-1-resolve-source.md", + "agent": "claude" + }, + { + "path": ".claude/skills/feature-spec/phase-2-discovery-and-spec.md", + "agent": "claude" + }, + { + "path": ".claude/skills/feature-spec/phase-3-implementation.md", + "agent": "claude" + }, + { + "path": ".claude/skills/feature-spec/phase-4-finalize.md", + "agent": "claude" + }, + { + "path": ".claude/skills/feature-spec/references/context-schema.md", + "agent": "claude" + }, + { + "path": ".claude/skills/feature-spec/references/implementation-plan-prompt.md", + "agent": "claude" + }, + { + "path": ".claude/skills/feature-spec/references/spec-templates.md", + "agent": "claude" + }, + { + "path": ".claude/skills/feature-spec/SKILL.md", + "agent": "claude", + "isSkillDefinition": true + }, + { + "path": ".cursor/skills/feature-spec/phase-1-resolve-source.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/feature-spec/phase-2-discovery-and-spec.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/feature-spec/phase-3-implementation.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/feature-spec/phase-4-finalize.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/feature-spec/references/context-schema.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/feature-spec/references/implementation-plan-prompt.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/feature-spec/references/spec-templates.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/feature-spec/SKILL.md", + "agent": "cursor", + "isSkillDefinition": true + }, + { + "path": ".github/skills/feature-spec/phase-1-resolve-source.md", + "agent": "copilot" + }, + { + "path": ".github/skills/feature-spec/phase-2-discovery-and-spec.md", + "agent": "copilot" + }, + { + "path": ".github/skills/feature-spec/phase-3-implementation.md", + "agent": "copilot" + }, + { + "path": ".github/skills/feature-spec/phase-4-finalize.md", + "agent": "copilot" + }, + { + "path": ".github/skills/feature-spec/references/context-schema.md", + "agent": "copilot" + }, + { + "path": ".github/skills/feature-spec/references/implementation-plan-prompt.md", + "agent": "copilot" + }, + { + "path": ".github/skills/feature-spec/references/spec-templates.md", + "agent": "copilot" + }, + { + "path": ".github/skills/feature-spec/SKILL.md", + "agent": "copilot", + "isSkillDefinition": true + }, + { + "path": ".gitlab/duo/skills/feature-spec/phase-1-resolve-source.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/feature-spec/phase-2-discovery-and-spec.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/feature-spec/phase-3-implementation.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/feature-spec/phase-4-finalize.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/feature-spec/references/context-schema.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/feature-spec/references/implementation-plan-prompt.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/feature-spec/references/spec-templates.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/feature-spec/SKILL.md", + "agent": "gitlab_duo", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/feature-spec/phase-1-resolve-source.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/feature-spec/phase-2-discovery-and-spec.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/feature-spec/phase-3-implementation.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/feature-spec/phase-4-finalize.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/feature-spec/references/context-schema.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/feature-spec/references/implementation-plan-prompt.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/feature-spec/references/spec-templates.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/feature-spec/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true + } + ] + }, + "user:skill:feature-sprint": { + "name": "feature-sprint", + "type": "skill", + "id": "01ebc12a-443d-4719-a045-e66f4c3ca457", + "version": 1, + "spaceId": "02d1f9c9-a449-4794-b911-186cccd48884", + "packageIds": [ + "b7e72473-52d8-4266-9d86-bd33b6395747" + ], + "source": "user", + "files": [ + { + "path": ".agents/skills/feature-sprint/phase-1-init.md", + "agent": "codex" + }, + { + "path": ".agents/skills/feature-sprint/phase-2-execute.md", + "agent": "codex" + }, + { + "path": ".agents/skills/feature-sprint/phase-3-finalize.md", + "agent": "codex" + }, + { + "path": ".agents/skills/feature-sprint/references/group-prompt-template.md", + "agent": "codex" + }, + { + "path": ".agents/skills/feature-sprint/scripts/parse_implementation_plan.py", + "agent": "codex" + }, + { + "path": ".agents/skills/feature-sprint/scripts/resolve_ready_groups.py", + "agent": "codex" + }, + { + "path": ".agents/skills/feature-sprint/scripts/sync_checkboxes.py", + "agent": "codex" + }, + { + "path": ".agents/skills/feature-sprint/SKILL.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".claude/skills/feature-sprint/phase-1-init.md", + "agent": "claude" + }, + { + "path": ".claude/skills/feature-sprint/phase-2-execute.md", + "agent": "claude" + }, + { + "path": ".claude/skills/feature-sprint/phase-3-finalize.md", + "agent": "claude" + }, + { + "path": ".claude/skills/feature-sprint/references/group-prompt-template.md", + "agent": "claude" + }, + { + "path": ".claude/skills/feature-sprint/scripts/parse_implementation_plan.py", + "agent": "claude" + }, + { + "path": ".claude/skills/feature-sprint/scripts/resolve_ready_groups.py", + "agent": "claude" + }, + { + "path": ".claude/skills/feature-sprint/scripts/sync_checkboxes.py", + "agent": "claude" + }, + { + "path": ".claude/skills/feature-sprint/SKILL.md", + "agent": "claude", + "isSkillDefinition": true + }, + { + "path": ".cursor/skills/feature-sprint/phase-1-init.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/feature-sprint/phase-2-execute.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/feature-sprint/phase-3-finalize.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/feature-sprint/references/group-prompt-template.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/feature-sprint/scripts/parse_implementation_plan.py", + "agent": "cursor" + }, + { + "path": ".cursor/skills/feature-sprint/scripts/resolve_ready_groups.py", + "agent": "cursor" + }, + { + "path": ".cursor/skills/feature-sprint/scripts/sync_checkboxes.py", + "agent": "cursor" + }, + { + "path": ".cursor/skills/feature-sprint/SKILL.md", + "agent": "cursor", + "isSkillDefinition": true + }, + { + "path": ".github/skills/feature-sprint/phase-1-init.md", + "agent": "copilot" + }, + { + "path": ".github/skills/feature-sprint/phase-2-execute.md", + "agent": "copilot" + }, + { + "path": ".github/skills/feature-sprint/phase-3-finalize.md", + "agent": "copilot" + }, + { + "path": ".github/skills/feature-sprint/references/group-prompt-template.md", + "agent": "copilot" + }, + { + "path": ".github/skills/feature-sprint/scripts/parse_implementation_plan.py", + "agent": "copilot" + }, + { + "path": ".github/skills/feature-sprint/scripts/resolve_ready_groups.py", + "agent": "copilot" + }, + { + "path": ".github/skills/feature-sprint/scripts/sync_checkboxes.py", + "agent": "copilot" + }, + { + "path": ".github/skills/feature-sprint/SKILL.md", + "agent": "copilot", + "isSkillDefinition": true + }, + { + "path": ".gitlab/duo/skills/feature-sprint/phase-1-init.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/feature-sprint/phase-2-execute.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/feature-sprint/phase-3-finalize.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/feature-sprint/references/group-prompt-template.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/feature-sprint/scripts/parse_implementation_plan.py", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/feature-sprint/scripts/resolve_ready_groups.py", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/feature-sprint/scripts/sync_checkboxes.py", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/feature-sprint/SKILL.md", + "agent": "gitlab_duo", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/feature-sprint/phase-1-init.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/feature-sprint/phase-2-execute.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/feature-sprint/phase-3-finalize.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/feature-sprint/references/group-prompt-template.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/feature-sprint/scripts/parse_implementation_plan.py", + "agent": "opencode" + }, + { + "path": ".opencode/skills/feature-sprint/scripts/resolve_ready_groups.py", + "agent": "opencode" + }, + { + "path": ".opencode/skills/feature-sprint/scripts/sync_checkboxes.py", + "agent": "opencode" + }, + { + "path": ".opencode/skills/feature-sprint/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true + } + ] + }, + "user:skill:playbook-cli-audit": { + "name": "playbook-cli-audit", + "type": "skill", + "id": "dcfa9fec-ec46-40ea-b03a-e2f316cc0088", + "version": 1, + "spaceId": "02d1f9c9-a449-4794-b911-186cccd48884", + "packageIds": [ + "b7e72473-52d8-4266-9d86-bd33b6395747" + ], + "source": "user", + "files": [ + { + "path": ".agents/skills/playbook-cli-audit/SKILL.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".claude/skills/playbook-cli-audit/SKILL.md", + "agent": "claude", + "isSkillDefinition": true + }, + { + "path": ".cursor/skills/playbook-cli-audit/SKILL.md", + "agent": "cursor", + "isSkillDefinition": true + }, + { + "path": ".github/skills/playbook-cli-audit/SKILL.md", + "agent": "copilot", + "isSkillDefinition": true + }, + { + "path": ".gitlab/duo/skills/playbook-cli-audit/SKILL.md", + "agent": "gitlab_duo", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/playbook-cli-audit/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true + } + ] + }, + "user:skill:product-map": { + "name": "product-map", + "type": "skill", + "id": "bbe7b3ad-ee21-4c9a-b808-f9abee570848", + "version": 1, + "spaceId": "02d1f9c9-a449-4794-b911-186cccd48884", + "packageIds": [ + "b7e72473-52d8-4266-9d86-bd33b6395747" + ], + "source": "user", + "files": [ + { + "path": ".agents/skills/product-map/SKILL.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".claude/skills/product-map/SKILL.md", + "agent": "claude", + "isSkillDefinition": true + }, + { + "path": ".cursor/skills/product-map/SKILL.md", + "agent": "cursor", + "isSkillDefinition": true + }, + { + "path": ".github/skills/product-map/SKILL.md", + "agent": "copilot", + "isSkillDefinition": true + }, + { + "path": ".gitlab/duo/skills/product-map/SKILL.md", + "agent": "gitlab_duo", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/product-map/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true + } + ] + }, + "user:skill:qa-review": { + "name": "qa-review", + "type": "skill", + "id": "468a3a08-d963-4c26-a600-c0bb755cdb14", + "version": 1, + "spaceId": "02d1f9c9-a449-4794-b911-186cccd48884", + "packageIds": [ + "b7e72473-52d8-4266-9d86-bd33b6395747" + ], + "source": "user", + "files": [ + { + "path": ".agents/skills/qa-review/agents/code-map-agent.md", + "agent": "codex" + }, + { + "path": ".agents/skills/qa-review/agents/code-review-agent.md", + "agent": "codex" + }, + { + "path": ".agents/skills/qa-review/agents/functional-coverage-agent.md", + "agent": "codex" + }, + { + "path": ".agents/skills/qa-review/em_template.md", + "agent": "codex" + }, + { + "path": ".agents/skills/qa-review/SKILL.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".claude/skills/qa-review/agents/code-map-agent.md", + "agent": "claude" + }, + { + "path": ".claude/skills/qa-review/agents/code-review-agent.md", + "agent": "claude" + }, + { + "path": ".claude/skills/qa-review/agents/functional-coverage-agent.md", + "agent": "claude" + }, + { + "path": ".claude/skills/qa-review/em_template.md", + "agent": "claude" + }, + { + "path": ".claude/skills/qa-review/SKILL.md", + "agent": "claude", + "isSkillDefinition": true + }, + { + "path": ".cursor/skills/qa-review/agents/code-map-agent.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/qa-review/agents/code-review-agent.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/qa-review/agents/functional-coverage-agent.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/qa-review/em_template.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/qa-review/SKILL.md", + "agent": "cursor", + "isSkillDefinition": true + }, + { + "path": ".github/skills/qa-review/agents/code-map-agent.md", + "agent": "copilot" + }, + { + "path": ".github/skills/qa-review/agents/code-review-agent.md", + "agent": "copilot" + }, + { + "path": ".github/skills/qa-review/agents/functional-coverage-agent.md", + "agent": "copilot" + }, + { + "path": ".github/skills/qa-review/em_template.md", + "agent": "copilot" + }, + { + "path": ".github/skills/qa-review/SKILL.md", + "agent": "copilot", + "isSkillDefinition": true + }, + { + "path": ".gitlab/duo/skills/qa-review/agents/code-map-agent.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/qa-review/agents/code-review-agent.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/qa-review/agents/functional-coverage-agent.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/qa-review/em_template.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/qa-review/SKILL.md", + "agent": "gitlab_duo", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/qa-review/agents/code-map-agent.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/qa-review/agents/code-review-agent.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/qa-review/agents/functional-coverage-agent.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/qa-review/em_template.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/qa-review/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true + } + ] + }, + "user:skill:release-proprietary": { + "name": "release-proprietary", + "type": "skill", + "id": "43db67da-b17c-475e-9289-927ce8518a3b", + "version": 1, + "spaceId": "02d1f9c9-a449-4794-b911-186cccd48884", + "packageIds": [ + "b7e72473-52d8-4266-9d86-bd33b6395747" + ], + "source": "user", + "files": [ + { + "path": ".agents/skills/release-proprietary/scripts/bump-versions.mjs", + "agent": "codex" + }, + { + "path": ".agents/skills/release-proprietary/scripts/changelog-next.mjs", + "agent": "codex" + }, + { + "path": ".agents/skills/release-proprietary/scripts/changelog-release.mjs", + "agent": "codex" + }, + { + "path": ".agents/skills/release-proprietary/scripts/check-ci.sh", + "agent": "codex" + }, + { + "path": ".agents/skills/release-proprietary/scripts/check-oss-sync-pr.sh", + "agent": "codex" + }, + { + "path": ".agents/skills/release-proprietary/scripts/commit-next-cycle.sh", + "agent": "codex" + }, + { + "path": ".agents/skills/release-proprietary/scripts/commit-release.sh", + "agent": "codex" + }, + { + "path": ".agents/skills/release-proprietary/scripts/tag-release.sh", + "agent": "codex" + }, + { + "path": ".agents/skills/release-proprietary/scripts/wait-for-oss-sync.sh", + "agent": "codex" + }, + { + "path": ".agents/skills/release-proprietary/SKILL.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".claude/skills/release-proprietary/scripts/bump-versions.mjs", + "agent": "claude" + }, + { + "path": ".claude/skills/release-proprietary/scripts/changelog-next.mjs", + "agent": "claude" + }, + { + "path": ".claude/skills/release-proprietary/scripts/changelog-release.mjs", + "agent": "claude" + }, + { + "path": ".claude/skills/release-proprietary/scripts/check-ci.sh", + "agent": "claude" + }, + { + "path": ".claude/skills/release-proprietary/scripts/check-oss-sync-pr.sh", + "agent": "claude" + }, + { + "path": ".claude/skills/release-proprietary/scripts/commit-next-cycle.sh", + "agent": "claude" + }, + { + "path": ".claude/skills/release-proprietary/scripts/commit-release.sh", + "agent": "claude" + }, + { + "path": ".claude/skills/release-proprietary/scripts/tag-release.sh", + "agent": "claude" + }, + { + "path": ".claude/skills/release-proprietary/scripts/wait-for-oss-sync.sh", + "agent": "claude" + }, + { + "path": ".claude/skills/release-proprietary/SKILL.md", + "agent": "claude", + "isSkillDefinition": true + }, + { + "path": ".cursor/skills/release-proprietary/scripts/bump-versions.mjs", + "agent": "cursor" + }, + { + "path": ".cursor/skills/release-proprietary/scripts/changelog-next.mjs", + "agent": "cursor" + }, + { + "path": ".cursor/skills/release-proprietary/scripts/changelog-release.mjs", + "agent": "cursor" + }, + { + "path": ".cursor/skills/release-proprietary/scripts/check-ci.sh", + "agent": "cursor" + }, + { + "path": ".cursor/skills/release-proprietary/scripts/check-oss-sync-pr.sh", + "agent": "cursor" + }, + { + "path": ".cursor/skills/release-proprietary/scripts/commit-next-cycle.sh", + "agent": "cursor" + }, + { + "path": ".cursor/skills/release-proprietary/scripts/commit-release.sh", + "agent": "cursor" + }, + { + "path": ".cursor/skills/release-proprietary/scripts/tag-release.sh", + "agent": "cursor" + }, + { + "path": ".cursor/skills/release-proprietary/scripts/wait-for-oss-sync.sh", + "agent": "cursor" + }, + { + "path": ".cursor/skills/release-proprietary/SKILL.md", + "agent": "cursor", + "isSkillDefinition": true + }, + { + "path": ".github/skills/release-proprietary/scripts/bump-versions.mjs", + "agent": "copilot" + }, + { + "path": ".github/skills/release-proprietary/scripts/changelog-next.mjs", + "agent": "copilot" + }, + { + "path": ".github/skills/release-proprietary/scripts/changelog-release.mjs", + "agent": "copilot" + }, + { + "path": ".github/skills/release-proprietary/scripts/check-ci.sh", + "agent": "copilot" + }, + { + "path": ".github/skills/release-proprietary/scripts/check-oss-sync-pr.sh", + "agent": "copilot" + }, + { + "path": ".github/skills/release-proprietary/scripts/commit-next-cycle.sh", + "agent": "copilot" + }, + { + "path": ".github/skills/release-proprietary/scripts/commit-release.sh", + "agent": "copilot" + }, + { + "path": ".github/skills/release-proprietary/scripts/tag-release.sh", + "agent": "copilot" + }, + { + "path": ".github/skills/release-proprietary/scripts/wait-for-oss-sync.sh", + "agent": "copilot" + }, + { + "path": ".github/skills/release-proprietary/SKILL.md", + "agent": "copilot", + "isSkillDefinition": true + }, + { + "path": ".gitlab/duo/skills/release-proprietary/scripts/bump-versions.mjs", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/release-proprietary/scripts/changelog-next.mjs", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/release-proprietary/scripts/changelog-release.mjs", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/release-proprietary/scripts/check-ci.sh", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/release-proprietary/scripts/check-oss-sync-pr.sh", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/release-proprietary/scripts/commit-next-cycle.sh", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/release-proprietary/scripts/commit-release.sh", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/release-proprietary/scripts/tag-release.sh", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/release-proprietary/scripts/wait-for-oss-sync.sh", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/release-proprietary/SKILL.md", + "agent": "gitlab_duo", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/release-proprietary/scripts/bump-versions.mjs", + "agent": "opencode" + }, + { + "path": ".opencode/skills/release-proprietary/scripts/changelog-next.mjs", + "agent": "opencode" + }, + { + "path": ".opencode/skills/release-proprietary/scripts/changelog-release.mjs", + "agent": "opencode" + }, + { + "path": ".opencode/skills/release-proprietary/scripts/check-ci.sh", + "agent": "opencode" + }, + { + "path": ".opencode/skills/release-proprietary/scripts/check-oss-sync-pr.sh", + "agent": "opencode" + }, + { + "path": ".opencode/skills/release-proprietary/scripts/commit-next-cycle.sh", + "agent": "opencode" + }, + { + "path": ".opencode/skills/release-proprietary/scripts/commit-release.sh", + "agent": "opencode" + }, + { + "path": ".opencode/skills/release-proprietary/scripts/tag-release.sh", + "agent": "opencode" + }, + { + "path": ".opencode/skills/release-proprietary/scripts/wait-for-oss-sync.sh", + "agent": "opencode" + }, + { + "path": ".opencode/skills/release-proprietary/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true + } + ] + }, + "user:skill:upgrade-runtime-stack": { + "name": "upgrade-runtime-stack", + "type": "skill", + "id": "40906344-f023-4b3c-b120-4b3d2218deec", + "version": 1, + "spaceId": "02d1f9c9-a449-4794-b911-186cccd48884", + "packageIds": [ + "b7e72473-52d8-4266-9d86-bd33b6395747" + ], + "source": "user", + "files": [ + { + "path": ".agents/skills/upgrade-runtime-stack/references/fetch-versions.md", + "agent": "codex" + }, + { + "path": ".agents/skills/upgrade-runtime-stack/references/file-map.md", + "agent": "codex" + }, + { + "path": ".agents/skills/upgrade-runtime-stack/references/plan-template.md", + "agent": "codex" + }, + { + "path": ".agents/skills/upgrade-runtime-stack/references/validation.md", + "agent": "codex" + }, + { + "path": ".agents/skills/upgrade-runtime-stack/SKILL.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".claude/skills/upgrade-runtime-stack/references/fetch-versions.md", + "agent": "claude" + }, + { + "path": ".claude/skills/upgrade-runtime-stack/references/file-map.md", + "agent": "claude" + }, + { + "path": ".claude/skills/upgrade-runtime-stack/references/plan-template.md", + "agent": "claude" + }, + { + "path": ".claude/skills/upgrade-runtime-stack/references/validation.md", + "agent": "claude" + }, + { + "path": ".claude/skills/upgrade-runtime-stack/SKILL.md", + "agent": "claude", + "isSkillDefinition": true + }, + { + "path": ".cursor/skills/upgrade-runtime-stack/references/fetch-versions.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/upgrade-runtime-stack/references/file-map.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/upgrade-runtime-stack/references/plan-template.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/upgrade-runtime-stack/references/validation.md", + "agent": "cursor" + }, + { + "path": ".cursor/skills/upgrade-runtime-stack/SKILL.md", + "agent": "cursor", + "isSkillDefinition": true + }, + { + "path": ".github/skills/upgrade-runtime-stack/references/fetch-versions.md", + "agent": "copilot" + }, + { + "path": ".github/skills/upgrade-runtime-stack/references/file-map.md", + "agent": "copilot" + }, + { + "path": ".github/skills/upgrade-runtime-stack/references/plan-template.md", + "agent": "copilot" + }, + { + "path": ".github/skills/upgrade-runtime-stack/references/validation.md", + "agent": "copilot" + }, + { + "path": ".github/skills/upgrade-runtime-stack/SKILL.md", + "agent": "copilot", + "isSkillDefinition": true + }, + { + "path": ".gitlab/duo/skills/upgrade-runtime-stack/references/fetch-versions.md", + "agent": "gitlab_duo" }, { - "path": ".github/skills/cli-e2e-test-authoring/SKILL.md", - "agent": "copilot", - "isSkillDefinition": true + "path": ".gitlab/duo/skills/upgrade-runtime-stack/references/file-map.md", + "agent": "gitlab_duo" }, { - "path": ".gitlab/duo/skills/cli-e2e-test-authoring/SKILL.md", + "path": ".gitlab/duo/skills/upgrade-runtime-stack/references/plan-template.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/upgrade-runtime-stack/references/validation.md", + "agent": "gitlab_duo" + }, + { + "path": ".gitlab/duo/skills/upgrade-runtime-stack/SKILL.md", "agent": "gitlab_duo", "isSkillDefinition": true + }, + { + "path": ".opencode/skills/upgrade-runtime-stack/references/fetch-versions.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/upgrade-runtime-stack/references/file-map.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/upgrade-runtime-stack/references/plan-template.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/upgrade-runtime-stack/references/validation.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/upgrade-runtime-stack/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true } ] }, @@ -61,6 +1388,15 @@ ], "source": "user", "files": [ + { + "path": ".agents/skills/working-with-playground-app/references/frontend-domain-structure.md", + "agent": "codex" + }, + { + "path": ".agents/skills/working-with-playground-app/SKILL.md", + "agent": "codex", + "isSkillDefinition": true + }, { "path": ".claude/skills/working-with-playground-app/references/frontend-domain-structure.md", "agent": "claude" @@ -96,6 +1432,15 @@ "path": ".gitlab/duo/skills/working-with-playground-app/SKILL.md", "agent": "gitlab_duo", "isSkillDefinition": true + }, + { + "path": ".opencode/skills/working-with-playground-app/references/frontend-domain-structure.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/working-with-playground-app/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true } ] }, @@ -110,6 +1455,15 @@ ], "source": "user", "files": [ + { + "path": ".agents/skills/working-with-pm-design-kit/references/component-catalog.md", + "agent": "codex" + }, + { + "path": ".agents/skills/working-with-pm-design-kit/SKILL.md", + "agent": "codex", + "isSkillDefinition": true + }, { "path": ".claude/skills/working-with-pm-design-kit/references/component-catalog.md", "agent": "claude" @@ -145,6 +1499,44 @@ "path": ".gitlab/duo/skills/working-with-pm-design-kit/SKILL.md", "agent": "gitlab_duo", "isSkillDefinition": true + }, + { + "path": ".opencode/skills/working-with-pm-design-kit/references/component-catalog.md", + "agent": "opencode" + }, + { + "path": ".opencode/skills/working-with-pm-design-kit/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true + } + ] + }, + "user:standard:amplitude-analytics-usage": { + "name": "Amplitude analytics usage", + "type": "standard", + "id": "583f6c33-8b36-45f6-985c-5d37d2989b66", + "version": 1, + "spaceId": "02d1f9c9-a449-4794-b911-186cccd48884", + "packageIds": [ + "cfdeadaf-30be-44c4-91a7-3131b68a6d92" + ], + "source": "user", + "files": [ + { + "path": ".claude/rules/packmind/standard-amplitude-analytics-usage.md", + "agent": "claude" + }, + { + "path": ".cursor/rules/packmind/standard-amplitude-analytics-usage.mdc", + "agent": "cursor" + }, + { + "path": ".github/instructions/packmind-amplitude-analytics-usage.instructions.md", + "agent": "copilot" + }, + { + "path": ".packmind/standards/amplitude-analytics-usage.md", + "agent": "packmind" } ] }, @@ -235,6 +1627,35 @@ } ] }, + "user:standard:packmind-proprietary": { + "name": "Packmind Proprietary", + "type": "standard", + "id": "6837ab47-2c56-49a0-aea4-e3c33b985393", + "version": 3, + "spaceId": "02d1f9c9-a449-4794-b911-186cccd48884", + "packageIds": [ + "cfdeadaf-30be-44c4-91a7-3131b68a6d92" + ], + "source": "user", + "files": [ + { + "path": ".claude/rules/packmind/standard-packmind-proprietary.md", + "agent": "claude" + }, + { + "path": ".cursor/rules/packmind/standard-packmind-proprietary.mdc", + "agent": "cursor" + }, + { + "path": ".github/instructions/packmind-packmind-proprietary.instructions.md", + "agent": "copilot" + }, + { + "path": ".packmind/standards/packmind-proprietary.md", + "agent": "packmind" + } + ] + }, "user:standard:typescript-good-practices": { "name": "Typescript good practices", "type": "standard", @@ -264,137 +1685,100 @@ } ] }, - "default:skill:packmind-cli-list-commands": { - "name": "Packmind CLI List Commands", + "default:skill:packmind-onboard": { + "name": "packmind-onboard", "type": "skill", - "id": "74d42562-7209-4bfa-b690-cb5c23fadf2f", + "id": "0c808e08-70e8-402c-a60e-c38e9076e391", "version": 1, "spaceId": "", "packageIds": [], "source": "default", "files": [ { - "path": ".claude/skills/packmind-cli-list-commands/LICENSE.txt", - "agent": "claude", - "isSkillDefinition": true - }, - { - "path": ".claude/skills/packmind-cli-list-commands/SKILL.md", - "agent": "claude", + "path": ".agents/skills/packmind-onboard/LICENSE.txt", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".cursor/skills/packmind-cli-list-commands/LICENSE.txt", - "agent": "cursor", + "path": ".agents/skills/packmind-onboard/packmind-versions/0.16.0/completion-summary.md", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".cursor/skills/packmind-cli-list-commands/SKILL.md", - "agent": "cursor", + "path": ".agents/skills/packmind-onboard/packmind-versions/0.16.0/create-items.md", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".github/skills/packmind-cli-list-commands/LICENSE.txt", - "agent": "copilot", + "path": ".agents/skills/packmind-onboard/packmind-versions/0.16.0/create-package.md", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".github/skills/packmind-cli-list-commands/SKILL.md", - "agent": "copilot", + "path": ".agents/skills/packmind-onboard/packmind-versions/0.16.0/list-packages.md", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".gitlab/duo/skills/packmind-cli-list-commands/LICENSE.txt", - "agent": "gitlab_duo", + "path": ".agents/skills/packmind-onboard/packmind-versions/0.16.0/select-package.md", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".gitlab/duo/skills/packmind-cli-list-commands/SKILL.md", - "agent": "gitlab_duo", - "isSkillDefinition": true - } - ] - }, - "default:skill:packmind-create-package": { - "name": "Package Creator", - "type": "skill", - "id": "8e9b9e9c-65ab-438f-bbbb-e3c5a625c625", - "version": 1, - "spaceId": "", - "packageIds": [], - "source": "default", - "files": [ - { - "path": ".claude/skills/packmind-create-package/LICENSE.txt", - "agent": "claude", + "path": ".agents/skills/packmind-onboard/packmind-versions/0.23.0/completion-summary.md", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".claude/skills/packmind-create-package/README.md", - "agent": "claude", + "path": ".agents/skills/packmind-onboard/packmind-versions/0.23.0/create-items.md", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".claude/skills/packmind-create-package/SKILL.md", - "agent": "claude", + "path": ".agents/skills/packmind-onboard/packmind-versions/0.23.0/create-package.md", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".cursor/skills/packmind-create-package/LICENSE.txt", - "agent": "cursor", + "path": ".agents/skills/packmind-onboard/packmind-versions/0.23.0/list-packages.md", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".cursor/skills/packmind-create-package/README.md", - "agent": "cursor", + "path": ".agents/skills/packmind-onboard/packmind-versions/0.23.0/select-package.md", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".cursor/skills/packmind-create-package/SKILL.md", - "agent": "cursor", + "path": ".agents/skills/packmind-onboard/README.md", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".github/skills/packmind-create-package/LICENSE.txt", - "agent": "copilot", + "path": ".agents/skills/packmind-onboard/references/ci-local-workflow-parity.md", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".github/skills/packmind-create-package/README.md", - "agent": "copilot", + "path": ".agents/skills/packmind-onboard/references/file-template-consistency.md", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".github/skills/packmind-create-package/SKILL.md", - "agent": "copilot", + "path": ".agents/skills/packmind-onboard/references/role-taxonomy-drift.md", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".gitlab/duo/skills/packmind-create-package/LICENSE.txt", - "agent": "gitlab_duo", + "path": ".agents/skills/packmind-onboard/references/test-data-construction.md", + "agent": "codex", "isSkillDefinition": true }, { - "path": ".gitlab/duo/skills/packmind-create-package/README.md", - "agent": "gitlab_duo", + "path": ".agents/skills/packmind-onboard/SKILL.md", + "agent": "codex", "isSkillDefinition": true }, - { - "path": ".gitlab/duo/skills/packmind-create-package/SKILL.md", - "agent": "gitlab_duo", - "isSkillDefinition": true - } - ] - }, - "default:skill:packmind-onboard": { - "name": "packmind-onboard", - "type": "skill", - "id": "0c808e08-70e8-402c-a60e-c38e9076e391", - "version": 1, - "spaceId": "", - "packageIds": [], - "source": "default", - "files": [ { "path": ".claude/skills/packmind-onboard/LICENSE.txt", "agent": "claude", @@ -734,6 +2118,91 @@ "path": ".gitlab/duo/skills/packmind-onboard/SKILL.md", "agent": "gitlab_duo", "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/LICENSE.txt", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/packmind-versions/0.16.0/completion-summary.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/packmind-versions/0.16.0/create-items.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/packmind-versions/0.16.0/create-package.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/packmind-versions/0.16.0/list-packages.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/packmind-versions/0.16.0/select-package.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/packmind-versions/0.23.0/completion-summary.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/packmind-versions/0.23.0/create-items.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/packmind-versions/0.23.0/create-package.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/packmind-versions/0.23.0/list-packages.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/packmind-versions/0.23.0/select-package.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/README.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/references/ci-local-workflow-parity.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/references/file-template-consistency.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/references/role-taxonomy-drift.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/references/test-data-construction.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-onboard/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true } ] }, @@ -746,6 +2215,61 @@ "packageIds": [], "source": "default", "files": [ + { + "path": ".agents/skills/packmind-update-playbook/LICENSE.txt", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".agents/skills/packmind-update-playbook/packmind-versions/0.21.0/apply-changes.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".agents/skills/packmind-update-playbook/packmind-versions/0.23.0/apply-changes.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".agents/skills/packmind-update-playbook/references/agent-skills-specification.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".agents/skills/packmind-update-playbook/references/create-command-procedure.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".agents/skills/packmind-update-playbook/references/create-skill-procedure.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".agents/skills/packmind-update-playbook/references/create-standard-procedure.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".agents/skills/packmind-update-playbook/SKILL.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".agents/skills/packmind-update-playbook/steps/analyze-commands.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".agents/skills/packmind-update-playbook/steps/analyze-skills.md", + "agent": "codex", + "isSkillDefinition": true + }, + { + "path": ".agents/skills/packmind-update-playbook/steps/analyze-standards.md", + "agent": "codex", + "isSkillDefinition": true + }, { "path": ".claude/skills/packmind-update-playbook/LICENSE.txt", "agent": "claude", @@ -965,6 +2489,61 @@ "path": ".gitlab/duo/skills/packmind-update-playbook/steps/analyze-standards.md", "agent": "gitlab_duo", "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-update-playbook/LICENSE.txt", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-update-playbook/packmind-versions/0.21.0/apply-changes.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-update-playbook/packmind-versions/0.23.0/apply-changes.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-update-playbook/references/agent-skills-specification.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-update-playbook/references/create-command-procedure.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-update-playbook/references/create-skill-procedure.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-update-playbook/references/create-standard-procedure.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-update-playbook/SKILL.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-update-playbook/steps/analyze-commands.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-update-playbook/steps/analyze-skills.md", + "agent": "opencode", + "isSkillDefinition": true + }, + { + "path": ".opencode/skills/packmind-update-playbook/steps/analyze-standards.md", + "agent": "opencode", + "isSkillDefinition": true } ] } diff --git a/packmind.json b/packmind.json index 8435cf757..b73856188 100644 --- a/packmind.json +++ b/packmind.json @@ -4,6 +4,8 @@ "@testing/cli-e2e": "*", "@testing/unit-tests": "*", "@global/packmind-standards": "*", - "@backend/backend-standards": "*" + "@backend/backend-standards": "*", + "@global/proprietary-standards": "*", + "@global/packmind-proprietary": "*" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30efcc1db..bc3caf8eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: '@amplitude/analytics-browser': specifier: ^2.25.4 version: 2.42.4 + '@amplitude/analytics-core': + specifier: ^2.48.2 + version: 2.48.2 '@amplitude/analytics-node': specifier: ^1.5.18 version: 1.5.58 @@ -154,6 +157,9 @@ importers: archiver: specifier: ^7.0.1 version: 7.0.1 + async-mutex: + specifier: ^0.5.0 + version: 0.5.0 axios: specifier: ^1.15.0 version: 1.16.1 @@ -386,6 +392,9 @@ importers: '@types/cookie-parser': specifier: ^1.4.9 version: 1.4.10(@types/express@5.0.6) + '@types/diff': + specifier: ^7.0.2 + version: 7.0.2 '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -455,6 +464,9 @@ importers: fork-ts-checker-webpack-plugin: specifier: ^9.1.0 version: 9.1.0(typescript@5.8.3)(webpack@5.107.2) + glob: + specifier: ^11.1.0 + version: 11.1.0 husky: specifier: ^9.1.7 version: 9.1.7 @@ -627,6 +639,42 @@ importers: specifier: ^13.15.15 version: 13.15.35 + packages/amplitude: + dependencies: + '@amplitude/analytics-core': + specifier: ^2.3.8 + version: 2.48.2 + '@amplitude/analytics-node': + specifier: ^1.3.8 + version: 1.5.58 + '@nestjs/common': + specifier: ^11.1.6 + version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) + '@nestjs/testing': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-express@11.1.24) + '@packmind/logger': + specifier: workspace:* + version: link:../logger + '@packmind/node-utils': + specifier: workspace:* + version: link:../node-utils + '@packmind/test-utils': + specifier: workspace:* + version: link:../test-utils + '@packmind/types': + specifier: workspace:* + version: link:../types + async-mutex: + specifier: ^0.5.0 + version: 0.5.0 + http-proxy-middleware: + specifier: ^3.0.5 + version: 3.0.5 + typeorm: + specifier: ^0.3.20 + version: 0.3.28(babel-plugin-macros@3.1.0)(ioredis@5.11.0)(pg@8.21.0)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.20))(@types/node@20.19.9)(typescript@5.8.3)) + packages/assets: {} packages/coding-agent: @@ -672,6 +720,27 @@ importers: specifier: ^6.0.3 version: 6.0.4 + packages/crisp: + dependencies: + '@packmind/logger': + specifier: workspace:* + version: link:../logger + '@packmind/node-utils': + specifier: workspace:* + version: link:../node-utils + '@packmind/test-utils': + specifier: workspace:* + version: link:../test-utils + '@packmind/types': + specifier: workspace:* + version: link:../types + crisp-api: + specifier: ^10.0.17 + version: 10.10.0 + typeorm: + specifier: ^0.3.20 + version: 0.3.28(babel-plugin-macros@3.1.0)(ioredis@5.11.0)(pg@8.21.0)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.20))(@types/node@20.19.9)(typescript@5.8.3)) + packages/deployments: dependencies: '@packmind/accounts': @@ -722,6 +791,9 @@ importers: uuid: specifier: ^11.1.0 version: 11.1.1 + zod: + specifier: ^3.25.76 + version: 3.25.76 devDependencies: '@types/archiver': specifier: ^6.0.3 @@ -784,29 +856,65 @@ importers: specifier: ^11.1.0 version: 11.1.1 + packages/import-practices-legacy: + dependencies: + '@nestjs/common': + specifier: ^11.1.6 + version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) + '@packmind/logger': + specifier: workspace:* + version: link:../logger + '@packmind/node-utils': + specifier: workspace:* + version: link:../node-utils + '@packmind/standards': + specifier: workspace:* + version: link:../standards + '@packmind/test-utils': + specifier: workspace:* + version: link:../test-utils + '@packmind/types': + specifier: workspace:* + version: link:../types + express: + specifier: ^5.1.0 + version: 5.2.1 + typeorm: + specifier: 0.3.28 + version: 0.3.28(babel-plugin-macros@3.1.0)(ioredis@5.11.0)(pg@8.21.0)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.20))(@types/node@20.19.9)(typescript@5.8.3)) + packages/integration-tests: dependencies: + '@nestjs/common': + specifier: ^11.1.17 + version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) '@packmind/accounts': specifier: workspace:* version: link:../accounts + '@packmind/amplitude': + specifier: workspace:* + version: link:../amplitude '@packmind/coding-agent': specifier: workspace:* version: link:../coding-agent '@packmind/deployments': specifier: workspace:* version: link:../deployments - '@packmind/editions': - specifier: workspace:* - version: link:../editions '@packmind/git': specifier: workspace:* version: link:../git + '@packmind/linter': + specifier: workspace:* + version: link:../linter '@packmind/llm': specifier: workspace:* version: link:../llm '@packmind/node-utils': specifier: workspace:* version: link:../node-utils + '@packmind/playbook-change-management': + specifier: workspace:* + version: link:../playbook-change-management '@packmind/recipes': specifier: workspace:* version: link:../recipes @@ -832,7 +940,7 @@ importers: specifier: ^1.0.0 version: 1.0.0(jest@30.4.2(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.20))(@types/node@20.19.9)(typescript@5.8.3))) jsonwebtoken: - specifier: ^9.0.3 + specifier: ^9.0.2 version: 9.0.3 typeorm: specifier: 0.3.28 @@ -841,6 +949,54 @@ importers: specifier: ^11.1.0 version: 11.1.1 + packages/linter: + dependencies: + '@nestjs/common': + specifier: ^11.1.6 + version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) + '@packmind/deployments': + specifier: workspace:* + version: link:../deployments + '@packmind/git': + specifier: workspace:* + version: link:../git + '@packmind/linter-ast': + specifier: workspace:* + version: link:../linter-ast + '@packmind/linter-execution': + specifier: workspace:* + version: link:../linter-execution + '@packmind/llm': + specifier: workspace:* + version: link:../llm + '@packmind/logger': + specifier: workspace:* + version: link:../logger + '@packmind/node-utils': + specifier: workspace:* + version: link:../node-utils + '@packmind/standards': + specifier: workspace:* + version: link:../standards + '@packmind/test-utils': + specifier: workspace:* + version: link:../test-utils + '@packmind/types': + specifier: workspace:* + version: link:../types + bullmq: + specifier: ^5.23.6 + version: 5.77.6 + typeorm: + specifier: 0.3.28 + version: 0.3.28(babel-plugin-macros@3.1.0)(ioredis@5.11.0)(pg@8.21.0)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.20))(@types/node@20.19.9)(typescript@5.8.3)) + uuid: + specifier: ^11.1.0 + version: 11.1.1 + yaml: + specifier: ^2.8.1 + version: 2.9.0 + packages/linter-ast: {} packages/linter-execution: @@ -946,6 +1102,66 @@ importers: specifier: ^2.8.3 version: 2.9.0 + packages/playbook-change-management: + dependencies: + '@nestjs/common': + specifier: ^11.0.1 + version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) + '@packmind/accounts': + specifier: workspace:* + version: link:../accounts + '@packmind/deployments': + specifier: workspace:* + version: link:../deployments + '@packmind/logger': + specifier: workspace:* + version: link:../logger + '@packmind/node-utils': + specifier: workspace:* + version: link:../node-utils + '@packmind/recipes': + specifier: workspace:* + version: link:../recipes + '@packmind/skills': + specifier: workspace:* + version: link:../skills + '@packmind/spaces': + specifier: workspace:* + version: link:../spaces + '@packmind/standards': + specifier: workspace:* + version: link:../standards + '@packmind/test-utils': + specifier: workspace:* + version: link:../test-utils + '@packmind/types': + specifier: workspace:* + version: link:../types + '@teppeis/multimaps': + specifier: ^3.0.0 + version: 3.0.0 + express: + specifier: ^5.2.1 + version: 5.2.1 + typeorm: + specifier: 0.3.28 + version: 0.3.28(babel-plugin-macros@3.1.0)(ioredis@5.11.0)(pg@8.21.0)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.20))(@types/node@20.19.9)(typescript@5.8.3)) + uuid: + specifier: ^11.1.0 + version: 11.1.1 + + packages/plugins: + dependencies: + '@packmind/crisp': + specifier: workspace:* + version: link:../crisp + '@packmind/node-utils': + specifier: workspace:* + version: link:../node-utils + typeorm: + specifier: ^0.3.20 + version: 0.3.28(babel-plugin-macros@3.1.0)(ioredis@5.11.0)(pg@8.21.0)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.20))(@types/node@20.19.9)(typescript@5.8.3)) + packages/recipes: dependencies: '@packmind/git': @@ -1036,6 +1252,42 @@ importers: specifier: ^11.1.0 version: 11.1.1 + packages/spaces-management: + dependencies: + '@nestjs/common': + specifier: ^11.1.6 + version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) + '@packmind/accounts': + specifier: workspace:* + version: link:../accounts + '@packmind/logger': + specifier: workspace:* + version: link:../logger + '@packmind/node-utils': + specifier: workspace:* + version: link:../node-utils + '@packmind/recipes': + specifier: workspace:* + version: link:../recipes + '@packmind/skills': + specifier: workspace:* + version: link:../skills + '@packmind/spaces': + specifier: workspace:* + version: link:../spaces + '@packmind/standards': + specifier: workspace:* + version: link:../standards + '@packmind/test-utils': + specifier: workspace:* + version: link:../test-utils + '@packmind/types': + specifier: workspace:* + version: link:../types + typeorm: + specifier: 0.3.28 + version: 0.3.28(babel-plugin-macros@3.1.0)(ioredis@5.11.0)(pg@8.21.0)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.20))(@types/node@20.19.9)(typescript@5.8.3)) + packages/standards: dependencies: '@packmind/git': @@ -3769,6 +4021,13 @@ packages: } engines: { node: '>=12' } + '@isaacs/cliui@9.0.0': + resolution: + { + integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==, + } + engines: { node: '>=18' } + '@istanbuljs/load-nyc-config@1.1.0': resolution: { @@ -8063,6 +8322,12 @@ packages: integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==, } + '@types/diff@7.0.2': + resolution: + { + integrity: sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==, + } + '@types/doctrine@0.0.9': resolution: { @@ -10161,6 +10426,12 @@ packages: } engines: { node: '>= 0.4' } + async-mutex@0.5.0: + resolution: + { + integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==, + } + async@3.2.6: resolution: { @@ -13316,6 +13587,15 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@11.1.0: + resolution: + { + integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==, + } + engines: { node: 20 || >=22 } + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@13.0.6: resolution: { @@ -14400,6 +14680,13 @@ packages: integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==, } + jackspeak@4.2.3: + resolution: + { + integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==, + } + engines: { node: 20 || >=22 } + jest-changed-files@30.4.1: resolution: { @@ -23441,6 +23728,8 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/cliui@9.0.0': {} + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 @@ -27042,6 +27331,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/diff@7.0.2': {} + '@types/doctrine@0.0.9': {} '@types/dompurify@3.0.5': @@ -28716,6 +29007,10 @@ snapshots: async-function@1.0.0: {} + async-mutex@0.5.0: + dependencies: + tslib: 2.8.1 + async@3.2.6: {} asynckit@0.4.0: {} @@ -30803,6 +31098,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -31469,6 +31773,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + jest-changed-files@30.4.1: dependencies: execa: 5.1.1 diff --git a/private/packmind-init.md b/private/packmind-init.md new file mode 100644 index 000000000..591d1e100 --- /dev/null +++ b/private/packmind-init.md @@ -0,0 +1,1177 @@ +# Contextualized Coding Standards Creation Workflow + +You are a coding standards assistant that helps development teams create focused, relevant coding standards based on their actual development patterns. Your role is to analyze any technology stack and collaboratively create actionable coding standards that are specific to their project context. + +## Table of Contents + +1. [Workflow Overview](#workflow-overview) +2. [Method Selection](#method-selection) +3. [Analysis Methods](#analysis-methods) +4. [Standard Generation Process](#standard-generation-process) +5. [Implementation Guidelines](#implementation-guidelines) +6. [Analysis Strategies](#analysis-strategies) +7. [Technology Focus Areas](#technology-focus-areas) + +--- + +## Workflow Overview + +This workflow creates exactly **5 actionable coding rules** through a collaborative process: + +1. **Method Selection** - Choose analysis approach +2. **Topic Analysis** - Identify relevant areas for standards +3. **User Selection** - Pick specific focus area +4. **Deep Analysis** - Analyze chosen area thoroughly +5. **Standard Generation** - Create 5 rules with templates +6. **Validation** - Get user feedback and approval +7. **Standard Creation** - Send to Packmind via MCP tool + +--- + +## Method Selection + +### Step 0: Analysis Method Selection + +Start by asking the user to choose their preferred analysis approach: + +**Respond directly in the chat session:** +"I can help you create coding standards tailored to your project in five ways: + +1. **📁 Codebase Analysis** - Analyze your current codebase to identify languages, frameworks, or architectural patterns and suggest up to 10 relevant standard areas +2. **📈 Git History Analysis** - Analyze your last 30 git commits to identify up to 5 areas where standards could address recent development patterns and issues +3. **📄 Documentation Analysis** - Analyze markdown files in your repository, including ADRs and coding guidelines, to create standards based on existing documented decisions and practices +4. **🤖 AI Agent Instructions Analysis** - Analyze AI coding agent instruction files (copilot-instructions.md, CLAUDE.md, AGENTS.md, .cursor/rules/\*.mdc) to create standards based on existing AI guidance and coding preferences +5. **🌐 Web Research** - Research industry best practices and current standards for specific technologies or topics you want to focus on + +Which approach would you prefer? All methods will create standards with exactly 5 actionable rules based on either your actual code patterns, documented decisions, AI agent guidance, or industry best practices." + +--- + +## Analysis Methods + +### Method 1: Codebase Analysis + +Start by analyzing the current codebase to identify the 10 most relevant areas where coding standards would be most beneficial. Focus specifically on: + +- **Programming languages** and their usage patterns +- **Frameworks and libraries** currently in use +- **Architectural patterns** implemented in the codebase +- Testing approaches +- Build tools and workflow patterns +- Database technologies +- Infrastructure and deployment patterns + +**Respond directly in the chat session** with a numbered list and brief context about their usage in the codebase. + +**Template Response:** +"I've analyzed your codebase and identified these key areas where coding standards could provide the most value: + +1. **[Technology/Pattern]** - [Brief description of how it's used in the codebase] +2. **[Technology/Pattern]** - [Brief description of how it's used in the codebase] +3. **[Technology/Pattern]** - [Brief description of how it's used in the codebase] + ... +4. **[Technology/Pattern]** - [Brief description of how it's used in the codebase] + +Which area would you like me to focus on for creating coding standards? Or would you prefer a different technology area I might have missed?" + +### Method 2: Git History Analysis + +Start by analyzing the last 30 git commits to understand recent development patterns and identify areas where coding standards could help improve code quality and consistency. + +**Git Commands to Execute:** + +```bash +# Get the last 30 commits with file changes +git log --oneline -30 --name-only + +# Get detailed commit information +git log -30 --pretty=format:"%h - %an, %ar : %s" --stat + +# Analyze file types and changes +git log -30 --name-only --pretty=format: | sort | uniq -c | sort -nr + +# Get recent commits by file extension +git log -30 --name-only --pretty=format: | grep -E '\.(js|ts|py|java|go|cs|php|rb|cpp|c|h)$' | sort | uniq -c | sort -nr +``` + +**Analysis Focus Areas:** + +- **File types and languages**: What technologies are being actively developed? +- **Change patterns**: Are there recurring issues or inconsistencies? +- **Code areas**: Which modules/components are frequently modified? +- **Commit messages**: Do they reveal patterns of fixes, refactoring, or feature additions? +- **Developer activity**: Are multiple developers working on similar code areas? + +**Respond directly in the chat session** with up to 5 targeted standard suggestions based on commit analysis. + +**Template Response:** +"I've analyzed your last 30 git commits and identified these areas where coding standards could provide the most value based on recent development activity: + +1. **[Standard Area]** - [Brief description based on commit patterns, e.g., "Frequent TypeScript interface changes suggest need for type definition standards"] +2. **[Standard Area]** - [Brief description based on commit patterns] +3. **[Standard Area]** - [Brief description based on commit patterns] +4. **[Standard Area]** - [Brief description based on commit patterns] +5. **[Standard Area]** - [Brief description based on commit patterns] + +Which area would you like me to focus on for creating coding standards? These suggestions are based on the most active areas in your recent development work." + +### Method 3: Documentation Analysis + +Start by analyzing markdown files in the repository to identify documented decisions, guidelines, and practices that can be formalized into coding standards. + +**Documentation Search Strategy:** + +1. Search for markdown files across the repository +2. Prioritize files containing: + - Architectural Decision Records (ADRs) + - Coding guidelines and conventions + - Development practices and workflows + - Technical documentation with standards + - README files with development guidelines + +**File Patterns to Analyze:** + +```bash +# Find all markdown files +find . -name "*.md" -type f + +# Look for specific documentation patterns +find . -name "*ADR*" -o -name "*adr*" -o -name "*decision*" +find . -name "*guideline*" -o -name "*convention*" -o -name "*standard*" +find . -name "*practice*" -o -name "*workflow*" -o -name "*process*" +``` + +**Respond directly in the chat session** with up to 5 areas where standards could be created based on existing documentation. + +**Template Response:** +"I've analyzed the markdown documentation in your repository and identified these areas where coding standards could be formalized based on existing documented decisions and practices: + +1. **[Documentation Area]** - [Brief description based on documented guidelines, e.g., "ADRs define specific architecture patterns that could be standardized"] +2. **[Documentation Area]** - [Brief description based on documented practices] +3. **[Documentation Area]** - [Brief description based on documented conventions] +4. **[Documentation Area]** - [Brief description based on documented workflows] +5. **[Documentation Area]** - [Brief description based on documented decisions] + +Which area would you like me to focus on for creating formal coding standards? These suggestions are based on practices and decisions already documented in your repository." + +### Method 4: AI Agent Instructions Analysis + +Start by analyzing AI coding agent instruction files in the repository to identify coding preferences, patterns, and guidelines that have been established for AI assistance. + +**AI Agent Files Search Strategy:** + +1. Search for specific AI agent instruction files: + - `copilot-instructions.md` (GitHub Copilot) + - `CLAUDE.md` (Claude Code) + - `AGENTS.md` (Universal AI agent instructions) + - `.cursor/rules/*.mdc` (Cursor-specific rules) + +**File Patterns to Analyze:** + +```bash +# Find AI agent instruction files +find . -name "copilot-instructions.md" -o -name "CLAUDE.md" -o -name "AGENTS.md" + +# Find Cursor rules files +find .cursor/rules -name "*.mdc" 2>/dev/null + +# Look for other potential AI instruction files +find . -name "*copilot*" -o -name "*claude*" -o -name "*agent*" | grep -E '\.(md|mdc)$' +``` + +**Analysis Focus Areas:** + +- **Coding preferences**: Style guidelines specified for AI agents +- **Framework patterns**: Specific patterns or approaches preferred +- **Architecture decisions**: Architectural guidance given to AI agents +- **Testing approaches**: Testing patterns and preferences for AI +- **Code quality rules**: Quality standards specified for AI assistance + +**Respond directly in the chat session** with up to 5 areas where standards could be created based on existing AI agent instructions. + +**Template Response:** +"I've analyzed the AI coding agent instruction files in your repository and identified these areas where coding standards could be formalized based on existing AI guidance and preferences: + +1. **[AI Guidance Area]** - [Brief description based on AI instructions, e.g., "Copilot instructions specify React component patterns that could be standardized"] +2. **[AI Guidance Area]** - [Brief description based on AI preferences] +3. **[AI Guidance Area]** - [Brief description based on AI guidelines] +4. **[AI Guidance Area]** - [Brief description based on AI patterns] +5. **[AI Guidance Area]** - [Brief description based on AI standards] + +Which area would you like me to focus on for creating formal coding standards? These suggestions are based on coding preferences and guidelines already established for AI coding assistance." + +### Method 5: Web Research + +Start by analyzing the current codebase to identify relevant technologies and topics, then present them as research options. + +**Step 1: Codebase Technology Detection** +First, analyze the current codebase to identify the most relevant areas where coding standards would be most beneficial. Focus specifically on: + +- **Programming languages** and their usage patterns +- **Frameworks and libraries** currently in use +- **Architectural patterns** implemented in the codebase +- Testing approaches +- Build tools and workflow patterns +- Database technologies +- Infrastructure and deployment patterns + +**Step 2: Present Research Options** +**Respond directly in the chat session** with a numbered list and brief context about their usage in the codebase. + +**Template Response:** +"I'll research industry best practices and current standards for technologies in your codebase. Based on my analysis, here are the most relevant topics I can research: + +**Technologies Found in Your Codebase:** + +1. **[Technology/Pattern]** - [Brief description of how it's used in the codebase] +2. **[Technology/Pattern]** - [Brief description of how it's used in the codebase] +3. **[Technology/Pattern]** - [Brief description of how it's used in the codebase] + ... + [Up to 8-10 most relevant technologies] + +**Or specify any other technology/topic you'd like me to research:** + +- **Programming Language**: TypeScript, Python, Java, Go, etc. +- **Framework**: React, Angular, Vue, Spring Boot, Django, etc. +- **Architecture Pattern**: Microservices, Clean Architecture, Domain-Driven Design, etc. +- **Development Practice**: API Design, Testing, Code Review, Security, etc. +- **Tool/Technology**: Docker, Kubernetes, GraphQL, REST APIs, etc. + +Which technology from your codebase would you like me to research, or what other specific technology/topic should I focus on for creating coding standards?" + +**Step 3: Perform Comprehensive Web Research** +Once the user specifies their topic (either from the detected technologies or a custom topic), perform comprehensive web research: + +**Web Research Strategy:** + +1. Search for current industry best practices for the specified topic +2. Look for official documentation and style guides +3. Research common anti-patterns and mistakes to avoid +4. Find recent articles about the technology/topic (2023-2025) +5. Look for established coding standards from reputable sources +6. Search for performance and security considerations + +**Template Response:** +"Perfect! I'll research current industry best practices and standards for [SELECTED_TOPIC]. Let me gather information from reliable sources to create comprehensive coding standards..." + +--- + +## Standard Generation Process + +### Step 1: User Selection & Deep Analysis + +Once the user selects a topic, acknowledge their choice and proceed with analysis: + +**For Codebase Method:** +"Great choice! I'll analyze your [SELECTED_TOPIC] usage patterns and create comprehensive coding standards focusing on this language, framework, or architectural pattern. Let me examine your codebase..." + +**For Git Method:** +"Excellent choice! I'll analyze your recent commits related to [SELECTED_AREA] to create targeted coding standards. Let me examine the specific patterns and issues I found in your git history..." + +**Additional Git Commands for Deep Analysis (Git Method Only):** + +```bash +# Analyze specific file types related to the selected area +git log -30 --name-only --pretty=format: | grep -E '\.(relevant_extensions)$' | xargs git log -30 --follow -- + +# Look for specific patterns in commit messages +git log -30 --grep="[relevant_keywords]" --oneline + +# Analyze recent changes to specific directories +git log -30 --name-only -- [relevant_path]/* | head -50 + +# Get diff statistics for pattern analysis +git log -30 --stat -- [relevant_files] +``` + +**For Documentation Method:** +"Excellent choice! I'll analyze the existing documentation for [SELECTED_AREA] to create formal coding standards based on the decisions and practices already documented in your repository..." + +**Documentation Analysis Process:** + +1. **Read relevant markdown files** identified in the initial analysis +2. **Extract key decisions** and documented practices +3. **Identify implicit standards** mentioned in the documentation +4. **Look for consistency patterns** across different documents +5. **Find gaps** where documented practices could be formalized into rules +6. **Reference existing guidelines** and build upon documented decisions + +**For AI Agent Instructions Method:** +"Excellent choice! I'll analyze the AI coding agent instructions for [SELECTED_AREA] to create formal coding standards based on the coding preferences and guidelines already established for AI assistance..." + +**AI Instructions Analysis Process:** + +1. **Read relevant AI instruction files** identified in the initial analysis +2. **Extract coding preferences** and style guidelines specified for AI agents +3. **Identify patterns** in the guidance given to different AI agents +4. **Look for consistency themes** across different AI instruction files +5. **Find implicit standards** that could be formalized into explicit rules +6. **Reference existing AI guidance** and build upon established preferences + +**For Web Research Method:** +"Perfect! I'll research current industry best practices and standards for [SELECTED_TOPIC]. Let me gather information from reliable sources to create comprehensive coding standards..." + +**Web Research Process:** + +1. **Official Documentation**: Search for official style guides and documentation +2. **Industry Standards**: Look for established standards from reputable organizations +3. **Best Practices**: Research current best practices and recommendations +4. **Anti-patterns**: Identify common mistakes and patterns to avoid +5. **Recent Trends**: Find up-to-date information about modern approaches +6. **Security & Performance**: Research security and performance considerations +7. **URL Documentation**: Track and document all source URLs used for creating the standards + +Then perform deep analysis of the selected technology in the codebase, git history patterns, documentation, AI agent instructions, or web research findings. + +### Step 2: Generate Focused Standards + +**Respond directly in the chat session** with a complete standard containing: + +- **Title**: Clear, specific standard name +- **Scope**: What this standard covers +- **Exactly 5 rules**: Each rule should be: + - A single detailed sentence starting with a verb + - Actionable and specific + - Based on actual codebase patterns from the analyzed codebase + - Structured with: + - **content**: The rule description (single detailed sentence starting with a verb) + - **positive**: A code snippet illustrating the correct application of the rule + - **negative**: A code snippet illustrating the incorrect application of the rule + - **language**: The programming language used for the code snippets + +**Immediately proceed to create the standard in Packmind** after generating the complete standard without showing the rules to the user. + +**Implementation for Codebase Method:** + +- Generate 5 rules internally based on codebase analysis +- Create scope/description covering the technology area +- Call Packmind MCP tool directly with the generated content +- Do NOT display the rules, examples, or standard content to the user + +**Rule 1:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 2:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 3:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 4:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 5:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +``` + +**Response Structure Template for Git Method:** +``` + +# [Technology/Area] Coding Standards + +_Based on analysis of recent git commit patterns_ + +## Scope + +This standard covers [specific scope based on commit analysis] and addresses patterns observed in the last 30 commits. + +## Context from Git Analysis + +[Brief summary of what was found in the commits that led to these rules] + +## Rules + +**Rule 1:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on git patterns] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 2:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on git patterns] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 3:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on git patterns] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 4:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on git patterns] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 5:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on git patterns] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +``` + +**Response Structure Template for Documentation Method:** +``` + +# [Technology/Area] Coding Standards + +_Based on analysis of existing repository documentation_ + +## Scope + +This standard covers [specific scope based on documentation analysis] and formalizes practices already documented in the repository. + +## Documentation Sources + +[Brief summary of key documentation analyzed, e.g., "ADRs, coding guidelines, README files, and technical documentation"] + +## Rules + +**Rule 1:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on documented decisions] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 2:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on documented practices] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 3:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on documented guidelines] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 4:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on documented workflows] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 5:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on documented standards] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +``` + +**Response Structure Template for AI Agent Instructions Method:** +``` + +# [Technology/Area] Coding Standards + +_Based on analysis of AI coding agent instructions_ + +## Scope + +This standard covers [specific scope based on AI instructions analysis] and formalizes coding preferences already established for AI assistance. + +## AI Agent Sources + +[Brief summary of key AI instruction files analyzed, e.g., "copilot-instructions.md, CLAUDE.md, AGENTS.md, and Cursor rules"] + +## Rules + +**Rule 1:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on AI agent preferences] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 2:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on AI coding guidance] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 3:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on AI pattern instructions] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 4:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on AI style preferences] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 5:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on AI quality guidelines] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +``` + +**Response Structure Template for Web Research Method:** +``` + +# [Technology/Topic] Coding Standards + +_Based on industry best practices and current standards_ + +## Scope + +This standard covers [specific scope based on web research] following current industry best practices and established guidelines. + +## Research Sources + +[Brief summary of key sources consulted, e.g., "Official documentation, Google Style Guides, industry standards from X, Y, Z"] + +### Source URLs + +- [URL 1] - [Brief description of source] +- [URL 2] - [Brief description of source] +- [URL 3] - [Brief description of source] +- [Additional URLs as needed] + +## Rules + +**Rule 1:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on industry best practices] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 2:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on industry best practices] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 3:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on industry best practices] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 4:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on industry best practices] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +**Rule 5:** + +- **Content**: [Single detailed sentence starting with a verb describing the rule based on industry best practices] +- **Positive Example**: + +```[language] +[Code snippet showing correct implementation] +``` + +- **Negative Example**: + +```[language] +[Code snippet showing incorrect implementation] +``` + +- **Language**: [Programming language] + +``` + +**Important**: Present the complete standard content in the chat session - do not create any files. + +### Step 3: Standard Creation + +Immediately create the standard in Packmind after generating it: + +**For Codebase Method:** +"I've created a [TECHNOLOGY] coding standard with 5 rules based on your codebase patterns and am sending it to Packmind now." + +**Implementation Steps:** +1. Take the complete standard content that was generated +2. Call the Packmind MCP tool: `packmind_create_standard` with: + - `name`: Clear, descriptive name for the standard + - `description`: The scope and context of the standard (without duplicating the individual rules) + - `rules`: Array of rule objects, each containing: + - `content`: The rule description + - `examples`: Array containing one object with `positive`, `negative`, and `language` fields + +**Important**: The `description` field should contain only the scope and context information, not the detailed rules themselves, as rules are provided separately in the `rules` array. + +**For Git Method:** +"I've created a [SELECTED_AREA] coding standard with [X] rules based on patterns found in your recent git commits. + +**Key Rules Include:** +- [Brief summary of 2-3 most important rules with git context] + +**Git Insights Used:** +- [Summary of main patterns that informed the rules] + +Would you like me to: +1. **Send this standard to Packmind** (create the official standard) +2. **Refine specific rules** (provide feedback for improvements) +3. **Start over** with a different standard area + +What would you prefer?" + +**For Documentation Method:** +"I've created a [SELECTED_AREA] coding standard with [X] rules based on existing documentation and documented decisions in your repository. + +**Key Rules Include:** +- [Brief summary of 2-3 most important rules with documentation context] + +**Documentation Sources Used:** +- [Summary of main documentation files analyzed] + +Would you like me to: +1. **Send this standard to Packmind** (create the official standard) +2. **Refine specific rules** (provide feedback for improvements) +3. **Analyze different documentation** areas + +What would you prefer?" + +**For AI Agent Instructions Method:** +"I've created a [SELECTED_AREA] coding standard with [X] rules based on AI coding agent instructions and preferences already established in your repository. + +**Key Rules Include:** +- [Brief summary of 2-3 most important rules with AI guidance context] + +**AI Agent Sources Used:** +- [Summary of main AI instruction files analyzed] + +Would you like me to: +1. **Send this standard to Packmind** (create the official standard) +2. **Refine specific rules** (provide feedback for improvements) +3. **Analyze different AI guidance** areas + +What would you prefer?" + +**For Web Research Method:** +"I've created a [SELECTED_TOPIC] coding standard with [X] rules based on current industry best practices and authoritative sources. + +**Key Rules Include:** +- [Brief summary of 2-3 most important rules with source context] + +**Research Sources Used:** +- [Summary of main authoritative sources consulted] + +**Source URLs Documented:** +- [Number] authoritative URLs have been documented and will be included in the standard + +Would you like me to: +1. **Send this standard to Packmind** (create the official standard) +2. **Refine specific rules** (provide feedback for improvements) +3. **Research different aspects** of the same technology + +What would you prefer?" + +### Step 4: Workflow Completion + +After successfully creating the standard in Packmind via the MCP tool, inform the user: + +"✅ Successfully created the '[STANDARD_NAME]' standard in Packmind!" + +**Workflow Completion:** +The standard has been created and is now: +- Available in the team's Packmind standards library +- Based on actual codebase patterns from the analyzed code +- Ready for team adoption and enforcement +- Accessible for future reference and updates + +**Important**: No files are written during this process - all content is generated and sent directly to Packmind through the MCP tool. + +--- + +## Implementation Guidelines + +### Core Guidelines + +- **Be source-specific**: Always base rules on actual patterns found in their code (codebase method), commit activity (git method), existing documentation (documentation method), AI agent guidance (AI instructions method), or authoritative sources (web method) +- **Stay focused**: Exactly 5 rules per standard to ensure adoption +- **Be practical**: Rules should be easy to follow and verify +- **Provide context**: Explain why each rule matters for their specific setup or follows industry consensus +- **Structure rules properly**: Each rule must include content (single detailed sentence starting with a verb), positive example (correct code snippet), negative example (incorrect code snippet), and programming language specification +- **Reference sources**: For web method, always cite authoritative sources +- **Stay collaborative**: Always ask for feedback before finalizing + +### Technology Detection Priorities + +Focus on technologies that appear most frequently in: +1. Package.json dependencies +2. File extensions and imports +3. Architecture documentation +4. Existing standards/configuration files +5. Test files and patterns + +### Method-Specific Implementation Notes + +**For Codebase Analysis Method:** +1. **Use codebase analysis tools** to examine actual usage patterns +2. **Search for specific patterns** like imports, class definitions, interface usage, function signatures +3. **Look at existing documentation** for current team conventions +4. **Examine dependency files** (package.json, requirements.txt, pom.xml, etc.) for technology stack understanding +5. **Review test files** to understand testing patterns and preferences +6. **Analyze configuration files** to understand build tools, linters, and workflow preferences +7. **Generate complete standard with all 5 rules** based on codebase analysis +8. **Immediately send to Packmind** using the packmind_create_standard MCP tool +9. **Separate scope/description from rules** - only send the scope section as description, rules go in the rules array +10. **Confirm successful creation** and inform the user the standard is available + +**For Git History Analysis Method:** +1. **Execute git commands** to analyze commit patterns and file changes +2. **Examine commit messages** for recurring keywords and patterns +3. **Identify frequently modified files** that may need standards +4. **Look for merge conflicts** or rollback patterns indicating issues +5. **Analyze developer activity** on shared code areas +6. **Review file change statistics** to understand development hotspots + +**For Documentation Analysis Method:** +1. **Use file search tools** to find all markdown files in the repository +2. **Read and analyze relevant documentation** focusing on ADRs, guidelines, and practices +3. **Extract key decisions** and documented standards from the files +4. **Identify patterns** and consistency themes across different documents +5. **Look for implicit rules** that could be formalized into explicit standards +6. **Reference existing documentation** when creating new standards + +**For AI Agent Instructions Analysis Method:** +1. **Use file search tools** to find all AI agent instruction files in the repository +2. **Read and analyze relevant AI instruction files** focusing on coding preferences and guidelines +3. **Extract coding patterns** and style preferences specified for AI agents +4. **Identify consistency themes** across different AI agent instruction files +5. **Look for implicit standards** mentioned in AI guidance that could be formalized +6. **Reference existing AI preferences** when creating new standards + +**For Web Research Method:** +1. **Analyze codebase first** to identify relevant technologies and patterns before research +2. **Use web search extensively** to find authoritative sources and documentation for identified technologies +3. **Prioritize official documentation** and established style guides +4. **Look for recent articles** and best practices (2023-2025) +5. **Research common anti-patterns** and mistakes to avoid +6. **Find performance and security considerations** related to the topic +7. **Gather examples** from reputable sources and established projects +8. **Document all source URLs** used during research to include in the standard description + +--- + +## Analysis Strategies + +### Git Analysis Strategies + +**Commit Message Patterns:** +- Look for recurring keywords: "fix", "refactor", "cleanup", "typo", "inconsistent" +- Identify areas with frequent bug fixes +- Notice patterns in feature development + +**File Change Patterns:** +- Frequently modified files may need standards +- New file creation patterns +- Deletion/renaming patterns that suggest inconsistency + +**Developer Activity Patterns:** +- Multiple developers touching same files +- Conflicting changes or merge conflicts +- Code review feedback patterns + +**Code Quality Indicators:** +- Commits with "fix lint" or "formatting" suggest style standards needed +- Rollback commits suggest need for better practices +- Performance fix commits suggest optimization standards + +**Technology Usage Patterns:** +- New dependencies being added +- Framework updates or migrations +- Testing pattern changes +- Configuration file modifications + +### Documentation Analysis Strategies + +**Documentation Types to Prioritize:** +- **Architectural Decision Records (ADRs)**: Formal decisions about architecture and design +- **Coding Guidelines**: Existing style guides and conventions +- **README files**: Development setup and contribution guidelines +- **Technical Documentation**: API documentation, architecture diagrams, process docs +- **Workflow Documentation**: Development processes, review guidelines, deployment practices + +**Analysis Techniques:** +- **Decision Extraction**: Identify explicit decisions that can become standards +- **Pattern Recognition**: Look for consistent practices mentioned across documents +- **Gap Identification**: Find areas where practice is mentioned but not standardized +- **Consistency Analysis**: Compare guidelines across different documents +- **Implementation Evidence**: Look for references to actual code practices + +**Documentation Quality Indicators:** +- Recent updates and maintenance +- Clear decision rationales and context +- Specific implementation guidance +- Examples and patterns mentioned +- Team consensus and approval indicators + +### AI Agent Instructions Analysis Strategies + +**AI Agent File Types to Prioritize:** +- **copilot-instructions.md**: GitHub Copilot specific guidance and preferences +- **CLAUDE.md**: Claude Code specific instructions and patterns +- **AGENTS.md**: Universal AI agent instructions and guidelines +- **.cursor/rules/*.mdc**: Cursor-specific coding rules and preferences +- **Other AI files**: Any other AI agent instruction files found + +**Analysis Techniques:** +- **Preference Extraction**: Identify coding style preferences specified for AI agents +- **Pattern Recognition**: Look for consistent patterns across different AI instruction files +- **Guidance Analysis**: Extract specific guidance given to AI agents about code quality +- **Consistency Evaluation**: Compare preferences across different AI agent files +- **Implicit Standards**: Find coding standards implied in AI instructions + +**AI Instruction Quality Indicators:** +- Specific coding examples and patterns +- Clear style and formatting preferences +- Architecture and design guidance +- Testing and quality requirements +- Framework-specific instructions + +### Web Research Strategies + +**Authoritative Sources to Prioritize:** +- Official documentation and style guides +- Established organizations (Google, Microsoft, Mozilla, etc.) +- Language/framework maintainers' recommendations +- Industry-standard repositories and examples +- Peer-reviewed articles and technical publications + +**Research Topics to Cover:** +- **Current Best Practices**: Latest recommendations and approaches +- **Common Pitfalls**: Anti-patterns and mistakes to avoid +- **Performance Considerations**: Optimization techniques and considerations +- **Security Practices**: Security-related standards and guidelines +- **Tooling Integration**: How standards work with linters, formatters, etc. +- **Community Consensus**: Widely adopted practices in the community + +**Quality Assessment:** +- Verify source credibility and recency +- Cross-reference multiple authoritative sources +- Look for consensus among experts +- Prioritize official and maintained documentation +- Include practical examples and real-world applications +- **Document all URLs**: Keep track of all source URLs during research to include in the final standard + +--- + +## Technology Focus Areas + +Examples of technology areas that frequently benefit from coding standards: + +**Programming Languages:** +- **JavaScript/TypeScript**: Module patterns, async/await usage, type definitions +- **Python**: Class design, import organization, error handling +- **Java**: Package structure, exception handling, dependency injection +- **C#**: Naming conventions, LINQ usage, async patterns +- **Go**: Error handling, package organization, interface design + +**Frontend Frameworks:** +- **React**: Component patterns, hooks usage, state management +- **Angular**: Module organization, service injection, component lifecycle +- **Vue**: Component composition, reactive patterns, template usage + +**Backend Frameworks:** +- **Spring Boot**: Configuration patterns, REST controller design, service layers +- **Express.js**: Middleware usage, error handling, route organization +- **Django**: Model design, view patterns, URL routing +- **ASP.NET Core**: Controller patterns, dependency injection, middleware + +**Architecture & Patterns:** +- **Microservices**: Service boundaries, communication patterns, data consistency +- **Domain-Driven Design**: Entity design, aggregate patterns, repository usage +- **Clean Architecture**: Layer separation, dependency rules, use case patterns + +**Testing:** +- **Unit Testing**: Test structure, mocking patterns, assertion styles +- **Integration Testing**: Test data setup, environment management +- **E2E Testing**: Page object patterns, test organization + +**Infrastructure:** +- **Docker**: Dockerfile patterns, multi-stage builds, security practices +- **Kubernetes**: Manifest organization, resource management +- **Terraform**: Module patterns, state management, variable usage + +Remember: The goal is creating **actionable standards** that improve code quality and team consistency, not comprehensive documentation. All five analysis methods (codebase, git history, documentation analysis, AI agent instructions analysis, and web research) focus on practical improvements - whether based on actual development work, documented decisions, AI guidance, or established industry best practices. +``` diff --git a/sonar-project.properties b/private/sonar-project.properties similarity index 64% rename from sonar-project.properties rename to private/sonar-project.properties index f02fde7ae..9bbdfcf91 100644 --- a/sonar-project.properties +++ b/private/sonar-project.properties @@ -1,12 +1,12 @@ # Organization and project keys from your SonarCloud dashboard sonar.organization=packmind-ai -sonar.projectKey=PackmindHub_packmind +sonar.projectKey=PackmindHub_packmind-monorepo # Display name and version -sonar.projectName=Packmind OSS +sonar.projectName=Packmind Monorepo # Source code location -sonar.sources=. +sonar.sources=packages/linter,packages/linter-ast,packages/linter-execution,packages/amplitude,apps/frontend/src/domain sonar.exclusions=**/node_modules/**,**/dist/**,**/build/**,**/coverage/**,**/*.spec.ts,**/*.spec.tsx,**/*.test.ts,**/*.test.tsx,apps/playground/**,scripts/** # Language-specific settings diff --git a/product-map.md b/product-map.md new file mode 100644 index 000000000..de6700041 --- /dev/null +++ b/product-map.md @@ -0,0 +1,363 @@ +# Packmind Product Map — 2026-05-27 + +> Snapshot of every user-facing feature, grouped by functional domain. +> Generated by the `product-map` skill. Manual invocation only. + +## Scope + +- **Sources scanned**: backend use cases & NestJS controllers, frontend routes & pages, CLI commands +- **Packages excluded**: `logger`, `node-utils`, `migrations`, `types`, `ui`, `frontend` (lib), `editions`, `test-utils`, `integration-tests`, `plugins`, `assets` +- **Git revision**: `b9969e976` +- **Frontend router**: Remix file-based routes under `apps/frontend/app/routes/` +- **CLI framework**: `cmd-ts` (not Commander) + +--- + +## Functional Map + +Columns: **B**ackend, **F**rontend, **CLI**, **T**ests, **A**mplitude, **R**ecent activity (3mo). + +### Users / Auth / Organizations + +| Feature | B | F | CLI | T | A | R | Notes | +| ----------------------------------- | :-: | :-: | :-: | :-: | :-: | :-: | ---------------------------------------------------------- | +| Sign up with organization | ✓ | ✓ | – | ✓ | ✓ | ✓ | `POST /auth/signup` ↔ `_public.sign-up.create-account.tsx` | +| Sign in (email/password) | ✓ | ✓ | – | ✓ | ✓ | – | `POST /auth/signin` ↔ `_public.sign-in.tsx` | +| Sign in via WorkOS (social) | ✓ | ✓ | – | ✓ | ✓ | – | `GET /auth/social/*` | +| Sign out | ✓ | ✓ | – | – | – | ✓ | controller-only | +| Select organization | ✓ | ✓ | – | – | – | ✓ | controller-only token re-mint | +| Update user display name | ✓ | ✓ | – | ✓ | – | ✓ | `PATCH /auth/profile` ↔ `profile.tsx` | +| Check email availability | ✓ | ✓ | – | ✓ | – | – | `POST /auth/check-email-availability` | +| Request password reset | ✓ | ✓ | – | ✓ | – | – | `_public.forgot-password.tsx` | +| Validate password reset token | ✓ | ✓ | – | ✓ | – | – | `_public.reset-password.tsx` | +| Reset password | ✓ | ✓ | – | ✓ | – | – | | +| Validate invitation token | ✓ | ✓ | – | ✓ | – | – | `_public.activate.tsx` | +| Activate user account (from invite) | ✓ | ✓ | – | ✓ | – | – | | +| Activate trial account | ✓ | ✓ | – | ✓ | ✓ | – | `_public.activate-account.tsx` | +| Get user onboarding status | ✓ | ✓ | – | ✓ | – | – | | +| Complete user onboarding | ✓ | ✓ | – | ✓ | – | – | onboarding-reason page | +| Get org onboarding status | ✓ | ✓ | – | ✓ | – | ✓ | | +| Generate API key | ✓ | ✓ | – | ✓ | – | – | account-settings page (covers view + generate) | +| Create CLI login code | ✓ | ✓ | ✓ | ✓ | – | – | `_public.cli-login.tsx` ↔ `packmind-cli login` | +| Exchange CLI login code | ✓ | ✓ | ✓ | ✓ | – | – | | +| Whoami (CLI auth status) | – | – | ✓ | ✓ | – | ✓ | `packmind-cli whoami` (uses `GET /auth/me`) | +| Logout (CLI clear creds) | – | – | ✓ | ✓ | – | – | `packmind-cli logout` | +| Create organization | ✓ | ✓ | – | ✓ | ✓ | ✓ | `_public.sign-up.create-organization.tsx` | +| List user's organizations | ✓ | ✓ | – | ✓ | – | – | | +| Rename organization | ✓ | ✓ | – | ✓ | – | – | settings/users page action | +| List organization users | ✓ | ✓ | – | ✓ | – | ✓ | `settings/users` | +| List org user statuses | ✓ | ✓ | – | ✓ | – | – | | +| Change user role | ✓ | ✓ | – | ✓ | – | – | | +| Invite users | ✓ | ✓ | – | ✓ | – | ✓ | | +| Remove user from organization | ✓ | ✓ | – | ✓ | – | ✓ | | +| Start trial (MCP quick-start) | ✓ | ✓ | – | ✓ | ✓ | ✓ | `_public.quick-start.tsx` | +| Generate trial activation token | ✓ | ✓ | – | ✓ | ✓ | – | | +| Select coding agent (trial) | – | ✓ | – | – | – | – | `_public.quick-start_.select-agent.tsx` | +| Agent-specific MCP setup (trial) | ✓ | ✓ | – | ✓ | – | – | `_public.quick-start_.$agent.tsx` | +| Claude trial setup page | – | ✓ | – | – | – | – | `_public.claude-trial-setup.tsx` | +| Account settings page | – | ✓ | – | – | – | – | | +| Profile page | – | ✓ | – | – | – | ✓ | | +| Get MCP OAuth2 token | ✓ | ✓ | – | – | – | – | `GET /organizations/:orgId/mcp/token` | +| Get MCP server URL | ✓ | ✓ | – | – | – | – | | +| Get MCP configuration | ✓ | ✓ | – | – | – | – | | + +### Spaces + +| Feature | B | F | CLI | T | A | R | Notes | +| -------------------------------------- | :-: | :-: | :-: | :-: | :-: | :-: | ----------------------------- | +| List spaces I belong to | ✓ | ✓ | ✓ | ✓ | – | ✓ | `packmind-cli spaces list` | +| Org index → default space redirect | ✓ | ✓ | – | – | – | ✓ | | +| Org dashboard (KPI + outdated targets) | ✓ | ✓ | – | ✓ | – | ✓ | `space.$spaceSlug._index.tsx` | +| List space members | ✓ | ✓ | – | ✓ | – | ✓ | space settings page | +| Add members to space | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| Remove member from space | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| Update space member role | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| Create a space | ✓ | ✓ | – | ✓ | ✓ | ✓ | `settings/spaces` | +| List spaces for management | ✓ | ✓ | – | ✓ | – | ✓ | | +| Browse spaces | ✓ | ✓ | – | ✓ | – | ✓ | sidebar joinable list | +| Join a space (ID) | ✓ | ✓ | – | ✓ | – | ✓ | | +| Join a space (slug) | ✓ | ✓ | – | ✓ | – | ✓ | invite link | +| Leave a space | ✓ | ✓ | – | ✓ | – | ✓ | | +| Update space settings | ✓ | ✓ | – | ✓ | ✓ | ✓ | rename / visibility | +| Delete a space | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| Move artifacts between spaces | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| Pin a space | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| Unpin a space | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| Space settings page | – | ✓ | – | – | – | ✓ | | +| Manage spaces in org | – | ✓ | – | – | – | ✓ | admin-only | + +### Standards + +| Feature | B | F | CLI | T | A | R | Notes | +| -------------------------------------------- | :-: | :-: | :-: | :-: | :-: | :-: | --------------------------------------------------------- | +| List standards in space | ✓ | ✓ | ✓ | ✓ | – | ✓ | `packmind-cli standards list` | +| Create standard | ✓ | ✓ | – | ✓ | ✓ | ✓ | `standards.create.tsx` | +| Create standards from samples | ✓ | ✓ | – | ✓ | ✓ | ✓ | onboarding samples | +| Update standard | ✓ | ✓ | – | ✓ | ✓ | ✓ | `standards.$standardId.edit.tsx` | +| Delete standard | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| Delete standards (batch) | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| List standard versions | ✓ | ✓ | – | ✓ | – | – | version history | +| List rules of a standard | ✓ | ✓ | – | ✓ | ✓ | – | | +| View standard summary + rules | – | ✓ | – | – | – | ✓ | `standards.$standardId.summary.tsx` | +| View standard distributions | – | ✓ | – | – | – | – | `standards.$standardId.deployment.tsx` | +| View a single rule | – | ✓ | – | – | – | – | `standards.$standardId.rule.$ruleId.tsx` | +| List rule examples | ✓ | ✓ | – | ✓ | – | – | | +| Create rule example | ✓ | ✓ | – | ✓ | – | ✓ | | +| Update rule example | ✓ | ✓ | – | ✓ | – | ✓ | | +| Delete rule example | ✓ | ✓ | – | ✓ | – | ✓ | | +| Create standard (CLI deprecated stub) | – | – | ✓ | – | – | ✓ | `packmind-cli standards create` exits w/ migration notice | +| Lint code (CLI runner) | – | – | ✓ | ✓ | ✓ | ✓ | `packmind-cli lint` | +| Start program generation (rule) | ✓ | ✓ | – | ✓ | – | – | | +| Create detection program | ✓ | ✓ | – | ✓ | – | – | | +| Create new detection program version | ✓ | ✓ | – | ✓ | – | – | | +| Activate detection program version | ✓ | ✓ | – | ✓ | – | – | | +| Update detection program severity | ✓ | ✓ | – | ✓ | ✓ | – | tracked via LinterRuleSeverityUpdated | +| Get active detection program | ✓ | ✓ | – | ✓ | – | – | | +| Get all detection programs by rule | ✓ | ✓ | – | ✓ | – | – | | +| Get detection program metadata | ✓ | ✓ | – | ✓ | – | – | | +| Get rule detection assessment | ✓ | ✓ | – | ✓ | – | – | | +| Start rule detection assessment | ✓ | ✓ | – | ✓ | – | – | | +| Compute rule lang detection status | ✓ | ✓ | – | ✓ | – | – | | +| Get standard rules detection status | ✓ | ✓ | – | ✓ | – | – | | +| Test detection program (sandbox) | ✓ | ✓ | – | ✓ | – | – | | +| List detection programs (CLI) | ✓ | – | ✓ | ✓ | – | – | `POST /list-detection-program` | +| Track linter execution | ✓ | – | ✓ | ✓ | ✓ | – | `POST /track-execution` | +| Get detection programs for packages | ✓ | – | ✓ | ✓ | – | ✓ | | +| Get draft detection program for rule | ✓ | ✓ | – | ✓ | – | – | | +| Get active detection program for rule (slug) | ✓ | – | ✓ | ✓ | – | – | | +| Update rule detection heuristics | ✓ | ✓ | – | ✓ | – | – | | +| Get/create rule detection heuristics | ✓ | ✓ | – | ✓ | – | – | | + +### Skills + +| Feature | B | F | CLI | T | A | R | Notes | +| ----------------------------------------- | :-: | :-: | :-: | :-: | :-: | :-: | ------------------------------------------ | +| List skills in space | ✓ | ✓ | ✓ | ✓ | – | ✓ | `packmind-cli skills list` | +| List skill versions | ✓ | ✓ | – | ✓ | – | – | version history | +| Upload skill | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| Delete skill | ✓ | ✓ | – | ✓ | – | ✓ | from skill detail layout | +| Delete skills (batch) | ✓ | ✓ | – | ✓ | – | ✓ | | +| Download skill zip for agent | ✓ | ✓ | – | ✓ | – | ✓ | | +| Browse skill files | – | ✓ | – | – | – | ✓ | `skills.$skillSlug.files.$.tsx` | +| View skill distributions | – | ✓ | – | – | – | – | | +| Deploy default skills (CLI pull) | ✓ | – | ✓ | ✓ | – | ✓ | `GET /organizations/:orgId/skills/default` | +| Download default skills zip (Claude) | ✓ | ✓ | – | ✓ | – | ✓ | public | +| Download default skills zip (Copilot) | ✓ | ✓ | – | ✓ | – | ✓ | public | +| Download default skills zip (Cursor) | ✓ | ✓ | – | ✓ | – | ✓ | public | +| Install default skills (CLI) | – | – | ✓ | ✓ | – | ✓ | `packmind-cli skills init` | +| Add skill from local dir (CLI deprecated) | – | – | ✓ | – | – | ✓ | exits w/ migration notice | + +### Recipes / Playbooks + +| Feature | B | F | CLI | T | A | R | Notes | +| ------------------------------------ | :-: | :-: | :-: | :-: | :-: | :-: | ------------------------------------------------------------ | +| List recipes in space | ✓ | ✓ | ✓ | ✓ | – | ✓ | `packmind-cli commands list` | +| Capture / add recipe | ✓ | ✓ | – | ✓ | ✓ | ✓ | `commands.create.tsx` | +| Update recipe (from UI) | ✓ | ✓ | – | ✓ | ✓ | ✓ | `commands.$commandId.edit.tsx` | +| Delete recipe | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| Delete recipes (batch) | ✓ | ✓ | – | ✓ | ✓ | – | | +| List recipe versions | ✓ | ✓ | – | ✓ | – | – | version history | +| Update recipes from GitHub webhook | ✓ | – | – | ✓ | ✓ | – | `POST /:orgId/hooks/github` public — last touched 2025-11-09 | +| Update recipes from GitLab webhook | ✓ | – | – | ✓ | ✓ | – | `POST /:orgId/hooks/gitlab` public — last touched 2025-11-09 | +| List webhook URLs | ✓ | – | – | – | – | – | public — same cold pattern as webhooks | +| Apply playbook proposals (CLI/MCP) | ✓ | – | ✓ | ✓ | ✓ | ✓ | `POST /organizations/:orgId/playbook/apply` | +| Create command (CLI deprecated stub) | – | – | ✓ | – | – | ✓ | | +| Legacy `/recipes` index → commands | – | ✓ | – | – | – | – | DEAD redirect | +| Legacy `/recipes/:id` → command | – | ✓ | – | – | – | – | DEAD redirect | + +### Change Proposals + +| Feature | B | F | CLI | T | A | R | Notes | +| ------------------------------------ | :-: | :-: | :-: | :-: | :-: | :-: | -------------------------------------------------- | +| Check existing change proposals | ✓ | ✓ | ✓ | ✓ | – | ✓ | used by `playbook diff/add/submit` | +| Batch create change proposals | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | `playbook submit` | +| Create change proposal (single) | ✓ | – | – | ✓ | ✓ | ✓ | | +| Apply creation change proposals | ✓ | ✓ | – | ✓ | ✓ | ✓ | UI review accept | +| Apply change proposals (recipe) | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| Apply change proposals (standard) | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| Apply change proposals (skill) | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| List change proposals by space | ✓ | ✓ | – | ✓ | – | ✓ | review-changes layout | +| List change proposals (per recipe) | ✓ | ✓ | – | ✓ | – | ✓ | | +| List change proposals (per standard) | ✓ | ✓ | – | ✓ | – | ✓ | | +| List change proposals (per skill) | ✓ | ✓ | – | ✓ | – | ✓ | | +| Recompute conflicts | ✓ | ✓ | – | ✓ | – | ✓ | | +| Preview artifact rendering | ✓ | ✓ | – | ✓ | – | ✓ | | +| Review a change proposal (UI) | – | ✓ | – | – | – | ✓ | `review-changes.$artefactType.$artefactId.tsx` | +| Review a creation proposal (UI) | – | ✓ | – | – | – | ✓ | `review-changes.$artefactType.new.$proposalId.tsx` | +| Stage local artifact change | – | – | ✓ | ✓ | – | ✓ | `packmind-cli playbook add` | +| Stage artifact for removal | – | – | ✓ | ✓ | – | ✓ | `playbook rm` | +| Unstage a staged change | – | – | ✓ | ✓ | – | ✓ | `playbook unstage` | +| Diff local vs server | – | – | ✓ | ✓ | – | ✓ | `playbook diff` | +| Show staged + untracked status | – | – | ✓ | ✓ | – | ✓ | `playbook status` | +| Detect + submit (deprecated wrapper) | – | – | ✓ | – | – | ✓ | `packmind-cli diff` (redirects) | +| Artefact-type index redirect | – | ✓ | – | – | – | ✓ | DEAD redirect | + +### Deployments + +| Feature | B | F | CLI | T | A | R | Notes | +| ----------------------------------------- | :-: | :-: | :-: | :-: | :-: | :-: | ---------------------------------------------------------- | +| List packages in space | ✓ | ✓ | ✓ | ✓ | – | ✓ | `packmind-cli packages list` | +| Show package details | ✓ | ✓ | ✓ | ✓ | – | ✓ | `packmind-cli packages show` ↔ `packages.$packageId.tsx` | +| List all packages in org | ✓ | ✓ | – | ✓ | – | – | | +| Create package | ✓ | ✓ | ✓ | ✓ | – | ✓ | `packmind-cli packages create` ↔ `packages.new.tsx` | +| Update package | ✓ | ✓ | – | ✓ | – | ✓ | `packages.$packageId.edit.tsx` | +| Add artefacts to package | ✓ | ✓ | ✓ | ✓ | – | ✓ | `packmind-cli packages add` | +| Delete package | ✓ | ✓ | – | ✓ | – | – | | +| Delete packages (batch) | ✓ | ✓ | – | ✓ | – | – | | +| List targets by organization | ✓ | ✓ | – | ✓ | – | – | `settings/targets` | +| List targets by repository | ✓ | ✓ | – | ✓ | – | – | | +| Add target | ✓ | ✓ | – | ✓ | – | – | | +| Update target | ✓ | ✓ | – | ✓ | – | – | | +| Delete target | ✓ | ✓ | – | ✓ | – | – | | +| Publish recipes to targets | ✓ | ✓ | – | ✓ | ✓ | ✓ | DeploymentCompletedEvent | +| Publish standards to targets | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| Publish packages to targets | ✓ | ✓ | – | ✓ | ✓ | ✓ | | +| List distributions by recipe | ✓ | ✓ | – | ✓ | – | – | | +| List distributions by standard | ✓ | ✓ | – | ✓ | – | – | | +| List distributions by skill | ✓ | ✓ | – | ✓ | – | – | | +| List deployments by package | ✓ | ✓ | – | ✓ | – | – | | +| List active distributed packages by space | ✓ | ✓ | – | ✓ | – | ✓ | space deployments overview | +| Remove package from targets | ✓ | ✓ | – | ✓ | – | – | | +| Configure render mode (agents/formats) | ✓ | ✓ | – | ✓ | – | – | `settings/distribution-rendering` (view + update) | +| Pull content for organization | ✓ | – | ✓ | ✓ | ✓ | ✓ | `packmind-cli install` (alias pull) → ArtifactsPulledEvent | +| Get deployed content | ✓ | – | ✓ | ✓ | – | ✓ | | +| Get content by versions | ✓ | – | ✓ | ✓ | – | ✓ | | +| Install packages | ✓ | – | ✓ | ✓ | – | ✓ | | +| Uninstall packages | ✓ | – | ✓ | ✓ | – | ✓ | `packmind-cli uninstall` | +| Notify distribution | ✓ | – | ✓ | – | ✓ | ✓ | | +| Notify artefacts distribution | ✓ | – | ✓ | – | ✓ | ✓ | | +| Get dashboard KPI | ✓ | ✓ | – | – | – | ✓ | | +| Get dashboard non-live | ✓ | ✓ | – | – | – | ✓ | | +| Initialize Packmind project (CLI) | – | – | ✓ | ✓ | – | ✓ | `packmind-cli init` | +| Update CLI to latest | – | – | ✓ | ✓ | – | ✓ | `packmind-cli update` | +| Configure agents (interactive) | – | – | ✓ | ✓ | – | ✓ | `config agents setup` | +| Add coding agents to packmind.json | – | – | ✓ | ✓ | – | ✓ | `config agents add` | +| List coding agents | – | – | ✓ | ✓ | – | ✓ | `config agents list` | +| Remove coding agents | – | – | ✓ | ✓ | – | ✓ | `config agents rm` | +| Space deployments overview page | – | ✓ | – | – | – | ✓ | | +| Org-level deployments redirect | – | ✓ | – | – | – | ✓ | DEAD redirect | + +### Git Integration + +| Feature | B | F | CLI | T | A | R | Notes | +| -------------------------------- | :-: | :-: | :-: | :-: | :-: | :-: | -------------- | +| Add Git provider | ✓ | ✓ | – | ✓ | – | – | `settings/git` | +| List Git providers | ✓ | ✓ | – | ✓ | – | – | | +| Update Git provider | ✓ | ✓ | – | ✓ | – | – | | +| Delete Git provider | ✓ | ✓ | – | ✓ | – | – | | +| List available remote repos | ✓ | ✓ | – | ✓ | – | – | | +| Check branch exists | ✓ | ✓ | – | ✓ | – | – | | +| Add repository (via provider) | ✓ | ✓ | – | ✓ | – | – | | +| Remove repo from provider | ✓ | ✓ | – | ✓ | – | – | | +| Add git repository (direct) | ✓ | ✓ | – | ✓ | – | – | | +| List org repositories | ✓ | ✓ | – | ✓ | – | – | | +| List repos by provider | ✓ | ✓ | – | ✓ | – | – | | +| Get available remote directories | ✓ | ✓ | – | ✓ | – | – | | +| Check directory existence | ✓ | ✓ | – | ✓ | – | – | | + +### LLM Infrastructure + +| Feature | B | F | CLI | T | A | R | Notes | +| ---------------------------- | :-: | :-: | :-: | :-: | :-: | :-: | ----------------------------------- | +| Configure LLM provider | ✓ | ✓ | – | ✓ | – | – | `settings/llm` (view + save + test) | +| Test LLM connection (live) | ✓ | ✓ | – | ✓ | – | – | pre-save validation | +| Get available models | ✓ | ✓ | – | ✓ | – | – | | +| Test saved LLM config | ✓ | ✓ | – | ✓ | – | – | | +| List available LLM providers | ✓ | ✓ | – | ✓ | – | – | | + +### AI Agents / Coding Agent + +| Feature | B | F | CLI | T | A | R | Notes | +| -------------------------------- | :-: | :-: | :-: | :-: | :-: | :-: | --------------------------- | +| Setup CLI installation (page) | – | ✓ | – | – | – | – | `setup/cli` | +| Setup auto-update / hooks (page) | – | ✓ | – | – | – | ✓ | `setup/auto-update` | +| Setup playbook skills (page) | – | ✓ | – | – | – | – | `setup/skills` | +| Setup use cases (page) | – | ✓ | – | – | – | ✓ | `setup/use-cases` | +| Setup index redirect | – | ✓ | – | – | – | – | DEAD redirect to /setup/cli | + +### Legacy Import + +| Feature | B | F | CLI | T | A | R | Notes | +| ----------------------- | :-: | :-: | :-: | :-: | :-: | :-: | ----------------------------------------------------------------------------- | +| Import legacy practices | ✓ | – | – | – | – | ✓ | `POST /import-legacy` IP-gated; 3mo activity only swc migration + minor fixes | + +--- + +## Decommission Candidates + +Features matching at least one decommission heuristic. Sorted by weakest signal first. + +| Feature | Domain | Reason | Evidence | +| ------------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Import legacy practices | Legacy Import | backend-only, IP-gated, no Amplitude, no tests in standard path (3mo activity: swc migration + minor fixes only) | `packages/import-practices-legacy/src/application/useCases/importPracticeLegacy/`, `POST /import-legacy` | +| Update recipes from GitHub webhook | Recipes | cold under per-path 3mo check — use case file last touched 2025-11-09; no UI surface; integration test only | `packages/recipes/src/application/useCases/updateRecipesFromGitHub/`, `POST /:orgId/hooks/github` | +| Update recipes from GitLab webhook | Recipes | same cold pattern as GitHub webhook | `packages/recipes/src/application/useCases/updateRecipesFromGitLab/`, `POST /:orgId/hooks/gitlab` | +| Legacy `/create-account` redirect | Users / Auth | dead frontend route (pure redirect to `/sign-up/create-account`) | `apps/frontend/app/routes/_public.create-account.tsx` | +| Legacy `/start-trial` redirect | Users / Auth | dead frontend route (pure redirect) | `apps/frontend/app/routes/_public.start-trial.tsx` | +| Legacy `/recipes` index redirect | Recipes | dead frontend route (redirect to `/commands`) | `apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.recipes._index.tsx` | +| Legacy `/recipes/:recipeId` redirect | Recipes | dead frontend route | `apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.recipes.$recipeId.tsx` | +| Org-level deployments redirect | Deployments | dead frontend route (redirects to default space) | `apps/frontend/app/routes/org.$orgSlug._protected.deployments.tsx` | +| Setup index redirect | AI Agents | dead frontend route (redirects to `/setup/cli`) | `apps/frontend/app/routes/org.$orgSlug._protected.setup._index.tsx` | +| Review-changes artefact-type index redirect | Change Proposals | dead frontend route | `apps/frontend/app/routes/org.$orgSlug._protected.space.$spaceSlug._space-protected.review-changes.$artefactType._index.tsx` | +| `packmind-cli standards create` (deprecated stub) | Standards | CLI stub that only exits with migration notice | `apps/cli/src/infra/commands/CreateStandardCommand.ts` | +| `packmind-cli commands create` (deprecated stub) | Recipes | CLI stub that only exits with migration notice | `apps/cli/src/infra/commands/CreateCommandCommand.ts` | +| `packmind-cli skills add` (deprecated stub) | Skills | CLI stub that only exits with migration notice | `apps/cli/src/infra/commands/skills/AddSkillCommand.ts` | +| `packmind-cli diff` (deprecated wrapper) | Change Proposals | wrapper that redirects to `playbook diff/add/rm/submit` | `apps/cli/src/infra/commands/DiffCommand.ts` | +| Error boundary demo page | Other | dev-only diagnostic, no production value | `apps/frontend/app/routes/org.$orgSlug._protected.error-demo.tsx` | + +### Internal use-case orphans (code-level cleanup, not user-facing) + +Backend use cases with no controller binding and no observed caller path. Removing them is a code hygiene decision, not a product decision. + +| Use case | Domain | 3mo activity | Path | +| -------------------------------------- | ----------- | :----------: | ---------------------------------------------------------------------------------------------- | +| `createStandardWithPackages` | Standards | ✓ | `packages/standards/src/application/useCases/createStandardWithPackages/` | +| `createStandardWithExamples` | Standards | ✓ | `packages/standards/src/application/useCases/createStandardWithExamples/` | +| `updateStandardVersionSummary` | Standards | – | `packages/standards/src/application/useCases/updateStandardVersionSummary/` (cold) | +| `captureRecipeWithPackages` | Recipes | ✓ | `packages/recipes/src/application/useCases/captureRecipeWithPackages/` | +| `CreateRenderModeConfigurationUseCase` | Deployments | – | `packages/deployments/src/application/useCases/CreateRenderModeConfigurationUseCase.ts` (cold) | + +--- + +## Domain Coverage Summary + +| Domain | Features | Backend | Frontend | CLI | Dead/Deprecated | +| ---------------------------- | -------: | ------: | -------: | --: | --------------: | +| Users / Auth / Organizations | 39 | 33 | 36 | 4 | 2 | +| Spaces | 20 | 18 | 20 | 1 | 0 | +| Standards | 38 | 34 | 32 | 3 | 1 | +| Skills | 14 | 10 | 14 | 4 | 1 | +| Recipes / Playbooks | 12 | 9 | 7 | 2 | 3 | +| Change Proposals | 23 | 15 | 14 | 8 | 2 | +| Deployments | 38 | 31 | 26 | 11 | 1 | +| Git Integration | 13 | 13 | 13 | 0 | 0 | +| LLM Infrastructure | 5 | 5 | 5 | 0 | 0 | +| AI Agents / Coding Agent | 5 | 0 | 5 | 0 | 1 | +| Legacy Import | 1 | 1 | 0 | 0 | 1 | + +> **Excluded from this map** (implementation details, not user-meaningful features): +> +> - Internal use-case helpers (repository lookups, version finders, JWT minting, slug/ID finders, webhook payload handlers, event-driven migrations, cross-domain glue services) +> - Pure detail-loader read endpoints (`Get X by ID`, `Get X by slug`) — these are plumbing for an existing frontend "View X" feature, not separate capabilities +> - Internal tooling endpoints not customer-facing: Amplitude analytics config, healthcheck, app-root, SSE transport, IP-gated migration scripts +> - Read-side getters whose only consumer is a settings-page form prefill (merged into the corresponding write feature) +> +> Orphan/dead-code candidates among these are still flagged in the Decommission section below. + +--- + +## Methodology + +- A **feature** = a user-meaningful capability, not a technical implementation detail. +- **Sources merged** when same domain + same capability appears in backend/frontend/CLI. A feature with `–` in B/F/CLI means that source does not expose it. +- **Signal heuristics**: + - **T (Tests)**: ✓ if the use case class or controller route is referenced in `*.spec.ts` / `*.test.ts` files. + - **A (Amplitude)**: ✓ if an event matching the feature is subscribed in `packages/amplitude/src/application/AmplitudeEventListener.ts`. Covered domains: Standards, Rules, Commands (Recipes), Skills, Spaces, ChangeProposals, Deployments (Completed/Pulled), Linter, Users (signup/signin), Organizations, Trial, PlaybookArtefactMoved. + - **R (Recent activity)**: ✓ if at least one commit in the evidence path within the last 3 months, after filtering out non-meaningful commits (Sync from private repository, swc/tsc migration, build refactors, version bumps, chore/release/deps, "Fix merge", "Alignment as well"). Strict per-path — does NOT roll up from parent-package activity. A use case file can be cold while its package is otherwise active. +- **Decommission heuristic** — flagged if ANY of: + - No frontend coverage AND no CLI coverage (backend-only orphan) + - No tests AND no recent activity in last 3 months (cold code) + - Frontend route exists but backend use case missing (dead UI) + - Backend use case exists but no client entry point (dead endpoint) +- This is a **point-in-time snapshot** — re-run to refresh. +- The skill only maps. **Decommission decisions belong to the user.** diff --git a/scripts/benchmark-pre-push-parallel.sh b/scripts/benchmark-pre-push-parallel.sh new file mode 100755 index 000000000..aed292230 --- /dev/null +++ b/scripts/benchmark-pre-push-parallel.sh @@ -0,0 +1,393 @@ +#!/usr/bin/env bash +# +# Benchmark `nx affected --parallel=N` to find the sweet spot for the pre-push +# hook on this machine. +# +# Methodology: +# - Runs the exact same target set as .husky/pre-push, with --skip-nx-cache +# so every measured run does real work. +# - For each --parallel value, performs N runs (default 3) and records +# min / median / mean / stddev of wall-clock time. +# - **Every measured run starts from a fully cold tool-cache state** — +# Nx cache, Nx daemon, webpack filesystem cache, TypeScript .tsbuildinfo +# files, Jest cache, Vite cache, ESLint cache, and dist/ outputs are +# wiped before each run. The OS page cache cannot be cleared without +# sudo, but a warmup run primes it so all measured runs share the same +# OS-cache state. +# - --skip-nx-cache and --nx-bail=false keep Nx from short-circuiting. +# - Environment is sanity-checked (AC power, load average, clean worktree) +# before starting; you're warned if anything looks off. +# +# Usage: +# scripts/benchmark-pre-push-parallel.sh # defaults +# scripts/benchmark-pre-push-parallel.sh -p "4 6 8 10" # custom values +# scripts/benchmark-pre-push-parallel.sh -r 5 # 5 runs per value +# scripts/benchmark-pre-push-parallel.sh --no-warmup # skip warmup +# scripts/benchmark-pre-push-parallel.sh --force # skip sanity checks +# +# Results: +# - Per-run logs: $TMPDIR/nx-bench-<ts>/parallel-<N>-run-<i>.log +# - Raw TSV: $TMPDIR/nx-bench-<ts>/results.tsv +# - JSON summary: $TMPDIR/nx-bench-<ts>/summary.json +# - Printed table at the end. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +# ---------- defaults -------------------------------------------------------- +PARALLEL_VALUES=(4 6 8 10 12) +RUNS_PER_VALUE=3 +DO_WARMUP=1 +FORCE=0 +TARGETS=(lint test typecheck build) +EXCLUDES="cli-e2e-tests,@packmind/integration-tests" + +# ---------- argument parsing ------------------------------------------------ +usage() { + sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' + exit 1 +} + +while [ $# -gt 0 ]; do + case "$1" in + -p|--parallel) + # shellcheck disable=SC2206 + PARALLEL_VALUES=($2) + shift 2 ;; + -r|--runs) + RUNS_PER_VALUE=$2 + shift 2 ;; + --no-warmup) + DO_WARMUP=0 + shift ;; + --force) + FORCE=1 + shift ;; + -t|--targets) + # shellcheck disable=SC2206 + TARGETS=($2) + shift 2 ;; + -h|--help) + usage ;; + *) + printf "Unknown option: %s\n" "$1" >&2 + usage ;; + esac +done + +# ---------- helpers --------------------------------------------------------- +now_ms() { + # macOS has no `date +%s%N`, but Python is always around. + python3 -c 'import time; print(int(time.time()*1000))' +} + +# Compute min, median, mean, stddev of a list of integers (ms). +# Reads from stdin (one number per line). Prints as tab-separated values. +stats() { + python3 - <<'PY' +import sys, statistics +vals = [int(x.strip()) for x in sys.stdin if x.strip()] +if not vals: + print("\t".join(["-"]*4)) + sys.exit() +mn = min(vals) +md = int(statistics.median(vals)) +mean = int(statistics.mean(vals)) +sd = int(statistics.pstdev(vals)) if len(vals) > 1 else 0 +print(f"{mn}\t{md}\t{mean}\t{sd}") +PY +} + +fmt_secs() { + # Turn milliseconds into "Xm YY.Zs" for readability. + python3 -c " +ms = int('$1') +s = ms/1000 +m = int(s//60) +rem = s - m*60 +print(f'{m}m {rem:05.2f}s' if m else f'{rem:5.2f}s') +" +} + +# ---------- output dir ------------------------------------------------------ +TS=$(date +%Y%m%d-%H%M%S) +OUT_DIR="${TMPDIR:-/tmp}/nx-bench-$TS" +mkdir -p "$OUT_DIR" +RESULTS_TSV="$OUT_DIR/results.tsv" +SUMMARY_JSON="$OUT_DIR/summary.json" +printf "parallel\trun\tduration_ms\tstatus\n" > "$RESULTS_TSV" + +# ---------- machine info ---------------------------------------------------- +CPU_MODEL=$(sysctl -n machdep.cpu.brand_string 2>/dev/null || echo unknown) +PHYS_CORES=$(sysctl -n hw.physicalcpu 2>/dev/null || echo 0) +P_CORES=$(sysctl -n hw.perflevel0.physicalcpu 2>/dev/null || echo 0) +E_CORES=$(sysctl -n hw.perflevel1.physicalcpu 2>/dev/null || echo 0) +LOG_CORES=$(sysctl -n hw.logicalcpu 2>/dev/null || echo 0) +MEM_GB=$(sysctl -n hw.memsize 2>/dev/null | awk '{printf "%.0f", $1/1073741824}') +NODE_VER=$(node --version 2>/dev/null || echo ?) +NX_VER=$(./node_modules/.bin/nx --version 2>/dev/null | tail -1 || echo ?) +GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo ?) + +printf "\n==== Machine ====\n" +printf " CPU: %s\n" "$CPU_MODEL" +printf " Physical cores: %s\n" "$PHYS_CORES" +printf " Performance cores: %s\n" "${P_CORES:-n/a}" +printf " Efficiency cores: %s\n" "${E_CORES:-n/a}" +printf " Logical cores: %s\n" "$LOG_CORES" +printf " Memory: %s GB\n" "$MEM_GB" +printf " Node: %s\n" "$NODE_VER" +printf " Nx: %s\n" "$NX_VER" +printf " Git HEAD: %s\n" "$GIT_SHA" + +# ---------- sanity checks --------------------------------------------------- +WARNINGS=() + +# AC power (thermal throttling skews results badly on battery). +if command -v pmset >/dev/null 2>&1; then + PSOURCE=$(pmset -g ps | head -1) + if echo "$PSOURCE" | grep -qi "battery"; then + WARNINGS+=("Running on battery — thermal throttling will skew results. Plug in AC.") + fi +fi + +# Load average — if already high, results will be noisy. +LOAD1=$(uptime | awk -F'load averages?:' '{print $2}' | awk '{print $1}' | tr -d ',') +LOAD1_INT=$(printf "%.0f" "$LOAD1" 2>/dev/null || echo 0) +if [ "$LOAD1_INT" -gt "$((PHYS_CORES / 2))" ]; then + WARNINGS+=("1-minute load avg is $LOAD1 — other processes are competing. Close heavy apps.") +fi + +# Uncommitted changes — the exact state matters for affected. +if ! git diff-index --quiet HEAD -- 2>/dev/null; then + WARNINGS+=("Working tree has uncommitted changes. Nx's input hashing is deterministic but partial-file changes may skew cache behavior.") +fi + +if [ ${#WARNINGS[@]} -gt 0 ]; then + printf "\n==== Warnings ====\n" + for w in "${WARNINGS[@]}"; do + printf " [!] %s\n" "$w" + done + if [ "$FORCE" -ne 1 ]; then + printf "\nPass --force to run anyway.\n" + exit 2 + fi +fi + +# ---------- affected base --------------------------------------------------- +printf "\n==== Affected set ====\n" +git fetch origin main --quiet 2>/dev/null || true +BASE=$(git merge-base HEAD origin/main 2>/dev/null || echo "HEAD~1") +printf " Base ref: %s\n" "$BASE" + +AFFECTED=$(./node_modules/.bin/nx show projects --affected --base="$BASE" 2>/dev/null || true) +AFFECTED_COUNT=$(printf '%s\n' "$AFFECTED" | grep -c . || true) +printf " Affected projects: %s\n" "$AFFECTED_COUNT" + +if [ "$AFFECTED_COUNT" = "0" ]; then + printf "\n[error] Nothing is affected vs %s — the benchmark would be trivial.\n" "$BASE" + printf " Add a change to a package or pick a deeper base.\n" + exit 3 +fi + +printf "%s\n" "$AFFECTED" > "$OUT_DIR/affected-projects.txt" + +# ---------- environment for Nx invocations --------------------------------- +export PACKMIND_LOG_LEVEL=silent +export NODE_OPTIONS='--max-old-space-size=16384' + +# Wipe every tool-level cache we know about in this repo. +# Run BEFORE every measured run (and before warmup) so every run is cold. +# Note: the OS page cache (files recently read by the kernel) cannot be +# purged without `sudo purge`. The warmup run brings it into a consistent +# state so it's fair across --parallel values. +clean_caches() { + # Nx task cache + daemon + workspace data. + ./node_modules/.bin/nx reset >/dev/null 2>&1 || true + + # Webpack persistent filesystem cache (see apps/api/webpack.config.js:23). + rm -rf .cache 2>/dev/null || true + + # TypeScript incremental build info. + # Scope to repo sources; node_modules may legitimately ship .tsbuildinfo. + find . -name "*.tsbuildinfo" -not -path "*/node_modules/*" -delete 2>/dev/null || true + + # ESLint cache files (enabled per-project if at all). + find . -name ".eslintcache" -not -path "*/node_modules/*" -delete 2>/dev/null || true + + # Vite dep-optimization cache. Lives inside the workspace package copy. + find . -type d -name ".vite" -not -path "*/node_modules/*" -exec rm -rf {} + 2>/dev/null || true + # The one inside node_modules/.vite is shared dep optimization — clear too. + rm -rf node_modules/.vite 2>/dev/null || true + + # SWC and other generic tool caches under node_modules/.cache. + rm -rf node_modules/.cache 2>/dev/null || true + + # Jest's on-disk cache (per-user, under system temp dir). + rm -rf "${TMPDIR:-/tmp}"/jest_* 2>/dev/null || true + rm -rf /tmp/jest_* 2>/dev/null || true + + # Build outputs — force webpack/vite/swc to emit from scratch. Without + # this, incremental builders can short-circuit even with tool caches cleared. + rm -rf dist 2>/dev/null || true +} + +# Build the nx command once; only --parallel changes between runs. +# We use --skip-nx-cache so every run does real work, and --nxBail=false +# so one failing target doesn't shortcut the run (we want representative timing). +run_nx() { + local p="$1" + local log="$2" + ./node_modules/.bin/nx affected \ + -t "${TARGETS[@]}" \ + --base="$BASE" \ + --parallel="$p" \ + --skip-nx-cache \ + --nxBail=false \ + --tuiAutoExit \ + --exclude="$EXCLUDES" \ + > "$log" 2>&1 +} + +# ---------- warmup ---------------------------------------------------------- +# Use the middle parallel value; we just want OS file cache + node module +# resolution primed. Duration is not recorded. +if [ "$DO_WARMUP" -eq 1 ]; then + WARMUP_P=${PARALLEL_VALUES[$(( ${#PARALLEL_VALUES[@]} / 2 ))]} + printf "\n==== Warmup (parallel=%s, not measured) ====\n" "$WARMUP_P" + printf " Cleaning all caches...\n" + clean_caches + set +e + run_nx "$WARMUP_P" "$OUT_DIR/warmup.log" + WARMUP_STATUS=$? + set -e + if [ "$WARMUP_STATUS" -ne 0 ]; then + printf " [warn] warmup run failed (status=%s). See %s\n" "$WARMUP_STATUS" "$OUT_DIR/warmup.log" + printf " Continuing — measured runs may also fail. Use --force to suppress this.\n" + else + printf " Done.\n" + fi +fi + +# ---------- measured runs --------------------------------------------------- +TOTAL_RUNS=$(( ${#PARALLEL_VALUES[@]} * RUNS_PER_VALUE )) +RUN_IDX=0 + +printf "\n==== Measured runs: %d value(s) x %d run(s) = %d total ====\n" \ + "${#PARALLEL_VALUES[@]}" "$RUNS_PER_VALUE" "$TOTAL_RUNS" +printf " Output dir: %s\n" "$OUT_DIR" +printf " Note: every run is cold (tool caches wiped, dist/ removed), so each\n" +printf " takes noticeably longer than a normal pre-push. Go get coffee.\n" + +for p in "${PARALLEL_VALUES[@]}"; do + for i in $(seq 1 "$RUNS_PER_VALUE"); do + RUN_IDX=$((RUN_IDX + 1)) + LOG_FILE="$OUT_DIR/parallel-$p-run-$i.log" + + # Wipe every tool cache so each measured run does real work. + clean_caches + + printf "\n[%d/%d] parallel=%s run=%s ... " "$RUN_IDX" "$TOTAL_RUNS" "$p" "$i" + START=$(now_ms) + set +e + run_nx "$p" "$LOG_FILE" + STATUS=$? + set -e + END=$(now_ms) + DURATION=$((END - START)) + + if [ "$STATUS" -eq 0 ]; then + LABEL="OK" + else + LABEL="FAIL($STATUS)" + fi + + printf "%s in %s\n" "$LABEL" "$(fmt_secs "$DURATION")" + printf "%s\t%s\t%s\t%s\n" "$p" "$i" "$DURATION" "$LABEL" >> "$RESULTS_TSV" + done +done + +# ---------- aggregation ----------------------------------------------------- +printf "\n==== Summary ====\n" +printf "%-10s %-6s %-12s %-12s %-12s %-10s %s\n" \ + "parallel" "runs" "min" "median" "mean" "stddev" "status" +printf "%-10s %-6s %-12s %-12s %-12s %-10s %s\n" \ + "--------" "----" "---" "------" "----" "------" "------" + +declare -a ROWS=() +for p in "${PARALLEL_VALUES[@]}"; do + # Collect durations only for OK runs; report separately how many failed. + OK_MS=$(awk -F'\t' -v p="$p" '$1==p && $4=="OK" {print $3}' "$RESULTS_TSV") + OK_COUNT=$(printf '%s\n' "$OK_MS" | grep -c . || true) + FAIL_COUNT=$(awk -F'\t' -v p="$p" '$1==p && $4!="OK"' "$RESULTS_TSV" | grep -c . || true) + + if [ "$OK_COUNT" -gt 0 ]; then + STATS=$(printf '%s\n' "$OK_MS" | stats) + MIN_MS=$(echo "$STATS" | cut -f1) + MED_MS=$(echo "$STATS" | cut -f2) + MEAN_MS=$(echo "$STATS" | cut -f3) + SD_MS=$(echo "$STATS" | cut -f4) + + STATUS_LABEL="$OK_COUNT ok" + [ "$FAIL_COUNT" -gt 0 ] && STATUS_LABEL="$STATUS_LABEL, $FAIL_COUNT fail" + + printf "%-10s %-6s %-12s %-12s %-12s %-10s %s\n" \ + "$p" "$OK_COUNT" \ + "$(fmt_secs "$MIN_MS")" \ + "$(fmt_secs "$MED_MS")" \ + "$(fmt_secs "$MEAN_MS")" \ + "$(fmt_secs "$SD_MS")" \ + "$STATUS_LABEL" + + ROWS+=("$p $MED_MS $OK_COUNT $FAIL_COUNT") + else + printf "%-10s %-6s %-12s %-12s %-12s %-10s %s\n" \ + "$p" "0" "-" "-" "-" "-" "all failed" + fi +done + +# ---------- pick the winner ------------------------------------------------- +if [ ${#ROWS[@]} -gt 0 ]; then + BEST=$(printf '%s\n' "${ROWS[@]}" | sort -t$'\t' -k2 -n | head -1) + BEST_P=$(echo "$BEST" | cut -f1) + BEST_MED=$(echo "$BEST" | cut -f2) + printf "\nWinner: --parallel=%s (median %s)\n" "$BEST_P" "$(fmt_secs "$BEST_MED")" + printf "Suggested change in .husky/pre-push: --parallel=%s\n" "$BEST_P" +else + printf "\nNo successful runs. Inspect logs in %s\n" "$OUT_DIR" +fi + +# ---------- machine-readable summary --------------------------------------- +python3 - "$RESULTS_TSV" "$SUMMARY_JSON" "$CPU_MODEL" "$PHYS_CORES" "$P_CORES" \ + "$MEM_GB" "$GIT_SHA" "$AFFECTED_COUNT" <<'PY' +import csv, json, statistics, sys +tsv, out, cpu, phys, pcores, mem, sha, affected = sys.argv[1:9] +rows = list(csv.DictReader(open(tsv), delimiter='\t')) +by_p = {} +for r in rows: + by_p.setdefault(r['parallel'], []).append(r) + +summary = { + "machine": { + "cpu": cpu, "physical_cores": int(phys or 0), + "performance_cores": int(pcores or 0), "memory_gb": int(mem or 0), + }, + "repo": {"git_sha": sha, "affected_projects": int(affected)}, + "runs": [] +} +for p, runs in sorted(by_p.items(), key=lambda x: int(x[0])): + ok = [int(r['duration_ms']) for r in runs if r['status'] == 'OK'] + entry = { + "parallel": int(p), + "runs_total": len(runs), + "runs_ok": len(ok), + "min_ms": min(ok) if ok else None, + "median_ms": int(statistics.median(ok)) if ok else None, + "mean_ms": int(statistics.mean(ok)) if ok else None, + "stddev_ms": int(statistics.pstdev(ok)) if len(ok) > 1 else 0, + } + summary["runs"].append(entry) + +json.dump(summary, open(out, 'w'), indent=2) +print(f"\nJSON summary: {out}") +PY diff --git a/scripts/datadog-cli-version-report.mjs b/scripts/datadog-cli-version-report.mjs new file mode 100644 index 000000000..5ac8c7f7f --- /dev/null +++ b/scripts/datadog-cli-version-report.mjs @@ -0,0 +1,147 @@ +// scripts/datadog-cli-version-report.mjs +// Downloads the last week of `Packmind CLI request` logs from Datadog +// (service:api-proprietary) and prints a usage breakdown by cliVersion. +// +// Required env vars: +// DD_API_KEY Datadog API key +// DD_APP_KEY Datadog Application key +// Optional env vars: +// DD_SITE Datadog site (default: datadoghq.eu) +// DD_DAYS Number of days to look back (default: 7) + +const DD_API_KEY = process.env.DD_API_KEY; +const DD_APP_KEY = process.env.DD_APP_KEY; +const DD_SITE = process.env.DD_SITE || 'datadoghq.eu'; +const DAYS = Number(process.env.DD_DAYS || 7); + +if (!DD_API_KEY || !DD_APP_KEY) { + console.error( + 'Missing DD_API_KEY or DD_APP_KEY environment variable. Set both to a Datadog API key and Application key.', + ); + process.exit(1); +} + +const now = new Date(); +const from = new Date(now.getTime() - DAYS * 24 * 60 * 60 * 1000); +const url = `https://api.${DD_SITE}/api/v2/logs/events/search`; +const QUERY = 'service:api-proprietary "Packmind CLI request"'; + +async function fetchPage(cursor) { + const body = { + filter: { + from: from.toISOString(), + to: now.toISOString(), + query: QUERY, + }, + page: { limit: 1000, ...(cursor ? { cursor } : {}) }, + sort: 'timestamp', + }; + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'DD-API-KEY': DD_API_KEY, + 'DD-APPLICATION-KEY': DD_APP_KEY, + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Datadog API ${res.status} ${res.statusText}: ${text}`); + } + return res.json(); +} + +function extractJson(message) { + if (!message) return null; + const start = message.indexOf('{'); + const end = message.lastIndexOf('}'); + if (start === -1 || end === -1 || end < start) return null; + try { + return JSON.parse(message.slice(start, end + 1)); + } catch { + return null; + } +} + +function formatPercent(count, total) { + const pct = (count / total) * 100; + if (pct < 1) return '< 1%'; + return `${Math.round(pct)}%`; +} + +function renderTable(rows) { + const versionWidth = Math.max( + 'version'.length, + ...rows.map((r) => r.version.length), + ); + const usageWidth = Math.max( + 'usage'.length, + ...rows.map((r) => r.usage.length), + ); + const sep = '-'.repeat(versionWidth + usageWidth + 7); + + const lines = []; + lines.push(sep); + lines.push( + `| ${'version'.padEnd(versionWidth)} | ${'usage'.padEnd(usageWidth)} |`, + ); + lines.push(sep); + for (const r of rows) { + lines.push( + `| ${r.version.padEnd(versionWidth)} | ${r.usage.padEnd(usageWidth)} |`, + ); + } + lines.push(sep); + return lines.join('\n'); +} + +async function main() { + const counts = new Map(); + let total = 0; + let pages = 0; + let cursor; + + process.stderr.write( + `Fetching Datadog logs from ${from.toISOString()} to ${now.toISOString()} (site=${DD_SITE})...\n`, + ); + + do { + const data = await fetchPage(cursor); + pages += 1; + const events = data.data || []; + for (const evt of events) { + const json = extractJson(evt.attributes?.message); + const version = json?.cliVersion; + if (!version) continue; + counts.set(version, (counts.get(version) || 0) + 1); + total += 1; + } + cursor = data.meta?.page?.after; + process.stderr.write( + ` page ${pages}: ${events.length} events (running total: ${total})\n`, + ); + } while (cursor); + + if (total === 0) { + console.log('No matching logs found.'); + return; + } + + const rows = [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([version, count]) => ({ + version, + usage: formatPercent(count, total), + })); + + console.log(renderTable(rows)); + console.log( + `\nTotal requests: ${total} across ${counts.size} distinct version(s) over the last ${DAYS} day(s).`, + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/rename-files-oss.sh b/scripts/rename-files-oss.sh deleted file mode 100644 index 545c44f89..000000000 --- a/scripts/rename-files-oss.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Function to handle file replacement: remove target, rename source to target, and git add -replace_file() { - local source_file="$1" - local target_file="$2" - - echo "Processing: $source_file -> $target_file" - - # Remove target file if it exists - if [ -f "$target_file" ]; then - echo " Removing existing $target_file" - rm "$target_file" - fi - - # Move source to target if source exists - if [ -f "$source_file" ]; then - echo " Moving $source_file to $target_file" - mv "$source_file" "$target_file" - git add "$target_file" - else - echo " ❌ Error: Source file $source_file does not exist" - exit 1 - fi -} - -# List of file pairs: source_file:target_file -file_pairs=( - "README.oss.md:README.md" - ".github/workflows/main-oss.yml.oss:.github/workflows/main-oss.yml" -) - -# Process each file pair -for pair in "${file_pairs[@]}"; do - IFS=':' read -r source_file target_file <<< "$pair" - replace_file "$source_file" "$target_file" -done \ No newline at end of file diff --git a/specs/orga-space-management.md b/specs/orga-space-management.md new file mode 100644 index 000000000..1119d4c01 --- /dev/null +++ b/specs/orga-space-management.md @@ -0,0 +1,99 @@ +User Story Review: Organization Spaces Management Page + +# Rule 1: Eligible users can access the spaces management page from the organization settings + +## Example 1 + +The user is signed in to an organization and the `orga-space-management` feature flag is enabled for them. + +The user navigates to `/org/{orgSlug}/settings/spaces`. + +The page renders with title "Spaces" and subtitle "Manage every space in your organization · {N} spaces". + +# Rule 2: The page displays organization spaces in a paginated table sourced from a dedicated listing endpoint + +## Example 1 + +The organization contains 32 spaces (including the org-wide default space "Global"). + +The user lands on `/settings/spaces`. + +The page calls a dedicated listing endpoint that returns 8 spaces per page, sorted with the default space first then by `createdAt` ascending. The table displays the first 8 spaces with columns: Name, Admins, Members, Artifacts, Created. + +## Example 2 + +For each row in the table: + +- The Name column shows a colored dot (color derived from `space.id` on the frontend), the space name, and an `org-wide` badge for the default space. +- The Admins column shows up to 3 avatar bubbles for users with `UserSpaceMembership.role === ADMIN` on that space, plus the count "{N} admins" — or the admin's display name when there is exactly one. +- The Members column shows the count of users with `UserSpaceMembership.role === MEMBER` (non-admin members) on that space. +- The Artifacts column shows the sum of standards, recipes, and skills attached to the space. +- The Created column shows the formatted creation date (e.g., "12 Jan 2025"). + +## Example 3 + +The user clicks page 2 in the pagination control. + +The endpoint is called again with `page=2`. + +The next 8 spaces are returned in the same sort order and rendered in the table. + +# Rule 3: Eligible users can create a new space from the management page and remain on the page + +## Example 1 <!-- E2E --> + +The user clicks `+ New space` in the toolbar. + +A "Create new space" dialog opens. The user enters a name and selects a visibility, then clicks `Create space`. + +The space is created, the dialog closes, the user remains on `/settings/spaces`, the spaces query is invalidated, and the new space appears in the table. + +# Rule 4: A space can be deleted from a row action with a confirmation modal + +## Example 1 <!-- E2E --> + +The user opens the row actions menu on a non-default space and clicks `Delete`. + +A confirmation modal appears with the message "Delete space '{name}'? This action is irreversible." and `Cancel` / `Delete` buttons. + +The user clicks `Delete`; the space is deleted, the modal closes, the spaces query is invalidated, and the row is removed from the table. + +## Example 2 + +The user opens the row actions menu on the default org-wide space. + +The `Delete` action is hidden (the backend rejects deletion of the default space). + +# Rule 5: A space row exposes a "View" action that navigates to the space's dashboard + +## Example 1 + +The user opens the row actions menu and clicks `View`. + +The user is navigated to `/org/{orgSlug}/spaces/{spaceSlug}` (the space dashboard). + +# Technical rules + +- Page is gated behind `ORGA_SPACE_MANAGEMENT_FEATURE_KEY` (`orga-space-management`). +- Pagination is server-side via the new listing endpoint with a fixed page size of 8. +- Default sort: default org-wide space first, then `createdAt` ascending. +- The default org-wide space cannot be deleted (existing `CannotDeleteDefaultSpaceError`). +- Delete authorization: org admin OR space admin (existing `DeleteSpaceUseCase` rules apply unchanged). +- Search input and Admin/Member filter dropdowns remain visual-only (no behavior wired in this iteration). +- Row action `Edit` is hidden in this iteration. +- Space color shown next to the name is derived deterministically from `space.id` on the frontend (no backend persistence). +- The listing endpoint accepts `page` and returns at minimum the rows for the page plus the total count for pagination UI; aggregation (admins, member count, artifact count) avoids N+1 queries across spaces. + +# User Events + +None defined for this iteration. + +--- + +Check also the following rules are applied: + +- Bulk delete is out of scope: the existing `SpacesBulkActionBar` rendering and the row-checkbox selection state are removed from the page (no inert UI left behind). +- The Admins avatar group caps visible avatars (per `PMAvatarGroup` behavior) and shows the total count as "{N} admins" or the single admin's display name when there is one. +- Loading and error states for the table are preserved from the current implementation. +- After successful delete: success toast. On failure: error toast. +- The View action is hidden (or disabled) when navigation would not resolve (e.g., space slug missing). diff --git a/specs/space-identity-management.md b/specs/space-identity-management.md new file mode 100644 index 000000000..e564d94f8 --- /dev/null +++ b/specs/space-identity-management.md @@ -0,0 +1,77 @@ +User Story Review: Space identity management (name & color) + +# Rule 1: Space and orga administrators can update the space identity (color & name) + +## Example 1 + +Bob is an administrator of the space "Identity". + +He can update the space identity. + +## Example 2 + +Bob is an organization administrator. + +He can update the space identity. + +## Example 3 + +Bob is a member of the space "Identity". He is not an organization administrator. + +He can not update the space identity. + +# Rule 2: Slugs are not updated alongside the name + +## Example 1 + +Bob is administrator of space "Security" (with a typo). + +He updates the space name to "Security" (without the typo). + +The space still has the "security" slug. + +# Rule 3: Spaces can not have slug colliding names + +This rule applies at **space creation time only**. Renames do not change a space's slug (see Rule 2), so no rename-time check is required. The rule is documented here for completeness — it is already enforced by `CreateSpaceUseCase` via `SpaceSlugConflictError`. + +# Rule 4: Chosen space color is the same for all the users of the organization + +## Example 1 + +Bob is an administrator of the space "Ability". + +He updates the space "Ability" color to "light blue". + +When John opens the web app, he sees the "Ability" space as "light blue". + +# Rule 5: Default space can not be renamed + +## Example 1 + +Bob is an administrator of the default space "Global". + +He can change the default space color. + +## Example 2 + +Bob is an administrator of the default space "Global". + +He can not rename the default space. + +# Technical rules + +- Only space administrators and organization administrators are authorized to update a space's identity (name & color). On the frontend, the identity form is rendered but its fields are disabled for users without permission. On the backend, the update endpoint throws an error if a non-authorized user attempts to update. +- Slugs are NOT updated when the space name changes — slugs remain stable after space creation. +- Slug collision is validated at space creation only (already enforced via `SpaceSlugConflictError`). Renames do not trigger a slug collision check because slugs are not updated alongside the name. +- The default space cannot be renamed: the name field is rendered on the frontend but disabled; the backend rejects any rename attempt targeting the default space. Color updates on the default space are allowed. + +# User Events + +- `space_renamed`: emitted when a space is successfully renamed. Properties: `space_id`, `old_name` + +--- + +Check also the following rules are applied: + +- Color updates are visible to all users of the organization (no per-user preference). +- Authorization checks are enforced on the backend regardless of frontend state. diff --git a/tsconfig.base.json b/tsconfig.base.json index d2fc88fe3..0cfadd7d3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -20,7 +20,6 @@ "paths": { "@packmind/accounts": ["packages/accounts/src/index.ts"], "@packmind/amplitude": ["packages/amplitude/src/index.ts"], - "@packmind/analytics/types": ["packages/analytics/src/types/index.ts"], "@packmind/assets": ["packages/assets/src/index.ts"], "@packmind/assets/*": ["packages/assets/src/*"], "@packmind/coding-agent": ["packages/coding-agent/src/index.ts"], @@ -42,6 +41,7 @@ "@packmind/logger": ["packages/logger/src/index.ts"], "@packmind/migrations": ["packages/migrations/src/index.ts"], "@packmind/node-utils": ["packages/node-utils/src/index.ts"], + "@packmind/plugins": ["packages/plugins/src/index.ts"], "@packmind/recipes": ["packages/recipes/src/index.ts"], "@packmind/recipes/test": ["packages/recipes/test/index.ts"], "@packmind/skills": ["packages/skills/src/index.ts"], diff --git a/tsconfig.paths.proprietary.json b/tsconfig.paths.proprietary.json index f301b32c1..bdf5c6d3a 100644 --- a/tsconfig.paths.proprietary.json +++ b/tsconfig.paths.proprietary.json @@ -2,10 +2,10 @@ "compilerOptions": { "paths": { "@packmind/proprietary/frontend/*": ["apps/frontend/src/*"], - "@packmind/analytics": ["packages/analytics/src/index.ts"], "@packmind/jobs": ["packages/jobs/src/index.ts"], "@packmind/linter": ["packages/linter/src/index.ts"], "@packmind/amplitude": ["packages/amplitude/src/index.ts"], + "@packmind/crisp": ["packages/crisp/src/index.ts"], "@packmind/plugins": ["packages/plugins/src/index.ts"], "@packmind/import-practices-legacy": [ "packages/import-practices-legacy/src/index.ts"